Wait for startup input to settle

This commit is contained in:
Charlie Marsh
2026-07-05 21:41:27 -04:00
parent 3f182a29ec
commit 5977c9ac10
4 changed files with 414 additions and 48 deletions
+68 -21
View File
@@ -45,6 +45,7 @@ use crate::notifications::detect_backend;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::tui::event_stream::EventBroker;
use crate::tui::event_stream::InitialInputPolicy;
use crate::tui::event_stream::TuiEventStream;
#[cfg(unix)]
use crate::tui::job_control::SuspendContext;
@@ -64,6 +65,7 @@ pub(crate) mod test_support;
/// Target frame interval for UI redraw scheduling.
pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL;
pub(crate) const STARTUP_INPUT_QUIET_PERIOD: Duration = Duration::from_secs(/*secs*/ 1);
/// A type alias for the terminal type used in this application
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
@@ -77,9 +79,9 @@ pub(crate) struct InitializedTerminal {
pub(crate) use startup::PreparedTerminal;
use startup::StartupInputBuffer;
use startup::StartupInputHandoff;
pub(crate) use startup::abandon_prepared_terminal;
use startup::capture_startup_input;
pub(crate) use startup::discard_terminal_input;
pub(super) fn flush_terminal_input_buffer() {
startup::flush_terminal_input_buffer();
@@ -108,6 +110,11 @@ impl Drop for Tui {
mod tests {
use std::io::Write as _;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio_stream::StreamExt;
use super::StartupInputBuffer;
use super::clear_for_viewport_change;
use super::should_emit_notification;
@@ -154,6 +161,28 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn startup_interrupt_survives_text_extraction() -> std::io::Result<()> {
let mut tui = make_test_tui()?;
let mut startup_input = StartupInputBuffer::default();
startup_input.handle_probe_input(b"draft\x03");
tui.startup_input = Some(startup_input);
assert_eq!(
tui.take_startup_text_with_capture(|_| Ok(()))?.as_deref(),
Some("draft")
);
let mut events = tui.startup_event_stream();
assert!(matches!(
events.next().await,
Some(super::TuiEvent::Key(key)) if key == KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)
));
Ok(())
}
#[test]
fn always_notification_condition_emits_when_focused() {
assert!(should_emit_notification(
@@ -643,31 +672,43 @@ impl Tui {
/// A screen that requests events becomes the new input owner, so text captured before the TUI
/// was ready must not survive through it and later appear in the composer.
pub fn event_stream(&mut self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
let discard_initial_input = self.claim_startup_input();
self.build_event_stream(discard_initial_input)
let startup_input = self.claim_startup_input();
self.build_event_stream(InitialInputPolicy::DiscardAll, startup_input)
}
pub(crate) fn startup_event_stream(
&mut self,
) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
self.claim_startup_input();
self.build_event_stream(/*discard_initial_input*/ true)
let startup_input = self.claim_startup_input();
self.build_event_stream(InitialInputPolicy::PreserveText, startup_input)
}
fn claim_startup_input(&mut self) -> bool {
if self.startup_input_active {
self.startup_input = None;
discard_terminal_input();
self.startup_input_active = false;
true
} else {
false
fn claim_startup_input(&mut self) -> StartupInputHandoff {
if !self.startup_input_active {
return StartupInputHandoff::default();
}
self.startup_input_active = false;
let startup_input = self.startup_input.take().unwrap_or_default().into_handoff();
#[cfg(unix)]
if startup_input.suspend_requested {
self.event_broker.pause_events();
let suspend_result = self.suspend_context.suspend(&self.alt_screen_active);
self.event_broker.resume_events();
if let Err(err) = suspend_result {
tracing::warn!(
event = "tui_startup_suspend_failed",
error = %err,
"failed to suspend TUI process during startup"
);
}
}
startup_input
}
fn build_event_stream(
&self,
discard_initial_input: bool,
initial_input_policy: InitialInputPolicy,
startup_input: StartupInputHandoff,
) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
#[cfg(unix)]
let stream = TuiEventStream::new(
@@ -683,11 +724,16 @@ impl Tui {
self.draw_tx.subscribe(),
self.terminal_focused.clone(),
);
let stream = if discard_initial_input {
stream.discarding_initial_input()
} else {
stream
let start_quiet = match initial_input_policy {
InitialInputPolicy::DiscardAll => startup_input.had_input,
InitialInputPolicy::PreserveText => startup_input.suppress_repeats,
};
let stream = stream.filtering_initial_input(
initial_input_policy,
start_quiet,
startup_input.interrupt_requested,
startup_input.suspend_requested,
);
Box::pin(stream)
}
@@ -705,9 +751,10 @@ impl Tui {
let mut input = self.startup_input.take().unwrap_or_default();
capture(&mut input)?;
// Keep ownership until `event_stream()` performs the final flush immediately before the
// crossterm stream takes over stdin.
Ok(input.into_text())
// Keep action/control state until `event_stream()` hands input to crossterm.
let text = input.take_text();
self.startup_input = Some(input);
Ok(text)
}
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
+250 -14
View File
@@ -17,6 +17,7 @@
//!
//! See https://ratatui.rs/recipes/apps/spawn-vim/ and https://www.reddit.com/r/rust/comments/1f3o33u/myterious_crossterm_input_after_running_vim for more details.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
@@ -26,18 +27,71 @@ use std::task::Context;
use std::task::Poll;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use tokio::sync::broadcast;
use tokio::sync::watch;
use tokio::time::Instant;
use tokio::time::Sleep;
use tokio_stream::Stream;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::WatchStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use super::STARTUP_INPUT_QUIET_PERIOD;
use super::TuiEvent;
/// Result type produced by an event source.
pub type EventResult = std::io::Result<Event>;
#[derive(Clone, Copy)]
pub(super) enum InitialInputPolicy {
DiscardAll,
PreserveText,
}
struct InitialInputFilter {
policy: InitialInputPolicy,
drain_ready: bool,
quiet_timer: Option<Pin<Box<Sleep>>>,
interrupt_forwarded: bool,
suspend_forwarded: bool,
}
impl InitialInputFilter {
fn new(
policy: InitialInputPolicy,
start_quiet: bool,
interrupt_forwarded: bool,
suspend_forwarded: bool,
) -> Self {
let quiet_timer = start_quiet.then(|| {
Box::pin(tokio::time::sleep_until(
Instant::now() + STARTUP_INPUT_QUIET_PERIOD,
))
});
Self {
policy,
drain_ready: !start_quiet,
quiet_timer,
interrupt_forwarded,
suspend_forwarded,
}
}
fn reset_quiet_timer(&mut self) {
let deadline = Instant::now() + STARTUP_INPUT_QUIET_PERIOD;
if let Some(timer) = &mut self.quiet_timer {
timer.as_mut().reset(deadline);
} else {
self.quiet_timer = Some(Box::pin(tokio::time::sleep_until(deadline)));
}
self.drain_ready = false;
}
}
/// Abstraction over a source of terminal events. Allows swapping in a fake for tests.
/// Value in production is [`CrosstermEventSource`].
pub trait EventSource: Send + 'static {
@@ -142,7 +196,8 @@ pub struct TuiEventStream<S: EventSource + Default + Unpin = CrosstermEventSourc
resume_stream: WatchStream<()>,
terminal_focused: Arc<AtomicBool>,
poll_draw_first: bool,
discard_initial_input: bool,
initial_input_filter: Option<InitialInputFilter>,
pending_interrupt: bool,
#[cfg(unix)]
suspend_context: crate::tui::job_control::SuspendContext,
#[cfg(unix)]
@@ -164,7 +219,8 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
resume_stream,
terminal_focused,
poll_draw_first: false,
discard_initial_input: false,
initial_input_filter: None,
pending_interrupt: false,
#[cfg(unix)]
suspend_context,
#[cfg(unix)]
@@ -172,17 +228,88 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
pub fn discarding_initial_input(mut self) -> Self {
self.discard_initial_input = true;
pub(super) fn filtering_initial_input(
mut self,
policy: InitialInputPolicy,
start_quiet: bool,
pending_interrupt: bool,
suspend_forwarded: bool,
) -> Self {
self.initial_input_filter = Some(InitialInputFilter::new(
policy,
start_quiet,
pending_interrupt,
suspend_forwarded,
));
self.pending_interrupt = pending_interrupt;
self
}
fn should_forward_initial_event(&mut self, event: &Event) -> bool {
let Some(filter) = &mut self.initial_input_filter else {
return true;
};
match event {
Event::Key(key_event) => {
filter.reset_quiet_timer();
if is_interrupt(*key_event) {
return if filter.interrupt_forwarded {
false
} else {
filter.interrupt_forwarded = true;
true
};
}
#[cfg(unix)]
if crate::tui::job_control::SUSPEND_KEY.is_press(*key_event) {
return if filter.suspend_forwarded {
false
} else {
filter.suspend_forwarded = true;
true
};
}
matches!(filter.policy, InitialInputPolicy::PreserveText)
&& is_text_input(*key_event)
}
Event::Paste(_) => {
filter.reset_quiet_timer();
matches!(filter.policy, InitialInputPolicy::PreserveText)
}
Event::Resize(_, _) | Event::FocusGained | Event::FocusLost => true,
_ => false,
}
}
fn poll_initial_input_filter(&mut self, cx: &mut Context<'_>) {
let Some(filter) = &mut self.initial_input_filter else {
return;
};
if filter.drain_ready {
self.initial_input_filter = None;
return;
}
let Some(timer) = &mut filter.quiet_timer else {
self.initial_input_filter = None;
return;
};
if timer.as_mut().poll(cx).is_ready() {
self.initial_input_filter = None;
}
}
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
///
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
/// the underlying stream and returns `Pending` to fully release stdin.
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
if std::mem::take(&mut self.pending_interrupt) {
return Poll::Ready(Some(TuiEvent::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
))));
}
// Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling
// until we return a mapped event, hit Pending, or see EOF/error.
loop {
@@ -217,7 +344,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
Poll::Ready(Some(())) => continue,
Poll::Ready(None) => return Poll::Ready(None),
Poll::Pending => {
self.discard_initial_input = false;
self.poll_initial_input_filter(cx);
return Poll::Pending;
}
}
@@ -225,10 +352,13 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
};
if self.discard_initial_input {
let Some(event) = poll_result else {
continue;
};
if !self.should_forward_initial_event(&event) {
continue;
}
if let Some(mapped) = poll_result.and_then(|event| self.map_crossterm_event(event)) {
if let Some(mapped) = self.map_crossterm_event(event) {
return Poll::Ready(Some(mapped));
}
}
@@ -282,6 +412,25 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
fn is_interrupt(key_event: KeyEvent) -> bool {
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& key_event.code == KeyCode::Char('c')
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_text_input(key_event: KeyEvent) -> bool {
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& key_event
.modifiers
.difference(KeyModifiers::SHIFT)
.is_empty()
&& match key_event.code {
KeyCode::Char(ch) => !ch.is_control(),
KeyCode::Backspace => true,
_ => false,
}
}
impl<S: EventSource + Default + Unpin> Unpin for TuiEventStream<S> {}
impl<S: EventSource + Default + Unpin> Stream for TuiEventStream<S> {
@@ -391,10 +540,13 @@ mod tests {
)
}
fn make_stream_discarding_initial_input(
fn make_stream_filtering_initial_input(
broker: Arc<EventBroker<FakeEventSource>>,
draw_rx: broadcast::Receiver<()>,
terminal_focused: Arc<AtomicBool>,
policy: InitialInputPolicy,
start_quiet: bool,
pending_interrupt: bool,
) -> TuiEventStream<FakeEventSource> {
TuiEventStream::new(
broker,
@@ -405,7 +557,12 @@ mod tests {
#[cfg(unix)]
Arc::new(AtomicBool::new(false)),
)
.discarding_initial_input()
.filtering_initial_input(
policy,
start_quiet,
pending_interrupt,
/*suspend_forwarded*/ false,
)
}
type SetupState = (
@@ -447,29 +604,108 @@ mod tests {
}
}
#[tokio::test(flavor = "current_thread")]
async fn initial_input_handoff_discards_only_events_already_ready() {
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn initial_input_handoff_waits_for_legacy_repeats_to_settle() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream_discarding_initial_input(broker, draw_rx, terminal_focused);
let mut stream = make_stream_filtering_initial_input(
broker,
draw_rx,
terminal_focused,
InitialInputPolicy::DiscardAll,
/*start_quiet*/ false,
/*pending_interrupt*/ false,
);
handle.send(Ok(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
))));
assert!(
timeout(Duration::from_millis(10), stream.next())
timeout(Duration::from_nanos(1), stream.next())
.await
.is_err()
);
tokio::time::advance(STARTUP_INPUT_QUIET_PERIOD / 2).await;
handle.send(Ok(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
))));
assert!(
timeout(Duration::from_nanos(1), stream.next())
.await
.is_err()
);
tokio::time::advance(STARTUP_INPUT_QUIET_PERIOD).await;
assert!(
timeout(Duration::from_nanos(1), stream.next())
.await
.is_err()
);
let expected = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
handle.send(Ok(Event::Key(expected)));
match timeout(Duration::from_secs(1), stream.next()).await {
match timeout(Duration::from_nanos(1), stream.next()).await {
Ok(Some(TuiEvent::Key(actual))) => assert_eq!(actual, expected),
other => panic!("expected post-handoff key event, saw {other:?}"),
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn startup_handoff_preserves_text_while_suppressing_actions() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream_filtering_initial_input(
broker,
draw_rx,
terminal_focused,
InitialInputPolicy::PreserveText,
/*start_quiet*/ true,
/*pending_interrupt*/ false,
);
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
handle.send(Ok(Event::Key(key)));
assert!(matches!(stream.next().await, Some(TuiEvent::Key(actual)) if actual == key));
handle.send(Ok(Event::Paste("b\nc".to_string())));
assert!(matches!(stream.next().await, Some(TuiEvent::Paste(text)) if text == "b\nc"));
let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
handle.send(Ok(Event::Key(backspace)));
assert!(matches!(stream.next().await, Some(TuiEvent::Key(actual)) if actual == backspace));
handle.send(Ok(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
))));
assert!(
timeout(Duration::from_nanos(1), stream.next())
.await
.is_err()
);
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn startup_handoff_forwards_interrupt_only_once() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream_filtering_initial_input(
broker,
draw_rx,
terminal_focused,
InitialInputPolicy::DiscardAll,
/*start_quiet*/ true,
/*pending_interrupt*/ true,
);
let interrupt = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(matches!(stream.next().await, Some(TuiEvent::Key(actual)) if actual == interrupt));
handle.send(Ok(Event::Key(interrupt)));
assert!(
timeout(Duration::from_nanos(1), stream.next())
.await
.is_err()
);
}
#[tokio::test(flavor = "current_thread")]
async fn draw_and_key_events_yield_both() {
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();
+62 -7
View File
@@ -41,6 +41,17 @@ pub(crate) struct StartupInputBuffer {
text: String,
char_count: usize,
pending_plain_whitespace: String,
suppressed_action: bool,
interrupt_requested: bool,
suspend_requested: bool,
}
#[derive(Default)]
pub(super) struct StartupInputHandoff {
pub(super) had_input: bool,
pub(super) suppress_repeats: bool,
pub(super) interrupt_requested: bool,
pub(super) suspend_requested: bool,
}
impl StartupInputBuffer {
@@ -52,15 +63,33 @@ impl StartupInputBuffer {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
self.interrupt_requested = true;
return;
}
if code == KeyCode::Char('z') && modifiers.contains(KeyModifiers::CONTROL) {
self.suspend_requested = true;
return;
}
if modifiers.difference(KeyModifiers::SHIFT).is_empty() {
match code {
KeyCode::Char(ch) if !ch.is_control() => self.push_plain_char(ch),
KeyCode::Backspace => self.pop_char(),
KeyCode::Enter => self.push_pending_plain_whitespace('\n'),
KeyCode::Tab => self.push_pending_plain_whitespace('\t'),
_ => self.pending_plain_whitespace.clear(),
KeyCode::Enter => {
self.suppressed_action = true;
self.push_pending_plain_whitespace('\n');
}
KeyCode::Tab => {
self.suppressed_action = true;
self.push_pending_plain_whitespace('\t');
}
_ => {
self.suppressed_action = true;
self.pending_plain_whitespace.clear();
}
}
} else {
self.suppressed_action = true;
self.pending_plain_whitespace.clear();
}
}
@@ -129,14 +158,23 @@ impl StartupInputBuffer {
while let Some(ch) = chars.next() {
match ch {
'\u{8}' | '\u{7f}' => self.pop_char(),
'\u{3}' => self.interrupt_requested = true,
'\u{1a}' => self.suspend_requested = true,
'\r' => {
self.suppressed_action = true;
if chars.peek() == Some(&'\n') {
chars.next();
}
self.push_pending_plain_whitespace('\n');
}
'\n' => self.push_pending_plain_whitespace('\n'),
'\t' => self.push_pending_plain_whitespace('\t'),
'\n' => {
self.suppressed_action = true;
self.push_pending_plain_whitespace('\n');
}
'\t' => {
self.suppressed_action = true;
self.push_pending_plain_whitespace('\t');
}
ch if !ch.is_control() => self.push_plain_char(ch),
_ => {}
}
@@ -157,8 +195,25 @@ impl StartupInputBuffer {
}
}
pub(super) fn into_text(self) -> Option<String> {
(!self.text.is_empty()).then_some(self.text)
pub(super) fn take_text(&mut self) -> Option<String> {
self.char_count = 0;
(!self.text.is_empty()).then(|| std::mem::take(&mut self.text))
}
pub(super) fn into_handoff(self) -> StartupInputHandoff {
let had_input = self.char_count > 0
|| !self.pending_plain_whitespace.is_empty()
|| self.suppressed_action
|| self.interrupt_requested
|| self.suspend_requested;
StartupInputHandoff {
had_input,
suppress_repeats: self.suppressed_action
|| self.interrupt_requested
|| self.suspend_requested,
interrupt_requested: self.interrupt_requested,
suspend_requested: self.suspend_requested,
}
}
}
+34 -6
View File
@@ -28,7 +28,10 @@ fn startup_input_keeps_text_without_replaying_actions() {
input.handle_event(event);
}
assert_eq!(input.into_text(), Some("hello\n\tworld".to_string()));
assert_eq!(input.take_text(), Some("hello\n\tworld".to_string()));
let handoff = input.into_handoff();
assert!(handoff.interrupt_requested);
assert!(handoff.suppress_repeats);
}
#[test]
@@ -45,7 +48,7 @@ fn startup_input_preserves_internal_plain_whitespace_but_drops_trailing_actions(
input.handle_event(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
}
assert_eq!(input.into_text(), Some("a\nb\tc".to_string()));
assert_eq!(input.take_text(), Some("a\nb\tc".to_string()));
}
#[test]
@@ -58,7 +61,7 @@ fn startup_probe_input_preserves_internal_plain_whitespace_across_phases() {
)));
input.handle_probe_input(b"\t");
assert_eq!(input.into_text(), Some("a\nb".to_string()));
assert_eq!(input.take_text(), Some("a\nb".to_string()));
}
#[cfg(unix)]
@@ -69,7 +72,7 @@ fn startup_probe_preserves_bracketed_paste_whitespace() {
b"a\r\n\t".to_vec(),
)]);
assert_eq!(input.into_text(), Some("a\n\t".to_string()));
assert_eq!(input.take_text(), Some("a\n\t".to_string()));
}
#[test]
@@ -77,7 +80,7 @@ fn startup_input_is_bounded() {
let mut input = StartupInputBuffer::default();
input.handle_event(Event::Paste("x".repeat(MAX_STARTUP_INPUT_CHARS + 1)));
assert_eq!(input.into_text(), Some("x".repeat(MAX_STARTUP_INPUT_CHARS)));
assert_eq!(input.take_text(), Some("x".repeat(MAX_STARTUP_INPUT_CHARS)));
}
#[test]
@@ -91,5 +94,30 @@ fn startup_input_applies_edits_across_capture_phases() {
KeyModifiers::NONE,
)));
assert_eq!(input.into_text(), Some("drap".to_string()));
assert_eq!(input.take_text(), Some("drap".to_string()));
}
#[test]
fn startup_probe_controls_survive_text_extraction() {
let mut input = StartupInputBuffer::default();
input.handle_probe_input(b"draft\x03\x1a");
assert_eq!(input.take_text(), Some("draft".to_string()));
let handoff = input.into_handoff();
assert!(handoff.interrupt_requested);
assert!(handoff.suspend_requested);
}
#[cfg(unix)]
#[test]
fn startup_paste_control_bytes_are_not_replayed_as_actions() {
let mut input = StartupInputBuffer::default();
input.handle_startup_probe_input(&[crate::terminal_probe::StartupInput::Paste(
b"a\x03\x1ab".to_vec(),
)]);
assert_eq!(input.take_text(), Some("ab".to_string()));
let handoff = input.into_handoff();
assert!(!handoff.interrupt_requested);
assert!(!handoff.suspend_requested);
}