Give work a deadline (timeouts and cancellation)
There is no timeout function in the standard library. A deadline is built from
two things you already have: a fiber, and a clock.
module deadline::main;
import std::core::{print, Fiber, Nursery, nursery};import std::clock::{Clock};
pub type Timeout = | TookTooLong;
/// The work. It never finishes on its own.fn worker() -> () with { clock: Clock } raises Timeout { loop { clock.sleep(50)!; }}
fn fan_out() -> () with { nursery: Nursery, clock: Clock } { nursery.adopt(Fiber::spawn(fn () => worker()!)); print(" worker adopted");}
/// Runs the fan-out in its own fiber, and gives up on it after `millis`.fn with_deadline(millis: Int) -> () with { clock: Clock } raises Timeout { let hand = Fiber::spawn(fn () => { nursery(fn () => fan_out())!; () });
clock.sleep(millis)!; print(" deadline reached; cancelling"); Fiber::cancel(hand); Fiber::wait(hand)! catch { ChildFailed => () }; print(" cancelled, and the wait returned"); raise Timeout::TookTooLong}
pub fn main() -> Int { with { clock: Clock::real() } { print("starting work with a 300ms deadline"); with_deadline(300)! catch { Timeout::TookTooLong => print("gave up: took too long") }; }; 0}starting work with a 300ms deadline worker adopted deadline reached; cancelling cancelled, and the wait returnedgave up: took too longThe whole program takes 308 ms for a 300 ms deadline.
The shape is: put the work in a fiber, sleep for the deadline, cancel the
handle, wait for it, then raise. wait rather than join, because a
cancelled fiber has no answer to take.
When the work finishing early has to be fast
Section titled “When the work finishing early has to be fast”That recipe always takes the whole deadline. The timing fiber sleeps the full interval whether the work finished in 2 ms or not at all, which is right when the deadline is a budget you intend to spend, and wrong for a call that usually returns immediately — a five-second guard on a health check would make every healthy check take five seconds.
Poll instead, when the common case is early completion:
let hand = Fiber::spawn(fn () => work());let started = clock.monotonic_millis();let mut done = false;let mut expired = false;while !done && !expired { if Fiber::finished(hand) { done = true; } else if clock.monotonic_millis() - started > millis { expired = true; } else { clock.sleep(25)!; }};if expired { Fiber::cancel(hand); // **Detach as well as cancel.** A fiber blocked in a call with no // cancellation point -- a connect to an unroutable host, say -- is not // freed by the cancel, and releasing its handle waits for it. `detach` // says the program is no longer interested, so its own exit is not held // up by work it has already given up on. Fiber::detach(hand); raise Timeout} else { Fiber::join(hand)!}The poll interval is the cost: 25 ms means up to 25 ms of latency added to a fast answer, against a deadline that would otherwise cost its whole length.
The work must have somewhere to be interrupted
Section titled “The work must have somewhere to be interrupted”A cancellation travels on a raises row. It is observed at a !, at a loop
back-edge in a function that can fail, or inside a wait. Work that does none of
those is not interrupted — not because cancellation is unreliable, but because
there is nowhere in it to look.
This is the one that costs people an afternoon, because the failure is a hang
with no message. The worker above is a named function with raises Timeout.
Written as an inline lambda instead:
// Cannot be cancelled: the loop has no failure channel.nursery.adopt(Fiber::spawn(fn () => { let mut n = 0; loop { n = n + 1; }}));Fiber::cancel returns, and Fiber::wait never does. The fiber was asked to
stop and had no cancellation point at which to notice.
A lambda cannot declare a with row or a raises row of its own, so the fix is
the shape used above: write the work as a named function that declares
raises, and let the lambda call it with !.
If the work genuinely cannot fail and must still be stoppable, give it a flag to
read — Shared<Bool> — and check it in the loop.
Cancelling a nursery cancels its children
Section titled “Cancelling a nursery cancels its children”A fiber whose body is a nursery takes its children with it. Cancelling the
parent cancels every child it is holding, including the ones it is currently
waiting on, so a fan-out under a deadline stops rather than waiting for workers
nobody told.
This is what makes the example above work: with_deadline cancels one handle,
and the worker inside the nursery stops.
Cancelling through a nursery costs what cancelling without one costs — measured at 106 ms against 107 ms for the same worker spawned directly.
What a cancelled fiber does on the way out
Section titled “What a cancelled fiber does on the way out”Cancellation is not a kill. The fiber unwinds the way a failure does: scoped
resources are released, Region finalizers run, and a nursery in the unwind
path stops its own children first. Cancellation-safe
resources has the resource rules.
A cancelled fiber’s answer is discarded. Fiber::join on one unwinds the
joiner along with it, which is why every example here uses wait.
What this does not give you
Section titled “What this does not give you”- No
raceand noselect. Waiting on the first of several fibers has to be built from a channel the workers send to. - A deadline is a sleep, not a scheduler deadline. The timing fiber sleeps the whole interval; it does not wake early because the work finished. For a deadline that ends as soon as either side does, have the work signal a channel and read the channel with its own timeout fiber.
- A cancelled fiber is not stopped at an arbitrary instruction. It stops at its next cancellation point, which is why a worker that spends 200 ms inside one infallible call takes up to 200 ms to notice.
Router::listencan be cancelled, and the process exits when it is. This was a known gap and is fixed; see Concurrency — drain first, stop last.
Khora 0.3.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource