ADR-006: Cleanup That Can Pause — the Cleanup Trait

Status: 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)

1. The three death paths (normative)

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:

  1. Normal scope exit or return — a pausable context; cleanup may pause.
  2. Error unwinding — likewise pausable; same conclusion.
  3. Cancellation or panic — torn down from outside (a lost 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.

2. Decisions

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

D2 — A value with pausable cleanup may not die in a 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.

D3 — Cancellation parks the cleanup with the runtime; panic gets only the fallback.

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.

D4 — Cleanup errors throw normally, and signatures tell the truth.

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.

D5 — Shutdown drains with a deadline.

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.

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

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.

3. Rejected alternatives

4. Consequences

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.

This site is open source. Improve this page.