Contract 01
Time and deadlines
Every wait has an end (Principle 2). This contract defines how Stoic names that end: deadlines that propagate through the task tree, timeouts that are deadlines with a duration, and the one sanctioned way to stop waiting for work that will not stop.
Origin
- Swift. SE-0526
withDeadlinewas accepted with modifications after three reviews and has not shipped. It makes the active deadline task-scoped, narrows nested deadlines to the tightest, passes the operation's own error through, and addsCancellationError.Reason(.deadlineExpired). It is cooperative: never a hard timeout. - gRPC and Go. Deadline propagation: a callee never gets more time than
its caller has left. Go 1.21's cancellation causes and
WithoutCancel. - In apps. The usual hand-rolled timeout is a continuation, two tasks and a lock-guarded "first answer" box. The timer is rarely cancelled when the work wins, and the losing work often cannot be cancelled at all — a CoreAudio start on a wedged queue, a synchronous call into a hung process. The task-group version gets abandoned because a group waits for every child, including the one that will never finish.
API
// The deadline in force for the current task, if any. Narrowed, never widened.
public struct Deadline: Sendable {
public let instant: AnyClock.Instant
public let clock: AnyClock
public static func after(_ duration: Duration, clock: AnyClock? = nil) -> Deadline
public static var current: Deadline? { get }
public var remaining: Duration { get } // zero once passed
public var hasPassed: Bool { get }
public func earlier(_ other: Deadline) -> Deadline
}
// Cooperative: cancel the operation when the deadline passes, then wait for it.
public nonisolated(nonsending) func withDeadline<R: Sendable, Failure: Error>(
_ deadline: Deadline, tolerance: Duration? = nil, label: String? = nil,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> R
) async throws(Failure) -> R
public nonisolated(nonsending) func withTimeout<R: Sendable, Failure: Error>(
_ duration: Duration, clock: AnyClock? = nil, tolerance: Duration? = nil, label: String? = nil,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> R
) async throws(Failure) -> R
// For work that cannot be cancelled: stop waiting, keep it accountable. (Not yet built; see below.)
public nonisolated(nonsending) func abandoningAfter<R: Sendable, Failure: Error>(
_ duration: Duration, label: String, limit: Int = 2, clock: AnyClock? = nil,
qos: DispatchQoS.QoSClass = .userInitiated, operation: @escaping @Sendable () throws(Failure) -> R
) async throws(PolicyError<Failure>) -> R
// Why the current task was cancelled: an extensible struct, first cause wins.
public struct CancellationCause: Hashable, Sendable {
public static let deadline, userRequested, superseded, scopeEnded, supervisorStopped
public static var current: CancellationCause? { get }
}Semantics
Propagation. withDeadline installs the earlier of the new deadline and
Deadline.current. A nested withTimeout(.seconds(30)) inside an outer
two-second deadline gets two seconds. Every Stoic primitive consults
Deadline.current: retry will not start an attempt that cannot finish,
semaphores and rate limiters stop waiting at it, scopes bound their
finalizers by it (with their own floor, design/04).
Execution model. The operation runs as a child task in a task group;
a timer runs as its sibling. This is the only model that lets a deadline
cancel the operation without cancelling the caller — cancellation is
irrevocable, so cancelling the caller's own task would leave it cancelled
after withDeadline returned. Two consequences, both deliberate:
- The closure is
sending @escaping @isolated(any)and carries@_inheritActorContext, exactly likeTask {}: written on the main actor, it runs on the main actor and may touch main-actor state, verified under Swift 6.4. The attribute is underscored; it is how the standard library spells this today, and Stoic will follow its replacement. - The result must be
Sendable, because it crosses from the child task. SE-0526, which the runtime implements without a child task, lifts this; Stoic forwards to it when it ships.
Cooperative timeout. When the operation finishes, the timer is cancelled — the defect
hand-rolled versions share — and the clock is left with no pending sleeper.
When the timer fires first, Stoic records CancellationCause/deadline and
cancels the operation's task, then waits for it to finish. The result is
whatever the operation returns or throws: passthrough, exactly as SE-0526.
Causes. Stoic records a cause before every cancellation it initiates,
in a box the cancelled task can read; nested scopes consult their enclosing
scopes' boxes. Cancellation no Stoic scope initiated reads as
userRequested, SE-0526's default. A first version recorded the caller's
cancellation from a cancellation handler; 300 repeated runs showed the child
can observe its cancellation before the handler runs, so the cause is now
resolved by elimination instead of by a racing write.
Already passed. If the deadline has passed before withDeadline is
entered, the operation is still run, already cancelled, so cancellation-aware
code exits immediately and cleanup inside it still happens. This matches
SE-0526 and keeps "the operation always ran" true for callers who rely on it.
Policies that would rather not start at all check Deadline.current first and
throw .rejected(.deadlineExceeded) (design/10).
Abandoning timeout (abandoningAfter). For work that ignores
cancellation — a synchronous CoreAudio call on a wedged queue, an
Accessibility call into a hung process — abandoningAfter returns
.rejected(.deadlineExceeded) at the deadline and leaves the work running,
accounted for. Review surfaced three hazards the first draft missed, and the
design answers each:
- Thread starvation. Blocking work holds a thread of the cooperative pool, which is only about as wide as the core count. Abandonable work therefore runs on a Dispatch queue, whose threads are not the cooperative pool's. The operation is synchronous by signature, so it cannot be mistaken for well-behaved async work. (A dedicated task executor was the first plan; Dispatch gives the same isolation with no unsafe executor code.)
- Use after close. Abandoned work may still hold a resource that its
scope is about to release. Scopes count abandoned work started inside
them and hand that count to finalizers (
Exit.abandoned), and a resource may declare its release.quarantine: leak it deliberately and report, rather than close it under a zombie. - Threads parked without bound. A label never occupies more than
limitthreads, running or abandoned: a call keeps its slot until its operation actually returns. Counting only abandoned work is not enough — a dozen concurrent callers are all admitted while running and all abandoned together. Callers past the cap wait for a slot, bounded by their deadline. - A permanent dead end. When every slot of a label is held by abandoned
work,
abandoningAfterrefuses at once (.rejected(.abandonmentLimit)) and emitsabandoned.wedged, which a supervisor (design/09) can escalate to its terminal action: restart the process, the only cure for a wedged system framework. - Starting doomed work. A caller that is already cancelled, or whose deadline has passed, is refused before anything is dispatched; otherwise the work would start only to be abandoned at once.
Abandoned work is listed in a process-wide ledger (label, age, call site)
and appears in diagnostics until it finishes; a late finish is reported with
its lateness. Built: abandoningAfter(_:label:limit:clock:qos:operation:), and its scope
integration: every scope counts work abandoned inside it (and inside scopes
within it) — scope.abandonedWork — and a release declared
ifWorkAbandoned: .quarantine is skipped and reported
(scope.release_quarantined) when that count is not zero. The count is not
a case of Exit, which would break every switch over it.
Clocks. Deadlines use the ambient continuous clock: if the machine sleeps through a deadline, it has passed. A deadline remembers its clock; comparing deadlines from different clocks converts through each clock's remaining duration at the moment of comparison (documented as approximate; mixing clocks is linted in tests).
Forwarding. When the standard library ships SE-0526, withDeadline
becomes a thin wrapper over it (same semantics, so callers see no change),
currentCancellationCause maps from Task.cancellationReason, and the
duplicate types are deprecated with one minor version of overlap.
Failure modes
| What happens if… | Behaviour |
|---|---|
| the operation finishes first | Timer cancelled and awaited; result returned; no event |
| the deadline passes, operation honours cancellation | Operation cancelled with cause .deadline; its error passes through; deadline.expired |
| the deadline passes, operation ignores cancellation (cooperative) | Stoic waits — cooperative means cooperative; deadline.expired reports the overrun when it ends |
| the deadline passes, operation ignores cancellation (abandoning) | Returns .rejected(.deadlineExceeded) at the deadline; operation tracked in the ledger |
a label has limit operations running (abandoning) |
Later callers wait for a slot until their deadline: .rejected(.deadlineExceeded) |
| every slot of a label is held by abandoned work | Refuses to start: .rejected(.abandonmentLimit); abandoned.wedged event |
| an enclosing deadline cancels an abandoning call | .rejected(.deadlineExceeded), not .cancelled |
| the caller is cancelled | Operation cancelled with cause .userRequested; timer cancelled; caller's error rules |
| an inner timeout is longer than the outer deadline | The outer deadline wins; the inner one is a no-op |
| the deadline has already passed on entry | Operation runs already cancelled (cooperative) / never starts (abandoning) |
| the caller is already cancelled on entry (abandoning) | Never starts: .rejected(.cancelled) |
| the clock is a test clock that never advances | Nothing fires; the test clock reports the pending deadline in its idle diagnostics |
| the Mac sleeps past the deadline | Continuous clock: the deadline passes on wake and fires immediately |
Events
deadline.expired (label, budget, overrun, outcome), abandoned.started,
abandoned.completed (lateness), abandoned.wedged.
Testing
- Virtual-clock tests for every row above, including the timer-cancelled check: after the operation wins, the clock must report no pending sleeper.
- Property: for any nesting of deadlines, the effective deadline is the minimum, and no operation observes a later one.
- Fault injection:
hangIgnoringCancellationmodels CoreAudio; the abandoning form must return within one scheduler tick of the deadline and leave exactly one ledger entry. - Stress: ten thousand races between completion and expiry with seeded
timing; no lost results, no double resumes (the noncopyable
Continuationof SE-0528 makes a double resume a compile error, and Stoic uses it).
Non-goals
Hard preemption of Swift tasks (impossible and unsafe), wall-clock
(Date-based) deadlines, and timeouts on synchronous code.