Synchronous resolution scopes
Status: Not implemented; separate proposal depending on linear obligations. Issue #149, B72. Current resource scopes and destruction do not provide the consuming-finalizer contract proposed here. All examples are pseudocode.
Why a separate extension
Manual transfer or resolution is a complete synchronous linearity rule, but a transaction around several fallible calls needs repetitive handling of every early exit. A manager can take responsibility, lend access to the body, and run an authorized resolver before the scope returns. This is cleanup policy on top of ownership; neither a new effect language nor async cleanup is necessary.
Python context managers illustrate the related control-flow guarantee:
following successful __enter__, ordinary body exits invoke __exit__. That
mechanism alone neither checks non-abandonment across calls/storage nor requires
every resource to enter a context. It does not statically stop escaped aliases,
promise cleanup success, or survive abrupt process termination. Plenty already
adds borrowing restrictions; linearity would additionally check the manager’s
own responsibility.
Proposed narrow contract
A resolution scope owns a hidden linear manager and registers a consuming
exit operation before lending body access. On each ordinary exit, body-local
owners finish first, borrowed access ends, then the manager is consumed exactly
once. The operation must resolve or transfer its payload on every returning
path. Its normal receiver is owned, unlike today’s borrowed __exit__.
with resolving(tx, release_local_transaction) as access:
insert(access, record)?
update(access, other_record)?
# every ordinary body exit consumes the manager and resolves tx
The illustrative resolver is explicitly a local-release policy that may attempt rollback but promises no remote success. There is no implicit commit-on-success policy. A library can offer a clearly named rollback/local-release guard; the language should not guess the business meaning of normal scope exit.
Acquisition and registration form one ownership contract: failure returns the unregistered owner, or occurs before transfer. Once registered, even a failed entry operation must return the owner or run the resolver. Do not copy today’s successful-entry rule in a way that leaks an already-owned obligation. Nested scopes finish in reverse order. If a later manager acquisition fails, earlier registered managers still resolve, and the failed acquisition’s returned owner must itself be handled.
The body receives a loan bounded by the manager. It cannot move the manager, replace its unresolved payload, retain the loan past exit, or return references to owned manager storage. Return values may be prepared before cleanup only if they obey these lifetime rules. A body-local linear owner is not automatically the manager’s responsibility unless explicitly transferred into it.
An explicit consuming finish inside the body needs a checked manager-state
transition that proves the exit operation has nothing left to resolve. The
first design may reject this and require manual ownership for commit/retry
flows. Assuming that a boolean finished flag makes a linear field disappear
would undermine the core rule. Similarly, borrowed with &mut existing cannot
consume the caller’s owner; adapting it requires a separate state contract.
Worked example: cancel_on_exit()
token.cancel_on_exit(), implemented for issue #355, is an instance of the
narrow unit-exit form specialized to one resolver. Its manager owns a share of
a CancellationToken; its exit operation is cancel(), which is synchronous,
infallible, returns unit and allocates nothing. Every ordinary exit, including
?, return, break, continue and a failed later manager in the same
header, runs it exactly once, in reverse manager order:
with spawn(serve, listener, stop.share())?, stop.cancel_on_exit():
prepare()?
# every exit cancels stop, then joins the task
The compiler check that pairs with it is the
cancellation rule:
when a function later cancels a token, a join or pool shutdown that waits for
work holding it must come after a cancel on every path. The guard satisfies
that rule by construction. It does not need linearity: the obligation is local
to one function and its exits, and cancel() cannot fail, so there is no
unresolved owner to return. It shows the shape a library resolver could take
once consuming exits exist, and why the infallible case is the first one to
make automatic.
Failure determines the available surface
The narrow automatic-exit form requires a synchronous resolver that returns unit after reaching an allowed terminal local outcome. It may block, and it may internally retry. If the only correct resolver returns a still-live owner on failure, this form is unavailable. Swallowing that failure is not resolution.
Today’s unit-returning __exit__ is sufficient to express an infallible
side effect, but not sufficient to prove that a borrowed manager’s linear
payload was consumed. A dedicated consuming-finalizer lowering (or a later
verified state-changing exit contract) is necessary. The hidden owner must not
subsequently reach ordinary drop with a live seal. This extension does not
change all existing managers or reinterpret existing __exit__ hooks.
Fallible finishing should initially remain explicit. A future combinator can
make error precedence and retained ownership visible instead of silently
overriding a pending return or propagating an exit error through today’s with:
resolve_scope(owner, body, resolver) -> ScopeOutcome[BodyValue, BodyError, R]
ScopeOutcome = Resolved(Result[BodyValue, BodyError], ResolutionReport)
| Unresolved(Result[BodyValue, BodyError], R, ResolveError)
Unresolved retains the owner R; the whole outcome is non-discardable. Both
body failure and resolution failure survive. If the body value/error is itself
linear, it remains owed in either branch. If allocation is needed to store an
outcome, its failure contract must retain these values too. The body borrows
the owner and returns a result to the combinator; it cannot return a borrowed
view of that owner. This sketch does not introduce nonlocal callback returns.
This explicit outcome is the recommended direction for recoverable resolution
failure, but callback syntax and a library implementation are open. A future
fallible with syntax would need equally explicit rules for simultaneous errors,
prepared return values, nested manager failures, and unresolved owners. It is
not a prerequisite for core linearity or the narrow unit-exit form.
Destructors, effects, and execution limits
An ordinary __del__ remains fallback memory/resource cleanup. It cannot be
used to launder a linear field into discardable storage. After terminal
resolution, remaining ordinary fields may be destroyed in their existing order.
This avoids requiring recoverable errors, suspension, or a stronger destructor
model merely to express meaningful finishing.
Resolver calls, including compiler-inserted ones, belong in the existing reachable-effect graph. A resolver forbidden on a worker makes that scope ineligible there; see the integration example. Opaque higher-order resolver effects remain subject to current certification limits. There is no generic “cleanup is always allowed” exception.
No bounded completion is promised: scope exit can wait indefinitely. An
immediate timeout return and completed cleanup cannot both be unconditional.
Transferring responsibility to a longer-lived scope is possible only if it owns
all needed resources and retains no loans whose owners have already returned.
Async cleanup expressibility and the runtime obligation to drive it need a
distinct proposal; neither Python async with nor a hypothetical async
destructor alone supplies that obligation.
Conformance and unresolved choices
Test normal return, ?, break, continue, nested acquisition failure, and explicit
body failure with a counted resolver: exactly one permitted resolution for each
registered owner, body loans ended first, and unchanged cleanup order for
ordinary values. Reject escaping loans, destroying an unentered manager,
calling an empty resolver, consuming through a borrowed manager, and a resolver
returning while still owning its payload. Verify worker diagnostics include
inserted exit calls and their origin scopes.
The consuming-exit declaration surface, safe explicit finish within a scope, and the fallible outcome combinator remain open decisions. The proposed defaults are explicit policy, no implicit commit, no ignored recoverable resolution error, and no async requirement.