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.
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:
return - a pausable context; cleanup may pause.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.
| 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).
| 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 |
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.
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:
let, a literal, a
call that hands one back, a parameter the function keeps.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
[h] and (h, 1) build a value that holds h, so a parameter standing there
is kept - the clause a struct literal already had.
Drop and Cleanup side by side, the settle rule in a
reader’s words (the value dies where ownership says it does), the
close() opt-in and the panic honesty note; Part I 7.2 the hook; Part II
12.4 the meaning of cancelled and cleaned up; Part III 13.3b
cleanup-deadline, Appendix A the exit-70 row that is neither a panic nor
success, and Appendix C NK2601, NK2403, NK2604, NK2605.70 restarts, alerts or stops the pipeline; one
that saw 0 would not have known.sync context,
because of a cleanup it never wrote; a cleanup after a break out of an
inner block runs later than the block’s end.keeps gains the literal
clause (D13).select offers an opt-in wait for the loser’s cleanup for
transactional races (ADR-292).main is
main’s failure and has its own.