Writing supervised tasks
Concepts
Writing supervised tasks
Section titled “Writing supervised tasks”A supervised worker is an async fn whose first parameter is its node. The
node is the task’s half of the lifecycle protocol, and three rules cover all
of it.
sequenceDiagram accDescr: One task body from spawn through readiness to the shutdown ack autonumber participant S as Supervisor participant T as Task body S->>T: spawn T->>T: init, set_ready() loop while running T->>T: run_cancellable_acked(work) end S->>T: shutdown requested T-->>S: ack (combinator did it) Note over T: Terminate: return. Pause: park.- Select your work against shutdown at every await point that can block
indefinitely, so a stop can reach you. The
run_cancellable*combinators do this for you. - An autonomous exit calls
mark_exited(), which acks and records the completion, so a worker that returns on its own reads as down and can be respawned by a controlActivate. Shells generated bytask:do this automatically after the worker returns. - Resources follow the mode. A
Terminatetask re-acquires everything on respawn (drop-on-exit is the cleanup). APausetask keeps what it holds across park and resume, and never re-acquires.
The task-side methods
Section titled “The task-side methods”| method | role |
|---|---|
run_cancellable_acked(fut).await | the everyday body: race fut against shutdown and complete the handshake on cancel |
run_cancellable(fut).await | same race, no ack: run cleanup between cancellation and your own ack_dropped() |
run_pausable(fut).await | Pause bodies: same race, and on a pause it acks, parks, and returns Err(Resumed) only after the resume; the loop body is the fresh cycle |
run_pausable_loop(body).await | the whole Pause protocol in one call: rebuilds body (an async closure) every cycle, never returns |
wait_shutdown().await | the primitive: park until a stop or pause is requested |
ack_dropped() | complete the handshake: clears running, wakes the supervisor’s wait. task: shells call this on return; run_cancellable_acked calls it on Err(Aborted); hand-written spawn: tasks call it themselves |
mark_exited() | ack plus record the completion (has_exited()) |
wait_resume().await | Pause only: park (after acking) until resumed |
mark_busy() / mark_idle() | pool load reporting; a real transition fires the scale signal itself |
shutdown_requested() | synchronous check, for example at the top of a loop |
has_exited() | true once the last instance’s body returned; cleared by the next spawn’s reset |
set_detached(true) | become self-managed from here on |
adopt(&token) | parked nodes: register a hand-spawned task for trace attribution |
open(&SIG).await | feature data-deps: run the signal’s gate (start its producer if Backed) and hand back the counted Open guard; Gated reads has the contract |
retire(&SIG, cooldown).await | feature data-deps: resolve once the signal’s readers have been gone a whole cooldown, then withdraw readiness and request the node’s own Deactivate |
veto(&SIG) | feature veto: this writer’s bit of a VetoGate; the gate latches until every contributor releases |
The cancellable combinators return Result<F::Output, Aborted> and the
pausable one Result<F::Output, Resumed>. Discarding the result
of run_cancellable_acked is fine: the ack already happened.
The canonical loops
Section titled “The canonical loops”Terminate / OnDemand worker. Acquire, then serve; respawn re-acquires:
async fn worker_task(node: &'static TaskNode) { let mut conn = acquire().await; loop { match node.run_cancellable_acked(conn.serve()).await { Ok(response) => handle(response), Err(_aborted) => return, // acked; drop(conn) is the cleanup } }}Use bare run_cancellable when cleanup must run between the cancellation and
the ack: flush, unpublish, bracket a busy/idle section.
Pause node. Ack, then park; the held resource survives. run_pausable_loop
owns the whole protocol:
async fn sensor_task(node: &'static TaskNode) { let mut bus = init_once().await; // kept across pause/resume node.run_pausable_loop(async || { let v = sample(&mut bus).await; // raced against the pause publish(v); }) .await // acks, parks, resumes inside; never returns}Per-cycle run_pausable is the same minus the loop, for a body with its own
control flow between cycles. When cleanup must run between the cancellation
and the ack, spell the tail out: run_cancellable, the cleanup,
ack_dropped(), wait_resume().await.
Pool worker. Same as Terminate, plus load reporting around the busy section:
loop { match node.run_cancellable(socket.accept(PORT)).await { Err(_aborted) => return, // idle here: nothing to bracket Ok(conn) => { node.mark_busy(); // idle->busy signals the pool let served = node.run_cancellable(serve(conn)).await; node.mark_idle(); // busy->idle signals again if served.is_err() { return; } // bracketed; the shell acks } }}Hold mark_busy() for the whole session your resource is tied up; the policy
only shrinks non-busy members.
Detached daemon. Detach as the first act, then own your lifecycle:
async fn power_task(node: &'static TaskNode, spawner: Spawner) { node.set_detached(true); // the supervisor is hands-off from here loop { /* drive sleep/wake yourself */ }}Parked node. Declared with neither task: nor spawn:; the app spawns
it because only it has the values. Keep trace attribution with adopt:
let token = pump_task(&PUMP, hw_handle).unwrap();PUMP.adopt(&token);spawner.spawn(token);Status anyone can read
Section titled “Status anyone can read”The same node is a free-standing health handle. Any code that can see the
static, including ISRs and driver callbacks, can call NODE.beat() or
NODE.report_status(..); monitors and status endpoints read:
| method | true when |
|---|---|
is_running() | an instance is up (spawned, not acked or exited) |
is_busy() | the instance reported mark_busy() |
is_disabled() | stopped at boot or deactivated directly (the Deactivate seed) |
is_collateral() | stopped as a dependent of a deactivated node; Activate on the ancestor releases it |
is_detached() | self-managed; lifecycle ops skip it |
has_exited() | the last instance’s body returned |
shutdown_requested() | a stop or pause was requested |
is_ready() | the task asserted readiness (feature readiness) |
is_stale(max_age) | running but no heartbeat within max_age (feature liveness) |
Compositions worth knowing: down is !is_running(); a parked Pause
node is mode Pause plus !is_running() plus shutdown_requested(); an
autonomous completion is has_exited() without shutdown_requested().
Ordering guarantees: when stop_node, teardown or deactivate return
Ok, the ack has happened and is_running() is already false. For bodies
that ack by returning, has_exited() is true in the same poll.
Special case: workers that never return
Section titled “Special case: workers that never return”A worker typed -> ! cannot return, so stop-and-respawn semantics are inert
on it by type. Give the node cancel and the generated shell races the body
against shutdown for you, or keep the node argument and race the work
yourself with one run_cancellable call.
Lifecycle and modes shows what the supervisor does to each of these loops, operation by operation.