The event loop
Understanding the runtime's loop is the key to reasoning about performance, ordering, and concurrency in a TUI app. This page walks through one tick of the loop end-to-end.
The tick
Each tick performs render → drain → poll. Under no load, the loop
blocks in poll for up to one frame (16 ms by default), then unblocks on
either an event or the timeout — yielding a steady ~60 FPS cadence even
when idle, so Subscription.intervals fire on time.
Frame budget
The default budget is 16 ms (~60 FPS). To choose a different rate, you can
(currently) only adjust by wrapping the app and replacing the poll timeout;
the next release will expose this via AppOptions passed to run.
Message priority
Inside a single tick, the drain runs before the event poll. This means
messages produced by Command.Async or subscriptions land first and can
change model state before the user's next keypress is processed. Drain is
bounded (default 64) so even a badly-behaved stream can't starve input.
Async commands — round trip
- spawn_detached from
core.async.taskputs the future on the executor; the task is governed by the sameCancellationTokenas the app loop. try_sendis lock-free in the hot path (single push onto an MPSC queue- wake).
- If the app is quitting, the token flips to cancelled and the task returns early — no message is delivered to a torn-down channel.
Subscriptions — round trip
Each Subscription variant hoists to one detached task on startup:
| Variant | Task body |
|---|---|
Interval(d, f) | loop { sleep(d).await; tx.try_send(f()) } |
Every(d, f) | loop { sleep(d).await; tx.try_send(f(Instant.now())) } |
Once(d, f) | sleep(d).await; tx.try_send(f()) |
StreamSub(s) | async for x in s { tx.try_send(x) } |
Batch([s…]) | spawn one detached task per nested subscription |
All tasks check cancel.is_cancelled() around every try_send, so a Quit
or Ctrl+C tears them down cleanly.
Ordering guarantees
- Commands produced from a single
updatecall are dispatched in the order you wrote them insideBatch/Sequence. Sequence(a, b)guaranteesacompletes beforebstarts.Batch(a, b)makes no ordering guarantee; interleaving is arbitrary.- Inside a tick, messages drain FIFO from the channel.
Global hotkeys
Regardless of handle_event, the runtime intercepts:
Ctrl+C→ graceful quit (like typing SIGINT).- (Planned)
Ctrl+Z→ suspend / resume with SIGTSTP/SIGCONT cooperation.
You can disable this by overriding handle_event to match Ctrl+C first
and swallow it — the runtime checks happen before handle_event, so it
is not possible to prevent Ctrl+C from quitting through handle_event
alone. A future option AppOptions { intercept_ctrl_c: false } will
expose this.
Backpressure
The default channel is unbounded — fast async producers can outrun the
loop's drain. If this is a concern, wrap your producers in the
throttle/debounce combinators from core.async.timer:
Subscription.from_stream(Heap(
raw_stream.throttle(Duration.from_millis(50))
))