Structured Task Ownership and Cancellation
Users disconnect, deadlines expire, providers stall, deployments shut down, and policies change. Continuing to spend model/GPU/tool resources after a result is unwanted is a correctness bug.
Structured task ownership means a parent knows its children and does not declare completion while they remain unobserved.
Learning objectives
Section titled “Learning objectives”- own every spawned child through a handle/supervisor;
- distinguish dropping futures, aborting tasks, and cooperative cancellation;
- propagate cancellation through a cloneable token;
- use
select!while defining cleanup and race semantics; - join children and classify panics/cancellation;
- design cancellation-safe steps and external-effect receipts;
- test cancellation deterministically;
- run the companion cancellation example.
Dependency and mental model
Section titled “Dependency and mental model”This chapter depends on futures/tasks and ownership. It precedes channels/backpressure, durable jobs, SSE disconnects, and graceful shutdown.
parent run├── evidence fetch├── provider call└── tool child cancellation flows downward outcomes/errors flow upwardparent terminal event only after owned children settleCancellation mechanisms
Section titled “Cancellation mechanisms”Drop a future
Section titled “Drop a future”When a future is dropped, it is no longer polled. This is immediate locally but may leave remote effects/work.
Abort a spawned task
Section titled “Abort a spawned task”JoinHandle::abort requests task cancellation at the next await/yield boundary. Destructors of live
state run, but synchronous code cannot be interrupted safely mid-instruction.
Cooperative token
Section titled “Cooperative token”The companion Cancellation uses a Tokio watch channel:
tokio::select! { result = operation => result, _ = cancellation.cancelled() => Err(RunError::Cancelled),}Children periodically await/select the token and perform explicit cleanup.
Use cooperative cancellation for semantic cleanup. Retain abort as a bounded shutdown fallback.
The companion token
Section titled “The companion token”#[derive(Clone)]pub struct Cancellation { sender: watch::Sender<bool>,}cancel stores true; new subscribers observe already-issued cancellation. This avoids a lost
one-shot notification:
pub async fn cancelled(&self) { let mut receiver = self.sender.subscribe(); if *receiver.borrow_and_update() { return; } // wait for change...}Cancellation is idempotent. It carries no reason yet; production tokens may pair a stable reason with the signal in run state/trace.
select! races need policy
Section titled “select! races need policy”If completion and cancellation become ready together, branch selection may vary unless biased/order behavior is specified. Decide whether:
- completed durable result wins;
- cancellation wins before committing;
- operation commit is reconciled by state/version;
- terminal event is derived from persisted truth.
Do not infer “the cancellation branch ran” means the provider/tool had no effect.
For side effects:
- assign idempotency key;
- record intent;
- invoke;
- record receipt/unknown outcome;
- reconcile after cancellation/crash.
Join every child
Section titled “Join every child”let child = tokio::spawn(work(token.clone()));// ...token.cancel();match child.await { Ok(outcome) => { /* observed */ } Err(join_error) if join_error.is_cancelled() => { /* aborted */ } Err(join_error) if join_error.is_panic() => { /* defect */ } Err(join_error) => { /* classify */ }}A detached task can outlive tenant/request authorization, retain large Arcs, and emit events after
terminal status.
For multiple children use a task set/supervisor that:
- assigns child IDs;
- cancels siblings under policy;
- drains every join result;
- bounds child count;
- records panic/cancellation;
- has shutdown deadline.
Cancellation-safe state transitions
Section titled “Cancellation-safe state transitions”Avoid marking a run completed before trace/output durability:
generate → verify → persist artifact → append completion → publish terminalIf cancellation happens between persist and terminal event, recovery must discover the artifact and finish/reconcile idempotently.
Cleanup should itself have a deadline. A stuck cleanup cannot block deployment forever. Escalate from cooperative cancellation to abort/process kill while recording incomplete cleanup.
Provider/tool/media specifics
Section titled “Provider/tool/media specifics”- HTTP request drop may close the connection but provider compute/billing can continue.
- Subprocess cancellation must signal, wait, then force kill; drain pipes to avoid deadlock.
- GPU kernels may continue after host task cancellation.
spawn_blockingwork generally cannot be forcibly stopped once running; use cooperative flags or isolate in a process.- database transaction/drop semantics depend on driver; test rollback.
- partial files need temp names and atomic publish after verification.
Deterministic cancellation tests
Section titled “Deterministic cancellation tests”Do not use arbitrary sleeps. Synchronize the test:
- child signals “started” through oneshot;
- parent sends cancellation;
- child observes token and returns;
- parent awaits join;
- assert no later events/effects.
The example and test follow this pattern.
Runnable companion implementation
Section titled “Runnable companion implementation”cd rust/rust-ai-engineeringcargo run -q -p mosaic-runtime --example structured_cancellationcargo test -p mosaic-runtime --examplesExpected:
worker: cancelledexamples/structured_cancellation.rs owns the JoinHandle, waits for start, cancels, and joins.
Failure investigation: run is cancelled but model calls continue
Section titled “Failure investigation: run is cancelled but model calls continue”Check:
- cancellation reaches the provider future/task;
- spawned handles are retained;
- SDK request is cancellation-safe;
- retries stop after cancellation;
- remote cancellation API exists;
- billing/receipts show post-cancel work;
- terminal event occurs after child drainage.
If remote compute cannot cancel, present cancellation as “result no longer awaited” and account for possible cost honestly.
Security and performance implications
Section titled “Security and performance implications”Security:
- cancellation must not bypass audit/receipt persistence;
- authorization revocation may require cancelling pending tools;
- clean partial sensitive artifacts;
- cap cleanup time;
- process-isolate unsafe native work when abort is insufficient.
Performance:
- cooperative checks have low cost;
- too-frequent polling can add overhead;
- cancellation storms can overload cleanup/storage;
- detached tasks create hidden resource tails;
- join supervision consumes bounded state per child.
Exercises
Section titled “Exercises”- Add cancellation reasons and terminal trace event.
- Supervise three children; cancel siblings on required-child failure.
- Add cleanup deadline then abort.
- Test cancellation before start, during wait, after durable commit.
- Design subprocess cancellation with SIGTERM/kill equivalents.
Solution guidance
Section titled “Solution guidance”Store reason in run state, not only the broadcast token. Drain all join results even after one failure. Use synchronization points, not sleep. Treat post-commit cancellation as reconciliation, not rollback fiction.
Check your understanding
Section titled “Check your understanding”- Does dropping a
JoinHandlecancel its task? - Can
spawn_blockingbe forcibly interrupted safely? - What if completion and cancellation are simultaneously ready?
- Why join after cancel?
- How do idempotency receipts help an unknown outcome?
Completion checklist
Section titled “Completion checklist”- Every child has an owner.
- Cancellation flows down and outcomes flow up.
- Cleanup and shutdown are bounded.
- External effects use idempotency/receipts.
- Tests synchronize deterministically.
- I ran the cancellation example/test.