skip to content

In Ruby, in what order do at_exit handlers run, and how can a handler read or change the exit status?

level: middleimportance: should knowfreq 33%

answer

  1. last registered, first run
  2. $! holds the ending exception
  3. SystemExit#status and success?
  4. exit inside a handler
  5. END { } registers once

basics

~20 s

at_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 lines
ruby
failures = []

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
end

go deeper

for a junior

Recall that at_exit registers code to run when the program ends and that the handlers run last-registered-first.

for a middle

Explain $! inside a handler, SystemExit#status and success?, how exit in a handler changes the status, and that exit! skips handlers entirely.

for a senior

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.

for a principal

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