diff --git a/cmd/rbgo/closed_main.go b/cmd/rbgo/closed_main.go index d6f9bece..66d9c182 100644 --- a/cmd/rbgo/closed_main.go +++ b/cmd/rbgo/closed_main.go @@ -3,7 +3,6 @@ package main import ( - "fmt" "os" "github.com/go-embedded-ruby/ruby/internal/vm" @@ -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 } diff --git a/cmd/rbgo/closed_main_wasm.go b/cmd/rbgo/closed_main_wasm.go index 395141d6..e10adb04 100644 --- a/cmd/rbgo/closed_main_wasm.go +++ b/cmd/rbgo/closed_main_wasm.go @@ -3,7 +3,6 @@ package main import ( - "fmt" "os" "github.com/go-embedded-ruby/ruby/internal/vm" @@ -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 } diff --git a/cmd/rbgo/die_signal_native.go b/cmd/rbgo/die_signal_native.go new file mode 100644 index 00000000..2e31b21b --- /dev/null +++ b/cmd/rbgo/die_signal_native.go @@ -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) + } +} diff --git a/cmd/rbgo/die_signal_unsupported.go b/cmd/rbgo/die_signal_unsupported.go new file mode 100644 index 00000000..88e64f6e --- /dev/null +++ b/cmd/rbgo/die_signal_unsupported.go @@ -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) {} diff --git a/cmd/rbgo/exit_status.go b/cmd/rbgo/exit_status.go new file mode 100644 index 00000000..817be5b3 --- /dev/null +++ b/cmd/rbgo/exit_status.go @@ -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 — ": ()" with a "\tfrom " +// 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) + } +} diff --git a/cmd/rbgo/exit_status_test.go b/cmd/rbgo/exit_status_test.go new file mode 100644 index 00000000..c4cf5967 --- /dev/null +++ b/cmd/rbgo/exit_status_test.go @@ -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 '
': 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 '
': 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") + } +} diff --git a/cmd/rbgo/main.go b/cmd/rbgo/main.go index 5b1d5521..250ba287 100644 --- a/cmd/rbgo/main.go +++ b/cmd/rbgo/main.go @@ -60,20 +60,25 @@ func runCmd(args []string) { usage() } - if err := run(src, name); err != nil { - reportError(err) - os.Exit(1) - } + // finish never returns: it is MRI's ruby_run_node -> rb_ec_cleanup, which + // turns the terminal exception into this process's exit status (or death by + // signal) rather than always exiting 0 or 1. See exit_status.go. + finish(run(src, name)) } -func run(src, name string) error { +// run compiles and interprets one program, returning the machine it ran on +// alongside the error, because the terminal exception has to be classified +// against that machine's class hierarchy (vm.ExitingSplit). The machine is +// non-nil whenever the program actually ran; a parse or compile failure returns +// nil, which finish handles. +func run(src, name string) (*vm.VM, error) { prog, err := parser.Parse(src) if err != nil { - return err + return nil, err } iseq, err := compiler.CompileWithEncoding(prog, compiler.MagicSourceEncoding(src)) if err != nil { - return err + return nil, err } iseq.Name = name // Program output goes to stdout; every diagnostic MRI puts on fd 2 — warnings, @@ -89,29 +94,7 @@ func run(src, name string) error { machine.SetScriptPath(name) // so require/require_relative resolve relative to the script } _, err = machine.Run(iseq) - return err -} - -// reportError prints an uncaught error to stderr. A Ruby exception (vm.RubyError) -// is rendered MRI-style — ": ()" with a "\tfrom " -// 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. -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 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) - } + return machine, err } func usage() { diff --git a/cmd/rbgo/stream_test.go b/cmd/rbgo/stream_test.go index 47112712..4c684712 100644 --- a/cmd/rbgo/stream_test.go +++ b/cmd/rbgo/stream_test.go @@ -44,7 +44,7 @@ func TestRunSendsWarningsToTheRealStderr(t *testing.T) { go func() { b, _ := io.ReadAll(outR); outCh <- string(b) }() go func() { b, _ := io.ReadAll(errR); errCh <- string(b) }() - runErr := run(program, script) + _, runErr := run(program, script) outW.Close() errW.Close() diff --git a/internal/vm/exit_status_internal_test.go b/internal/vm/exit_status_internal_test.go new file mode 100644 index 00000000..6007475c --- /dev/null +++ b/internal/vm/exit_status_internal_test.go @@ -0,0 +1,294 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows && !wasm + +package vm + +import ( + "testing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestExitingSplitClassification is eval_error.c exiting_split, table-driven. +// It is the decision the rbgo CLI turns into the process's disposition, and it +// is the half of issue #676 that could be unit-tested at all: on origin/main +// neither ExitingSplit nor TerminalExit existed, so every one of these rows is a +// witness rather than a guard. +func TestExitingSplitClassification(t *testing.T) { + for _, tc := range []struct { + name, src string + wantStatus int + wantSignal int + wantMessage bool + }{ + // SystemExit -> EXITING_WITH_STATUS, status = @status, and NO message: + // a program that ends by calling exit is terminating, not crashing. + {"exit 3", `exit 3`, 3, 0, false}, + {"exit false", `exit false`, 1, 0, false}, + {"exit 0", `exit 0`, 0, 0, false}, + {"exit!(4)", `exit!(4)`, 4, 0, false}, + {"abort", `abort "boom"`, 1, 0, false}, + {"Process.exit(5)", `Process.exit(5)`, 5, 0, false}, + {"Process.exit!(6)", `Process.exit!(6)`, 6, 0, false}, + {"Process.abort", `Process.abort "boom"`, 1, 0, false}, + // A raise of SystemExit itself, with no status ivar set by Kernel#exit: + // SystemExit.new's default @status is 0. + {"raise SystemExit", `raise SystemExit`, 0, 0, false}, + // SignalException -> EXITING_WITH_SIGNAL. No message for the class itself + // (`rb_obj_is_instance_of(errinfo, rb_eSignal)` is true), so nothing is + // printed and the process dies by the signal. + {"uncaught SIGTERM", `Process.kill(:TERM, Process.pid)`, 128 + 15, 15, false}, + // ...but a SUBCLASS of SignalException DOES print, which is why an uncaught + // Interrupt shows "Interrupt" on stderr and a bare SignalException shows + // nothing. Getting this backwards is invisible in the exit status. + {"uncaught Interrupt", `Process.kill(:INT, Process.pid)`, 128 + 2, 2, true}, + // SIGSEGV prints even as the base class (exiting_split's explicit case). + {"SIGSEGV prints", `raise SignalException.new("SEGV")`, 128 + 11, 11, true}, + // Signal 0 cannot kill anything; kill(2) defines it as a liveness probe, + // so re-raising it would be a no-op and leave the process exiting 0 on what + // is really a failure. + {"SignalException with signo 0", `raise SignalException.new(0)`, 1, 0, true}, + // Everything else -> EXIT_FAILURE with a message, unchanged. + {"RuntimeError", `raise "x"`, 1, 0, true}, + {"a bare raise from a native method", `Integer("nope")`, 1, 0, true}, + {"NoMemoryError", `raise NoMemoryError`, 1, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + machine, out, err := runSrcErr(t, tc.src) + if err == nil { + texit, ok := machine.TerminalExit() + if !ok { + t.Fatalf("no error and no terminal exit (output %q)", out) + } + err = texit + } + d, ok := machine.ExitingSplit(err) + if !ok { + t.Fatalf("ExitingSplit did not classify %T %v", err, err) + } + if d.Status != tc.wantStatus { + t.Errorf("Status = %d, want %d", d.Status, tc.wantStatus) + } + if d.Signal != tc.wantSignal { + t.Errorf("Signal = %d, want %d", d.Signal, tc.wantSignal) + } + if d.Message != tc.wantMessage { + t.Errorf("Message = %v, want %v", d.Message, tc.wantMessage) + } + }) + } +} + +// TestExitingSplitRejectsANonRubyError proves the front-end still has a path for +// a parse, compile or host IO error: ExitingSplit must report false rather than +// classifying it as a plain failure, because those errors are not Ruby exceptions +// and have no class hierarchy to place. +func TestExitingSplitRejectsANonRubyError(t *testing.T) { + machine, _, _ := runSrcErr(t, `1`) + if _, ok := machine.ExitingSplit(errNotRuby{}); ok { + t.Error("ExitingSplit classified a non-Ruby error") + } +} + +type errNotRuby struct{} + +func (errNotRuby) Error() string { return "not a ruby exception" } + +// TestTerminalExitIsAbsentWithoutOne pins the negative: a program that ends +// normally, and one whose SystemExit was RESCUED, must leave no terminal exit — +// otherwise `rbgo -e '...rescue SystemExit...'` would exit non-zero on a program +// MRI exits 0 for. An assertion that only checked the positive case would pass +// with a VM that recorded every SystemExit ever raised. +func TestTerminalExitIsAbsentWithoutOne(t *testing.T) { + for _, tc := range []struct{ name, src string }{ + {"a program that ends normally", `x = 1`}, + {"a rescued SystemExit", `begin; exit 3; rescue SystemExit; end`}, + {"a rescued SystemExit through ensure", `begin; exit 3; rescue SystemExit; ensure; x = 1; end`}, + } { + t.Run(tc.name, func(t *testing.T) { + machine, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if texit, ok := machine.TerminalExit(); ok { + t.Errorf("TerminalExit reported %v, want none", texit) + } + }) + } +} + +// TestExitBangSkipsAtExitHandlers is process.c rb_f_exit_bang, which is _exit(2) +// and therefore runs no exit handler. Measured against MRI 4.0.5: +// `at_exit { puts "x" }; exit!(4)` prints nothing and exits 4, where plain +// `exit 4` prints "x". +// +// MRI also skips `ensure` blocks, which this does NOT yet do — the exception +// machinery has no unwinding path that `ensure` does not see. The ensure row +// below records the remaining divergence rather than asserting MRI's answer, +// and says so, so nobody reads it as a specification. +func TestExitBangSkipsAtExitHandlers(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"exit! runs no at_exit", `at_exit { puts "handler" }; exit!(4)`, ""}, + {"exit runs at_exit", `at_exit { puts "handler" }; exit 4`, "handler\n"}, + {"exit! still lets ensure run (a KNOWN divergence from MRI, which skips it)", + `begin; exit!(4); ensure; puts "ensure"; end`, "ensure\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + machine, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v", err) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + texit, ok := machine.TerminalExit() + if !ok { + t.Fatal("no terminal exit recorded") + } + d, _ := machine.ExitingSplit(texit) + if d.Status != 4 { + t.Errorf("Status = %d, want 4", d.Status) + } + }) + } +} + +// TestAtExitExitOverridesTheStatus is rb_ec_cleanup reading ec->errinfo AFTER +// rb_ec_teardown: a handler that calls exit sets the status, and overrides one an +// earlier exit asked for. Measured against MRI 4.0.5: 7, not 3, and 7 on a +// program that never called exit at all. +func TestAtExitExitOverridesTheStatus(t *testing.T) { + for _, tc := range []struct { + name, src string + want int + }{ + {"an at_exit exit overrides an earlier one", `at_exit { exit 7 }; exit 3`, 7}, + {"an at_exit exit on a program that ends normally", `at_exit { exit 7 }`, 7}, + {"the LAST handler to exit wins", `at_exit { exit 7 }; at_exit { exit 8 }`, 7}, + // A handler that raises something else must not become the status: MRI + // swallows it, and exiting_split would have made it 1. + {"a raising handler leaves the status alone", `at_exit { raise "x" }; exit 3`, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + machine, _, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v", err) + } + texit, ok := machine.TerminalExit() + if !ok { + t.Fatal("no terminal exit recorded") + } + d, _ := machine.ExitingSplit(texit) + if d.Status != tc.want { + t.Errorf("Status = %d, want %d", d.Status, tc.want) + } + }) + } +} + +// TestProcessExitFamilyIsPublic is issue #676's second defect: Init_process +// declares exit, exit! and abort with rb_define_module_function, so the qualified +// spelling is public. rbgo had only Kernel's private copies and raised +// NoMethodError. +func TestProcessExitFamilyIsPublic(t *testing.T) { + src := `p Process.respond_to?(:exit), Process.respond_to?(:exit!), Process.respond_to?(:abort) +begin + Process.exit(5) +rescue SystemExit => e + puts "exit #{e.status}" +end +begin + Process.exit!(6) +rescue SystemExit => e + puts "exit! #{e.status}" +end +begin + Process.exit +rescue SystemExit => e + puts "bare #{e.status}" +end` + want := "true\ntrue\ntrue\nexit 5\nexit! 6\nbare 0\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// TestClassIsAWalksTheHierarchy covers classIsA's two branches, including the +// one a Ruby program cannot reach: a class name that is not a constant at all, +// which a raise() from a native method can produce. +func TestClassIsAWalksTheHierarchy(t *testing.T) { + machine, _, _ := runSrcErr(t, `1`) + for _, tc := range []struct { + class, ancestor string + want bool + }{ + {"SystemExit", "SystemExit", true}, + {"SystemExit", "Exception", true}, + {"SystemExit", "StandardError", false}, + {"Interrupt", "SignalException", true}, + {"SignalException", "Interrupt", false}, + {"RuntimeError", "SystemExit", false}, + {"NoSuchClassAnywhere", "NoSuchClassAnywhere", true}, + {"NoSuchClassAnywhere", "SystemExit", false}, + } { + if got := machine.classIsA(tc.class, tc.ancestor); got != tc.want { + t.Errorf("classIsA(%q, %q) = %v, want %v", tc.class, tc.ancestor, got, tc.want) + } + } +} + +// TestExitStatusAndSignoOfMissingObject covers the nil-object paths: a +// SystemExit or SignalException raised by a native raise() carries a class name +// but no exception object, and reading its ivars must not panic. +func TestExitStatusAndSignoOfMissingObject(t *testing.T) { + machine, _, _ := runSrcErr(t, `1`) + if got := machine.exitStatusOf(nil); got != 0 { + t.Errorf("exitStatusOf(nil) = %d, want 0", got) + } + if got := machine.signoOf(nil); got != 0 { + t.Errorf("signoOf(nil) = %d, want 0", got) + } + d, ok := machine.ExitingSplit(RubyError{Class: "SystemExit", Message: "exit"}) + if !ok || d.Status != 0 || d.Message { + t.Errorf("objectless SystemExit -> %+v, want status 0 and no message", d) + } + d, ok = machine.ExitingSplit(RubyError{Class: "SignalException", Message: "SIGTERM"}) + if !ok || d.Signal != 0 || d.Status != 1 || !d.Message { + t.Errorf("objectless SignalException -> %+v, want the plain-failure fallback", d) + } +} + +// TestExitStatusAndSignoOfANonIntegerIvar covers the last arm of exitStatusOf and +// signoOf: the ivar is present but not an Integer, or the object has no ivars at +// all. eval_error.c's sysexit_status is a bare rb_ivar_get with no type check, so +// a value that is not a number has to mean 0 rather than crash — and a program +// CAN put one there (`e.instance_variable_set(:@status, "x")`). +func TestExitStatusAndSignoOfANonIntegerIvar(t *testing.T) { + machine, _, _ := runSrcErr(t, `1`) + // A value with no ivar table at all. + if got := machine.exitStatusOf(objectOne()); got != 0 { + t.Errorf("exitStatusOf(1) = %d, want 0", got) + } + if got := machine.signoOf(objectOne()); got != 0 { + t.Errorf("signoOf(1) = %d, want 0", got) + } + // And one whose @status a Ruby program replaced with a String. + _, out, err := runSrcErr(t, `e = SystemExit.new +e.instance_variable_set(:@status, "x") +p e.status`) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } +} + +// objectOne is an object.Value with no instance-variable table, which is what +// exitStatusOf and signoOf see when a SystemExit or SignalException was raised +// with a class name rather than an object. +func objectOne() object.Value { return object.IntValue(1) } diff --git a/internal/vm/kernel_introspect.go b/internal/vm/kernel_introspect.go index 5ce28637..d55aabb8 100644 --- a/internal/vm/kernel_introspect.go +++ b/internal/vm/kernel_introspect.go @@ -223,7 +223,7 @@ func (vm *VM) registerKernelIntrospection() { return vm.raiseSystemExit(vm.exitStatusArg(args, 0), "exit") }) vm.cObject.define("exit!", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - return vm.raiseSystemExit(vm.exitStatusArg(args, 1), "exit") + return vm.raiseSystemExitImmediate(vm.exitStatusArg(args, 1), "exit") }) // abort(message = nil): MRI's rb_f_abort (process.c) puts the message — // through StringValue, so a non-String converts with #to_str — on $stderr and @@ -666,6 +666,14 @@ func (vm *VM) frameFileLabel(i int) string { // is swallowed so the remaining handlers still run (MRI reports it but keeps // going); other control-flow signals propagate. func (vm *VM) runAtExit() { + // Kernel#exit! / Process.exit! runs none of them: process.c rb_f_exit_bang + // calls _exit(2), which is precisely "terminate without running the exit + // handlers". Measured against MRI 4.0.5: `at_exit { puts "x" }; exit!(4)` + // prints nothing and exits 4. + if vm.exitImmediate { + vm.atExit = nil + return + } for i := len(vm.atExit) - 1; i >= 0; i-- { vm.runAtExitOne(vm.atExit[i]) } @@ -675,7 +683,17 @@ func (vm *VM) runAtExit() { func (vm *VM) runAtExitOne(blk *Proc) { defer func() { if r := recover(); r != nil { - if _, ok := r.(RubyError); ok { + if rerr, ok := r.(RubyError); ok { + // An at_exit handler that itself calls exit sets the process's status, + // and overrides one an earlier exit had already asked for: eval.c + // rb_ec_cleanup reads ec->errinfo AFTER rb_ec_teardown has run the + // handlers, and exiting_split gives that reading (mode0) precedence + // over the pre-teardown one (mode1). Measured against MRI 4.0.5: + // `at_exit { exit 7 }; exit 3` exits 7, and `at_exit { exit 7 }` on a + // program that ends normally exits 7 too. + if vm.isSystemExit(rerr.Class) { + vm.terminalExit = &rerr + } return // swallow: a failing at_exit hook must not abort the others } panic(r) @@ -824,3 +842,130 @@ func (vm *VM) registerGvarTracing() { return object.NewArrayFromSlice(append([]object.Value(nil), elems...)) }) } + +// ExitDisposition is how a terminal exception ends the process. It is the result +// of eval_error.c's exiting_split, the function rb_ec_cleanup calls to split an +// uncaught exception into "exit with this status", "die by this signal" and +// "print this exception first": +// +// SystemExit -> EXITING_WITH_STATUS, status = the @status ivar, no message +// SignalException -> EXITING_WITH_SIGNAL, sig = the @signo ivar; a message +// ONLY for SIGSEGV or for a SUBCLASS of SignalException +// (`!rb_obj_is_instance_of(errinfo, rb_eSignal)`), which +// is why an uncaught Interrupt prints and an uncaught +// bare SignalException does not +// anything else -> EXITING_WITH_STATUS | EXITING_WITH_MESSAGE, status 1 +// +// rb_ec_cleanup then returns Status as main()'s exit code, and — last of all, +// after every finalizer — calls ruby_default_signal(Signal) when a signal was +// named, which restores SIG_DFL and re-raises it so the process really dies BY +// the signal and a shell reports 128+signo with WIFSIGNALED set. +type ExitDisposition struct { + Status int // the process exit status + Signal int // the signal to die by, or 0 + Message bool // print the exception MRI-style before exiting +} + +// ExitingSplit classifies an error that propagated out of Run, reporting false +// when it is not a Ruby exception at all (a parse, compile or host IO error), +// which the caller reports and exits 1 for as before. +// +// This is the whole of issue #676's front-end half: the VM already raised the +// right SystemExit with the right @status, and nothing translated it into the +// process's exit code — so `rbgo -e 'exit 3'` exited 0. It is also the terminal +// half of #691: an uncaught SignalException must leave the process dead by its +// signal, not exited 0 or 1. +func (vm *VM) ExitingSplit(err error) (ExitDisposition, bool) { + rerr, ok := err.(RubyError) + if !ok { + return ExitDisposition{}, false + } + switch { + case vm.classIsA(rerr.Class, "SystemExit"): + return ExitDisposition{Status: int(vm.exitStatusOf(rerr.Obj))}, true + case vm.classIsA(rerr.Class, "SignalException"): + signo := int(vm.signoOf(rerr.Obj)) + // A signal number of 0 is Signal.trap(:EXIT)'s slot and cannot kill + // anything; treat it as a plain failure rather than re-raising signal 0, + // which kill(2) defines as a liveness probe. + if signo == 0 { + return ExitDisposition{Status: 1, Message: true}, true + } + segv, isSegv := signalNumberOf("SEGV") + return ExitDisposition{ + Signal: signo, + Status: 128 + signo, // the status to fall back to if the re-raise cannot run + Message: (isSegv && signo == segv) || rerr.Class != "SignalException", + }, true + default: + return ExitDisposition{Status: 1, Message: true}, true + } +} + +// classIsA reports whether the named class is name or a descendant of it, the +// rb_obj_is_kind_of test exiting_split applies. An unknown class name can only +// match itself, which keeps a raise() from a native method (which carries a name +// rather than an object) classifiable. +func (vm *VM) classIsA(className, ancestor string) bool { + c, ok := vm.consts[className].(*RClass) + if !ok { + return className == ancestor + } + for ; c != nil; c = c.super { + if c.name == ancestor { + return true + } + } + return false +} + +// exitStatusOf reads SystemExit#status off the exception object (eval_error.c +// sysexit_status, which is rb_ivar_get(exc, id_status)). A SystemExit raised +// through raise() rather than Kernel#exit has no object, and MRI's +// SystemExit.new defaults @status to 0. +func (vm *VM) exitStatusOf(exc object.Value) int64 { + if exc == nil { + return 0 + } + if s, ok := getIvar(exc, "@status").(object.Integer); ok { + return int64(s) + } + return 0 +} + +// signoOf reads SignalException#signo off the exception object +// (rb_ivar_get(errinfo, id_signo)). +func (vm *VM) signoOf(exc object.Value) int64 { + if exc == nil { + return 0 + } + if s, ok := getIvar(exc, "@signo").(object.Integer); ok { + return int64(s) + } + return 0 +} + +// raiseSystemExitImmediate is raiseSystemExit for Kernel#exit! / Process.exit!, +// which process.c rb_f_exit_bang implements as _exit(2): the status still travels +// on a SystemExit (so it is rescuable and reportable the same way), but no +// at_exit handler runs. MRI also skips `ensure` blocks, which needs an unwinding +// path the exception machinery does not have here; that remainder is recorded +// rather than pretended away. +func (vm *VM) raiseSystemExitImmediate(status int64, message string) object.Value { + vm.exitImmediate = true + return vm.raiseSystemExit(status, message) +} + +// TerminalExit reports the SystemExit that ended the run, if one did. Run +// returns no error for a SystemExit — a program that calls exit is terminating, +// not crashing, and an embedded host must not see a failure — but the @status it +// carries IS the process's exit code, so a host that owns a process asks for it +// here. It is MRI's ec->errinfo as rb_ec_cleanup finds it after rb_ec_teardown: +// an at_exit handler that itself calls exit overwrites the status, which is why +// this is read AFTER Run returns rather than captured at the raise site. +func (vm *VM) TerminalExit() (error, bool) { + if vm.terminalExit == nil { + return nil, false + } + return *vm.terminalExit, true +} diff --git a/internal/vm/process.go b/internal/vm/process.go index 29a4f7c3..f847aa8b 100644 --- a/internal/vm/process.go +++ b/internal/vm/process.go @@ -99,6 +99,7 @@ func (vm *VM) registerProcess() { mod.consts["CLOCK_MONOTONIC"] = object.IntValue(clockMonotonic) vm.consts["Process::CLOCK_MONOTONIC"] = object.IntValue(clockMonotonic) + vm.defineProcessExit(def) // unconditional: Init_process declares these on every platform vm.registerProcessPosix(mod, def) vm.registerProcessResiduals(mod, def) } @@ -302,17 +303,30 @@ func (vm *VM) defineProcessGroups(def func(string, NativeFn)) { // anything else goes through signm2signo (which accepts a leading '-' to mean // "signal the process group"); every remaining argument is a pid coerced with // NUM2PIDT, and the return value is the number of pids signalled. +// +// The pid == self arm is the subject of issue #691 and does NOT reach kill(2): +// rb_f_kill consults the installed disposition and, for a signal Ruby handles +// itself, enqueues it and runs rb_thread_execute_interrupts() before returning, +// so the Ruby-level effect (a trap, a SignalException, an Interrupt) happens +// synchronously inside Process.kill. vm.selfSignal is that arm plus +// rb_signal_exec; it reports false when MRI would really signal the OS. func (vm *VM) defineProcessKill(def func(string, NativeFn)) { def("kill", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { if len(args) < 2 { raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) } sig := vm.killSignalNumber(args[0]) + self := os.Getpid() for _, pv := range args[1:] { pid, send := int(vm.procToInt(pv)), sig if sig < 0 { // killpg(pgid, sig) == kill(-pgid, sig) pid, send = -pid, -sig } + // rb_f_kill takes the self arm only for a POSITIVE signal aimed at this + // very process; a process-group kill (sig < 0) always goes to the OS. + if sig > 0 && pid == self && vm.selfSignal(send) { + continue + } if err := procKill(pid, send); err != nil { sysFail(err, "") } @@ -321,6 +335,26 @@ func (vm *VM) defineProcessKill(def func(string, NativeFn)) { }) } +// defineProcessExit installs Process.exit, Process.exit! and Process.abort. +// process.c Init_process declares all three with rb_define_module_function on +// rb_mProcess, which makes them PUBLIC singleton methods on the module as well +// as private instance methods — so `Process.exit(5)` is a legal spelling where +// rbgo previously reached only Kernel's private copies and raised NoMethodError +// (issue #676's second defect). The bodies are rb_f_exit / rb_f_exit_bang / +// rb_f_abort, the same functions Kernel's copies name, so they share +// exitStatusArg's exit_status_code mapping and raiseSystemExit. +func (vm *VM) defineProcessExit(def func(string, NativeFn)) { + def("exit", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.raiseSystemExit(vm.exitStatusArg(args, 0), "exit") + }) + def("exit!", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.raiseSystemExit(vm.exitStatusArg(args, 1), "exit") + }) + def("abort", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + return vm.send(vm.main, "abort", args, blk) + }) +} + // rlimValue wraps a rlim_t as a Ruby Integer. RLIM_INFINITY is ~0 on Linux, // which does not fit in an int64, so an out-of-range limit promotes to a Bignum // rather than wrapping negative (MRI's RLIM2NUM does the same through diff --git a/internal/vm/puppet_cli_features_test.go b/internal/vm/puppet_cli_features_test.go index 01b5c473..c017119d 100644 --- a/internal/vm/puppet_cli_features_test.go +++ b/internal/vm/puppet_cli_features_test.go @@ -8,7 +8,16 @@ import "testing" // TestSignalModule covers the Signal module and Kernel#trap: name normalisation // across Symbol/String("SIG"-prefixed and bare)/Integer designators, the -// previous-handler return value of #trap, #list and #signame, all pinned to MRI. +// previous-handler return value of #trap, #list and #signame. +// +// Five rows here PINNED rbgo's own divergence while the comment said they were +// pinned to MRI: Signal.signame("x") answered nil where MRI raises TypeError, +// Signal.signame with no argument answered nil where MRI raises ArgumentError, +// Signal.trap(987654) invented a slot where MRI range-checks against NSIG, +// Signal.trap(:QUIT) with no block recorded DEFAULT where MRI calls +// rb_block_proc() and fails, and an object with only #to_s was accepted where +// signm2signo coerces through #to_str. Each replacement below was MEASURED +// against MRI 4.0.5 (ruby 4.0.5 (2026-05-20) +PRISM [arm64-darwin25]) and says so. func TestSignalModule(t *testing.T) { cases := []struct{ src, want string }{ // First trap returns "DEFAULT"; a second returns the prior Proc. @@ -25,20 +34,35 @@ func TestSignalModule(t *testing.T) { {`p Signal.list.class`, "Hash\n"}, {`p Signal.signame(2)`, "\"INT\"\n"}, {`p Signal.signame(987654)`, "nil\n"}, - {`p Signal.signame("x")`, "nil\n"}, - {`p Signal.signame`, "nil\n"}, - // An unknown Integer designator falls back to its decimal string. - {`Signal.trap(987654) { }; p Signal.trap(987654, "DEFAULT").class`, "Proc\n"}, + // MEASURED on MRI 4.0.5: sig_signame takes NUM2INT, so a String is a + // TypeError, not nil. This row read "nil" and claimed to be pinned to MRI. + {`begin; Signal.signame("x"); rescue TypeError => e; puts e.message; end`, + "no implicit conversion of String into Integer\n"}, + // MEASURED on MRI 4.0.5: ArgumentError, not nil. + {`begin; Signal.signame; rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 0, expected 1)\n"}, + // MEASURED on MRI 4.0.5: trap_signm range-checks against NSIG, so an + // out-of-range number is an ArgumentError rather than a fresh slot named + // after its decimal string. + {`begin; Signal.trap(987654) { }; rescue ArgumentError => e; puts e.message; end`, + "invalid signal number (987654)\n"}, // Kernel#trap reaches the same machinery without the Signal receiver. {`p trap("USR1") { }`, "\"DEFAULT\"\n"}, // Defaulting with two args and no block records the literal handler. {`Signal.trap(:HUP, "DEFAULT"); p Signal.trap(:HUP, "IGNORE")`, "\"DEFAULT\"\n"}, - // A no-block, single-arg trap records DEFAULT. - {`Signal.trap(:QUIT); p Signal.trap(:QUIT, "DEFAULT")`, "\"DEFAULT\"\n"}, - // A designator that is neither Symbol/String/Integer is accepted via its - // string form, returning the prior handler ("DEFAULT") for that fresh slot. + // MEASURED on MRI 4.0.5: sig_trap calls rb_block_proc() for a one-argument + // trap, which without a block is an ArgumentError — it does not record + // DEFAULT. + {`begin; Signal.trap(:QUIT); rescue ArgumentError => e; puts e.message; end`, + "tried to create Proc object without a block\n"}, + // MEASURED on MRI 4.0.5: signm2signo coerces through #to_str (via + // rb_check_string_type), NOT #to_s, so an object with only #to_s is a + // "bad signal type" naming its class. This row accepted it as a fresh slot. {`class SigName; def to_s; "CUSTOMSIG"; end; end -p Signal.trap(SigName.new) { }`, "\"DEFAULT\"\n"}, +begin; Signal.trap(SigName.new) { }; rescue ArgumentError => e; puts e.message; end`, + "bad signal type SigName\n"}, + {`class SigStr; def to_str; "HUP"; end; end +p Signal.trap(SigStr.new, "IGNORE")`, "\"DEFAULT\"\n"}, } for _, c := range cases { if got := eval(t, c.src); got != c.want { diff --git a/internal/vm/signal.go b/internal/vm/signal.go index 8c839362..c4059b3f 100644 --- a/internal/vm/signal.go +++ b/internal/vm/signal.go @@ -5,111 +5,626 @@ package vm import ( + "syscall" + "github.com/go-embedded-ruby/ruby/internal/object" ) -// registerSignal installs the Signal module and Kernel#trap. A program installs -// a handler (Signal.trap(:INT) { ... }) and the VM records it, returning the -// previous handler the way MRI does ("DEFAULT" the first time, then the prior -// Proc/String). The embedded host does not yet wire OS signals to the recorded -// handlers — delivery is the scheduler's job in a later round — but registration -// must succeed because CLI applications such as `puppet apply` trap :INT during -// setup. Signal.list maps the common signal names to their numbers and -// Signal.signame inverts a number, both as plain lookups. +// trapKind is what the VM does when a signal is delivered, mirroring the values +// MRI stores in vm->trap_list.cmd[sig] (signal.c trap_handler): +// +// 0 (absent) -> the signal's default Ruby action +// Qtrue ("IGNORE"/"") -> discard +// Qundef ("EXIT") -> raise SystemExit +// 0 + SIG_DFL ("SYSTEM_DEFAULT") -> hand the signal back to the OS +// a Proc / command String -> run it +type trapKind int + +const ( + trapDefault trapKind = iota // no handler installed: default_handler(sig) + trapIgnore // "IGNORE" / "SIG_IGN" / "" + trapNil // trap(sig, nil): ignores, and reports back nil + trapExit // "EXIT" + trapSystemDefault // "SYSTEM_DEFAULT": SIG_DFL, the OS acts + trapCommand // a Proc (or a command String) to run +) + +// trapCmd is one entry of the VM's trap list. +type trapCmd struct { + kind trapKind + cmd object.Value // the Proc/String for trapCommand; the value #trap reports back +} + +// signalManaged reports whether MRI installs its own sighandler for sig, which +// is signal.c default_handler's first case group (and the same set Init_signal +// passes to install_sighandler). It is exactly the set rb_signal_exec turns into +// a Ruby exception when no user handler is installed, and — because +// signal_ignored() then sees MRI's own handler rather than SIG_DFL — the set +// rb_f_kill routes through signal_enque instead of kill(2) when the target is +// this process. +func (vm *VM) signalManaged(sig int) bool { + switch sig { + case sigNumber("INT"), sigNumber("HUP"), sigNumber("QUIT"), sigNumber("TERM"), + sigNumber("ALRM"), sigNumber("USR1"), sigNumber("USR2"), sigNumber("CHLD"): + return true + } + return false +} + +// signalFatal reports whether rb_f_kill delivers sig to this process for real +// even when the target is self: SIGSEGV, SIGBUS, SIGKILL, SIGILL, SIGFPE and +// SIGSTOP cannot be deferred to a Ruby safe point, so signal.c's rb_f_kill +// calls kill(2) directly for them ("switch (sig)" inside the pid == self arm). +func signalFatal(sig int) bool { + switch sig { + case sigNumber("SEGV"), sigNumber("BUS"), sigNumber("KILL"), + sigNumber("ILL"), sigNumber("FPE"), sigNumber("STOP"): + return true + } + return false +} + +// signalReserved is signal.c reserved_signal_p: the signals MRI refuses to let a +// program trap at all, because it needs its own handlers for them. SEGV, BUS, +// ILL and FPE are synchronous and cannot be delivered to the main thread, and +// VTALRM is the interrupt the thread scheduler itself runs on. +func signalReserved(sig int) bool { + switch sig { + case sigNumber("SEGV"), sigNumber("BUS"), sigNumber("ILL"), + sigNumber("FPE"), sigNumber("VTALRM"): + return true + } + return false +} + +// signalUntrappable are the two signals sigaction(2) refuses outright, so MRI's +// trap() -> ruby_signal() gets SIG_ERR and calls rb_sys_fail_str(name), raising +// Errno::EINVAL rather than ArgumentError. POSIX fixes both numbers, so this +// needs no host table. +func signalUntrappable(sig int) bool { + return sig == sigNumber("KILL") || sig == sigNumber("STOP") +} + +// registerSignal installs the Signal module, Kernel#trap, and the SignalException +// / Interrupt methods that carry a signal's identity (#signo, #signm). +// +// The disposition a program installs is recorded in vm.trapList, MRI's +// vm->trap_list.cmd[] (signal.c trap/trap_handler), and consulted by +// Process.kill when the target is this process — see vm.selfSignal, which is +// signal.c rb_f_kill's pid == self arm plus rb_signal_exec. The VM deliberately +// does NOT install an OS signal handler and never calls os/signal.Notify: the +// Go runtime stays the sole owner of signal disposition. See vm.selfSignal for +// why that is the faithful reading of the C rather than a shortcut. func (vm *VM) registerSignal() { mod := newClass("Signal", nil) mod.isModule = true vm.consts["Signal"] = mod - - // handlers records the most recently installed handler per normalised signal - // name so trap can report the previous one. It lives on the VM-shared module - // object via an ivar so a single map is reused. - handlers := map[string]object.Value{} + vm.trapList = map[int]trapCmd{} trap := func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { if len(args) == 0 { return raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") } - name := signalName(args[0]) - prev, had := handlers[name] - if !had { - prev = object.NewString("DEFAULT") + sig := vm.trapSignm(args[0]) + // sig_trap refuses a reserved signal before it looks at the handler. + if signalReserved(sig) { + // Every signal signalReserved names has a siglist entry, so unlike + // sig_trap (whose NSIG table can be sparser than its handler set) there + // is no numeric spelling of this message to fall back to. + name, _ := signalNameOf(sig) + return raise("ArgumentError", "can't trap reserved signal: SIG%s", name) + } + // SIGKILL and SIGSTOP cannot be caught or ignored: sigaction(2) fails + // EINVAL, which trap() turns into Errno::EINVAL through rb_sys_fail_str. + if signalUntrappable(sig) { + sysFail(syscall.EINVAL, signoToSignm(sig)) } + var next trapCmd switch { case blk != nil: - handlers[name] = blk // a Proc is itself a Value, returned as the previous handler later + next = trapCmd{kind: trapCommand, cmd: blk} case len(args) >= 2: - handlers[name] = args[1] // "DEFAULT" / "IGNORE" / "SIG_IGN" / a command string + next = vm.trapHandler(args[1]) default: - handlers[name] = object.NewString("DEFAULT") + return raise("ArgumentError", "tried to create Proc object without a block") } + prev := vm.trapPrevious(sig) + vm.trapList[sig] = next return prev } mod.smethods["trap"] = &Method{name: "trap", owner: mod, native: trap} - // Signal.list returns the {name => number} table; Signal.signame inverts it. + // Signal.list returns the {name => number} table. MRI's sig_list walks the + // same siglist it uses everywhere else, so EXIT (0) is a member — a fact + // core/signal/list_spec.rb asserts directly. mod.smethods["list"] = &Method{name: "list", owner: mod, native: func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { h := object.NewHash() - for name, num := range signalNumbers { - h.Set(object.NewString(name), object.IntValue(int64(num))) + for _, e := range siglist { + h.Set(object.NewString(e.name), object.IntValue(int64(e.signo))) } return h }} + // Signal.signame inverts the table. MRI's sig_signame takes NUM2INT — so a + // non-Integer goes through #to_int and a #to_int that returns a non-Integer is + // a TypeError — and signo2signm returns NULL (nil) for a number no entry + // carries. signo2signm returns the FIRST siglist entry with that number, which + // is why siglist lists a canonical name ahead of its aliases. mod.smethods["signame"] = &Method{name: "signame", owner: mod, - native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { - if len(args) == 0 { - return object.NilV - } - n, ok := args[0].(object.Integer) - if !ok { - return object.NilV + native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 1 { + return raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args)) } - for name, num := range signalNumbers { - if int64(num) == int64(n) { - return object.NewString(name) - } + n := vm.toIntCoerce(args[0]) + if name, ok := signalNameOf(int(n)); ok { + return object.NewString(name) } return object.NilV }} // Kernel#trap is the same operation reachable without the Signal receiver. vm.cObject.define("trap", trap) + + vm.registerSignalException() } -// signalNumbers maps the portable signal names to their POSIX numbers. The set -// covers the signals a Ruby program is likely to trap; it is not exhaustive. -var signalNumbers = map[string]int{ - "HUP": 1, "INT": 2, "QUIT": 3, "ILL": 4, "TRAP": 5, "ABRT": 6, - "FPE": 8, "KILL": 9, "SEGV": 11, "PIPE": 13, "ALRM": 14, "TERM": 15, - "USR1": 30, "USR2": 31, "CHLD": 20, "CONT": 19, "STOP": 17, "TSTP": 18, - "WINCH": 28, "INFO": 29, +// registerSignalException adds the identity methods SignalException carries. +// signal.c Init_signal defines #initialize (esignal_init), #signo +// (esignal_signo) and aliases #signm to #message; Interrupt overrides +// #initialize (interrupt_init) to fix the signal at SIGINT. +func (vm *VM) registerSignalException() { + // A hard assertion: builtins.go builds the exception hierarchy before it calls + // registerSignal, so an absent SignalException is a broken registration order, + // not a state to carry on from quietly. + cSig := vm.consts["SignalException"].(*RClass) + // esignal_init(sig) / esignal_init(signo, message = signo2signm(signo)): + // the first argument is tried as an Integer through #to_int; if it converts, + // a second argument may supply the message and arity is 1..2, otherwise the + // argument is a signal NAME and arity is exactly 1. A name is stored with a + // "SIG" prefix when it was given without one (`sig = "SIG" + sig` when the + // matched prefix length differs from signame_prefix_len), which is why + // SignalException.new("TERM").message is "SIGTERM". + cSig.define("initialize", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*RObject) + if len(args) == 0 { + return raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + var signo int + var message string + if n, isInt := vm.toIntMaybe(args[0]); isInt { + if len(args) > 2 { + return raise("ArgumentError", "wrong number of arguments (given %d, expected 1..2)", len(args)) + } + signo = int(n) + if signo < 0 || signo > sigMaxNumber { + return raise("ArgumentError", "invalid signal number (%d)", signo) + } + if len(args) > 1 { + message = args[1].ToS() + } else { + message = signoToSignm(signo) + } + } else { + if len(args) != 1 { + return raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args)) + } + // esignal_init calls signm2signo with exit = FALSE, so "EXIT" is not a + // signal name here even though Signal.trap(:EXIT) is legal. + name, named := vm.signalNameArg(args[0]) + if !named { + return raise("ArgumentError", "bad signal type %s", vm.objClassName(args[0])) + } + num, found := signalNumberOfEx(name, false) + if !found { + return raise("ArgumentError", "unsupported signal '%s'", withSIG(name)) + } + signo, message = num, withSIG(name) + } + o.ivars["@message"] = object.NewString(message) + o.ivars["@signo"] = object.IntValue(int64(signo)) + return object.NilV + }) + cSig.define("signo", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@signo") + }) + // rb_alias(rb_eSignal, "signm", "message"). + cSig.define("signm", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.send(self, "message", nil, nil) + }) + + // interrupt_init: args[0] = SIGINT, args[1..] = the caller's arguments, so + // Interrupt.new carries signo 2 and an EMPTY message (MRI passes no name, and + // esignal_init's message argument defaults to "" rather than "SIGINT" here + // because interrupt_init supplies argv[1] itself). + cInt := vm.consts["Interrupt"].(*RClass) + { + cInt.define("initialize", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*RObject) + // Interrupt.new with no argument leaves the message UNSET, so + // Exception#message falls back to the class name and #signm reads + // "Interrupt" (core/exception/interrupt_spec.rb). The Interrupt that + // rb_interrupt() raises is built with an explicit empty message instead, + // which is why a rescued one reports "" — measured on MRI 4.0.5, where + // Interrupt.new.message is "Interrupt" and the raised one's is "". + if len(args) > 0 { + o.ivars["@message"] = object.NewString(args[0].ToS()) + } + o.ivars["@signo"] = object.IntValue(int64(sigNumber("INT"))) + return object.NilV + }) + } +} + +// trapSignm coerces Signal.trap's first argument to a signal number, applying +// signal.c trap_signm: a Fixnum is range-checked against NSIG, anything else +// goes through signm2signo with exit = TRUE, so "EXIT" and 0 are accepted. +// +// Note what it does NOT do: trap_signm tests FIXNUM_P, so there is no #to_int +// conversion anywhere on this path — core/signal/trap_spec.rb asserts that +// explicitly with a mock that fails if #to_int is called, and a Float is a +// "bad signal type" rather than a truncated number. +func (vm *VM) trapSignm(v object.Value) int { + if n, isInt := v.(object.Integer); isInt { + if n < 0 || int64(n) > int64(sigMaxNumber) { + raise("ArgumentError", "invalid signal number (%d)", int64(n)) + } + return int(n) + } + return vm.signm2signo(v, true) +} + +// signm2signo is signal.c signm2signo with negative = FALSE: a Symbol becomes its +// name, a String is used as is, anything else must answer #to_str (through +// rb_check_string_type, so an object that does not is a "bad signal type" naming +// its CLASS, not a conversion failure), the "SIG" prefix is optional, and a name +// no entry carries is "unsupported signal". +// +// allowExit is the C parameter `exit`: FOREACH_SIGNAL skips the first table entry +// when it is false, and that entry is EXIT — so Signal.trap accepts :EXIT while +// SignalException.new and Process.kill do not. +func (vm *VM) signm2signo(v object.Value, allowExit bool) int { + name, ok := vm.signalNameArg(v) + if !ok { + raise("ArgumentError", "bad signal type %s", vm.objClassName(v)) + } + if num, found := signalNumberOfEx(name, allowExit); found { + return num + } + raise("ArgumentError", "unsupported signal '%s'", withSIG(name)) + return 0 } -// signalName normalises a signal designator to its bare name (no "SIG" prefix), -// accepting a Symbol, a String ("INT"/"SIGINT") or an Integer. -func signalName(v object.Value) string { - switch s := v.(type) { +// signalNameArg is signm2signo's argument coercion alone: a Symbol, a String, or +// an object whose #to_str returns one. It reports false for anything else so the +// caller can raise the "bad signal type" ArgumentError naming the class. +func (vm *VM) signalNameArg(v object.Value) (string, bool) { + switch t := v.(type) { case object.Symbol: - return stripSIG(string(s)) + return string(t), true case *object.String: - return stripSIG(string(s.Bytes())) - case object.Integer: - for name, num := range signalNumbers { - if int64(num) == int64(s) { - return name - } + return string(t.Bytes()), true + } + if vm.respondsToDynamic(v, "to_str") { + if s, isStr := vm.send(v, "to_str", nil, nil).(*object.String); isStr { + return string(s.Bytes()), true } - return v.ToS() + } + return "", false +} + +// trapHandler maps Signal.trap's second argument to a disposition, following +// signal.c trap_handler's switch on the command string's length exactly: nil and +// "" and "IGNORE"/"SIG_IGN" ignore, "DEFAULT"/"SIG_DFL" restore the signal's +// default RUBY action, "SYSTEM_DEFAULT" restores the OS default, "EXIT" exits, +// and anything else is a command to run. +func (vm *VM) trapHandler(v object.Value) trapCmd { + if v == object.NilV { + // trap_handler leaves *cmd as Qnil for a nil handler, and trap()'s oldcmd + // switch has an explicit `case Qnil: break` that does NOT rewrite it — so a + // signal whose handler was set to nil reports back nil, not "IGNORE". + return trapCmd{kind: trapNil, cmd: object.NilV} + } + if p, isProc := v.(*Proc); isProc { + return trapCmd{kind: trapCommand, cmd: p} + } + switch name := v.ToS(); name { + case "", "IGNORE", "SIG_IGN": + return trapCmd{kind: trapIgnore, cmd: object.NewString("IGNORE")} + case "DEFAULT", "SIG_DFL": + return trapCmd{kind: trapDefault, cmd: object.NewString("DEFAULT")} + case "SYSTEM_DEFAULT": + return trapCmd{kind: trapSystemDefault, cmd: object.NewString("SYSTEM_DEFAULT")} + case "EXIT": + return trapCmd{kind: trapExit, cmd: object.NewString("EXIT")} default: - return v.ToS() + return trapCmd{kind: trapCommand, cmd: v} } } +// trapPrevious is what Signal.trap returns: signal.c trap()'s oldcmd switch. A +// signal with nothing installed reports the name of the disposition it had — +// "DEFAULT" for a signal MRI handles itself, "SYSTEM_DEFAULT" for one left to +// the OS — rather than nil, and an installed Proc or command comes back as +// itself. +func (vm *VM) trapPrevious(sig int) object.Value { + // Signal 0 (Signal.trap(:EXIT)) reports nil, not a disposition name: trap() + // starts with `if (sig == 0) oldfunc = SIG_ERR;`, and SIG_ERR matches none of + // the three arms of the oldcmd switch's `case 0`, so it falls to Qnil. + // Measured on MRI 4.0.5: Signal.trap(:EXIT) {} => nil. + if sig == 0 { + if prev, had := vm.trapList[0]; had && prev.kind == trapCommand { + return prev.cmd + } + return object.NilV + } + // An absent entry IS trapDefault (MRI's trap_list.cmd[sig] == 0), so the + // zero value of trapCmd already says what to report. + prev := vm.trapList[sig] + switch prev.kind { + case trapDefault: + if vm.signalManaged(sig) { + return object.NewString("DEFAULT") + } + return object.NewString("SYSTEM_DEFAULT") + case trapIgnore: + return object.NewString("IGNORE") + case trapNil: + return object.NilV + case trapSystemDefault: + return object.NewString("SYSTEM_DEFAULT") + case trapExit: + return object.NewString("EXIT") + } + return prev.cmd +} + +// selfSignal is signal.c rb_f_kill's "target pid is self" arm followed by +// rb_signal_exec, and it is the whole of issue #691. +// +// MRI does NOT send the signal to itself for the ordinary cases. rb_f_kill's +// comment says why — "When target pid is self, many caller assume signal will be +// delivered immediately and synchronously" — so it consults the installed +// disposition and, for a signal it handles itself, calls signal_enque(sig) and +// then rb_thread_execute_interrupts() before returning. The Ruby-level effect +// therefore happens INSIDE Process.kill, on the calling thread, and no OS signal +// is ever raised. +// +// That is what reconciles MRI's model with Go's. MRI installs a C handler +// (Init_signal -> install_sighandler) purely so an ASYNCHRONOUS signal can be +// parked in signal_buff and re-emerge at the next Ruby safe point; the safe point +// for a self-directed kill is the next statement, and signal_enque plus an +// immediate interrupt check expresses it without the handler. Go's runtime owns +// signal disposition and os/signal.Notify changes it process-wide, so +// reproducing MRI by installing a Go handler would buy nothing here and cost the +// asynchronous delivery race that IS the observed defect: the old code called +// kill(2) and then raced the Go runtime's default action against the program's +// own exit, which is why the old behaviour was 15-20% fatal rather than always +// fatal. Executing the disposition synchronously removes the race by +// construction, needs no build tag on a target without signals, and leaves Go's +// disposition untouched. +// +// What it does NOT do, and MRI does: an EXTERNAL signal (one another process +// sends us) still meets Go's default disposition, so a trapped SIGTERM arriving +// from outside kills us where MRI would run the trap. Handling that needs +// os/signal.Notify plus a delivery point in the interpreter loop; it is recorded +// as follow-up work rather than smuggled in here. Note what MRI itself does with +// a signal that arrives while no Ruby frame is executing: sighandler only fills +// signal_buff, and if the VM has already reached rb_ec_cleanup nothing runs it — +// the process then dies through ruby_default_signal. So "no Ruby frame" is not a +// case MRI serves either. +// +// handled reports whether the signal was consumed here; false means the caller +// (Process.kill) must fall through to a real kill(2). +func (vm *VM) selfSignal(sig int) (handled bool) { + if sig == 0 || signalFatal(sig) { + return false // kill(pid, 0) probes; a fatal signal cannot be deferred + } + cmd, had := vm.trapList[sig] + if !had { + cmd = trapCmd{kind: trapDefault} + } + switch cmd.kind { + case trapIgnore, trapNil: + // signal_ignored() == 1: MRI neither sends nor runs anything. + return true + case trapSystemDefault: + // signal_ignored() == -1 (SIG_DFL): MRI really does kill(pid, sig). + return false + case trapExit: + // rb_threadptr_signal_exit: SystemExit with EXIT_FAILURE. + vm.raiseSystemExit(1, "exit") + return true + case trapCommand: + // signal_exec: the handler runs with the signal number as its argument. + vm.runTrapCommand(cmd.cmd, sig) + return true + } + // trapDefault. rb_signal_exec's cmd == 0 arm: SIGINT becomes Interrupt, + // HUP/QUIT/TERM/ALRM/USR1/USR2 become SignalException, and any other signal + // MRI does not handle is left to the OS. + if !vm.signalManaged(sig) { + return false + } + switch sig { + case sigNumber("INT"): + vm.raiseSignalException("Interrupt", sig, "") + case sigNumber("CHLD"): + // Init_signal installs sighandler for SIGCHLD to reap children; there is + // no cmd == 0 case for it in rb_signal_exec, so it raises nothing. + default: + vm.raiseSignalException("SignalException", sig, signoToSignm(sig)) + } + return true +} + +// runTrapCommand runs an installed trap. A Proc (or anything that responds to +// #call) is called with the signal number, as signal.c signal_exec does through +// rb_eval_cmd_kw(cmd, [INT2NUM(sig)]). +func (vm *VM) runTrapCommand(cmd object.Value, sig int) { + arg := []object.Value{object.IntValue(int64(sig))} + if p, isProc := cmd.(*Proc); isProc { + vm.callProcWithBlock(p, arg, nil) + return + } + vm.send(cmd, "call", arg, nil) +} + +// raiseSignalException raises SignalException (or Interrupt) already carrying +// @signo, so a rescuer sees #signo and #signm without the class having to +// re-derive them. +func (vm *VM) raiseSignalException(class string, signo int, message string) { + c := vm.consts[class].(*RClass) + exc := vm.send(c, "new", []object.Value{object.NewString(message)}, nil) + o := exc.(*RObject) + o.ivars["@signo"] = object.IntValue(int64(signo)) + o.ivars["@message"] = object.NewString(message) + panic(vm.excError(vm.captureBacktrace(exc))) +} + +// signalEntry is one row of the signal name table. +type signalEntry struct { + name string + signo int +} + +// siglist is signal.c's siglist: the signal names Ruby knows, with a CANONICAL +// name ahead of each of its aliases, because signo2signm returns the first entry +// matching a number (core/signal/signame_spec.rb asserts ABRT wins over IOT and +// CHLD over CLD). EXIT is a member with number 0 — Signal.trap(:EXIT) is +// at_exit spelt as a signal, and core/signal/list_spec.rb reads it back. +// +// The numbers are this table's own, not the host's. They are correct on Darwin +// and correct everywhere for the signals whose numbers POSIX fixes (HUP 1, +// INT 2, QUIT 3, KILL 9, PIPE 13, ALRM 14, TERM 15); USR1/USR2/CHLD/STOP/TSTP +// and the BSD extras differ on Linux, which is a pre-existing gap recorded with +// this change rather than fixed by it (a per-GOOS table needs a build tag on a +// target, wasip1, that has no signal constants at all). +var siglist = []signalEntry{ + {"EXIT", 0}, + {"HUP", 1}, {"INT", 2}, {"QUIT", 3}, {"ILL", 4}, {"TRAP", 5}, + {"ABRT", 6}, {"IOT", 6}, + {"FPE", 8}, {"KILL", 9}, {"BUS", 10}, {"SEGV", 11}, {"SYS", 12}, + {"PIPE", 13}, {"ALRM", 14}, {"TERM", 15}, {"URG", 16}, + {"STOP", 17}, {"TSTP", 18}, {"CONT", 19}, + {"CHLD", 20}, {"CLD", 20}, + {"TTIN", 21}, {"TTOU", 22}, {"IO", 23}, {"XCPU", 24}, {"XFSZ", 25}, + {"VTALRM", 26}, {"PROF", 27}, {"WINCH", 28}, {"INFO", 29}, + {"USR1", 30}, {"USR2", 31}, +} + +// sigMaxNumber is NSIG's role in esignal_init and trap_signm: the inclusive +// upper bound a signal number may take before ArgumentError. +const sigMaxNumber = 64 + +// signalNumbers indexes siglist by name for lookup. signalNames indexes it by +// number, keeping the FIRST (canonical) name for each. +var signalNumbers, signalNames = func() (map[string]int, map[int]string) { + byName := make(map[string]int, len(siglist)) + byNo := make(map[int]string, len(siglist)) + for _, e := range siglist { + byName[e.name] = e.signo + if _, seen := byNo[e.signo]; !seen { + byNo[e.signo] = e.name + } + } + return byName, byNo +}() + +// sigNumber is siglist's number for a canonical name. It panics on an unknown +// name, which can only be a typo in this package. +func sigNumber(name string) int { + n, ok := signalNumbers[name] + if !ok { + panic("vm: unknown signal name " + name) + } + return n +} + +// signalNumberOf is signalNumberOfEx allowing EXIT, for callers that only need +// the lookup. +func signalNumberOf(name string) (int, bool) { + return signalNumberOfEx(name, true) +} + +// signalNumberOfEx looks a name up in siglist, accepting "TERM", "SIGTERM" or +// :TERM. allowExit is signm2signo's FOREACH_SIGNAL(sigs, !exit): with it false +// the EXIT entry is skipped, so "EXIT" is unsupported. +func signalNumberOfEx(name string, allowExit bool) (int, bool) { + bare := stripSIG(name) + if !allowExit && bare == "EXIT" { + return 0, false + } + n, ok := signalNumbers[bare] + return n, ok +} + +// signalNameOf is signal.c signo2signm. +func signalNameOf(signo int) (string, bool) { + n, ok := signalNames[signo] + return n, ok +} + +// signoToSignm is rb_signo2signm: the "SIG"-prefixed name, or "SIG" for a +// number no entry carries (rb_sprintf("SIG%u", signo)). +func signoToSignm(signo int) string { + if name, ok := signalNameOf(signo); ok { + return "SIG" + name + } + return "SIG" + object.IntValue(int64(signo)).ToS() +} + +// withSIG prefixes a bare signal name, as esignal_init does when the name it was +// given carried no prefix. +func withSIG(name string) string { + if stripSIG(name) == name { + return "SIG" + name + } + return name +} + func stripSIG(name string) string { if len(name) > 3 && name[:3] == "SIG" { return name[3:] } return name } + +// objClassName is MRI's rb_obj_classname: the name of the object's CLASS, which +// for nil is "NilClass" and not "nil". classNameOf answers the type name a format +// string wants, which differs for exactly the values signm2signo's error message +// is most likely to be handed (nil, true, false) — core/signal/trap_spec.rb reads +// the difference. +func (vm *VM) objClassName(v object.Value) string { + if c := vm.classOf(v); c != nil { + return c.name + } + // classOf ends in classOfPlatform, which answers nil for a value type this + // platform does not place (it is a plain `return nil` on Windows). Nothing a + // Ruby expression can produce reaches that, but a nil dereference in an error + // path is a poor way to find out, so fall back to the type name. + return classNameOf(v) +} + +// toIntMaybe is signal.c's rb_check_to_integer(v, "to_int"): it reports whether +// v converts to an Integer WITHOUT raising, which is how esignal_init decides +// between "first argument is a signal number" and "first argument is a signal +// name". A String responds to neither #to_int nor an implicit conversion, so +// SignalException.new("TERM") takes the name branch. +func (vm *VM) toIntMaybe(v object.Value) (int64, bool) { + if i, ok := v.(object.Integer); ok { + return int64(i), true + } + if _, isStr := v.(*object.String); isStr { + return 0, false + } + if _, isSym := v.(object.Symbol); isSym { + return 0, false + } + if vm.respondsToDynamic(v, "to_int") { + if i, ok := vm.send(v, "to_int", nil, nil).(object.Integer); ok { + return int64(i), true + } + } + return 0, false +} diff --git a/internal/vm/signal_disposition_internal_test.go b/internal/vm/signal_disposition_internal_test.go new file mode 100644 index 00000000..38f606cd --- /dev/null +++ b/internal/vm/signal_disposition_internal_test.go @@ -0,0 +1,614 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows && !wasm + +package vm + +import ( + "bytes" + "strings" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" +) + +// runSrcErr runs a program and returns its stdout plus whatever error came out +// of Run, so a test can assert on a program that ends by raising or exiting. +// runSrc (aot_dispatch_test.go) t.Fatals on an error, which is the wrong shape +// for everything here. +func runSrcErr(t *testing.T, src string) (*VM, string, error) { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + var buf bytes.Buffer + machine := New(&buf) + _, runErr := machine.Run(iseq) + return machine, buf.String(), runErr +} + +// TestSelfSignalRaisesInsteadOfKilling is the witness for issue #691: on +// origin/main this program's process DIED (128+15) between 10% and 20% of runs +// and printed nothing the rest of the time, because Process.kill called kill(2) +// and then raced the Go runtime's default disposition against the program's own +// exit. signal.c rb_f_kill never sends a deferrable signal to itself; it enqueues +// it and runs rb_thread_execute_interrupts() before returning, so the raise +// happens synchronously. +// +// A/B: this test cannot pass on the old code even in the runs where the process +// survived, because no exception was raised at all there either ("NO RAISE - kept +// running" in the issue). Reverting vm.selfSignal's call site in +// defineProcessKill to an unconditional procKill still compiles and fails here. +func TestSelfSignalRaisesInsteadOfKilling(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + // rb_signal_exec's cmd == 0 arm: SIGTERM and the rest of default_handler's + // first group become SignalException, carrying #signo and #signm. + {"TERM", `begin + Process.kill(:TERM, Process.pid) +rescue SignalException => e + puts "#{e.class} #{e.message} #{e.signo} #{e.signm}" +end +puts "alive"`, "SignalException SIGTERM 15 SIGTERM\nalive\n"}, + // SIGINT is the one case rb_signal_exec routes to rb_interrupt() instead, + // and interrupt_init leaves the message EMPTY. + {"INT", `begin + Process.kill(:INT, Process.pid) +rescue Interrupt => e + puts "#{e.class} #{e.message.inspect} #{e.signo}" +end +puts "alive"`, "Interrupt \"\" 2\nalive\n"}, + // Interrupt < SignalException, so the wider rescue catches it. + {"INT rescued as SignalException", `begin + Process.kill("SIGINT", Process.pid) +rescue SignalException => e + puts e.class +end`, "Interrupt\n"}, + // A trap runs with the signal NUMBER as its argument (signal_exec -> + // rb_eval_cmd_kw(cmd, [INT2NUM(sig)])). + {"trap block", `Signal.trap(:TERM) { |s| puts "trapped #{s}" } +Process.kill(:TERM, Process.pid) +puts "after"`, "trapped 15\nafter\n"}, + // A trap installed as a Proc rather than a block. + {"trap proc", `Signal.trap(:HUP, ->(s) { puts "hup #{s}" }) +Process.kill(:HUP, Process.pid)`, "hup 1\n"}, + // "IGNORE" discards it: signal_ignored() == 1, so MRI neither sends the + // signal nor runs anything. + {"IGNORE", `Signal.trap(:TERM, "IGNORE") +Process.kill(:TERM, Process.pid) +puts "after"`, "after\n"}, + {"SIG_IGN", `Signal.trap(:TERM, "SIG_IGN") +Process.kill(:TERM, Process.pid) +puts "after"`, "after\n"}, + {"nil handler ignores", `Signal.trap(:TERM, nil) +Process.kill(:TERM, Process.pid) +puts "after"`, "after\n"}, + // "DEFAULT" restores default_handler(sig), which for TERM is MRI's own + // handler — so the SignalException comes back rather than the OS default. + {"DEFAULT restores the raise", `Signal.trap(:TERM) { puts "no" } +Signal.trap(:TERM, "DEFAULT") +begin + Process.kill(:TERM, Process.pid) +rescue SignalException => e + puts "back to #{e.message}" +end`, "back to SIGTERM\n"}, + // "EXIT" is rb_threadptr_signal_exit: SystemExit with EXIT_FAILURE. + {"EXIT", `Signal.trap(:TERM, "EXIT") +begin + Process.kill(:TERM, Process.pid) +rescue SystemExit => e + puts "exit #{e.status}" +end`, "exit 1\n"}, + // Process.kill(0, pid) is a liveness probe and must reach the OS, returning + // the number of pids signalled. + {"signal 0 probes", `p Process.kill(0, Process.pid)`, "1\n"}, + // A signal MRI leaves to the OS raises nothing here, so the trap list has + // to report SYSTEM_DEFAULT rather than DEFAULT for it (trap()'s oldcmd). + {"trap previous handler", `p Signal.trap(:HUP, "IGNORE") +p Signal.trap(:HUP, "DEFAULT") +p Signal.trap(:WINCH, "IGNORE")`, "\"DEFAULT\"\n\"IGNORE\"\n\"SYSTEM_DEFAULT\"\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + }) + } +} + +// TestSelfSignalFallsThroughToTheOS pins the two dispositions where rb_f_kill +// really does call kill(2) on this process, by swapping the procKill seam rather +// than signalling the test binary. Without the seam this could only be tested by +// killing the test process, which is why the seam is what the assertion reads. +func TestSelfSignalFallsThroughToTheOS(t *testing.T) { + for _, tc := range []struct { + name, src string + wantSig int + }{ + // SIGKILL cannot be deferred to a safe point: rb_f_kill's inner switch + // calls kill(pid, sig) for it directly. + {"KILL is delivered for real", `Process.kill(:KILL, Process.pid)`, 9}, + // "SYSTEM_DEFAULT" sets SIG_DFL, so signal_ignored() returns -1 and + // rb_f_kill falls through to kill(2). + {"SYSTEM_DEFAULT is delivered for real", + `Signal.trap(:TERM, "SYSTEM_DEFAULT") +Process.kill(:TERM, Process.pid)`, 15}, + // A signal MRI installs no handler for has SIG_DFL disposition too. + {"an unmanaged signal is delivered for real", `Process.kill(:WINCH, Process.pid)`, 28}, + } { + t.Run(tc.name, func(t *testing.T) { + orig := procKill + t.Cleanup(func() { procKill = orig }) + var gotPid, gotSig int + calls := 0 + procKill = func(pid, sig int) error { + calls++ + gotPid, gotSig = pid, sig + return nil + } + if _, out, err := runSrcErr(t, tc.src); err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if calls != 1 { + t.Fatalf("procKill called %d times, want 1", calls) + } + if gotSig != tc.wantSig { + t.Errorf("signal = %d, want %d", gotSig, tc.wantSig) + } + if gotPid <= 0 { + t.Errorf("pid = %d, want this process", gotPid) + } + }) + } +} + +// TestSelfSignalOnlyShortCircuitsThisProcess proves the synchronous arm is +// reached only for pid == self and only for a positive signal: another pid, and a +// process-GROUP kill even of our own group, must still go to the OS. A version +// that tested `pid == self` alone would pass while silently swallowing +// Process.kill(-15, Process.getpgrp). +func TestSelfSignalOnlyShortCircuitsThisProcess(t *testing.T) { + orig := procKill + t.Cleanup(func() { procKill = orig }) + var sent [][2]int + procKill = func(pid, sig int) error { sent = append(sent, [2]int{pid, sig}); return nil } + + src := `Process.kill(:TERM, 999999) rescue nil +Process.kill(-15, Process.pid) rescue nil +begin + Process.kill(:TERM, Process.pid) +rescue SignalException + puts "self raised" +end` + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != "self raised\n" { + t.Errorf("output = %q, want %q", out, "self raised\n") + } + if len(sent) != 2 { + t.Fatalf("procKill calls = %v, want exactly the foreign pid and the group kill", sent) + } + if sent[0][0] != 999999 || sent[0][1] != 15 { + t.Errorf("foreign kill = %v, want [999999 15]", sent[0]) + } + // killpg(pgid, sig) == kill(-pgid, sig): the pid is negated, the signal is not. + if sent[1][0] >= 0 || sent[1][1] != 15 { + t.Errorf("group kill = %v, want a negative pid with signal 15", sent[1]) + } +} + +// TestSignalExceptionConstruction covers signal.c esignal_init and +// interrupt_init directly: the name branch, the number branch, the explicit +// message, and each ArgumentError. +func TestSignalExceptionConstruction(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + // The name branch prefixes SIG when the name was given without one. + {"from a name", `e = SignalException.new("TERM"); puts "#{e.message} #{e.signo}"`, "SIGTERM 15\n"}, + {"from a prefixed name", `e = SignalException.new("SIGTERM"); puts "#{e.message} #{e.signo}"`, "SIGTERM 15\n"}, + {"from a symbol", `e = SignalException.new(:HUP); puts "#{e.message} #{e.signo}"`, "SIGHUP 1\n"}, + // The number branch defaults the message through rb_signo2signm. + {"from a number", `e = SignalException.new(15); puts "#{e.message} #{e.signo}"`, "SIGTERM 15\n"}, + {"from a number with a message", `e = SignalException.new(15, "boom"); puts "#{e.message} #{e.signo}"`, "boom 15\n"}, + // rb_signo2signm falls back to "SIG%u" for a number no name carries. + {"an unnamed number", `e = SignalException.new(62); puts "#{e.message} #{e.signo}"`, "SIG62 62\n"}, + // #signm is an alias of #message (rb_alias in Init_signal). + {"signm aliases message", `e = SignalException.new(15, "boom"); p e.signm == e.message`, "true\n"}, + // interrupt_init fixes the signal at SIGINT and leaves the message empty. + // Interrupt.new leaves the message unset, so Exception#message falls back + // to the class name. The Interrupt rb_interrupt() raises carries an explicit + // empty message instead — see TestInterruptMessageFallsBackToTheClassName. + {"Interrupt", `e = Interrupt.new; puts "#{e.message.inspect} #{e.signo}"`, "\"Interrupt\" 2\n"}, + {"Interrupt with a message", `e = Interrupt.new("ouch"); puts "#{e.message} #{e.signo}"`, "ouch 2\n"}, + // Errors. A name nothing matches, and a number outside NSIG. + {"unknown name", `begin; SignalException.new("NOPE"); rescue ArgumentError => e; puts e.message; end`, + "unsupported signal 'SIGNOPE'\n"}, + {"number too large", `begin; SignalException.new(9999); rescue ArgumentError => e; puts e.message; end`, + "invalid signal number (9999)\n"}, + {"no argument", `begin; SignalException.new; rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 0, expected 1..2)\n"}, + // The name branch takes exactly one argument (argnum stays 1). + {"a name with a second argument", `begin; SignalException.new("TERM", "x"); rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 2, expected 1)\n"}, + // The NUMBER branch takes 1..2 (argnum becomes 2), so three is the error. + {"a number with two extra arguments", + `begin; SignalException.new(15, "x", "y"); rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 3, expected 1..2)\n"}, + // rb_check_to_integer consults #to_int, so an object that answers it takes + // the NUMBER branch and gets the default message from rb_signo2signm. + {"an object with #to_int", `o = Object.new +def o.to_int; 15; end +e = SignalException.new(o); puts "#{e.message} #{e.signo}"`, "SIGTERM 15\n"}, + // ...and one whose #to_int hands back a non-Integer falls through to the + // NAME branch, where its #to_str (absent) makes it a bad signal type. + {"an object whose #to_int is not an Integer", `o = Object.new +def o.to_int; "nope"; end +begin; SignalException.new(o); rescue ArgumentError => e; puts e.message; end`, + "bad signal type Object\n"}, + {"an object that is neither a number nor a name", `begin + SignalException.new(Object.new) +rescue ArgumentError => e + puts e.message +end`, "bad signal type Object\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + }) + } +} + +// TestSignalListAndSignameCanonicalNames pins the two properties +// core/signal/list_spec.rb and core/signal/signame_spec.rb assert and that a +// Go map cannot provide on its own: EXIT is a member with number 0, and a +// number shared by a canonical name and an alias resolves to the CANONICAL one. +// With signalNames built from an unordered map this test would fail at random, +// which is exactly the shape of defect an ordered siglist exists to prevent. +func TestSignalListAndSignameCanonicalNames(t *testing.T) { + src := `p Signal.list["EXIT"], Signal.list["KILL"] +p Signal.list["CLD"] == Signal.list["CHLD"] +p Signal.list["IOT"] == Signal.list["ABRT"] +p Signal.signame(Signal.list["ABRT"]), Signal.signame(Signal.list["CHLD"]) +p Signal.signame(0), Signal.signame(-1), Signal.signame(9999)` + want := "0\n9\ntrue\ntrue\n\"ABRT\"\n\"CHLD\"\n\"EXIT\"\nnil\nnil\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } + // Every key Signal.list reports must be a name ruby/spec's own RUBY_SIGNALS + // list knows; a name outside it fails core/signal/list_spec.rb. + known := map[string]bool{} + for _, n := range strings.Fields(`EXIT HUP INT QUIT ILL TRAP IOT ABRT EMT FPE KILL BUS SEGV SYS +PIPE ALRM TERM URG STOP TSTP CONT CHLD CLD TTIN TTOU IO XCPU XFSZ VTALRM PROF +WINCH USR1 USR2 LOST MSG PWR POLL DANGER MIGRATE PRE GRANT RETRACT SOUND INFO`) { + known[n] = true + } + for _, e := range siglist { + if !known[e.name] { + t.Errorf("siglist has %q, which ruby/spec's RUBY_SIGNALS does not list", e.name) + } + } +} + +// TestSignameCoercesThroughToInt covers sig_signame's NUM2INT: a non-Integer +// converts through #to_int, and a #to_int that hands back a non-Integer is a +// TypeError rather than a silent nil. +func TestSignameCoercesThroughToInt(t *testing.T) { + src := `o = Object.new +def o.to_int; 0; end +p Signal.signame(o) +begin; Signal.signame("hello"); rescue TypeError => e; puts "TypeError"; end +b = Object.new +def b.to_int; "not an int"; end +begin; Signal.signame(b); rescue TypeError => e; puts "TypeError"; end` + want := "\"EXIT\"\nTypeError\nTypeError\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// TestSignoToSignmFallback exercises rb_signo2signm's sprintf branch and +// withSIG's prefixing rule at the Go level, since neither is reachable through +// Ruby for every input. +func TestSignoToSignmFallback(t *testing.T) { + for _, tc := range []struct { + in int + want string + }{{15, "SIGTERM"}, {0, "SIGEXIT"}, {62, "SIG62"}, {-1, "SIG-1"}} { + if got := signoToSignm(tc.in); got != tc.want { + t.Errorf("signoToSignm(%d) = %q, want %q", tc.in, got, tc.want) + } + } + for _, tc := range []struct{ in, want string }{ + {"TERM", "SIGTERM"}, {"SIGTERM", "SIGTERM"}, {"SIG", "SIGSIG"}, + } { + if got := withSIG(tc.in); got != tc.want { + t.Errorf("withSIG(%q) = %q, want %q", tc.in, got, tc.want) + } + } + if _, ok := signalNumberOf("NOPE"); ok { + t.Error("signalNumberOf(NOPE) reported a number") + } +} + +// TestSigNumberPanicsOnATypo proves sigNumber's guard fires, so a misspelt name +// inside this package cannot silently become signal 0 (which kill(2) treats as a +// liveness probe and would therefore look like it worked). +func TestSigNumberPanicsOnATypo(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("sigNumber accepted an unknown name") + } + }() + _ = sigNumber("NOT_A_SIGNAL") +} + +// TestTrapArgumentErrors covers Signal.trap's own arity and coercion errors. +func TestTrapArgumentErrors(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"no arguments", `begin; Signal.trap; rescue ArgumentError => e; puts e.message; end`, + "wrong number of arguments (given 0, expected 1..2)\n"}, + {"one argument and no block", `begin; Signal.trap(:TERM); rescue ArgumentError => e; puts e.message; end`, + "tried to create Proc object without a block\n"}, + {"an unknown signal name", `begin; Signal.trap("NOPE", "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "unsupported signal 'SIGNOPE'\n"}, + {"a signal number out of range", `begin; Signal.trap(9999, "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "invalid signal number (9999)\n"}, + // trap_signm accepts a number, including 0 (Signal.trap(:EXIT)'s slot). + {"a numeric signal", `p Signal.trap(15, "IGNORE")`, "\"DEFAULT\"\n"}, + {"Kernel#trap is the same operation", `trap(:TERM, "IGNORE") +Process.kill(:TERM, Process.pid) +puts "after"`, "after\n"}, + // A command that is neither a Proc nor one of the keywords is #call'd. + {"an arbitrary callable", `h = Object.new +def h.call(s); puts "called #{s}"; end +Signal.trap(:HUP, h) +Process.kill(:HUP, Process.pid)`, "called 1\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + }) + } +} + +// TestTrapRefusesReservedAndUntrappableSignals covers signal.c sig_trap's two +// refusals, which happen BEFORE it looks at the handler: reserved_signal_p names +// the signals MRI needs its own handlers for, and SIGKILL/SIGSTOP are the two +// sigaction(2) rejects outright, which trap() reports as Errno::EINVAL through +// rb_sys_fail_str rather than as ArgumentError. +func TestTrapRefusesReservedAndUntrappableSignals(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"SEGV", `begin; Signal.trap(:SEGV) {}; rescue ArgumentError => e; puts e.message; end`, + "can't trap reserved signal: SIGSEGV\n"}, + {"BUS", `begin; Signal.trap(:BUS) {}; rescue ArgumentError => e; puts e.message; end`, + "can't trap reserved signal: SIGBUS\n"}, + {"ILL", `begin; Signal.trap(:ILL) {}; rescue ArgumentError => e; puts e.message; end`, + "can't trap reserved signal: SIGILL\n"}, + {"FPE", `begin; Signal.trap(:FPE) {}; rescue ArgumentError => e; puts e.message; end`, + "can't trap reserved signal: SIGFPE\n"}, + {"VTALRM", `begin; Signal.trap(:VTALRM) {}; rescue ArgumentError => e; puts e.message; end`, + "can't trap reserved signal: SIGVTALRM\n"}, + // Errno::EINVAL, not ArgumentError: the refusal comes from the kernel. + {"KILL", `begin; Signal.trap(:KILL) {}; rescue StandardError => e; puts e.class; end`, + "Errno::EINVAL\n"}, + {"STOP", `begin; Signal.trap(:STOP) {}; rescue StandardError => e; puts e.class; end`, + "Errno::EINVAL\n"}, + // The refusal comes first: a reserved signal is refused even with a handler + // that trap_handler would have accepted. + {"reserved beats the handler", `begin; Signal.trap(:SEGV, "IGNORE"); rescue ArgumentError => e; puts "refused"; end`, + "refused\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + }) + } +} + +// TestTrapSignalArgumentCoercion is signal.c trap_signm plus signm2signo's +// argument coercion. The two negatives matter most and are easy to get backwards: +// trap_signm tests FIXNUM_P, so #to_int is NEVER called (a Float is a bad type, +// not a truncated number), while signm2signo DOES call #to_str. A single +// "coerce to a number somehow" implementation passes the positive rows and fails +// both negatives, which is what origin/main did. +func TestTrapSignalArgumentCoercion(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"a String name", `p Signal.trap("HUP", "IGNORE")`, "\"DEFAULT\"\n"}, + {"a prefixed name", `p Signal.trap("SIGHUP", "IGNORE")`, "\"DEFAULT\"\n"}, + {"a Symbol name", `p Signal.trap(:HUP, "IGNORE")`, "\"DEFAULT\"\n"}, + {"an Integer", `p Signal.trap(Signal.list["HUP"], "IGNORE")`, "\"DEFAULT\"\n"}, + // EXIT / 0 is legal for trap (signm2signo's exit = TRUE), and it reports its + // PREVIOUS handler as nil rather than as a disposition name, because trap() + // forces oldfunc = SIG_ERR for signal 0 and SIG_ERR matches none of the + // oldcmd switch's three arms. MEASURED on MRI 4.0.5. + {"EXIT is a trappable name", `p Signal.trap(:EXIT, "IGNORE")`, "nil\n"}, + {"signal 0 is trappable", `p Signal.trap(0, "IGNORE")`, "nil\n"}, + {"signal 0 with a block", `p Signal.trap(:EXIT) { }`, "nil\n"}, + // ...but an installed handler for signal 0 still comes back as itself. + {"signal 0 reports an installed handler", `Signal.trap(:EXIT, "cmd") +p Signal.trap(:EXIT, "IGNORE")`, "\"cmd\"\n"}, + {"signal 0 reports an installed Proc", `Signal.trap(:EXIT) { } +p Signal.trap(:EXIT, "IGNORE").class`, "Proc\n"}, + // ... but NOT for SignalException, whose esignal_init passes exit = FALSE. + {"EXIT is not a SignalException name", + `begin; SignalException.new("EXIT"); rescue ArgumentError => e; puts e.message; end`, + "unsupported signal 'SIGEXIT'\n"}, + // #to_str is consulted. + {"#to_str", `o = Object.new +def o.to_str; "HUP"; end +p Signal.trap(o, "IGNORE")`, "\"DEFAULT\"\n"}, + // #to_int is NOT. + {"#to_int is never called", `o = Object.new +def o.to_int; raise "to_int must not be called"; end +begin; Signal.trap(o, "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "bad signal type Object\n"}, + // rb_obj_classname, so nil is NilClass rather than "nil". + {"nil names its class", `begin; Signal.trap(nil, "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "bad signal type NilClass\n"}, + {"a Float is a bad type, not a truncation", + `begin; Signal.trap(100.0, "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "bad signal type Float\n"}, + {"true names its class", `begin; Signal.trap(true, "IGNORE"); rescue ArgumentError => e; puts e.message; end`, + "bad signal type TrueClass\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out, err := runSrcErr(t, tc.src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != tc.want { + t.Errorf("output = %q, want %q", out, tc.want) + } + }) + } +} + +// TestTrapNilReportsNilNotIgnore pins trap()'s oldcmd switch, which has an +// explicit `case Qnil: break` that leaves a nil handler reporting back as nil — +// where "IGNORE" and "" both rewrite it to the string "IGNORE". Both DISPOSITIONS +// are the same (the signal is discarded), so a test that only checked the +// behaviour would pass with them collapsed. +func TestTrapNilReportsNilNotIgnore(t *testing.T) { + src := `Signal.trap(:HUP, nil) +p Signal.trap(:HUP, "DEFAULT") +Signal.trap(:HUP, "IGNORE") +p Signal.trap(:HUP, "DEFAULT") +Signal.trap(:HUP, "") +p Signal.trap(:HUP, "DEFAULT") +Signal.trap(:TERM, nil) +Process.kill(:TERM, Process.pid) +puts "a nil handler still discards the signal"` + want := "nil\n\"IGNORE\"\n\"IGNORE\"\na nil handler still discards the signal\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// TestInterruptMessageFallsBackToTheClassName pins the difference measured on MRI +// 4.0.5 between the two ways an Interrupt comes into being: Interrupt.new leaves +// the message unset, so Exception#message answers the class name ("Interrupt"), +// while the one rb_interrupt() raises carries an explicit EMPTY message. Setting +// @message unconditionally in #initialize satisfies the raised case and breaks +// core/exception/interrupt_spec.rb, which is how it was found. +func TestInterruptMessageFallsBackToTheClassName(t *testing.T) { + src := `p Interrupt.new.message, Interrupt.new.signm, Interrupt.new.signo +p Interrupt.new("x").message +begin + Process.kill(:INT, Process.pid) +rescue Interrupt => e + p e.message, e.signo +end` + want := "\"Interrupt\"\n\"Interrupt\"\n2\n\"x\"\n\"\"\n2\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// stubValue is an object.Value of a type classOf cannot place, which is the only +// way to reach objClassName's fallback: classOf ends in classOfPlatform, whose +// Windows form is a plain `return nil`. No Ruby expression can produce one, so +// the branch is reachable only from Go — and leaving it uncovered would leave a +// nil dereference in an error path untested. +type stubValue struct{} + +func (stubValue) ToS() string { return "stub" } +func (stubValue) Inspect() string { return "stub" } +func (stubValue) Truthy() bool { return true } + +func TestObjClassNameFallsBackWhenClassOfCannotPlaceTheValue(t *testing.T) { + machine, _, _ := runSrcErr(t, `1`) + if got := machine.objClassName(object.NilV); got != "NilClass" { + t.Errorf("objClassName(nil) = %q, want NilClass", got) + } + if got := machine.objClassName(stubValue{}); got == "" { + t.Error("objClassName gave no name for an unplaceable value") + } +} + +// TestSelfSignalOnSIGCHLDRaisesNothing covers the one member of +// default_handler's managed set that rb_signal_exec has NO cmd == 0 case for: +// Init_signal installs a handler for SIGCHLD to reap children, so the signal is +// consumed at a safe point, but nothing is raised. A reading that treated "MRI +// installs a handler" as "MRI raises" would raise SignalException here. +func TestSelfSignalOnSIGCHLDRaisesNothing(t *testing.T) { + orig := procKill + t.Cleanup(func() { procKill = orig }) + calls := 0 + procKill = func(int, int) error { calls++; return nil } + _, out, err := runSrcErr(t, `Process.kill(:CHLD, Process.pid); puts "nothing raised"`) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != "nothing raised\n" { + t.Errorf("output = %q, want %q", out, "nothing raised\n") + } + if calls != 0 { + t.Errorf("procKill called %d times; SIGCHLD must be consumed at the safe point", calls) + } +} + +// TestTrapPreviousReportsEveryDisposition walks every arm of trap()'s oldcmd +// switch. SYSTEM_DEFAULT and EXIT are only observable through this return value — +// their DISPOSITIONS are "let the OS have it" and "raise SystemExit", neither of +// which names itself — so a test of behaviour alone leaves both unwitnessed. +func TestTrapPreviousReportsEveryDisposition(t *testing.T) { + src := `p Signal.trap(:TERM, "SYSTEM_DEFAULT") +p Signal.trap(:TERM, "EXIT") +p Signal.trap(:TERM, "SIG_DFL") +p Signal.trap(:TERM, ->(s) {}) +p Signal.trap(:TERM, "IGNORE").class +p Signal.trap(:TERM, "command string") +p Signal.trap(:TERM, "DEFAULT")` + want := "\"DEFAULT\"\n\"SYSTEM_DEFAULT\"\n\"EXIT\"\n\"DEFAULT\"\nProc\n\"IGNORE\"\n\"command string\"\n" + _, out, err := runSrcErr(t, src) + if err != nil { + t.Fatalf("run: %v (output %q)", err, out) + } + if out != want { + t.Errorf("output = %q, want %q", out, want) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 898a337e..04c73b12 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -303,6 +303,24 @@ type VM struct { arModels map[*RClass]*ActiveRecordModel arTableNames map[*RClass]string + // terminalExit is the SystemExit that ended the run, kept because Run + // deliberately returns no error for one (a program that calls exit is not + // crashing) while its @status is still the process's exit code. nil until a + // SystemExit reaches the top. See TerminalExit. + terminalExit *RubyError + + // exitImmediate records that the terminal SystemExit came from Kernel#exit! / + // Process.exit!, which process.c rb_f_exit_bang implements with _exit(2) — so + // no at_exit handler runs. Set by raiseSystemExitImmediate, read by runAtExit. + exitImmediate bool + + // trapList is MRI's vm->trap_list.cmd[] (signal.c trap/trap_handler): the + // signal disposition a program installed with Signal.trap / Kernel#trap, + // keyed by signal number. An absent key means the signal's default Ruby + // action. Process.kill consults it when the target is this process — see + // vm.selfSignal, which is rb_f_kill's pid == self arm. + trapList map[int]trapCmd + cBasicObject, cObject, cModule, cClass *RClass cKernel *RClass // the Kernel module (included into Object; its methods live on cObject) cRefinement *RClass // the Refinement class (a Module subclass; instances are the anonymous modules Module#refine creates) @@ -1133,6 +1151,13 @@ func (vm *VM) Run(iseq *bytecode.ISeq) (result object.Value, err error) { // 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) { + // ...but the STATUS it carries is the process's exit code, and + // discarding it is issue #676: `rbgo -e 'exit 3'` exited 0. Record + // the terminal SystemExit so a host that owns a process (the rbgo + // CLI) can do what MRI's ruby_run_node -> rb_ec_cleanup does with it, + // while an embedded host that has no process still sees a clean + // return. See vm.TerminalExit and cmd/rbgo/exit_status.go. + vm.terminalExit = &rerr vm.frameNames = vm.frameNames[:0] vm.frameFiles = vm.frameFiles[:0] vm.frameCrefs = vm.frameCrefs[:0]