Skip to content

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

  1. Write two child scripts (as strings, run with Open3.capture3(RbConfig.ruby, "-e", …)) that register an at_exit handler printing cleanup and then call exit 2 inside a begin … rescue => e … end block, the second one with exit! 2 instead. Print each child's stdout, stderr and status, and read off which of the handler, the rescue and the line after the block ran.
  2. Write a child that rescues SystemExit, prints e.status, and re-raises it with a bare raise; confirm the parent still sees status 3 and that the at_exit handler 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.

1. exit 2 inside begin/rescue => e: the handler runs, the rescue is skipped, status 2
   stdout "cleanup", stderr "", status 2
2. exit! 2: the handler is skipped too, and the status is still 2
   stdout "", stderr "", status 2

See also