ADR-239: Ownership Says When a Value Dies, and the Compiler Settles Its Cleanup There

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)

1. Context

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.

2. Decisions

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. 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.

D2 — Ownership decides when a value dies; its 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:

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.

D3 — 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. 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.

D4 — No code of its own: the settle is a call, and the rules for calls answer 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:

NK2602 is retired; NK2601 is D1’s shape.

D5 — Cancellation orphans, the runtime drains, and the deadline names; a panic parks nothing.

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.

D6 — 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.

3. What building it found

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.

4. Rejected alternatives

This site is open source. Improve this page.