Cooperative cancellation
Contract
A Cancellation::Source owns an idempotent cancel() capability. Its token
can be passed through a Thread, Channel, or Supervisor argument. Sources and
tokens are ordinary d6 objects; their cancellation state is a shared
Channel whose closure is permanent and visible in every heap. Cancelling a
source does not consume a message or cancel its parent. A child source retains
its parent token and optionally adds a deadline. Checkpoints consult both.
Deadlines use Time.monotonic() in seconds, calculated when the source is
created, and stay absolute when copied into another thread. A zero timeout
expires immediately; negative durations fail. Explicit cancellation is checked
before ancestor cancellation/deadlines, then the local deadline. Cancellation
is terminal; racing cancellation and completion may result in either outcome.
checkpoint() raises Cancellation::Cancelled; deadline expiry raises its
subclass DeadlineExceeded. These are ordinary exceptions, so normal ensure
cleanup runs. Broad StandardError rescues must re-raise cancellation or handle
it explicitly before applying retry policies. No asynchronous exception is
injected into another thread and no thread is forcibly killed.
token.sleep, token.receive(channel), and token.send(channel, value) use
native channel wakeups. Each wait registers with the target channel and every
ancestor cancellation source, and uses the earliest absolute deadline. Closing
any source wakes its waiters directly. Ordinary worker waits have no periodic
polling timer. Scheduling and GC can still add latency; this is not a real-time
guarantee. Channel wrappers preserve close/EOF and type errors. A checkpoint
precedes each operation; cancellation can race with a successful operation,
so callers must account for side effects.
A VM with installed Signal.trap handlers returns from a native wait at least
every 10 ms of requested waiting so the VM can dispatch pending handlers. An
interrupted system call also returns to the VM. The token wrapper retries
spurious wakeups after a checkpoint; signal handlers can safely cancel sources.
Cancellation.scope(callback, timeout) owns every child started with
scope.spawn(callable, *args). The callable receives (token, *args) and must
obey ordinary Thread transfer rules. The body may capture local state; child
callables may not. Normal exit joins children; body failure cancels and joins
children before re-raising. A child failure cancels siblings, joins all children,
and propagates the first failure observed in spawn order. The body's exception
wins if both fail. Expected child cancellation ends that child normally. The
scope's token is checked after successful joining so deadline expiry is not
reported as success. Explicit Scope.close() cancels then joins, and can raise
a child error. Scope objects are owned by their creating thread.
Boundaries
Existing Channel.receive, Thread.join, sockets, SQL calls, and arbitrary
CPU loops do not gain cancellation implicitly: no exception is raised in them,
and a worker that is not supervised is never woken. A scoped child must
checkpoint and use bounded/nonblocking I/O; an uncooperative child can still hold
up joining. Only Supervisor#stop(timeout_ms) and a sibling restart end a
supervised attempt out of a blocking wait, and they do it as an uncatchable
interruption, not as Cancelled.
Supervisor.stop() still waits: cancel the shared source before calling it, and
catch expected cancellation at the supervised child's boundary so it returns
normally instead of restarting. stop(timeout_ms) additionally interrupts
attempts that outlast the timeout, between instructions and out of most blocking
native waits (see threads for which). Process crashes do not execute ensure.
Native wait protocol
Channel.wait_readable(cancellations, deadline) and wait_writable are
readiness hints, not reservations. They return nil on readiness, target
closure, cancellation-source closure, deadline expiry, or a spurious wakeup.
Callers recheck cancellation and retry their nonblocking operation.
Each wait owns a nonblocking pipe and registers a notification link on every channel. Registration and the readiness/closed check hold that channel's lock, so a transition either precedes the check or signals the registered pipe. Send, receive, and close notify registered waiters under the same lock. Cleanup unregisters every link before closing the pipe or freeing its storage. Only one channel lock is held at a time; no user code runs under it. Live receiver and argument handles keep the channels alive until the wait returns. Duplicate cancellation channels are allowed.
Deadlines are absolute monotonic timestamps. The native wait recomputes its
relative poll timeout from that timestamp, including after long timeout
segments, without extending the deadline. Each active wait uses two file
descriptors; allocation or descriptor exhaustion raises an ordinary runtime
error and leaves no registered waiter behind.
IO.poll accepts cancellation options and uses the same registration protocol,
adding the notification pipe alongside the caller's descriptors. Its original
integer timeout form remains available. The options form uses an absolute
deadline, so retries do not extend it. Spurious returns have no ready entries.
token.poll(readables, writables) checks cancellation before and after waiting;
a token cancelled alongside socket readiness raises instead of reporting ready.
token.read(socket, count) retries until bytes or EOF, and
token.write(socket, string) retries partial writes until all bytes are sent.
These helpers accept only nonblocking TCP Sockets. They neither close the
socket nor roll back a partial write: use ensure for ownership cleanup, and
account for a prefix already sent if a write is cancelled. Sockets stay in their
owning VM; pass a cancellation source across threads instead of the socket.
token.resolve(host) supports cancellable system DNS. token.connect(host, port)
resolves hostnames and tries IPv4/IPv6 addresses under one inherited deadline,
closing failed or cancelled attempts. Successful sockets belong to the caller.
Abandoned DNS lookups retain only native resources in a bounded worker pool;
they do not prevent VM cleanup or process exit. TLS connection setup has its own
cancellable call (connect_tls, below). A token does not interrupt a SQL call or a TLS
read or write, and there are no implicit VM checkpoints; only
Supervisor#stop(timeout_ms) ends those (see Boundaries). HTTP servers must opt into token-aware waits. Gremlin's optional
shutdown_token mode integrates these waits, a shared drain deadline across
workers, and joins before returning;
installing the cancellation package alone does not alter an existing I/O loop.
Reference service
Job service uses SQLite persistence, HTTP, and a supervised worker. Jobs have checkpoints, independent monotonic deadlines, and a durable cancellation request. Service shutdown cancels the shared root token: Gremlin closes its listener and drains HTTP for up to 0.5 seconds while the worker requeues interrupted work. After Gremlin returns, the owner joins the supervisor and closes its database; the worker closes its own database in ensure. Normal job failures retain the jobs package's retry/backoff policy; cancellation and timeout are separate terminal outcomes.
TLS handshakes
Cancellation 0.6.0 adds token.connect_tls(host, port, options = nil), sharing one
inherited deadline across DNS, TCP, and verified TLS negotiation. Failed or cancelled
handshakes abort the owned connection without blocking on TLS shutdown. Successful
calls return a caller-owned blocking TLS socket; application reads/writes remain
separate work. See TLS handshake contracts
for options, ownership, and the native stepping API.