exit raises SystemExit¶
Level: 201 · anyone writing a command-line tool, or a rescue around code that might call exit
One line: exit is not a system call but a raise of SystemExit, so ensure clauses and at_exit handlers run, a rescue SystemExit can stop it, and a bare rescue cannot see it — while exit! really does leave at once, abort is exit 1 with a message on stderr, and an unrescued Interrupt ends the process by the signal, not by a status.
Ending a Ruby process is an exception in flight. exit (and exit 3, exit true, exit false) raises SystemExit, whose status becomes the process's exit status once it reaches the top; on the way up it unwinds like any exception — ensure clauses run, a rescue SystemExit (or rescue Exception) can catch it and carry on, and because SystemExit is not a StandardError a bare rescue lets it through. After the top is reached, the at_exit handlers run, last-registered first, and only then does the process end. exit! skips all of that — no unwinding, no handlers — and abort("msg") prints the message to stderr and then does an ordinary exit 1. An exception that nobody rescues is reported on stderr, the process exits with status 1, and the at_exit handlers still run, with $! holding the exception. Interrupt, what Ctrl-C raises, is a SignalException, not a StandardError; left unrescued, Ruby re-delivers the signal so that the parent sees a process killed by SIGINT rather than a status. The program below cannot show any of this in its own process without ending it, so each row starts a child Ruby with RbConfig.ruby -e and prints the child's stdout, stderr and exit status.
Verified output of exit_at_exit_and_system_exit_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. exit raises SystemExit, which a handler can rescue
begin; exit; rescue SystemExit => e; ...; end; puts "still running"
stdout: rescued SystemExit: status 0, success? true
stdout: still running
exit status 0
2. a bare rescue does not see it
begin; exit 3; rescue => e; puts "caught"; end; puts "after"
exit status 3
3. the exit status: an Integer, or true for 0 and false for 1
exit 3
exit status 3
exit true
exit status 0
exit false
exit status 1
4. at_exit handlers run in reverse order of registration
three at_exit blocks, then puts "main done"
stdout: main done
stdout: third registered
stdout: second registered
stdout: first registered
exit status 0
5. exit! skips them
at_exit { puts "at_exit ran" }; exit! 4
exit status 4
6. abort writes its message to stderr, runs at_exit, and exits 1
at_exit { puts "at_exit ran" }; abort("fatal: giving up")
stdout: at_exit ran
stderr: fatal: giving up
exit status 1
7. an uncaught exception reports on stderr and exits 1; at_exit still runs, and sees it in $!
at_exit { puts "at_exit sees $!: ..." }; puts "before"; raise "boom"
stdout: before
stdout: at_exit sees $!: RuntimeError
stderr: -e:1:in '<main>': boom (RuntimeError)
exit status 1
8. Interrupt is a SignalException; an unrescued one ends the process by the signal
raise Interrupt, rescued as SignalException
stdout: Interrupt < SignalException, signo 2
exit status 0
a real SIGINT, not rescued: Process.kill("INT", Process.pid); sleep 5
stderr: -e:1:in '...': Interrupt
stderr: from -e:1:in '...'
killed by signal 2
a real SIGINT, rescued as Interrupt
stdout: rescued Interrupt
exit status 0
9. this program's own at_exit handler prints last
Reading the output¶
Row 1: exit inside begin … rescue SystemExit => e is caught, its status is 0 and success? is true, and the child prints still running and ends with status 0 — the exit was cancelled. Row 2 is the same with a bare rescue: nothing is printed, not caught and not after, and the status is 3; SystemExit passed straight through. Row 3 maps the argument to the status: 3 is 3, true is 0, false is 1.
Row 4 registers three at_exit blocks and prints main done first, then third, second, first — reverse order, like a stack of cleanups. Row 5's exit! 4 prints nothing: the handler never ran, and the status is still 4. Row 6 is abort: the message on stderr, the handler on stdout, status 1. Row 7 is an uncaught RuntimeError: before on stdout, the one-line report -e:1:in '<main>': boom (RuntimeError) on stderr, status 1 — and the at_exit handler still ran, and found the exception in $! (the child's stdout and stderr are captured as two streams, so the output cannot show which of the two lines came first).
Row 8 is the signal. raise Interrupt is rescued as a SignalException with signo 2. A real SIGINT, sent by the child to itself and not rescued, leaves no exit status at all: the parent sees killed by signal 2 (the frame label in the stderr line is masked, since which frame the signal lands in is the interpreter's business), which is how a shell tells a Ctrl-C from an exit 130. Rescued as Interrupt, the same signal is an ordinary exception and the child exits 0. Row 9 is this program's own at_exit handler, registered on its first lines and printed after everything else.
Compared with Python¶
Python ends a process the same way — sys.exit raises SystemExit, finally clauses run, except SystemExit cancels it (row 1), and except Exception does not see it (row 2) because SystemExit hangs off BaseException. Row 3 has a trap in the same words: sys.exit(True) exits with status 1, because True is the integer 1, where Ruby's exit true is success — and sys.exit(False) is 0. atexit.register (row 4) runs its handlers in reverse like at_exit, and os._exit (row 5) is exit!. Row 6: there is no abort; sys.exit("fatal: giving up") prints the string to stderr and exits 1, which is the same thing. Row 7 differs in what the handler can see: the traceback has already been printed when atexit runs, and sys.exception() is None — Ruby's $! is still set. Row 8: KeyboardInterrupt is what the default SIGINT handler raises, a BaseException; uncaught, it too ends the process by the signal (subprocess reports a negative return code, printed here as killed by signal 2). The twin prints only the last line of each child's stderr, because CPython's traceback layout — the source echo and the caret lines — differs between 3.12 and 3.14, while the last line does not.
Verified output of exit_at_exit_and_system_exit_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. sys.exit raises SystemExit, which a handler can catch
try: sys.exit() except SystemExit as e: ...; print("still running")
stdout: caught SystemExit: code None
stdout: still running
exit status 0
2. except Exception does not see it
try: sys.exit(3) except Exception: print("caught"); print("after")
exit status 3
3. the exit status: an Integer -- and True is 1, False is 0
sys.exit(3)
exit status 3
sys.exit(True)
exit status 1
sys.exit(False)
exit status 0
4. atexit handlers run in reverse order of registration
three atexit.register calls, then print("main done")
stdout: main done
stdout: third registered
stdout: second registered
stdout: first registered
exit status 0
5. os._exit skips them
atexit.register(...); os._exit(4)
exit status 4
6. sys.exit with a string writes it to stderr, runs atexit, and exits 1
atexit.register(...); sys.exit("fatal: giving up")
stdout: atexit ran
stderr (last line): fatal: giving up
exit status 1
7. an uncaught exception prints a traceback and exits 1; atexit still runs, but sys.exception() is gone
atexit.register(...); print("before"); raise RuntimeError("boom")
stdout: before
stdout: atexit sees sys.exception(): None
stderr (last line): RuntimeError: boom
exit status 1
8. KeyboardInterrupt is a BaseException; an uncaught one ends the process by the signal
raise KeyboardInterrupt, caught as BaseException
stdout: KeyboardInterrupt < BaseException
exit status 0
a real SIGINT, not caught: os.kill(os.getpid(), signal.SIGINT); time.sleep(5)
stderr (last line): KeyboardInterrupt
killed by signal 2
a real SIGINT, caught as KeyboardInterrupt
stdout: caught KeyboardInterrupt
exit status 0
9. this program's own atexit handler prints last
The Python library's Ctrl-C is a signal ↗ is the page on row 8 from the Python side: KeyboardInterrupt is what the default handler does with SIGINT, and signal.signal() replaces it.
Try it¶
- Write two child scripts (as strings, run with
Open3.capture3(RbConfig.ruby, "-e", …)) that register anat_exithandler printingcleanupand then callexit 2inside abegin … rescue => e … endblock, the second one withexit! 2instead. Print each child's stdout, stderr and status, and read off which of the handler, the rescue and the line after the block ran. - Write a child that rescues
SystemExit, printse.status, and re-raises it with a bareraise; confirm the parent still sees status 3 and that theat_exithandler ran.
Solution to 1
exit_at_exit_and_system_exit_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: two child scripts that differ in one character, exit 2 versus exit! 2,
# and what each does to an at_exit handler, to a bare rescue, and to the status the
# parent sees.
require "open3"
require "rbconfig"
def run(code)
out, err, status = Open3.capture3(RbConfig.ruby, "--disable-gems", "-e", code)
puts " stdout #{out.chomp.inspect}, stderr #{err.chomp.inspect}, status #{status.exitstatus}"
end
puts "1. exit 2 inside begin/rescue => e: the handler runs, the rescue is skipped, status 2"
run('at_exit { puts "cleanup" }; begin; exit 2; rescue => e; puts "rescued #{e.class}"; end; puts "after"')
puts "2. exit! 2: the handler is skipped too, and the status is still 2"
run('at_exit { puts "cleanup" }; begin; exit! 2; rescue => e; puts "rescued #{e.class}"; end; puts "after"')
Verified output of exit_at_exit_and_system_exit_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- A bare
rescuecatchesStandardError, not everything — why row 2's rescue is blind toSystemExit, in-process rescue,elseandensurerun in a fixed order — theensureclauses that anexitstill runs and anexit!skips- ARGV, ENV and the exit status — the parent's side:
$?andProcess::Status - Processes,
forkandwait— how the children in this lesson are started and reaped - Ctrl-C is a signal ↗ — the Python library on
KeyboardInterruptand SIGINT handlers - The signals you cannot catch ↗ — the Linux library on SIGINT, SIGKILL and what a shell's
trapcan and cannot do - SystemExit ↗ — the reference:
status,success? - Bang means dangerous, question mark means predicate — A trailing
!marks the more dangerous of two versions of a method - Warnings go to stderr, and
-wfinds more of them —warnprints to stderr;-wsets$VERBOSEtotrueand makes Ruby report unused variables, redefined… - Threads are OS threads that take turns under the GVL —
Thread.new { }starts a real OS thread that shares every object with the others;joinwaits and… Timeout.timeoutinterrupts a thread from outside —Timeout.timeout(0.2) { sleep 5 }raisesTimeout::Errorafter a fifth of a second because a watcher…systemreturns true, false or nil —systemreturnstruefor exit 0,falsefor any other status andnilwhen the command could not be…