Status: Superseded by ADR-297 — its D1-D2 are D1-D2 there, D3 is D4, D4 is D3, D5 is D5 and D8, D6 is D11
Date: September 27, 2026
Target Version: Nikaia 0.0.224
Answers: #188, a cleanup the deadline cut off names the
resource — step 1, the parked-cleanup queue — and the cleanup half of #199
Refines: ADR-006 D1 (what impl Cleanup holds, and how the
call gets to every exit), D2 (the sync rule, which needs no code of its own);
builds its D3, D4 and D5
Related: ADR-112 (exit 70 on the panic path),
ADR-115 D3 (a cleanup that fails while a branch is failing joins
that branch’s error), ADR-236 (where a moved cleanup is
narrated), ADR-148 (cancellation)
ADR-006 was accepted and never built. Read again before building it, two of its decisions described a harder mechanism than the language needs.
D1 put cleanup and drop in one trait and had the frontend insert
cleanup().await on every exit path of every block: each return, each
failing call, each break out of a loop, each arm of a match that leaves.
That is a control-flow rewrite of the whole body, done by a compiler that has
no control-flow graph, and it re-derives something the language below already
knows exactly: where a value dies. Rust’s drop elaboration answers that on
every path, moves included, and it is not the frontend’s to recompute.
D2 made a second rule for a value with a pausable cleanup in a sync
context, with a code of its own (NK2602). But the cleanup is a pause, and
the rules for pauses — a door’s block, a par_iter lambda, a lambda handed to
a sync function type — are already written, tested and worded. A second rule
beside them is a second place for them to drift.
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. Anything else
written in impl Cleanup is refused with NK2601: another method, another
shape, and a drop, which the message points at impl Drop. That is Rust’s
own split, and it is also the reading one: a drop is the same method whether
or not the type has a cleanup beside it.
Lowering. A type with an impl Cleanup is held in
nikaia_std::cleanup::Cleaned<T> wherever its name stands — a let, a field,
an element, a parameter — and made in one by its literal. The method is
emitted as the type’s own, and a one-line bridge implements std’s Cleanup
trait over it. At user_parallelism = yes the wrapper is CleanedSend<T>,
whose parked work may move with its task.
Drop parks the cleanup, and the compiler settles.Cleaned<T>’s Drop does not run the cleanup, which it cannot: 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 inside the body ends the body and not the
function, so the function’s settle point is reached on every path — which is
all of ADR-006 D1’s every exit that needs a rewrite. What holds such a value
has its cleanup too, because the value dies with it: a struct field, a list
element, a nullable. A pausing lambda’s body and a task’s body are blocks and
settle like any other.
What this leaves as it was, named: a break, continue or return out of an
inner block passes that block’s settle point, and what died there is settled
at the next one out — the function’s, at the latest — still in the order it
died.
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. A body that already failed
keeps its error, and the cleanup’s failure joins it as a secondary, the
chain every error carries — ADR-115 D3’s attachment, for a
branch that is a call. x.close() runs the cleanup at the call and hands its
failure back there, with nothing parked afterwards; close takes the value.
A settle point that cannot fail — every cleanup in the function is one that cannot — hands the body’s result back untouched, so a function that raises its own error keeps its channel typed as it was. One that can fail puts the cleanup’s failure in the channel, which is then the box, as for any failure whose type the program does not name. A function that cannot fail and whose cleanup nonetheless did (one that is refused below unless it caught) says it on standard error, as an orphan does.
A failure is never settled away. A block whose cleanup can fail and which has no channel to fail into — a lambda’s body, a guarded half — writes no settle point of its own; the value stays parked for the next one out, which has one. (0.0.225: until then such a block settled quietly and the failure was lost.)
An overlap branch has a queue of its own (0.0.225). The branches run at
once in one task, so a settle point in one would otherwise run what another
parked, and a failure would land on the wrong branch. Each branch is polled
with its own queue swapped in (cleanup::branch); where its channel is the box
it is settled at the branch’s end and a failure joins that branch’s error
(cleanup::branch_after) — ADR-115 D3 for a branch written in
place. What a branch could not settle goes to the settle point around the
overlap. A branch that is a block counts as one that can fail where a value
it binds has a cleanup that can.
What joins a boxed error is kept (0.0.225). An error that reaches the box
with no envelope — a library’s, or one a callee raised in a typed channel that
a ? put in the box — used to drop whatever joined it. It is now put in an
envelope with no site at the moment something joins it, so a cleanup’s failure
survives next to it.
The checker records the settle point as a call to cleanup::settle, which
pauses, or cleanup::settle_failing, which may also fail with a
cleanup::Failure. So every analysis that reads calls reads it: sync inference, the throws column, touches.
The refusals are the existing ones, with the value named:
NK2605 — this function can fail because the cleanup of f can fail,
with close() catch { … } as the other way out (ADR-006 D4’s message);NK2202 — the cleanup of held can pause, and this runs with a lock held;NK2209 in a par_iter lambda, NK2206 in a lambda handed to a sync
function type.NK2602 is retired; NK2601 is D1’s shape.
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 under cleanup-deadline. A failure has no caller and is
said on standard error.
The deadline names. A cleanup still running when the deadline expires is
ADR-112’s failure of the program — exit status 70, on the panic
path — and the message names each resource by what its type says it is (the
cleanup of the file report.txt, of a Slow), which is what D2 asked for
and could only count.
A panic parks nothing. Cleaned<T>’s Drop sees the thread panicking and
drops the value as it is: its impl Drop runs and nothing that waits.
std has the first one: 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 like
any entry, with impl Cleanup for fs::Writer, so a program’s use of it goes
through D2–D4 exactly as its own types do.
The drain at the end of main never reached its park once main’s value was
in: main’s alarm was asked below and never taken above, so the loop went
round for ever and the clock a sleeping task waits for was never read. A task
nobody joined that slept after main was done waited out the whole 30-second
deadline. Fixed in rt::exec::block_on, with a test beside ADR-148’s.
Drop — fire-and-forget, the
alternative ADR-006 §3 already rejected: no order, no failure, no deadline.return, and writes a pause into every line of every function
that holds such a value.NK2602 — a second rule for a case the pause rules already
answer, in their words.