cancel() asks a child task to stop. It returns void, it is idempotent, and it
is a no-op on a task that already completed.
fn watch_changes(): int {
println("watching")
return 0
}
fn should_stop(): bool {
return true
}
fn supervise(): void {
task := spawn { watch_changes() }
if should_stop() {
task.cancel()
}
}Cancellation is a request, not a join. The call does not wait for the child
to finish unwinding and does not produce the child’s T. Lifetime and result
handling are separate decisions.
The timeline
parent calls cancel()
↓
the request is recorded for the child's whole subtree
↓
each running descendant reaches a cancellation-aware boundary
↓
its continuation stops and its owned frame unwinds
↓
defers, Drop bodies, capture releases, descendant cleanup run
↓
the task state becomes reclaimableEvery one of those steps happens after your cancel() returned. That gap is
exactly why cancellation cannot serve as a lock, a revocation, or a database
rollback: by the time it takes effect, the child may already have done the thing
you wanted to prevent.
Delivery is cooperative
The scheduler and generated safepoints observe the request. A child is not interrupted at an arbitrary machine instruction — it stops when it next reaches a scheduler boundary, a host call, or a loop safepoint.
That is what preserves compiler-managed cleanup: captured managed values and frame locals are dropped along the cancellation path instead of being abandoned.
struct Conn {
fd: int
}
impl Drop for Conn {
fn drop(self): void {
println("closing fd ${self.fd}")
}
}
fn connect(addr: string): Conn {
return Conn { fd: addr.len() }
}
fn serve(conn: Conn): int {
println("serving on ${conn.fd}")
return conn.fd
}
fn run(): void {
task := spawn {
conn := connect("127.0.0.1:8080")
serve(conn)
}
task.cancel()
}Conn.drop runs on the cancellation path just as it would on the normal return
path. Cleanup is synchronous: a drop body cannot itself suspend.
defer covers the case where the cleanup is a statement rather than a type’s
responsibility:
fn open_slot(): int {
println("open")
return 7
}
fn close_slot(slot: int): void {
println("close ${slot}")
}
fn work(slot: int): int {
return slot
}
fn run(): void {
task := spawn {
slot := open_slot()
defer close_slot(slot)
work(slot)
}
task.cancel()
}The defer owns release for the child’s path. Parent code should not separately
close a child-owned handle unless the API explicitly shares that ownership —
otherwise you get a double release the moment cancellation and normal completion
race.
Descendants
Tasks form a parent/child tree, and cancellation walks it. Cancelling a task also stops its subtree, even when the intermediate handles were discarded.
fn leaf(n: int): int {
println("leaf ${n}")
return n
}
fn run(): void {
outer := spawn {
spawn { leaf(1) }
spawn { leaf(2) }
leaf(0)
}
outer.cancel()
}The two inner spawns have no handles a parent could reach, but they are still in
the tree, so outer.cancel() reaches them. An explicit child cancellation and an
ancestor cancellation converge on the same subtree model; do not write code that
depends on observing which ancestor requested the stop.
Race cancels; select does not
race cancels the losing arms automatically — that is the reason to use it.
fn fast(): int { return 1 }
fn slow(): int { println("slow") return 2 }
fn first(): int {
return race {
fast()
slow()
}
}select consumes the winning source and leaves everything else running and
owned by you. Track which arm won, then cancel or await only the sources you
still hold. The winning handle is already consumed and must not be touched again.
fn fetch_left(): int { return 1 }
fn fetch_right(): int { return 2 }
fn use_value(v: int): void { println("got ${v}") }
fn pick(): void {
left := spawn { fetch_left() }
right := spawn { fetch_right() }
mut left_won := false
select {
v := left => {
left_won = true
use_value(v)
}
v := right => use_value(v)
}
if left_won {
right.cancel()
} else {
left.cancel()
}
}Shutting down a stream producer
For a producer feeding a Stream[T], closing and cancelling do different jobs,
and which you want depends on where the producer is parked.
fn produce(out: Stream[int], count: int): void {
mut i := 0
for i < count {
if !out.send_await(i) {
return
}
i = i + 1
}
out.close()
}
fn consumer_stopped(): bool {
return true
}
fn run(): void {
events := Stream.new[int](2)
producer := spawn { produce(events, 1000) }
if consumer_stopped() {
events.close()
producer.cancel()
}
}close() wakes a producer parked on backpressure: send_await returns false
and the loop exits through the producer’s own return, which is the cleanest
possible stop. cancel() covers the case where the child is parked on something
else entirely — a host call, a nested join. Doing both is belt and braces, and it
is cheap because cancel() on a finished task does nothing.
The ordering matters. Closing first gives the producer a chance to stop through its normal path; cancelling first forces an unwind from wherever it happens to be.
Cancellation is not an error channel
await() does not return a Cancelled variant, and nothing is injected into T.
error TaskError {
Cancelled
Backend
}
fn work(n: int): int ! TaskError {
if n < 0 { error Backend }
return n
}
fn run(n: int): int {
task := spawn { work(n) }
return task.await().unwrap_or(-1)
}That Cancelled variant is an ordinary application error the code above never
produces. Define one only when task lifetime genuinely belongs to your domain
contract — otherwise the type invites callers to handle a case that never occurs.
Cleanup rules
Cancellation can race with normal completion and with an explicit close. Three
rules follow:
Make cleanup idempotent. Normal completion, an early error, an explicit cancel, and parent shutdown may all converge on the same resource boundary.
struct Session {
id: int
closed: bool
}
impl Session {
fn release(mut self): void {
if self.closed {
return
}
self.closed = true
println("released session ${self.id}")
}
}
fn open(id: int): Session {
return Session { id: id, closed: false }
}
fn run(id: int): void {
task := spawn {
mut session := open(id)
defer session.release()
println("using session ${session.id}")
}
task.cancel()
}Never put commit logic only in cleanup. Cancellation cleanup should release or roll back owned state. Successful publication belongs on the normal completion path, where you know the work actually finished.
Do not let cleanup depend on suspension. Drop bodies and defers run synchronously, so a “flush to the network before closing” step cannot live there. Flush on the normal path and treat cleanup as a local release.
Effects
Task.cancel() contributes Cancel. race contributes Spawn, Suspend, and
Cancel.
select also picks up a conservative Cancel from semantic lowering even though
it cancels nothing at runtime. That is an analysis over-approximation, not a
promise of loser cancellation — see Effects.
A composed example
A worker that can be stopped from two directions: an explicit shutdown signal, or the supervisor cancelling it outright.
struct Job {
id: int
weight: int
}
struct Outcome {
completed: int
weight: int
}
fn process(job: Job): int {
println("processing job ${job.id}")
return job.weight
}
fn worker(queue: Stream[Job]): Outcome {
mut completed := 0
mut weight := 0
for Some(job) := queue.recv() {
weight = weight + process(job)
completed = completed + 1
}
return Outcome { completed: completed, weight: weight }
}
fn enqueue(queue: Stream[Job], jobs: int): void {
mut i := 1
for i <= jobs {
if !queue.send_await(Job { id: i, weight: i * 2 }) {
return
}
i = i + 1
}
queue.close()
}
fn supervise(jobs: int, abort: bool): Outcome {
queue := Stream.new[Job](4)
feeder := spawn { enqueue(queue, jobs) }
runner := spawn { worker(queue) }
if abort {
// Close first so the feeder stops through its own `send_await`
// check, then cancel whatever is still parked elsewhere.
queue.close()
feeder.cancel()
runner.cancel()
return Outcome { completed: 0, weight: 0 }
}
feeder.await()
return runner.await()
}Both children get an outcome on both paths: joined on the normal path, closed then cancelled on the abort path. No handle is left without a decision.