Skip to content
7 changes: 2 additions & 5 deletions cmd/rbgo/closed_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package main

import (
"fmt"
"os"

"github.com/go-embedded-ruby/ruby/internal/vm"
Expand All @@ -20,8 +19,6 @@ import (
// runtime stays alive for browser callbacks.
func main() {
machine := vm.New(os.Stdout)
if _, err := machine.Run(embeddedProgram()); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
_, err := machine.Run(embeddedProgram())
finish(machine, err) // never returns; see exit_status.go
}
4 changes: 1 addition & 3 deletions cmd/rbgo/closed_main_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package main

import (
"fmt"
"os"

"github.com/go-embedded-ruby/ruby/internal/vm"
Expand All @@ -26,8 +25,7 @@ import (
func main() {
machine := vm.New(os.Stdout)
if _, err := machine.Run(embeddedProgram()); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
finish(machine, err) // never returns; see exit_status.go
}
select {} // keep the Go runtime alive so JS event/animation callbacks keep firing
}
41 changes: 41 additions & 0 deletions cmd/rbgo/die_signal_native.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

//go:build !wasm && !windows

package main

import (
"os"
"os/signal"
"syscall"
"time"
)

// dieBySignal is signal.c ruby_default_signal: restore the signal's default
// disposition and raise(sig) at self, so the process is KILLED BY the signal
// rather than exiting with a number that merely looks the same. A shell then
// reports 128+signo with WIFSIGNALED set, which is what MRI leaves behind for an
// uncaught SignalException and what `$?.signaled?` reads in a parent.
//
// signal.Reset undoes any os/signal.Notify the process installed, which is the
// Go-level equivalent of `signal(sig, SIG_DFL)`; the Go runtime's own handler for
// a signal nobody is watching already performs the default action (for SIGTERM
// that is to die, resetting to SIG_DFL and re-raising internally).
//
// It returns instead of blocking for ever if the signal has not taken effect,
// so the caller can still exit with 128+signo. The wait is not load-bearing: a
// slow host does not change the status the caller reports, only how long it
// takes to report it.
func dieBySignal(signo int) {
sig := syscall.Signal(signo)
signal.Reset(sig)
if err := syscall.Kill(os.Getpid(), sig); err != nil {
return
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
}
24 changes: 24 additions & 0 deletions cmd/rbgo/die_signal_unsupported.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

//go:build wasm || windows

package main

// dieBySignal cannot do what signal.c ruby_default_signal does on these targets,
// for two different reasons, and it is deliberately the same no-op for both.
//
// On wasm neither wasip1 nor js/wasm has kill(2) or a settable signal
// disposition, so there is no way to be killed BY a signal at all.
//
// On Windows there is no kill(2) either (Go does not define syscall.Kill there),
// and MRI's own answer is a different mechanism — the C runtime's raise(), whose
// observable effect on a waiting parent is not the POSIX WIFSIGNALED this code
// would be imitating. **There is no Windows host here to measure that on**, so
// rather than adapt the assertion to a guess, this returns and the caller exits
// 128+signo: the same NUMBER a POSIX shell reports, and a defensible status for a
// program killed by a signal, with the platform-specific part left unclaimed.
// The two end-to-end rows that assert death BY a signal skip on Windows and say
// this is why (cmd/rbgo/exit_status_test.go is POSIX-only for the same reason).
func dieBySignal(int) {}
94 changes: 94 additions & 0 deletions cmd/rbgo/exit_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

package main

import (
"fmt"
"os"

"github.com/go-embedded-ruby/ruby/internal/vm"
)

// finish is the front-end's last step, MRI's ruby_run_node -> rb_ec_cleanup
// (eval.c). It is shared by every main in this package — the CLI, the
// closed-world native binary and the closed-world wasm binary — because a
// deliberate exit status has to reach the process from all three.
//
// rb_ec_cleanup splits the terminal exception with exiting_split (see
// vm.ExitingSplit), prints it only when exiting_split says so, and RETURNS the
// status to main(), which passes it to exit(). When a signal was named it does
// one more thing, last of all: ruby_default_signal(sig), which sets SIG_DFL and
// raise(sig)s, so the process dies BY the signal and a waiting shell sees
// WIFSIGNALED with 128+signo rather than a plain exit of the same number.
//
// finish never returns.
func finish(machine *vm.VM, err error) {
if machine == nil {
// The program never ran (a parse or compile failure), so there is no class
// hierarchy to classify anything against and no Ruby exception to classify.
if err != nil {
reportError(err)
os.Exit(1)
}
os.Exit(0)
}
if err == nil {
// A program that ended by calling exit unwinds cleanly — Run reports no
// error for a SystemExit — but its status is still the process's. This is
// rb_ec_cleanup reading ec->errinfo after rb_ec_teardown has run the exit
// handlers, which is why it is asked for here and not before.
if texit, ok := machine.TerminalExit(); ok {
err = texit
} else {
os.Exit(0)
}
}
d, ok := machine.ExitingSplit(err)
if !ok {
// Not a Ruby exception: a parse, compile or host IO error.
reportError(err)
os.Exit(1)
}
if d.Message {
reportError(err)
}
if d.Signal != 0 {
dieBySignal(d.Signal)
// dieBySignal returns only where the target cannot re-raise a signal at
// all (wasm) or where the re-raise did not take effect; the status is then
// the 128+signo a shell would have reported anyway.
}
os.Exit(d.Status)
}

// reportError prints an uncaught error to stderr. A Ruby exception (vm.RubyError)
// is rendered MRI-style — "<frame>: <message> (<Class>)" with a "\tfrom <frame>"
// line per outer frame from its backtrace — so a crashing program shows the call
// chain that led to the raise. A non-Ruby error (parse/compile/IO) prints plainly.
//
// An exception with an EMPTY message prints its class name alone, with no
// parenthesised suffix: eval_error.c print_errinfo takes the `elen == 0` branch
// and writes rb_class_name(eclass) by itself. That is the shape an uncaught
// Interrupt has, since Interrupt.new carries no message.
func reportError(err error) {
rerr, ok := err.(vm.RubyError)
if !ok {
fmt.Fprintf(os.Stderr, "%v\n", err)
return
}
frames := rerr.Backtrace()
body := rerr.Message + " (" + rerr.Class + ")"
if rerr.Message == "" {
body = rerr.Class
}
if len(frames) == 0 {
fmt.Fprintln(os.Stderr, body)
return
}
fmt.Fprintf(os.Stderr, "%s: %s\n", frames[0], body)
for _, f := range frames[1:] {
fmt.Fprintf(os.Stderr, "\tfrom %s\n", f)
}
}
169 changes: 169 additions & 0 deletions cmd/rbgo/exit_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) the go-embedded-ruby/ruby authors
//
// SPDX-License-Identifier: BSD-3-Clause

//go:build !rbgo_closed && !wasm && !windows

package main

import (
"os"
"os/exec"
"strings"
"syscall"
"testing"
)

// The exit status is the one thing no in-process test can observe: os.Exit ends
// the process, and being killed by a signal ends it harder. These tests therefore
// re-exec THIS TEST BINARY with WT37_EXIT_SRC set, which makes the child run one
// Ruby program through the same run()+finish() pair `rbgo run` uses and then exit.
//
// The precondition is established by the test itself — it sets the variable on
// the child it spawns — so unlike a test gated on an externally-supplied
// RBGO_* variable this one cannot silently not run. os.Args[0] is the compiled
// test binary, so no Go toolchain is needed and it runs in every CI lane.
const exitSrcEnv = "WT37_EXIT_SRC"

// childMain is the re-exec entry point. It runs before any assertion in the
// child, so the child never reaches the testing framework's own output.
func childMain() {
if src, ok := os.LookupEnv(exitSrcEnv); ok {
finish(run(src, "-e")) // never returns
}
}

func TestMain(m *testing.M) {
childMain()
os.Exit(m.Run())
}

// runChild runs one program in a child process and reports its stdout, stderr,
// exit status, and whether the child was KILLED BY a signal rather than exiting.
func runChild(t *testing.T, src string) (stdout, stderr string, status int, signaled bool, signo int) {
t.Helper()
c := exec.Command(os.Args[0], "-test.run=^TestExitStatusReachesTheProcess$")
c.Env = append(os.Environ(), exitSrcEnv+"="+src)
var so, se strings.Builder
c.Stdout, c.Stderr = &so, &se
err := c.Run()
if err != nil {
ee, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("re-exec %s: %v", os.Args[0], err)
}
if ws, okWS := ee.Sys().(syscall.WaitStatus); okWS && ws.Signaled() {
return so.String(), se.String(), 128 + int(ws.Signal()), true, int(ws.Signal())
}
status = ee.ExitCode()
}
return so.String(), se.String(), status, false, 0
}

// TestExitStatusReachesTheProcess is the end-to-end witness for issue #676 and
// for the terminal half of #691. Every "want" here was measured against MRI 4.0.5
// (ruby 4.0.5 (2026-05-20) +PRISM [arm64-darwin25]) with the same program.
//
// On origin/main this test's first row alone fails: `rbgo -e 'exit 3'` exited 0,
// because run() returned no error for a SystemExit and runCmd exited 0. Reverting
// runCmd to `if err := run(...); err != nil { reportError(err); os.Exit(1) }`
// still compiles and fails ten of these rows.
func TestExitStatusReachesTheProcess(t *testing.T) {
if _, isChild := os.LookupEnv(exitSrcEnv); isChild {
// The child never gets here: childMain ran finish() from TestMain. Guard
// anyway so a mistake shows as a failure rather than a recursive spawn.
t.Fatal("child reached the test body; childMain did not run")
}
for _, tc := range []struct {
name, src string
wantStatus int
wantOut, wantErr string
wantSignal int // non-zero: the child must be KILLED BY this signal
}{
{name: "exit 3", src: `exit 3`, wantStatus: 3},
{name: "exit false", src: `exit false`, wantStatus: 1},
{name: "exit 0", src: `exit 0`, wantStatus: 0},
{name: "exit true", src: `exit true`, wantStatus: 0},
{name: "exit!(4)", src: `exit!(4)`, wantStatus: 4},
{name: "abort", src: `abort "boom"`, wantStatus: 1, wantErr: "boom\n"},
{name: "bare abort", src: `abort`, wantStatus: 1},
{name: "raise", src: `raise "x"`, wantStatus: 1,
wantErr: "-e:1:in '<main>': x (RuntimeError)\n"},
{name: "Process.exit(5)", src: `Process.exit(5)`, wantStatus: 5},
{name: "Process.exit!(6)", src: `Process.exit!(6)`, wantStatus: 6},
{name: "Process.abort", src: `Process.abort "pboom"`, wantStatus: 1, wantErr: "pboom\n"},
// A rescued SystemExit must NOT exit non-zero — the easiest way to "fix"
// the status is to exit on every SystemExit ever raised, and this row is
// what makes that wrong.
{name: "a rescued SystemExit exits 0",
src: `begin; exit 3; rescue SystemExit => e; puts "status=#{e.status}"; end`,
wantOut: "status=3\n"},
{name: "at_exit runs and the status survives",
src: `at_exit { puts "atexit" }; exit 3`, wantStatus: 3, wantOut: "atexit\n"},
{name: "ensure runs and the status survives",
src: `begin; exit 3; ensure; puts "ensure"; end`, wantStatus: 3, wantOut: "ensure\n"},
{name: "an at_exit exit overrides the status",
src: `at_exit { exit 7 }; exit 3`, wantStatus: 7},
{name: "exit! runs no at_exit handler",
src: `at_exit { puts "atexit" }; exit!(4)`, wantStatus: 4},
// A rescued self-signal leaves the process healthy: this is the #691 row
// that was 10-20% fatal on origin/main.
{name: "a rescued SignalException exits 0",
src: `begin
Process.kill(:TERM, Process.pid)
rescue SignalException => e
puts "#{e.class} #{e.message} #{e.signo}"
end
puts "alive"`, wantOut: "SignalException SIGTERM 15\nalive\n"},
// An UNCAUGHT SignalException must leave the process dead BY the signal,
// with nothing on stderr (exiting_split gives the base class no message).
{name: "an uncaught SignalException dies by its signal",
src: `Process.kill(:TERM, Process.pid); puts "unreached"`,
wantStatus: 128 + 15, wantSignal: 15},
// An uncaught Interrupt is the same death, but it DOES print, because it is
// a subclass. Its message is empty, so print_errinfo writes the class name
// alone with no parenthesised suffix.
{name: "an uncaught Interrupt prints its class and dies by SIGINT",
src: `Process.kill(:INT, Process.pid); puts "unreached"`,
wantStatus: 128 + 2, wantSignal: 2, wantErr: "-e:1:in '<main>': Interrupt\n"},
} {
t.Run(tc.name, func(t *testing.T) {
out, errOut, status, signaled, signo := runChild(t, tc.src)
if status != tc.wantStatus {
t.Errorf("exit status = %d, want %d (stderr %q)", status, tc.wantStatus, errOut)
}
if out != tc.wantOut {
t.Errorf("stdout = %q, want %q", out, tc.wantOut)
}
if errOut != tc.wantErr {
t.Errorf("stderr = %q, want %q", errOut, tc.wantErr)
}
// "killed by signal 15" and "exited with 143" give a shell the same $?
// but a different WIFSIGNALED, and MRI's ruby_default_signal produces
// the former. Asserting only the number would pass on a plain exit.
if tc.wantSignal != 0 {
if !signaled {
t.Errorf("child exited normally, want death by signal %d", tc.wantSignal)
} else if signo != tc.wantSignal {
t.Errorf("killed by signal %d, want %d", signo, tc.wantSignal)
}
} else if signaled {
t.Errorf("child was killed by signal %d, want a normal exit", signo)
}
})
}
}

// TestParseErrorStillExitsOne pins the path where finish has no machine to
// classify against: a program that never compiled. The status has always been 1;
// the risk the new code introduced is a nil-pointer dereference on the machine
// run() could not build, which this row is here to catch.
func TestParseErrorStillExitsOne(t *testing.T) {
_, errOut, status, signaled, _ := runChild(t, `def (`)
if status != 1 || signaled {
t.Errorf("status = %d (signaled %v), want a plain exit 1", status, signaled)
}
if errOut == "" {
t.Error("no diagnostic on stderr for a parse error")
}
}
Loading
Loading