Skip to content

Cancellation

Request cooperative shutdown, understand when it takes effect, and get cleanup ordering right.

Updated View as Markdown

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 reclaimable

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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close