Skip to content

Writing supervised tasks

Concepts

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.
  1. 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.
  2. 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 control Activate. Shells generated by task: do this automatically after the worker returns.
  3. Resources follow the mode. A Terminate task re-acquires everything on respawn (drop-on-exit is the cleanup). A Pause task keeps what it holds across park and resume, and never re-acquires.
methodrole
run_cancellable_acked(fut).awaitthe everyday body: race fut against shutdown and complete the handshake on cancel
run_cancellable(fut).awaitsame race, no ack: run cleanup between cancellation and your own ack_dropped()
run_pausable(fut).awaitPause 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).awaitthe whole Pause protocol in one call: rebuilds body (an async closure) every cycle, never returns
wait_shutdown().awaitthe 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().awaitPause 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).awaitfeature 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).awaitfeature 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.

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

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:

methodtrue 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.

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.