d6 docs

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.

Edit this page on GitHub