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
}
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);
}
}