Cleanup TraitStatus: Superseded by ADR-297 — its D1 is D1-D2 there (as refined by ADR-239), D2 is D3, D3 is D5, D4 is D4, D5 is D6, D6 is D10 Related: ADR-005 D6 (no suspension while a lock is held — the rule D5’s termination argument rests on), ADR-025 (a block’s end may fail, and what that does to the enclosing function), ADR-023 D6 (the secondary error D4 attaches)
Nikaia promises RAII — cleanup happens, you cannot forget it — and implicit async, where any function may pause at I/O. These collide: real cleanup is often itself I/O (flushing a buffered file, rolling back a transaction, a TLS goodbye), but a destructor in the Rust backend is synchronous and cannot contain a suspension point.
A value dies on one of three paths, and they differ in what is possible:
return — a pausable context; cleanup may pause.select, a
supervisor restart) or dying of a bug. This context cannot wait on
arbitrary I/O: whoever cancels must not inherit an unbounded wait, panic
unwinding cannot pause, and a target that traps rather than unwinds runs no
destructors at all.The rule that follows: on paths 1 and 2 cleanup may pause; on path 3 every design needs a synchronous fallback. A proposal that answers only one half is incomplete.
Cleanup is a pausable cleanup plus a synchronous drop, inserted by the compiler.impl Cleanup for BufferedFile {
// Pausable, may fail. Called automatically at end of scope,
// on normal exit and during unwinding.
fn cleanup(&mut self) throws IoError { self.flush() }
// Last resort: must not pause, must not fail. Runs after
// cleanup(), or alone when cleanup() cannot run (path 3).
fn drop(&mut self) { /* release the handle — nothing that waits */ }
}
What Rust user code cannot do generically — insert cleanup().await on every
exit path of a block — the frontend does during lowering, the same class of
transformation as the state machines rustc builds for async fn. The .nika
source shows nothing, so Part I 6.4’s promise survives intact.
The semantics are C# await using and Python async with, except that there
the user writes the construct at every use site and here the compiler inserts
it. Plain Drop remains for the majority of types whose teardown is memory work.
sync context.A sync function cannot pause, so no such value may go out of scope inside one.
Checked statically and virally through the existing sync system, reported as
NK2602 with the ways out named: hand the resource back, close it before
entering the sync part, or use a non-buffering variant.
This composes with ADR-005 D6 for free: access takes a sync
lambda, so async cleanup can never fall due while a lock is held.
Cancellation. The canceller never waits on foreign I/O. The runtime adopts
the due cleanup() runs as its own detached tasks: the cancelled task is gone
immediately, the cleanup proceeds in order, and the runtime waits for all parked
cleanups before the program exits (D5). Structured draining, not
fire-and-forget — no silent data loss.
Panic. Only the synchronous drop runs. On a target that traps rather than
unwinds, no destructors run at all; that fact is stated next to the RAII
guarantee in Part I 6.4 rather than being discoverable from an availability
table.
The inserted cleanup() is a real call site: if it declares throws IoError,
the enclosing function can now fail and must say so. This is correct rather than
annoying — a buffered write can fail at flush, and hiding that is the
decades-old stdio data-loss bug. The diagnostic names the resource
responsible.
Where the value dies while an error is already unwinding, the cleanup error is
attached to the primary one as a secondary error rather than replacing it —
the secondary list every error carries (ADR-115 D1), which an
overlap’s other failures use as well.
Precise handling is available by calling close() explicitly, which consumes
the resource and returns the error normally; after that no implicit cleanup
runs. On the parked path there is no caller to throw to, so the error goes to
the runtime’s error hook — consistent with the cancelled task’s result already
being discarded.
At program end the runtime waits for parked cleanups and detached tasks,
bounded by cleanup-deadline in nikaia.toml (default 30 s; "0" disables
draining). On expiry the remainder are cancelled, which runs their synchronous
drop, and the program ends with exit status 70 and a message naming every
resource that did not finish cleanly, delivered the way a panic’s is — standard
error and the panic hook (ADR-112).
This cannot hang. The deadline timer is driven by the runtime and does not
depend on any cleanup making progress. Cancelling at the deadline runs drop,
which terminates by construction. And the classic deadlock — cleanup waiting on
a lock held by a suspended task — is already impossible under
ADR-005 D6, since locks are never held across a suspension point.
What remains (a dead TLS peer, a slow disk) is exactly what a deadline is for.
Honest limit: FFI that blocks the thread rather than pausing will block a single-threaded event loop and with it the deadline timer. That is the general single-threaded-runtime FFI hazard, not something cleanup introduces, and the sidecar pattern is the mitigation.
Abort means no unwinding runs; it need not mean no code runs. rustc invokes a
registered panic hook before aborting even under panic = abort, and Nikaia
passes that moment through as std::panic::on_panic.
NK2604); a crash-reporting library exports a function the
app’s hook calls. Handler chains in the crash path are complexity where
surprise is least affordable.PanicInfo.sync: mid-panic there is no runtime to pause on. Third use of
the same vocabulary, after ADR-005 D6 and D2 above.Drop that may block (Rust’s std::fs::File today) — freezes
a single-threaded event loop, is unimplementable on WASM where the browser forbids
blocking the main thread, and swallows errors.drop — no ordering guarantee, so the
program can exit before the flush; errors land nowhere. Survives only as D3’s
structured variant, which D5 makes lossless.close() as the model (today’s async-Rust convention) — honest,
but reintroduces the forgettable cleanup duty Part I 6.4 exists to abolish.
Kept as the opt-in of D4.catch and select needs explicit handling, which contradicts the
happy-path promise.async_drop — direction-confirming but semantically
in flux. An unstable feature that only accepts more correct programs could be
declined today and picked up unchanged on the day one is needed, because
nothing would have been built on top of it. async_drop is not that kind of
feature: it would define behaviour, and behaviour cannot be handed back.
Too much to outsource.Part I 6.4 carries Drop and Cleanup side by side, and the panic honesty
note. Part II 12.4’s “cancelled and cleaned up” gains defined semantics.
Part III gains cleanup-deadline and the NK26xx diagnostics.
A function can acquire a throws obligation from cleanup it never wrote, which
is by design — and is the same rule ADR-025 reaches from the
direction of loops.
The ledger is unaffected: Cleanup changes when code runs at scope exit, not
what borrows what.
Open: whether select should offer an opt-in “wait for the loser’s cleanup”
mode for transactional race patterns.