ADR-148: select { … } Keeps the First Branch to Finish, and a Task Handle Has cancel()

Status: Superseded by ADR-292 — its D1-D4 are D12-D15 there Date: September 19, 2026 Target Version: Nikaia 0.0.31 Related: ADR-050 (overlap { … }, the sibling that keeps every branch), ADR-141 D2 (which marked the page unspecified), ADR-129 (the C cancel, which leans on the same race), ADR-006 D3 (a parked cleanup and the deadline), ADR-084 (what a keyword costs), ADR-126 D1 (_ where a value arrives), Part II 12.4, 12.7

1. Context

Part II 12.4 writes a select block and ADR-141 D2 marked it unspecified: a construct with no record. The semantics are not missing — the loser stops at its pause point, its cleanup is adopted and finished in the background, the cleanup-deadline bounds it (ADR-006 D3) — and the runtime’s race is what ADR-129’s C cancel already leans on. What is missing is the construct.

And a word. Today nothing but losing a select cancels a task, so a program cannot say stop to something it started — which is a gap the C direction has already had to work around.

2. Decisions

D1 — select is a block construct, and its arms bind.

select {
    result = heavy_math()      => { return result }
    _      = sleep(5.seconds()) => { throw Timeout::TooSlow }
}

An arm is pattern = expr => { … }: the expression is started, the first one to finish wins, its value is bound to the pattern and its block runs. _ is the ignore pattern where the value is not wanted — a value arrives and is ignored, which is ADR-126 D1’s own position and not the catch-all arm ADR-145 took away from it.

Syntax and not a function, which is the whole of why it costs a keyword: a branch binds a name and then runs a block, and no function parameter can be given that shape.

D2 — The losers are cancelled, and that is the one mechanism.

A loser stops at its current pause point; its values are torn down; a cleanup that pauses is adopted by the runtime and finished in the background, bounded by the cleanup-deadline. The program does not wait for the loser’s teardown, because it should not pay for it.

None of that is new here — it is ADR-006 D3 written down again where it is now reachable.

D3 — A task handle has cancel(), with exactly those semantics.

spawn hands back a TaskHandle; it gains cancel(), which does to that task what losing a select does to a loser. One mechanism, so that a program’s own cancel, a select’s loser and ADR-129’s C cancel are the same thing said three ways rather than three things.

It costs nothing new in the runtime, which already cancels a loser.

D4 — overlap and select are a pair.

ADR-050’s overlap { … } runs its branches at once and keeps every result; select runs them at once and keeps the first. A language with one and not the other has half a pair, and the second is the argument for the keyword: it is not a new idea, it is the missing half of one already taken.

3. Consequences

4. What this does not decide

5. What is built

All of it.

  1. The keyword, the block and the arm grammar. select is reserved and has its construct in the same change, which is ADR-051 D3’s one ground that holds — and the number that allowed it is zero: nothing in examples/, in tests/ or in the three pages wrote it as a name. _ and a binding name are the two patterns.
  2. The lowering, which is a match over which arm won: std gains race2..race8 and a Race<n> per arity, beside the overlap<n> it already had. An arm binding a name and then running a block is a match arm in the language below, so nothing had to be invented for it. A block of one arm is refused with its reason, as an overlap of one branch is.
  3. The loser’s teardown, and nothing writes it. The losing futures are dropped when the race returns; a future dropped at its suspension point tears its values down, and a cleanup that pauses is adopted and bounded by the cleanup-deadline (ADR-006 D3). D2’s “that is the one mechanism” is a fact about the emitted code rather than a rule this record added.
  4. cancel() on the handle, over a flag the task reads at its pause point and the waker that makes it notice now rather than when whatever it was waiting for answers.
  5. Part II 12.4’s example as a test that runs, with a timeout short enough that a test does not wait five seconds for it, and the unspecified mark taken off the page.

cancel() takes the handle, the way join does — and that is what became of §4’s whether a cancelled task can be observed to have been cancelled: it is a question no program can ask, because after the call there is no handle to ask with. It also means a cancelled task is never joined, so nothing waits for a value that is not coming.

An arm that can fail propagates from the arm that won and not from the race: every arm is wrapped in Ok so the vehicle sees one shape, and the ? is the first line of the winning arm’s body. overlap’s D5 puts its ?s on the tuple instead, because there every branch has a value.

This site is open source. Improve this page.