main Still Holds Is Freed, Unless an Optimization Leaves It to the OSStatus: Accepted
Date: October 14, 2026
Answers: whether a program frees what it still holds when main ends, or
leaves the memory to the operating system (#607)
Related:
ADR-297 D5, D6 (cleanups, the drain, cleanup-deadline);
ADR-306 D14 (optimization words);
ADR-343 D1, ADR-346 D6 (how main ends);
ADR-218 (no unsafe in std);
Part III 13.3, 17.1
When main ends, every value it still holds dies: its Drop runs, its
cleanup runs (Part I 6.4), and its memory is handed back to the allocator one
block at a time. The operating system takes the whole memory of the process
when it ends anyway, so the last step changes nothing anybody can see; it only
costs instructions. Should a program skip it?
skip-free-at-exit:on leaves the memory to the operating system.By default everything main still holds is freed as it dies, as anywhere
else in the program.
optimization = "skip-free-at-exit:on" in nikaia.toml, or
--optimization=skip-free-at-exit:on for one build, changes only the memory:
from the end of main’s last statement until the drain begins, every value
still dies in order, its Drop and its cleanup still run, but the block it
occupied is not handed back. The drain (unjoined tasks, parked cleanups,
cleanup-deadline), the exit status and what a detached task’s error prints
are those of the default. A main that leaves by return, by an error or by
process::exit frees as by default.
What the option changes is written into the program: an allocator that is the
system’s and skips a free in that window, and one call after main’s last
statement. A program built without it carries neither and pays nothing.
The option skips frees, not deaths: nothing that writes, flushes, closes or
rolls back is skipped, so no Drop in std has to become a cleanup first.
The audit, on crates/nikaia-std/src at 0.0.918, for the record: no Drop
in std has an effect outside the process. PollingMain, Armed,
InFlight, Sender, Released, Own, Frame, Cancellable, InATask,
Watch, OpenBlock and the Cleaned wrappers act on the runtime’s own
state (a queue, a count, a registration, a lock), and Cleaned parks the
value’s cleanup, which then runs in the drain. std holds no BufWriter or
LineWriter; a File and a socket close their descriptor, which the kernel
does at the end of the process in the same way.
heaptrack) reads every block still allocated at exit as a
leak. A program that frees by default is clean under them without a switch
to remember.optimization, part of the build’s cache key.Drop with an
outside effect that is not a cleanup is lost without a word.std::process::exit after the drain or mem::forget of main’s
values. Both skip the values’ deaths, so a cleanup owned by one of them
never runs (see Reasons).nikaia-runtime.toml, ADR-303 D5). The allocator
has to be declared in the program; a setting read at startup would put the
check into every program’s every free, and the operator gains nothing from
choosing it.benches/brc (1 000 000 rows, about 400 stations), callgrind, instructions
of the whole process, release builds on the same commit:
user-parallelism = "no", remove-bounds-checks:on,remove-overflow-checks:on,
opt-level = 3, no LTO; the same output byte for byte:
| build | instructions | conditional branches | mispredicted |
|---|---|---|---|
| default | 300 192 748 | 40 946 704 | 1 606 404 |
skip-free-at-exit:on |
300 184 756 | 40 947 386 | 1 574 539 |
The free at the end of brc costs about 8 000 instructions, 0.003 per cent: a
map of about 400 stations. What the option saves grows with the number of
blocks main still holds at its end, not with the work the program did.