Contract 04
Scopes and resources
A scope is Effect's Scope plus acquireRelease, built from the parts
Swift 6.4 finally ships. Anything acquired in a scope is released when the
scope ends — on success, on failure, on cancellation — in reverse order,
shielded from cancellation, and within a bounded time. Work forked in a
scope is cancelled and awaited before any release runs.
Origin
- Effect / Cats Effect.
Scope,acquireRelease,Resource,Exit; Cats'uncancelablemask for the release path. - Rust. RAII and
Drop, and their weak point: async drop is still nightly-only in 2026. Swift is now ahead:awaitindefer(SE-0493) inside a cancellation shield (SE-0504) gives guaranteed async cleanup under cancellation. - Arrow.
Sagawith compensations as a first-class primitive. - In apps. Sockets closed by hand on each exit path, observers removed
in
deinit(which cannotawait), idle-release timers that race the next load, and model or session handles that leak a little on every reload.
API
public struct Scope: ~Copyable {
public func acquire<R: Sendable, Failure: Error>(label:, shielded: Bool = false, timeout: Duration? = nil,
_ acquire: nonisolated(nonsending) () async throws(Failure) -> R,
release: @escaping @Sendable (R, Exit) async throws -> Void) async throws(Failure) -> R
public func acquire<R, Failure>(_ resource: Resource<R, Failure>) async throws(Failure) -> R
public func onExit(label:, timeout:, _ finalizer: @escaping @Sendable (Exit) async throws -> Void)
}
public struct Resource<Value: Sendable, Failure: Error>: Sendable { init(label:shielded:timeout:acquire:release:) }
public enum Exit: Sendable { case success, failure(any Error), cancelled }
public nonisolated(nonsending) func withScope<R, Failure: Error>(
exitBudget: Duration = .seconds(10), finalizerTimeout: Duration = .seconds(5), label: String? = nil,
_ body: nonisolated(nonsending) (borrowing Scope) async throws(Failure) -> R) async throws(Failure) -> R
public struct TaskScope: ~Copyable { func fork(label:, _:); acquire…; onExit… }
public nonisolated(nonsending) func withTaskScope<R: Sendable>(…,
@_inheritActorContext _ body: sending @escaping @isolated(any) (borrowing TaskScope) async throws -> R) async throws -> R
public struct Saga: ~Copyable { func step(_ action:, compensate:) }
public nonisolated(nonsending) func withSaga<R, Failure: Error>(…, _ body: (borrowing Saga) async throws(Failure) -> R) async throws(Failure) -> RSemantics
The handle cannot escape. Scope, TaskScope and Saga are
~Copyable and handed to the body borrowing; the compiler rejects
capturing them in an escaping closure. Verified against Swift 6.4.
(~Escapable would say the same, but constructing one still needs
experimental lifetime features.)
Acquire is atomic with registration. Swift cannot interrupt
synchronous code, so the release is registered before the value reaches the
body. shielded: true runs the acquire itself hidden from cancellation.
Exit. When the body returns, throws or is cancelled, finalizers run one
at a time, newest first. Waiting for them is shielded
(withTaskCancellationShield), so a cancelled task still cleans up. Each
finalizer runs in its own task and gets min(its timeout, max(exitDeadlineFloor, what is left of exitBudget)): a total budget, so n finalizers cannot add up
to n × timeout, and a floor (exitDeadlineFloor, 250 ms), because cleanup
that never runs is worse than cleanup that runs late. The floor lifts only
what the budget left over; it never lengthens a timeout the caller asked for,
so an explicit timeout: .milliseconds(10) is ten milliseconds. A finalizer
that overruns is abandoned — scope.finalizer_abandoned, error level — and
the next one runs.
Finalizers cannot change the outcome. They may throw; the error is
reported (scope.finalizer_failed) and the body's result or error passes
through unchanged. Work whose failure must change the outcome — committing
a transaction — belongs at the end of the body. Review rejected asserting
in debug on a reported failure: it would crash every test of that path.
TaskScope. Children forked into a task scope are cancelled
(scopeEnded) and awaited when the body ends, before any finalizer runs,
so no fork can use a released resource. A failing fork cancels the body and
its siblings (siblingFailed); the scope then throws ConcurrentFailure
with every fork failure followed by the body's own error if it failed for
another reason. The body runs as a child task — the only way a fork's
failure can cancel it without cancelling the caller — so, like Task {}, it
inherits the caller's actor and returns a Sendable result. withScope
has no forks, so its body runs inline with any captures and a typed error.
Saga. A step's compensation is registered only once the step
succeeds, and runs only if the saga ends in failure or cancellation:
newest first, shielded, time-bounded, each one even if an earlier one
failed. Compensations live in memory; effects that must be undone across a
crash need a durable journal (design/15, StoicStore).
Typestate. For a single resource with states to move through, use a
~Copyable type with consuming transitions; a scope then guarantees
release even under cancellation by holding a release token, not the
typestate value itself (ownership is single, so the value cannot be both
returned to the body and kept by the release closure).
Failure modes
| What happens if… | Behaviour |
|---|---|
| the body succeeds | Children awaited, finalizers run with .success, value returned |
| the body throws | Finalizers run with .failure(error), error rethrown unchanged |
| the task is cancelled mid-body | Finalizers run with .cancelled, shielded; CancellationError rethrown |
| a finalizer throws (logs) / fails | Reported at error level; remaining finalizers run; outcome unchanged |
| work started in the scope was abandoned | Releases marked .quarantine are skipped (leaked on purpose); scope.release_quarantined |
| a finalizer hangs | Abandoned after its timeout, listed in AbandonedWork; scope.finalizer_abandoned |
| the acquire throws | Nothing registered; error rethrown; earlier acquisitions are released |
| a forked child throws | Body and siblings cancelled; after cleanup, ConcurrentFailure with every child error |
| the enclosing deadline has already passed at exit | Finalizers still get exitDeadlineFloor each |
someone tries to keep the Scope |
Compile error |
Events
scope.finalizer_failed, scope.finalizer_abandoned, scope.finalizer_slow, scope.release_quarantined
(over half its allowance), scope.child_failed, scope.fork_after_exit,
saga.compensation_failed (a saga's compensation threw; the scope event is
not also emitted), and abandoned.completed when an abandoned finalizer
finally ends (it is listed in AbandonedWork until then).
Testing
- Exhaustive outcome matrix (success, throw, cancel) × (0, 1, n finalizers)
× (finalizer ok, throws, hangs) × (0, 1, n children), on the virtual clock,
asserting order,
Exitvalue and outcome. - Property: for any random interleaving of acquire, fork, throw and cancel, every acquired resource is released exactly once, in reverse order.
- A compile-fail check proves the scope handle cannot escape. Swift Testing
cannot assert that code fails to compile, so
scripts/check.shcompiles each file inscripts/compile-failwithswiftc -emit-silagainst the built module and requires the expected error (and a control that compiles). - Leak test: thousands of scopes under random cancellation; the resource counter returns to zero.
Non-goals
Reference-counted shared resources (RcRef) before a real use appears; async
deinit (no Swift proposal exists).