Skip to content

A deliberate exit status never reaches the process: rbgo -e 'exit 3' exits 0 #676

Open

Description

@tannevaled

rbgo -e 'exit 3' exits 0, where MRI exits 3. Every spelling of a
deliberate non-zero exit is lost, so rbgo cannot signal failure to a shell,
a Makefile or a CI step.

Measured against MRI 4.0.5 (ruby 4.0.5 (2026-05-20) +PRISM [arm64-darwin25])
on the branch of #675:

program MRI status rbgo status
exit 3 3 0
exit false 1 0
exit!(4) 4 0
abort "boom" 1 0
raise "x" 1 1 (correct)
exit 0 0 0 (correct)
Process.exit(5) 5 1, NoMethodError (see below)
Process.exit!(6) 6 1, NoMethodError (see below)

The VM half is already correct — only the front-end's last step is missing

This is not a SystemExit implementation gap. Everything inside the VM behaves
as MRI does:

$ rbgo -e 'begin; exit 3; rescue SystemExit => e; puts "status=#{e.status} success=#{e.success?}"; end'
status=3 success=false          # identical to MRI
$ rbgo -e 'at_exit { puts "atexit ran" }; exit 3'
atexit ran                      # identical to MRI; only the status differs
$ rbgo -e 'begin; exit 3; ensure; puts "ensure ran"; end'
ensure ran                      # identical to MRI; only the status differs

SystemExit is raised, carries the right status and success?, is rescuable,
and at_exit and ensure both run. The defect is at exactly one boundary: the
CLI never translates a terminal SystemExit into the process's exit code. MRI
does this in main() → ruby_run_node → ruby_cleanup, which calls
error_handle and returns ex from the SystemExit's status
(eval.c/ruby.c, tag v3_4_0).

abort follows from the same cause: rb_f_abort raises SystemExit with
status 1 (process.c).

Second, separate defect: Process.exit is private

MRI declares exit, exit! and abort as module functions on Process
(Init_process: rb_define_module_function(rb_mProcess, "exit", ...)), so
Process.exit(5) is public. rbgo only has the private Kernel copies, so the
qualified spelling raises NoMethodError. Worth fixing together, but it is a
different cause from the status loss.

What this does NOT affect

The ruby/spec ratchet is unaffected: scripts/conformance/rubyspec/run.sh
scores each file by parsing the RBGO_RESULT pass=… fail=… line the shim
prints, not by reading the child's exit status. I checked this before filing,
because a harness that read the status would have been scoring every file as a
success. Every conformance total measured so far stands.

Found while verifying which stream Kernel#abort's message reaches, in #667.

Activity

  1. tannevaled commented on Sep 26, 2026

    @tannevaled
    ContributorAuthor

    Correction to this issue's central claim, which was mine and was wrong.

    I wrote: "This is not a SystemExit implementation gap. Everything inside the VM
    behaves as MRI does … The defect is at exactly one boundary: the CLI never
    translates a terminal SystemExit into the process's exit code."

    That localisation is false. internal/vm/vm.go:1131 deliberately discards it,
    and says so:

    // A SystemExit (Kernel#exit/abort) is a clean program termination, not a
    // crash: unwind to the top, run at_exit handlers, and return without an
    // error — so a real CLI that ends by calling exit (e.g. Puppet's
    // exit_on_fail) terminates quietly rather than printing a backtrace.
    if vm.isSystemExit(rerr.Class) {

    So no front-end change alone could have fixed it. The status was never handed out
    of the VM to be translated.

    Why I got it wrong, since the reasoning error is the reusable part. I probed
    what Ruby code can observe: rescue SystemExit sees the right status, at_exit
    runs, ensure runs. All three are true. I inferred "everything inside is correct,
    therefore only the last step outside is missing" — and that does not follow. Those
    probes establish that the exception is raised and unwinds correctly; they say
    nothing about whether its value survives the VM's own boundary. Observability
    from inside the language is not evidence about where a value is dropped on the way
    out.

    The comment above is also worth reading as its own lesson: the discard was a
    correct decision for the problem it was written for (a CLI ending in exit should
    not print a backtrace) and became wrong when the exit status started to matter.
    The premise moved; the code did not.

    Fixed in #693, which had to touch vm.go for exactly this reason and flagged it as
    outside its granted file set.

    Two further corrections from that work, both to figures in this issue's neighbour
    #691:

    • the rate is 10–20% (3/20, 3/20, 2/20 across spellings), not the 2-in-3 I
      quoted from three runs;
    • Process.exit being private was right, and the fix makes all nine spellings
      byte-identical to MRI 4.0.5 across 20 runs each.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions