A handler runs to completion regardless of client behaviour. If the
client TCP-disconnects mid-handler, the handler has no signal until
the next _send returns EPIPE. By then the handler has already
done its expensive work (DB query, downstream HTTP fan-out, JSON
serialisation).
the reactor allocates one CancelCell per
connection (heap-allocated Int). Cancel is a thin handle the
reactor passes to a CancelHandler.serve(req, cancel); the handler
polls cancel.cancelled() between expensive steps and short-circuits
when True.
Cancellation is cooperative, not preemptive — Mojo can't preempt
synchronous code, and synchronous preemption would defeat the
reactor's per-thread invariant anyway. The contract is: the handler
checks the flag at boundaries it owns; the reactor sets it at one of:
CancelReason.PEER_CLOSED — recv == 0 (peer FIN) observed
before the response was queued.
CancelReason.TIMEOUT — a per-request, per-handler, or
per-body-read deadline fired.
CancelReason.SHUTDOWN — HttpServer.drain(timeout_ms) was
called.
The default-initialised Cancel returned by Cancel.never() is a
sentinel that never fires; tests and synthetic CancelHandler calls
that don't have a real reactor cell behind them use it.
Implementation
A CancelCell heap-allocates a single Int and owns its
lifetime. Cancel carries the cell's address as an Int and
rebuilds a fresh Pointer[Int, MutUntrackedOrigin] per access
— the same pattern the multicore Scheduler uses for the
stopping flag, and the only one that survives Mojo's origin /
aliasing model when passing the cancel handle across function-call
boundaries. Reads / writes go
through Atomic[Int64] acquire-load / release-store (the
cell can be flipped from a peer thread on shutdown), which lowers to
a plain mov on x86-64 (TSO) and to ldar / stlr on ARM64:
- Storing a typed
Pointer[Int, MutUntrackedOrigin] as a
struct field in Cancel produces stale reads after the struct is
passed through a function (verified empirically: reads at a
numerically-correct address returned the pointer struct size,
8, instead of the live byte).
- An inline
UInt8 cell on the owning struct (instead of heap)
does not have a stable address across the synchronous call chain
the reactor builds, again per direct testing.
The combination here — heap allocation for cell stability + Int
address in Cancel for transport stability — is the only one that
passes the test suite and the
cell.flip(reason); h.serve(req^, cell.handle()) round-trip.
Why Int instead of UInt8 for the cell value: a byte-sized
heap cell hit the same aliasing failure as the typed-pointer field,
suggesting Mojo's aliasing model treats sub-word-aligned loads
through MutUntrackedOrigin differently than full-word loads. A
machine word avoids the path the bug appeared to depend on. The
cell is one cache line per connection — acceptable cost for the
cancel infrastructure.