Async, Tasks & Subscriptions #

All async work enters the app the same way everything else does: as messages through update.

Spawning work #

// A stream of messages — the LLM-turn shape:
self.request = Some(ctx.spawn(chat_stream(prompt)));
// A one-shot future:
ctx.perform(async move { Msg::Loaded(fetch().await) }).detach();

ctx.spawn takes a Stream<Item = Msg>; each item is fed back into update. ctx.perform is the one-shot convenience. Both return a [Task].

Cancellation is drop #

A Task cancels its work when dropped. Held in the model, this turns cancellation into ordinary state manipulation:

struct Model { request: Option<Task>, /* … */ }
// Esc cancels the stream — that's the whole implementation:
Msg::Cancel => self.request = None,

No cancellation tokens, no flags, no channels. Replacing the Task with a new one cancels the old work the same way, which also closes a subtle bug class: a replaced request can't finish later and clobber shared state, because it was dropped at whatever await point it had reached.

Interrupt is not cancel. Some work must report its outcome even when the user stops it — a shell command whose exit status the app records, for example. Spawn that work with .detach() (it outlives its handle) and deliver the interrupt through the work's own channel; the final outcome-message still arrives.

Staleness #

Cancellation is prompt but asynchronous: a message the work had already queued can still be delivered after you dropped its Task. Treat validity as a property of the model, not of the channel:

Msg::Chunk(delta) => {
if self.request.is_some() { // still streaming?
self.reply.push_str(&delta);
} // otherwise: stale, ignore
}

Durable work #

Cancel-on-drop has an inverse hazard: work that must not die with the app. A delete the user confirmed, a file save, a config write — if the run loop exits while that future is in flight, a detached task dies with the process and the write is silently lost.

ctx.persist is perform + detach for exactly that work: no Task is returned (work that must complete is uncancellable by definition), and the driver waits for every persist future at teardown, before the run loop returns:

Msg::Delete(entry) => {
self.entries.remove(&entry); // optimistic UI
let store = self.store.clone();
ctx.persist(async move {
store.delete(entry).await; // survives an immediate exit
Msg::DeleteDone
});
}

The message still delivers normally while the app runs, and is dropped after exit. By default the teardown wait is unbounded; cap it with RunOptions::persist_grace when a wedged resource must not hang the exit. Custom drivers get the same via Runtime::persists() — spawn the final effects, then .wait().await the tracker.

Handing values to spawned work #

Moving a value into a spawned task couples its delivery to that task's survival: if a keystroke replaces (cancels) the task before it runs, the value is dropped with it. The classic symptom is a mode switch the UI shows but the backend never received.

Mailbox<T> decouples them — a shared slot where update posts and whichever task actually runs takes:

struct Model { pending_engine: Mailbox<Engine>, /* … */ }
// update: park the swap; any future query installs it.
Msg::CycleMode => self.pending_engine.post(new_engine),
// inside the spawned query, under whatever lock guards the backend:
if let Some(engine) = pending_engine.take() {
backend.engine = engine;
}

A cancelled task never reaches take, so the value simply waits for the next one. Posting replaces an undelivered value — the slot is latest-wins state, not a queue.

Subscriptions #

Recurring inputs are declared, not managed. After every update the driver diffs what you declare against what's running — new keys start, missing keys cancel, changed intervals restart:

fn subscriptions(&self) -> Subscriptions<Msg> {
Subscriptions::new()
.when(self.session_active, |s| {
s.every("poll", Duration::from_secs(30), || Msg::Poll)
})
.stream("fs-events", || watch_files())
}

"Poll while a session is active" is a when on model state; stopping the poll is not declaring it. The same staleness note applies: one queued tick may arrive after a subscription is removed.

If you came from Elm before 0.17: subscriptions are what signals became — declarative descriptions of external input, derived from the model, with the runtime owning the plumbing.

Drivers #

driver_tokio::run(app) (feature tokio, default) multiplexes terminal events, task messages, animation ticks, and subscriptions. It batches: when a burst of messages is queued — a fast LLM stream — they're processed as one batch and presented as one frame, so you never need to debounce streams yourself.

The synchronous run(app) drives keyboard-only apps with no executor; it rejects apps that spawn or subscribe, with an error saying to use the tokio driver. Custom drivers build on Runtime — see testing, which uses the same surface.

Startup work #

App::init runs once before the first frame — push a banner, spawn an initial request, process a command-line prompt:

fn init(&mut self, ctx: &mut Ctx<'_, Self>) {
ctx.push(welcome_banner());
if let Some(prompt) = self.initial_prompt.take() {
self.submit(prompt, ctx);
}
}