Errors, Handlers, `ensure`, and Resource Lifetime
Canonical source:
docs/guide/07-errors-handlers-ensure-and-resource-lifetime.mdat locked revisione99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.
Status: Non-normative programming guide
Normative owners:
spec/semantics/ERRORS.md,spec/semantics/EXECUTION_AND_CONTROL.md,spec/semantics/CALLABLES.md, andspec/PROTOS_GRAMMAR.md
Protos treats failure and cleanup as ordinary object/protocol behavior while still giving their control effects precise semantics.
The central model is:
Errors are objects. Signaling is a non-resumable control transfer. Handlers are dynamically scoped.
ensureprotects one dynamic extent and runs cleanup exactly once when that extent is semantically left.
There are no required try, catch, throw, or finally keywords, no second
Exception hierarchy, and no deterministic object destructor.
Expected failures can still be ordinary values
Section titled “Expected failures can still be ordinary values”Not every unsuccessful result has to be signaled.
A protocol may deliberately use an ordinary value when absence or failure is part of its normal data model.
For example, an API may define:
find(...)// -> value or nullwhen that is its actual contract.
Exceptional conditions use Error signaling instead:
problem: Error()problem.signal()Do not treat null as the universal replacement for failure. The protocol
defines whether an outcome is ordinary data or an Error transfer.
Errors are ordinary objects
Section titled “Errors are ordinary objects”Error is a standard prototype rooted in the ordinary object model.
An application can create a more specific category by delegation:
ConfigurationError: Error {}
MissingSetting: ConfigurationError {}Instances created from those prototypes participate in ordinary delegation:
e: MissingSetting()The Error system does not introduce a separate class hierarchy beside the normal Protos delegation graph.
Standard Error categories use delegation
Section titled “Standard Error categories use delegation”Core and normative domains define a deliberately small set of standard Error prototypes.
Examples include:
ErrorSlotNotFoundInvalidSuperInvalidReturnCancelledIOErrorInvalidIOArgumentIOLifecycleErrorThe immediate parent relationships are normative.
A standard failure category is a prototype, not normally a singleton failure instance. The runtime can create an ordinary fresh Error object that delegates to the required category.
That distinction matters for identity and handler matching.
Standard failure occurrences are fresh objects
Section titled “Standard failure occurrences are fresh objects”When a normative rule says that an operation signals a new failure but does not already identify an existing Error object to propagate, each failure occurrence gets fresh semantic identity.
Conceptually:
first: nullsecond: null
Error.handle( () => { missingName }, (error) => { first = error })
Error.handle( () => { anotherMissingName }, (error) => { second = error })
first === secondmust not become true merely because both failures belong to the same category.
The category may match; the occurrence identity remains distinct.
Signaling preserves an existing Error’s identity
Section titled “Signaling preserves an existing Error’s identity”When you explicitly signal an Error object:
problem: Error()problem.signal()the exact problem object is the signaled object.
Signaling does not implicitly:
clonewrapreplaceconvertattach visible stack slotsA matching handler receives that exact object.
If the same Error is later re-signaled under a semantic rule that records and replays that exact failure, its identity remains that Error in the same value domain.
signal() never returns to its signaling point
Section titled “signal() never returns to its signaling point”The standard protocol is:
error.signal()and it is non-resumable.
This code:
continued: false
Error.handle( () => { Error().signal() continued = true }, (error) => { null })does not continue after signal().
The signaling continuation has been abandoned.
The handler may determine the result of the enclosing handle(...) operation,
but it does not supply a value back into signal().
Core has no privileged resume or retry
Section titled “Core has no privileged resume or retry”Core v0.1 does not define Error operations that mean:
resume the signal pointretry the protected expressionuse this replacement valuerestart from a named recovery pointA user object can define ordinary messages with names such as resume or
retry, but those names have no special Error-control authority.
Recovery that wants another attempt is written explicitly using ordinary control mechanisms.
For example:
tryAgain: () => { ...}can be invoked again by application logic, but that is a new ordinary
invocation, not resumption of an abandoned signal() continuation.
handle installs a dynamic handler
Section titled “handle installs a dynamic handler”The standard handler operation is:
matchPrototype.handle(body, handler)A common broad handler is:
Error.handle( () => { riskyOperation() }, (error) => { recover(error) })A narrower handler can use a specific Error prototype:
IOError.handle( () => { readData() }, (error) => { recoverIO(error) })The receiver is the match prototype.
body and handler must both be semantic Closures.
Handler installation is eager about its arguments
Section titled “Handler installation is eager about its arguments”Ordinary expression evaluation happens first.
Conceptually the order is:
evaluate receiverevaluate body expressionevaluate handler expressionvalidate body is Closurevalidate handler is Closureinstall handlerinvoke body()If validation fails, the new handler has not been installed and body is not
invoked.
This avoids a half-installed dynamic scope.
Handler matching follows ordinary delegation
Section titled “Handler matching follows ordinary delegation”Suppose an Error object delegates as:
someError -> InvalidIOArgument -> IOError -> Error -> ObjectThen handlers matching any Error object on that chain are candidates.
For example:
IOError.handle(...)matches someError.
No hidden Java class, enum tag, string code, or second exception-type system is consulted.
Exact object identity is simply the narrowest possible delegation match when the match object is the signaled Error itself.
The dynamically innermost matching handler wins
Section titled “The dynamically innermost matching handler wins”Handlers are dynamically scoped.
If matching handlers are nested:
Error.handle( () => { IOError.handle( () => { readData() }, (error) => { innerRecovery(error) } ) }, (error) => { outerRecovery(error) })an IOError raised while the inner protected extent is active selects the
dynamically innermost matching handler.
You do not construct or order a separate handler list. Ordinary nesting defines the dynamic order.
A selected handler is a one-shot unwind destination
Section titled “A selected handler is a one-shot unwind destination”When a matching handler is selected, its handler frame becomes inactive before
unwinding crosses ensure scopes and before the handler Closure is invoked. Think
of that selection as committing one unwind destination for the original Error.
If crossed cleanup completes normally, control reaches that destination and the handler receives the original Error. If crossed cleanup escapes with a later transfer, that later transfer supersedes the original one and the consumed destination is abandoned rather than reused for the replacement transfer.
The same consumption also gives this important rule:
The selected handler cannot catch an Error raised by its own handler Closure.
If that Closure signals another Error, handler search continues among still active outer handlers.
A handler receives the exact signaled Error
Section titled “A handler receives the exact signaled Error”The handler Closure receives one argument:
(error) => { ...}and that argument is the exact signaled Error object selected by the transfer.
If the handler completes normally, its normal result becomes the result of the
enclosing handle(...).
For example:
result: Error.handle( () => { Error().signal() }, (error) => { 42 })
// result == 42This does not mean signaling resumed. The protected body was abandoned; the
handler supplied the outer handle result.
Normal protected completion bypasses the handler
Section titled “Normal protected completion bypasses the handler”If body completes normally:
result: Error.handle( () => { object }, (error) => { fallback })then the handler Closure is not invoked and handle returns the exact normal
result produced by body.
The handler is an Error boundary, not an unconditional callback.
Handlers belong to the dynamic execution flow
Section titled “Handlers belong to the dynamic execution flow”A handler is not a property stored on the Error prototype, Actor, Closure, or module merely because those objects participate in the call.
Its active installation belongs to the current dynamic control flow.
If that same task explicitly suspends while the handler remains active, the handler remains part of that suspended continuation and is active again when that task resumes.
Suspension does not terminate the handler scope.
Distinct child tasks do not inherit dynamic handlers
Section titled “Distinct child tasks do not inherit dynamic handlers”Creating an asynchronous child task while a handler is active does not copy the handler into that child task’s dynamic state.
The child has its own Error outcome rules.
If a child fails and its Future records that Error, a later consumer operation that explicitly observes that failure can re-signal the recorded Error in the consumer’s then-current handler context.
That composition will be developed further in the Futures chapter. The important rule here is that dynamic handlers are not ambient Actor-global state.
ensure protects one dynamic extent
Section titled “ensure protects one dynamic extent”Deterministic cleanup uses the ordinary protocol:
body.ensure(cleanup)Both the receiver body and the one cleanup argument must be semantic
Closures.
A typical shape is:
(() => { useResource()}).ensure(() => { releaseResource()})This is Protos’ fundamental unwind-safe cleanup mechanism.
It is not special finally syntax.
ensure validates before entering the protected extent
Section titled “ensure validates before entering the protected extent”The conceptual sequence is:
evaluate receiver/body Closureevaluate cleanup expressionvalidate body Closurevalidate exactly one cleanup argumentvalidate cleanup Closureenter protected extentinvoke body()If validation fails, neither body nor cleanup is invoked and no cleanup frame is left installed.
Cleanup runs on every semantic scope exit
Section titled “Cleanup runs on every semantic scope exit”Once the protected body begins, cleanup runs exactly once when the protected extent is left through any Core v0.1 exit mode:
normal completionnon-local returnError unwindcooperative cancellation unwindThat is the central resource-lifetime guarantee.
You do not have to duplicate cleanup along every return/Error path.
Normal ensure preserves the body’s exact result
Section titled “Normal ensure preserves the body’s exact result”If the body completes normally:
object: {}
result: (() => { object}).ensure(() => { cleanup()})and cleanup also completes normally, then:
result === objectThe cleanup Closure’s normal result is ignored.
ensure does not copy, convert, canonicalize, or re-read the body’s result.
Nested cleanup is LIFO
Section titled “Nested cleanup is LIFO”Nested protected extents unwind structurally from inner to outer.
Conceptually:
(() => { (() => { work() }).ensure(() => { cleanupInner() })}).ensure(() => { cleanupOuter()})exits as:
cleanupInnercleanupOuterThis follows ordinary dynamic nesting. Core does not expose a global cleanup registry or programmer-visible cleanup stack.
Suspension is not cleanup
Section titled “Suspension is not cleanup”A suspension inside the protected body is not semantic scope exit.
For example, if an explicit operation such as:
future.value()suspends the current task, the ensure cleanup does not run merely because the
runtime temporarily unwound a host stack or moved execution to another carrier.
When the task resumes, it continues inside the same protected extent.
Cleanup still runs exactly once when that extent is actually left.
Cleanup itself may suspend
Section titled “Cleanup itself may suspend”Cleanup is ordinary Protos execution.
If cleanup needs to wait for asynchronous release, it can explicitly perform that wait:
(() => { use(file)}).ensure(() => { file.close().value()})If that explicit wait suspends, resumption continues in the same cleanup activation. Already-completed cleanup effects must not be duplicated.
Returning a Future is not the same as awaiting cleanup
Section titled “Returning a Future is not the same as awaiting cleanup”If cleanup merely returns a Future:
(() => { work()}).ensure(() => { file.close()})ensure does not automatically observe or flatten that Future merely because it
was returned.
The cleanup’s normal result is ignored.
If deterministic release requires waiting for asynchronous completion, perform the required observation explicitly inside cleanup according to that resource’s protocol.
The transfer that triggered cleanup stays pending
Section titled “The transfer that triggered cleanup stays pending”While cleanup runs, the result/control transfer that caused scope exit remains pending.
If cleanup finishes normally, that pending outcome continues unchanged.
So a normal cleanup does not transform:
normal resultnon-local returnError unwindcancellation unwindinto a different result.
A later cleanup transfer takes precedence
Section titled “A later cleanup transfer takes precedence”Cleanup is ordinary executable code and can itself transfer control.
If cleanup begins a new transfer that leaves cleanup, that later transfer supersedes the pending earlier one.
For Error precedence, when the cleanup Error escapes cleanup:
body signals originalErrorcleanup signals cleanupErrorcleanupError escapes cleanupthe active transfer becomes cleanupError. If cleanup installs a handler that
completely handles cleanupError inside cleanup and cleanup then completes
normally, no replacement transfer escapes and originalError remains pending.
Core does not automatically create:
suppressed errorscause chainsaggregate exceptionscomposite failure wrappersto preserve both.
Libraries may build such reporting structures explicitly when desired.
Handler selection happens before crossed cleanup
Section titled “Handler selection happens before crossed cleanup”There is an important interaction between handle and ensure.
Suppose a handler has already been selected for originalError, and unwinding
toward that handler crosses an ensure scope.
The selected handler is already inactive while that cleanup runs: it is the
one-shot destination chosen for originalError.
If cleanup completes normally, the unwind reaches that destination and the
selected handler receives originalError.
If a new Error escapes cleanup, it supersedes originalError; the consumed
destination is abandoned and normal search among still-active outer or
cleanup-installed handlers applies. The consumed handler is not reused even if
the replacement Error would also match it.
Cancellation-safe cleanup is narrowly protected
Section titled “Cancellation-safe cleanup is narrowly protected”When cooperative cancellation has already been observed and cancellation unwind is running cleanup, that same already-delivered cancellation request is not re-delivered at suspension boundaries inside that cleanup.
This lets cleanup perform asynchronous release without immediately being interrupted by the very cancellation it is processing.
This is not a general cancellation mask. Future.cancel() is idempotent, so
repeating cancel() on that same pending Future does not manufacture a stronger
request that pierces the shielding of the request already being unwound.
Cleanup can still fail with ordinary Errors or encounter other independent outcomes.
If cleanup completes normally, the pending cancellation continues afterward.
If cleanup signals an Error, the later Error takes precedence and the task fails with that Error rather than completing as cancelled.
Resource release is explicit protocol behavior
Section titled “Resource release is explicit protocol behavior”Core v0.1 defines no deterministic object destructor.
Resources expose their own explicit operations, for example:
file.close()socket.close()The object’s protocol defines what those operations mean and whether they are synchronous or Future-returning.
ensure gives programmers and libraries the general control guarantee needed to
compose those explicit release operations safely.
Garbage collection is not resource management
Section titled “Garbage collection is not resource management”Do not rely on object reachability or a host garbage collector to close deterministic external resources.
Core provides no guarantee that a GC finalizer or reachability callback will run at the semantic point when a resource must be released.
Likewise, ensure is an unwind guarantee while the owning execution can still
run Protos code; it is not a promise that cleanup runs after fail-stop Process/Node
loss or forced termination that prevents further execution. Durable or distributed
recovery requires an explicit higher-level protocol.
The safe mental model is:
acquire explicitlyuse in a protected extentrelease explicitly in cleanupHigher-level resource helpers belong in ordinary libraries
Section titled “Higher-level resource helpers belong in ordinary libraries”With ensure available, libraries can build conveniences such as conceptual:
use(resource, body)withOpen(path, body)withLock(lock, body)without adding each pattern as Core syntax or a new control mechanism.
The helper can acquire the resource, run the user Closure under ensure, and
perform the resource-specific release operation.
This is another example of mechanisms over institutions: one precise dynamic cleanup primitive supports many ordinary library abstractions.
A practical acquisition/cleanup pattern
Section titled “A practical acquisition/cleanup pattern”For a resource already acquired:
(() => { consume(resource)}).ensure(() => { resource.close().value()})is the basic shape when close() returns a Future whose terminal completion must
be observed before cleanup is finished.
When acquisition itself can fail or be cancelled, use the resource/domain contract carefully: only release a resource that actually became owned, and account for the exact commitment/custody rules of that acquisition.
The Standard Library can hide those domain-specific details behind higher-level
helpers while still relying on the same handle/ensure foundations.
Current ordinary-Protos usage
Section titled “Current ordinary-Protos usage”Current repository code already composes these mechanisms in ordinary Protos.
The filesystem convenience module:
uses Error.handle(...) and ensure(...) around asynchronous filesystem
acquisition, reads, cancellation, and cleanup.
The package tool also uses dynamic handlers at ordinary language level:
These are implementation/library examples, not new Error semantics.
Representative executable conformance
Section titled “Representative executable conformance”The retained conformance suite includes focused evidence for handler and cleanup behavior:
../../protos/tests/conformance/error/handle.protoscovers normal protected completion, demonstrates that signaling does not resume the body, and covers selected-handler deactivation;../../protos/tests/conformance/control/ensure-normal-and-validation.protoscovers body-result preservation;../../protos/tests/conformance/control/ensure-control-transfer.protoscovers nested LIFO cleanup;../../protos/tests/conformance/control/ensure-cancellation-structure.protoscovers cleanup during cancellation unwind.
Executable tests demonstrate current behavior. The specification remains the normative authority.
Practical rules to remember
Section titled “Practical rules to remember”- Errors are ordinary objects rooted by delegation at
Error. - A standard failure occurrence normally creates a fresh Error identity.
- Explicitly signaling an existing Error preserves that exact object.
Error.signal()is non-resumable; code after the signal point is abandoned.matchPrototype.handle(body, handler)installs one dynamic unwinding handler.- Handler matching follows ordinary Error-object delegation.
- The dynamically innermost matching handler wins.
- The selected handler is inactive before its handler Closure and crossed cleanup execute.
- Distinct asynchronous child tasks do not inherit a parent’s dynamic handlers.
body.ensure(cleanup)runs cleanup exactly once on normal, return, Error, or cancellation scope exit.- Suspension/replay is not semantic scope exit and must not duplicate cleanup.
- Normal cleanup preserves the body’s pending result/transfer; a later cleanup transfer supersedes it.
- Cleanup’s normal result is ignored; explicitly await asynchronous release when the resource protocol requires it.
- Use explicit resource release; never depend on GC finalization for deterministic lifetime.
- Build higher-level resource helpers in ordinary Protos on top of
ensure.
Normative references
Section titled “Normative references”For exact behavior, consult:
../../spec/semantics/ERRORS.mdfor Error objects, failure occurrence identity,signal, non-resumability, dynamichandle, matching, selection, and Error precedence;../../spec/semantics/EXECUTION_AND_CONTROL.mdfor the standard Closureensureprotocol, scope exits, suspension, LIFO cleanup, pending-transfer precedence, and deterministic cleanup;../../spec/semantics/CALLABLES.mdfor Closure-family receiver rules and ordinary invocation;../../spec/PROTOS_GRAMMAR.mdfor the fact thatsignal,handle, andensureadd no dedicatedthrow/try/catch/finallygrammar;03-closures-methods-and-receivers.mdfor the Closure invocation model used by handlers and cleanup;04-control-flow-through-protocols.mdfor the broader principle that Core control behavior remains ordinary protocol composition.
Those documents define the language. This chapter supplies the programmer-facing model for failure handling and deterministic cleanup.