In Ruby, in what order do at_exit handlers run, and how can a handler read or change the exit status?
answer
- last registered, first run
- $! holds the ending exception
- SystemExit#status and success?
- exit inside a handler
- END { } registers once
basics
~20 sat_exit handlers run in reverse order of registration when the program ends. Inside one, $! holds the exception that ended the program (a SystemExit after exit, nil after a normal finish), and calling exit there replaces the final status.
solid answer
~40 s`at_exit { ... }` converts the block to a `Proc`, registers it and returns it; without a block it raises `ArgumentError`. When the program ends through a normal finish, `exit`, `abort` or an uncaught exception, Ruby runs the handlers last-registered-first, like a stack, so the handler registered earliest (often the outermost resource) runs last. Inside a handler, `$!` is the exception that ended the program: a `SystemExit` after `exit` or `abort` (check `$!.status` and `$!.success?`), the uncaught error otherwise, and `nil` after a normal finish. Calling `exit(n)` inside a handler sets the process status to `n`; test runners use exactly this to report results. `exit!` skips every handler, and `END { }` blocks join the same list.
code
ruby · 12 linesfailures = []
at_exit do
# main finished normally ($! is nil) but work was recorded as failed
exit 1 if $!.nil? && !failures.empty?
end
Dir.glob("/var/log/app/*.log").each do |path|
File.rename(path, "#{path}.1")
rescue SystemCallError => e
failures << e
endgo deeper
Recall that at_exit registers code to run when the program ends and that the handlers run last-registered-first.
Explain $! inside a handler, SystemExit#status and success?, how exit in a handler changes the status, and that exit! skips handlers entirely.
Judge when process-wide cleanup belongs in at_exit versus ensure, keep handlers fast during shutdown, and watch for libraries that register handlers behind your back.
Decide how services and scripts report shutdown: which cleanup is guaranteed, which statuses signal what, and whether libraries may register exit hooks at all.
## What `at_exit` registers `Kernel#at_exit` takes a block, converts it to a `Proc` (so it keeps the variables visible where it was written) and adds it to the list of **end procs**, returning the `Proc`. Calling it without a block raises `ArgumentError`. `END { ... }` blocks are added to the same list; the difference is that an `END` block registers only once, even if its line runs many times. ## When handlers run, and when they do not | How the program ends | Handlers run? | `$!` inside a handler | |---|---|---| | main script finishes | yes | `nil` | | `exit(n)` | yes | `SystemExit`, `status == n` | | `abort(msg)` | yes | `SystemExit`, `status == 1`, `message == msg` | | uncaught exception | yes | that exception | | `exit!(n)` | no | (never runs) | A process killed by a signal that cannot be handled never reaches Ruby code at all, so no handler can help there. ## Order: a stack Handlers run in **reverse order of registration**. The mental model is a stack of cleanups: code that acquires a resource early registers its cleanup early, and that cleanup runs last, after everything built on top of it. ```ruby at_exit { puts "3: release the rotation lock" } at_exit do if $!.is_a?(SystemExit) && !$!.success? puts "2: rotation failed with status #{$!.status}" end end at_exit { puts "1: flush metrics" } puts "rotating" exit 4 # rotating # 1: flush metrics # 2: rotation failed with status 4 # 3: release the rotation lock # process status: 4 ``` ## Reading and changing the status - **Reading.** `$!` tells the handler why the program is ending. For `SystemExit`, `status` returns the integer and `success?` whether it is zero. For any other exception the status will be 1. - **Changing.** Calling `exit(n)` inside a handler raises a new `SystemExit` there, and its status becomes the process status. Test runners register a handler that runs the whole suite at exit time and then calls `exit` with the result; Minitest's `autorun` works this way. - **Registering from a handler.** A handler may itself call `at_exit`; the new handler still runs before the process ends. ## Practical uses and traps 1. **Cleanup that must happen on every path**, such as deleting a PID or lock file. Prefer `ensure` around the main work when the cleanup belongs to one block of code; use `at_exit` when the resource is process-wide. 2. **Final reporting**: print a summary, push a metric, or turn a recorded failure into a non-zero status. 3. **Libraries registering handlers** are surprising: they run in every program that loads the library. Document it, or offer an explicit `shutdown` method instead. 4. **Handlers must be quick and safe.** They run while the program is shutting down; a handler that blocks on the network delays the exit of a cron job or a container stop. 5. **Handlers inherited by child processes** run again in each child unless the child ends with `exit!`. ## Comparison with `ensure` - `ensure` is lexical: it protects one `begin` block and runs as the stack unwinds. - `at_exit` is global: it runs once, at program end, after every `ensure` on the way out has run. - Neither runs after `exit!`. ## Errors inside handlers If a handler raises, Ruby reports the error and carries on with the remaining handlers instead of abandoning them. If the error is a `SystemExit` (the handler called `exit`), nothing is printed and its status becomes the process status; if it is another exception, its message and backtrace are printed and the status becomes 1. The practical consequences: - a failing cleanup handler does not stop the other cleanups from running; - a handler that calls `exit` changes the status but does not cancel the handlers still queued; - wrapping each handler's body in its own `begin`/`rescue` keeps a cleanup failure from turning a successful run into status 1.
- Why do test runners hook `at_exit` instead of running tests when the file loads?Test files are loaded one after another; the runner needs all of them defined before it starts. Registering a handler defers the run to program end, after every file has loaded, and the handler then calls `exit` with the suite's result so the shell sees pass or fail.
- When would you choose `ensure` over `at_exit` for cleanup?When the resource belongs to one block of work, such as a file opened for a single rotation. `ensure` runs as soon as that block ends and keeps cleanup next to acquisition. `at_exit` suits process-wide resources such as a PID file that lives as long as the program.
at_exit handlers are like plates stacked as a kitchen closes: the last plate placed on top is the first one washed, and the bottom plate, put down first, is washed last.
saying these in an interview costs you the question
- at_exit handlers run in the order they were registered
- $! is always nil inside an at_exit handler
- Calling exit inside an at_exit handler has no effect on the status
- exit! still runs at_exit handlers but skips ensure
- An END block inside a loop registers one handler per iteration