RFA-213 · Case file with fixtures · Case 185 of 694 · Runtime evidence
std::process::exit Skips Rust Destructors
std::process::exit terminates without unwinding and does not run Drop on thread stacks. Return ExitCode or Result from main when normal RAII cleanup is required.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- targets with process spawning; exit-code representation is platform-specific
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- process::exit terminates immediately without unwinding or running destructors on the current stack or other thread stacks.
- First discriminating check
- Run the cleanup type in a child process, compare process::exit with returning ExitCode, and observe an external marker from the parent.
I once saw process::exit used as an easy way to return an error code from deep inside a command. The code owned buffered output and cleanup guards. None of their Drop implementations ran.
The failing program starts a child containing a destructor that writes a marker file. The child calls process::exit(17). Its parent observes exit code 17 and no marker.
Immediate process termination is not normal scope exit.
process::exit does not unwind
std::process::exit never returns. It terminates the process without running destructors on the current stack or the stacks of other threads.
This skips ordinary RAII cleanup for values such as:
BufWriter pending bytes
temporary-file deletion guards
lock guards and transaction guards
tracing flush guards
application shutdown objects
The operating system still reclaims process memory and closes handles, but that is not the same as executing application cleanup logic.
The repaired program returns ExitCode from the child path. The local marker drops before main completes, and the process still reports code 17.
Returning from main preserves normal scope cleanup
Rust allows main to return types implementing Termination, including ExitCode and suitable Result values. Returning lets stack scopes end normally before the runtime terminates the process.
For a command-line application, I usually propagate errors to one top-level boundary, print the final diagnostic there, flush important output, and return the selected code.
This keeps policy at the edge. A parsing helper should not decide to kill unrelated threads and bypass all cleanup because one input was invalid.
Panic behaviour depends on the panic strategy
With unwinding enabled, a panic normally drops initialized stack values as unwinding crosses their scopes. With panic=abort, the process stops without that destructor path. A panic that occurs while another panic is already unwinding can also abort.
The Rust Reference destructor section groups exit, abort, and aborting panic behaviour as ways normal destructors may not run.
I do not build correctness that requires Drop to execute under every possible process failure. Power loss, kill signals, and hardware failure exist too. RAII gives strong normal and unwinding cleanup, not an external durability guarantee.
Drop must not be the only record of committed work
A destructor is excellent for releasing an in-process resource. It is a poor only mechanism for committing business state.
If a payment, database migration, or distributed lease needs a durable transition, I write that transition explicitly and make it recoverable. Drop can provide best-effort local cleanup around the protocol, but another process must be able to determine what happened after a crash.
Temporary files often use write, sync where required, rename, and startup cleanup rules. Hoping a destructor deletes every partial file is not enough.
Other threads are stopped too
The documentation explicitly includes other thread stacks. Calling process::exit from one worker does not join the remaining threads or run their local destructors.
This can cut logs in half, abandon buffered telemetry, and leave external operations at an unknown point. The OS releases process-owned resources, while remote systems may still have received partial requests.
I send an error toward the owning thread instead. The owner cancels or joins work according to a declared shutdown order, then returns the final process status.
Exit handlers are a different mechanism
Platform exit handlers may still run, but they are not Rust stack destructors. They have strict synchronization concerns because exit can be called while other threads are using shared state.
I do not move normal Rust cleanup into global exit hooks to compensate for a deep process::exit. Structured ownership and a top-level return remain easier to test.
The fixture needs a separate process
Testing this inside one process would terminate the test runner before it could inspect anything. The evidence uses a parent-child design. The child chooses between immediate exit and returning; the parent verifies code and marker.
This is a general testing principle for aborts, signals, and crashes: observe them from outside the failure boundary. An in-process assertion cannot run after the process is gone.
I also remove the marker before every run so stale evidence cannot create a false pass.
The core principle is that scope-based cleanup requires scope exit. process::exit bypasses it. I return ExitCode from the program boundary for controlled failures and design durable recovery for cases where no destructor can be guaranteed.