Manuscript Documentation
v0.1.0

Cancellation and timeouts#

Cancellation asks work to stop. It does not mean the work has already stopped; code must reach a checkpoint, leave its scopes, and finish cleanup. Request work follows the request’s execution lifetime. Accepted scheduled work follows its scheduler’s lifetime.

Requests#

Hosts signal requests, joined groups signal their operations, and schedulers expose cancellation of admitted work:

Manuscript
let id = try background.start(operation: work)
background.cancel(id: id)

The submission fragment belongs to a function returning Result[U, StartError]; work must have the standard imports callable type. Other application error types need explicit adaptation.

Successful Scheduler.start admission yields an OperationId through Result[OperationId, StartError]. cancel(id: id) returns Unit, is idempotent for an admitted ID, and requests cancellation without joining. Dropping an ID has no effect. The service retains responsibility for work until it finishes or service shutdown joins it. IDs the service did not admit, including IDs from other services or from joined groups, and IDs whose status has been evicted are harmless no-ops; an ID never identifies unrelated work.

cancel, status, and close are receiver operations of the imported Scheduler service; there are no ambient cancellation or join functions. Using an already-typed service does not require importing its otherwise unused type name. background.close() is the waiting boundary: it stops admission and performs the configured drain, cancellation, and joining sequence. A closed service stays closed through all aliases.

Late cancellation cannot overwrite an already committed completion. For scheduled work, the first accepted stop reason determines WorkStatus, except that a programming fault during cleanup takes precedence and reports Crashed. Joined groups have no per-operation cancellation; their caller-visible outcomes are defined in joined groups .

Propagation and checkpoints#

Cancellation is cooperative: stopped work unwinds at its next checkpoint. These are checkpoints:

  • runtime operation entry and completion;
  • blocking I/O and other adapter calls;
  • each loop back-edge, before the next iteration of for or while starts;
  • an explicit check_cancelled() call.

Loop checkpoints make CPU-bound loops respond promptly without source changes; they cost one poll of the context’s stop signal per iteration. check_cancelled() remains available for long straight-line work. It is an imported standard function, not a language intrinsic:

Manuscript
import check_cancelled from "@std/tasks.ms"

Importing it supplies the operation declaration; the runtime supplies the current execution context when it is called. Cleanup runs under a shielded context, so checkpoints inside cleanup do not observe the initiating cancellation. No checkpoint occurs between with acquisition and cleanup registration; see completion and acquisition .

Cancelling a request or current operation cancels its nested groups. Each group joins its operations, each operation runs its registered with cleanup, and the caller unwinds.

Scheduler service cancellation reaches the work admitted under that service. Cancellation of a submitting request does not cancel accepted work, because the background lifetime was selected at admission. Cancellation before admission must not start work. Admission and acknowledgement can race with request cancellation; callers must use application idempotency keys if a retried submission must not duplicate work.

Completed external writes remain completed. Cancellation does not provide rollback; a transaction’s explicit resource contract does.

Cleanup is shielded from the initiating cancellation and expired work deadline. It may exceed that deadline; it retains capability authority and remains subject to host escalation . See cleanup faults .

For native application shutdown, use registered with cleanup; observing SIGINT/SIGTERM does not claim the signal or protect code after SignalStream.next(). See the shutdown cookbook for a runnable server, cancelled-worker ordering, and current host limitations.

Waiting for a synchronized value#

Synchronized.lock() waits cooperatively and observes cancellation. If cancellation wins before the guard is delivered, the runtime releases that acquisition. Use with locked = cell.lock() so a delivered guard is registered for cleanup before another checkpoint.

Cancellation of a lock holder unwinds its with scope and closes the guard. It never releases the lock while the holder is still running. Updates already made through locked.set() remain committed; releasing the guard does not roll them back. Guard cleanup is idempotent and shielded from the initiating cancellation. See synchronizing updates .

Cross-task lock cycles wait rather than faulting automatically. Cancellation or a group timeout can stop an ordinary lock waiter, but cannot cancel a shielded cleanup wait or abandon a mandatory cleanup join. Release a held guard before joining work that needs it; deadlines do not guarantee escape from cleanup cycles.

Timeouts#

Timeouts are named library arguments, not syntax annotations. Duration values come from the standard time API; naming Duration requires its own import from @std/time.ms.

TaskGroup(timeout: 2.seconds) sets one group budget, starting with the first submission. The budget applies across its operations; timeout is the only budget argument.

On expiry, unfinished tasks stop and join before a failed task or group join reports Timeout. A timeout bounds ordinary work, not cleanup or joining. Foreign code that does not cooperate can delay joining; host escalation is the final fallback.

To share one budget across sequential steps, submit one callback containing those steps:

Manuscript
with tasks = TaskGroup(timeout: 2.seconds):
    let task = tasks.start(sync)
    let outcome = try task.join()

This fragment requires an enclosing Result with ExecutionError. The callback’s domain return value remains unchanged. Nested groups inherit cancellation from their fixed parent.

Deadlines#

Hosts establish deadlines for application, session, CLI, and request execution contexts. Every operation inherits its fixed parent’s deadline. A joined group can only shorten it with timeout; nothing in source can extend an inherited deadline. The effective deadline is the earliest of the inherited deadline and any group timeout.

Budgets use the runtime’s monotonic timer, not a Clock capability or calendar time. A test runner may virtualize that timer; a fake Clock value does not affect execution budgets.

When the caller’s own deadline expires, the caller unwinds after its groups join; it does not receive Timeout. Only a group’s own timeout produces Timeout.

Scheduler work inherits the service’s fixed execution context, including that context’s deadline. It does not inherit the submitting request’s deadline. A service cannot revive cancelled work or extend its host’s deadline. Its context is selected at construction, not at each call to start.

Deadline configuration is a host operation. Task handles do not expose deadline mutation; there are no deadline annotations on functions or absolute deadline arguments. See scheduling work for drain budgets and adapter contexts .