ADR-297: A Cleanup Runs Where Ownership Says the Value Dies — Settled by the Compiler, Drained Under a Deadline, and a Cleanup Cut Off Is Exit 70

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: how teardown that performs I/O can run automatically under implicit async (Part I 6.4’s RAII promise against a synchronous destructor); what status and which channel an expired cleanup-deadline ends the program with; #188 (a cleanup the deadline cut off names the resource - the parked-cleanup queue) and the cleanup half of #199; #186 (the cleanup point the ledger should narrate, ADR-094 D5) Related: ADR-005 D6 - no pause while a lock is held; D3 and D6 rest on it. ADR-025 - a block’s end may fail into the enclosing function. ADR-023 D6 and ADR-292 D8, D10 - the secondary list a cleanup failure joins, and a branch’s own. ADR-303 D5 - cleanup-deadline is runtime configuration (nikaia-runtime.toml). ADR-094 D5 - a kept parameter moves its cleanup into the callee; D12 is the narration it owed. ADR-100 D5 and ADR-308 D11 - the ledger diff and NK2402, which D12 mirrors. ADR-292 - select and cancel(), the cancellations D5 handles. Part I 6.4 and 7.2, Part II 12.4, Part III 13.3b, 13.5 and Appendix A.

1. Question

Nikaia promises RAII - cleanup happens and cannot be forgotten - and implicit async, where any function may pause at I/O. Real teardown is often I/O (a flush, a rollback, a TLS goodbye), and a destructor in the language below is synchronous. So: where does a pausable cleanup run, who runs it, what happens when it fails, and what happens when it cannot run at all - and how does a reader learn that a change elsewhere moved where it runs?

A value dies on one of three paths, and they differ in what is possible:

  1. Normal scope exit or return - a pausable context; cleanup may pause.
  2. Error unwinding - likewise pausable.
  3. Cancellation or panic - torn down from outside, or dying of a bug. Whoever cancels must not inherit an unbounded wait, panic unwinding cannot pause, and a target that traps runs no destructors at all.

On paths 1 and 2 cleanup may pause; on path 3 every design needs a synchronous fallback. A design that answers only one half is incomplete.

2. How others do it

Language / tool Its answer What it costs there Source
Rust std::fs::File synchronous Drop that may block freezes a single-threaded event loop; a flush error is swallowed §3
async Rust convention explicit close().await the cleanup duty is the developer’s, and forgettable §3
rustc async_drop pausable destructor unstable and semantically in flux; it would define behaviour, which cannot be handed back §3
C# await using, Python async with pausable teardown at scope end written by the user at every use site D1
sysexits.h EX_SOFTWARE = 70: the program could not complete its work correctly - D7

What does not transfer: the managed languages’ using relies on the user writing the construct; here the compiler places it, as Part I 6.4 promises. What does: Rust’s drop elaboration already knows where a value dies on every path, moves included, so this language need not recompute it (D2).

3. Options, weighed

Option Lost because See
Synchronous Drop that may block freezes the event loop, impossible on WASM’s main thread, swallows errors D2
Fire-and-forget spawn inside drop (or running the cleanup on its own task from Drop) no order, no failure, no deadline; survives only as D5’s structured parking D5
Explicit close() as the model brings back the forgettable duty; kept as the opt-in of D4 D4
Linear types every early return, catch and select needs explicit handling D2
Building on rustc async_drop outsources behaviour to an unstable feature D1
One trait with cleanup and drop, the frontend inserting cleanup().await on every exit path a control-flow rewrite of every body without a control-flow graph, re-deriving drop elaboration D1, D2
A settle point after every statement a pause written into every line that holds such a value, for a little earlier cleanup after a return D2
A rule and a code of its own (NK2602) for a pausable cleanup in a sync context a second place for the pause rules to drift D3
An expired deadline as a warning with no stated status an unsaid status is 0, which is what a supervisor reads D7
A setting that turns an expired deadline back into 0 lets an operator declare data loss acceptable in a file the author never sees D9
A moved cleanup point refused rather than narrated the program is correct either way; NK2402’s reason D12

4. Decision

D1 — impl Cleanup is the one method fn cleanup(ref mut self); the synchronous fallback is its own impl Drop.

impl Cleanup for BufferedFile {
    fn cleanup(ref mut self) throws {
        fs::write(self.path.clone(), fs::Root::Anywhere, self.held.clone(); append: true)
    }
}

impl Drop for BufferedFile {      // only where one is needed
    fn drop(ref mut self) { … }   // runs after cleanup, or alone
}

cleanup may pause and may fail, with throws where it can. User code never calls it; the compiler does, and the .nika source shows nothing. Anything else written in impl Cleanup - another method, another shape, a drop - is refused with NK2601, whose message points a drop at impl Drop. Plain Drop stays for the teardown that is memory work.

Lowering: a type with an impl Cleanup is held in nikaia_std::cleanup::Cleaned<T> (CleanedSend<T> at user_parallelism = yes) wherever its name stands, and made in one by its literal; the method is the type’s own, bridged to std’s Cleanup trait.

D2 — Ownership decides when a value dies; its Drop parks the cleanup, and the compiler settles it.

Changed by ADR-283 D6: a buffer a tethered view points into dies with its last tether, so its cleanup runs then rather than at the end of the scope that made it, and the diagnostic says so where the two orders differ.

Cleaned<T>’s Drop does not run the cleanup: it parks it in the running task’s queue. The compiler writes settle points, which run what was parked in the order the values died:

A return or a failing call ends the body and not the function, so the function’s settle point is reached on every path. What holds such a value - a struct field, a list element, a nullable - has its cleanup too. A pausing lambda’s body and a task’s body are blocks and settle like any other. A break, continue or return out of an inner block passes that block’s settle point; what died there is settled at the next one out, the function’s at the latest, still in the order it died.

D3 — Where nothing may pause, nothing with a pausable cleanup may die; the settle is a call, and the rules for calls answer it.

The checker records a settle point as a call to cleanup::settle (pauses) or cleanup::settle_failing (may also fail with a cleanup::Failure), so sync inference, the throws column and touches read it like any call. The refusals are the existing ones, naming the value: NK2605 (this function can fail because the cleanup of f can fail, with close() catch { … } as the way out), NK2202 in a door’s block, NK2209 in a par_iter lambda, NK2206 in a lambda handed to a sync function type. NK2602 is retired. With ADR-005 D6 this means a pausable cleanup never falls due while a lock is held.

D4 — A failing cleanup fails the function, joins a failure already on its way, and close() hands it back at the call.

The function’s settle point sees the body’s result. A body that succeeded and a cleanup that failed is the function’s failure, so a function acquires a throws obligation from a cleanup it never wrote, by design. A body that already failed keeps its error and the cleanup’s failure joins it as a secondary (ADR-292 D8). x.close() takes the value, runs the cleanup at the call and hands its failure back there; nothing is parked afterwards.

A settle point that cannot fail hands the body’s result back untouched, so a typed channel stays typed; one that can fail puts the cleanup’s failure in the channel, which is then the box. A failure is never settled away: a block with no channel to fail into (a lambda’s body, a guarded half) writes no settle point, and the value stays parked for the next one out. An error that reaches the box with no envelope gets one when something joins it, so the cleanup’s failure survives beside it. An overlap branch is polled with a queue of its own (cleanup::branch); where its channel is the box it settles at the branch’s end and a failure joins that branch’s error (cleanup::branch_after, ADR-292 D10); what it could not settle goes to the settle point around the overlap.

D5 — Cancellation orphans what was parked, and the runtime drains it; a panic parks nothing.

The canceller never waits on foreign I/O. A task swaps its own queues in while it is polled; what is still parked when it is over - cancelled at a pause point, dropped by the runtime, or dying after its last settle point - is handed to the runtime as an orphan. When main’s value is in, the drain starts the orphans, in the order they died, as tasks it waits for (D6). An orphan’s failure has no caller and is said on standard error.

A panic runs only the synchronous drop: Cleaned<T>’s Drop sees the thread panicking and drops the value as it is. On a target that traps rather than unwinds no destructors run at all, and Part I 6.4 says so beside the RAII guarantee.

D6 — Shutdown drains with a deadline, and the wait cannot hang.

At program end the runtime waits for orphaned cleanups and detached tasks, bounded by cleanup-deadline in nikaia-runtime.toml (default "30s"; "0" does not drain). On expiry the remainder are cancelled, which runs their synchronous drop. The timer is the runtime’s and does not depend on any cleanup progressing; drop terminates by construction; and a cleanup waiting on a lock held by a suspended task is impossible under ADR-005 D6. What is left - a dead peer, a slow disk - is what a deadline is for. Honest limit: FFI that blocks the thread rather than pausing blocks a single-threaded loop and its timer with it; the sidecar pattern is the mitigation.

D7 — An expired cleanup-deadline ends the program with exit status 70.

70 is EX_SOFTWARE: the program could not complete its work correctly. It is fixed, documented, and distinct from a panic’s status (an abort is 134 on Linux), so a script can tell the two apart. A program whose cleanups all finished exits as it would have anyway. One code, one meaning.

D8 — The message goes the panic path and names each resource.

The message is delivered as a panic’s is (Part I 7.2): to standard error and through the panic hook, never to standard output, which may be the very file somebody is waiting for. It names each resource cut off by what its type says it is - the cleanup of the file report.txt, of a Slow. Under panic = "abort" the process is gone before the exit code can be set, so the status is the abort’s; the hook has already said what happened.

D9 — No setting turns an expired deadline back into 0.

cleanup-deadline = "0" does not drain, expires nothing, and the program exits as its code decides - a choice visible in the configuration. No key turns an expired deadline into success.

D10 — The panic hook is one last synchronous moment, one per application.

std::panic::on_panic passes through the moment rustc gives a registered hook before aborting, even under panic = abort. One per application, set by the root package; a library calling it is NK2604. It runs on every panic on every target - before the trap, or before the task is poisoned - and supervisors get their crash information from the same PanicInfo. It may not pause; it may block, briefly. It is for diagnosis, not cleanup: it does not flush or salvage, because those objects may be broken in exactly the way that caused the panic. A panic inside the hook aborts at once.

D11 — std’s first cleanup: fs::create hands back a buffered fs::Writer.

write adds to the buffer and does no I/O; flush writes what is held; the cleanup is the flush, and it can fail. It is described in std.contracts with impl Cleanup for fs::Writer, so a program’s use of it goes through D2-D4 as its own types do.

D12 — A cleanup point that moves with a contract is narrated at the call: NK2403.

A callee that keeps a parameter runs its cleanup when it is done with it; one that only reads it is handed a view, and the cleanup runs at the end of the caller’s block (ADR-094 D5). The build compares the committed nikaia.contracts with the one it inferred. A parameter whose keeps changed, on a type whose teardown does something - an impl Drop or impl Cleanup in any ledger the build reads, on the type or a type it holds - is a moved cleanup point, and each call in the package that hands it a name is told once:

warning[NK2403]: `look` keeps `h` now, so `h`'s cleanup runs when `look` is done
                 with it rather than at the end of this block

A warning and not a refusal, as NK2402: the program is correct either way, and committing the ledger diff is the acknowledgement. A temporary is not narrated; a parameter the old signature lacks is new, not moved. A call into another package is not narrated: that package’s build rewrites its ledger first (ADR-100 D5), so there is nothing committed to compare.

D13 — A list or tuple literal keeps what it is given.

[h] and (h, 1) build a value that holds h, so a parameter standing there is kept - the clause a struct literal already had.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.