Repository navigation
signal+exit: a self-directed signal becomes a Ruby exception, and a deliberate exit status reaches the process (#691, #676) - #693
Merged
Conversation
…eliberate exit status reaches the process (#691, #676) Two defects at one boundary — the Go runtime's process state and the VM's exception machinery — fixed together because they are mirror images. #691 Process.kill(:TERM, Process.pid) killed the process where MRI raises a rescuable SignalException. signal.c rb_f_kill does NOT send the signal when the target is this process: it consults the installed disposition and, for a signal Ruby handles itself, calls signal_enque(sig) then rb_thread_execute_interrupts() before returning, so the effect happens synchronously inside Process.kill. That is now what vm.selfSignal does. No OS handler is installed and os/signal.Notify is never called, so Go's runtime stays the sole owner of signal disposition and the asynchronous-delivery race that WAS the defect is gone by construction. #676 A terminal SystemExit's status never reached the process. Its cause was not only the front-end: vm.Run deliberately DISCARDS the SystemExit, so no front-end change alone could have worked. Run now records it (vm.terminalExit) while still returning cleanly for an embedded host, and cmd/rbgo translates it the way eval.c rb_ec_cleanup does, through eval_error.c exiting_split. Also from the same C reading: Process.exit/exit!/abort are module functions (Init_process's rb_define_module_function), SignalException carries #signo and #signm (esignal_init/esignal_signo), Interrupt fixes signo at SIGINT with an empty message (interrupt_init), an uncaught SignalException dies BY its signal (ruby_default_signal) and prints only for a SUBCLASS or SIGSEGV, exit! runs no at_exit handler (rb_f_exit_bang/_exit), and an at_exit that itself exits wins. Measured against MRI 4.0.5, 20 runs per witness: 29 of 31 spellings now byte-identical on stdout, stderr and exit status where 9 were before. The racy witnesses went from a 10-20% fatal rate to 0/20. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and the process exit status end to end Three files, each a witness rather than a guard — every row fails on origin/main with the production change reverted (and the revert compiles): internal/vm/signal_disposition_internal_test.go drives rb_f_kill's self arm through every disposition trap_handler can install, including the two where MRI really does call kill(2) (SIGKILL, SYSTEM_DEFAULT) — read through the procKill seam, since the alternative is killing the test process — and the negative that a foreign pid and a process-group kill are NOT short-circuited. internal/vm/exit_status_internal_test.go is eval_error.c exiting_split as a table, including the asymmetry that only a SUBCLASS of SignalException (or SIGSEGV) prints, plus the negatives that a rescued SystemExit records no terminal exit and that a raising at_exit does not become the status. cmd/rbgo/exit_status_test.go is the only witness for the status itself: it re-execs the compiled test binary with WT37_EXIT_SRC set, so the precondition is established by the test rather than by an external variable, no Go toolchain is needed, and it runs in every lane. It asserts WIFSIGNALED as well as the number, because "killed by 15" and "exited 143" give a shell the same $?. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t looks at a handler
Twelve more ruby/spec examples in core/signal/trap_spec.rb and
core/exception/interrupt_spec.rb, all from reading signal.c rather than from
guessing:
- sig_trap refuses a RESERVED signal (reserved_signal_p: SEGV, BUS, ILL, FPE,
VTALRM) with ArgumentError, before it looks at the handler at all.
- SIGKILL and SIGSTOP are refused by the KERNEL, so trap() -> ruby_signal()
gets SIG_ERR and rb_sys_fail_str raises Errno::EINVAL, not ArgumentError.
- trap_signm tests FIXNUM_P, so #to_int is NEVER called on this path and a
Float is a "bad signal type", not a truncated number; signm2signo DOES
coerce through #to_str. One "coerce somehow" implementation passes the
positives and fails both negatives, which is what was there.
- rb_obj_classname, so the bad-type message says NilClass, not nil.
- trap(sig, nil) reports back nil on the next trap: trap()'s oldcmd switch has
an explicit `case Qnil: break` that "IGNORE" and "" do not take. Both
dispositions discard the signal, so only the RETURN distinguishes them.
- signm2signo's `exit` parameter: EXIT is a trappable name but not a
SignalException name (FOREACH_SIGNAL skips the first entry when exit is false).
- Interrupt.new leaves the message unset so Exception#message answers the class
name, while the one rb_interrupt() raises carries an explicit empty message.
Measured on MRI 4.0.5: "Interrupt" and "" respectively.
TestSignalModule (puppet_cli_features_test.go) had five rows PINNING rbgo's own
divergence under a comment claiming they were pinned to MRI — signame("x")
answering nil where MRI raises TypeError, signame with no argument answering
nil, trap(987654) inventing a slot, trap(:QUIT) with no block recording
DEFAULT, and an object with only #to_s being accepted. Each replacement was
measured against MRI 4.0.5 on this host and the row says so.
core/signal/trap_spec.rb 24 -> 35 of MRI's 36 through the same shim (the
remaining one wants Thread#group); core/exception/interrupt_spec.rb 2 -> 3 of 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dead helper The corrected coverage recipe (diff the below-100 LIST against origin/main's, each cover -func resolved in the tree that produced its profile, and diff the FULL measured set so a function that LEAVES it is visible) showed ten new below-100 functions. They are now zero, by two routes. Unreachable branches removed rather than tested: a duplicated arity guard in Signal.trap, the numeric spelling of "can't trap reserved signal" (every signal reserved_signal_p names has a siglist entry, unlike MRI whose NSIG table can be sparser than its handler set), and four `if !ok`/`if !isObj` guards that a fixed registration order and `new`'s allocator make impossible — now hard assertions, which fail loudly instead of doing nothing. trapPrevious's absent-entry arm was a copy of its trapDefault arm; the zero value of trapCmd already says it. Reachable branches given witnesses: SIGCHLD, the one member of default_handler's managed set rb_signal_exec has NO cmd == 0 case for (it is consumed at the safe point and raises nothing — a reading that equated "MRI installs a handler" with "MRI raises" would raise here); every arm of trap()'s oldcmd switch, since SYSTEM_DEFAULT and EXIT are observable ONLY through that return value; the esignal_init number branch's arity and its #to_int / #to_str fall-through; and the non-Integer @status ivar, which a Ruby program can install. signalName is DELETED. It was the old trap's name normaliser, replaced by trapSignm and signm2signo, and nothing called it any more. It is the one function that LEAVES the measured set in this change — a deliberate removal, stated here so it is not read as silent orphaning. Below-100 list: 16 on origin/main, 16 here, no new entries. 27 functions enter the measured set, all at 100%. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or MRI's answer Go does not define syscall.Kill on Windows, so die_signal_native.go's !wasm tag broke the windows lane's build. The no-op form now covers `wasm || windows` and says why they are the same no-op for two DIFFERENT reasons: wasm has no settable signal disposition at all, while Windows has a mechanism (the C runtime's raise(), which MRI's ruby_default_signal uses) 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 an assertion to a guess the platform-specific part is left unclaimed: the caller exits 128+signo, the same NUMBER a POSIX shell reports. The two end-to-end rows that assert death BY a signal, and the VM-level tests that drive Process.kill (registered only under registerProcessPosix), are POSIX-only and say so in their build tags. Coverage is measured on the ubuntu lane, which they still run in. darwin/arm64, linux/amd64, linux/arm64, windows/amd64 and wasip1/wasm all build; go vet is clean for windows/amd64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… disposition name
signal.c trap() opens with `if (sig == 0) oldfunc = SIG_ERR;`, and SIG_ERR
matches none of the three arms of the oldcmd switch's `case 0` (SIG_IGN,
SIG_DFL, sighandler), so it falls through to Qnil. rbgo answered
"SYSTEM_DEFAULT". Measured on MRI 4.0.5: `Signal.trap(:EXIT) { }` is nil, and a
handler already installed for signal 0 still comes back as itself.
Found by probing the pathological Process.kill/Signal.trap shapes against MRI
after the main work was measured green — two pids in one kill, kill(0) on a
nonexistent pid and Signal.trap(:EXIT) — on the principle that the cases a
corpus does not exercise are where a disposition table hides its last error.
The first two already matched (raised 15; Errno::ESRCH).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #691 and #676. They are mirror images of one defect at one boundary — the
Go runtime's process state against the VM's exception machinery — so they are
fixed together rather than with two designs for one boundary.
The C read, and the design decision it forced
Cached from
ruby/rubytagv3_4_0:signal.c,process.c,eval.c,eval_error.c,ruby.c.signal.c rb_f_killanswers the design question. When the target pid isthis process, MRI does not send the signal. Its own comment says why —
"When target pid is self, many caller assume signal will be delivered
immediately and synchronously" — so it consults the installed disposition via
signal_ignored()and, for a signal Ruby handles itself, callssignal_enque(sig)and thenrb_thread_execute_interrupts()beforereturning. Only
SIGSEGV,SIGBUS,SIGKILL,SIGILL,SIGFPEandSIGSTOPgo tokill(2)from that arm, because they cannot be deferred to asafe point.
So MRI's C handler (
Init_signal→install_sighandler) exists to park anasynchronous signal in
signal_buffuntil the next Ruby safe point. For aself-directed kill the safe point is the next statement, and
signal_enqueplusan immediate interrupt check expresses that without the handler.
That is how the two models are reconciled: rbgo installs no OS handler and
never calls
os/signal.Notify. Go's runtime stays the sole owner of signaldisposition. This is not a shortcut around the C — it is the C's own answer for
this case — and it removes the asynchronous-delivery race that was the defect
by construction rather than by timing. It also needs no build tag on a target
without signals.
What happens to a signal that arrives while no Ruby frame is executing? MRI
does not serve that case either:
sighandleronly fillssignal_buff, and ifthe VM has already reached
rb_ec_cleanupnothing ever runs it — the processthen dies through
ruby_default_signal. In rbgo an externally deliveredsignal still meets Go's default disposition, which is MRI's behaviour when no
trap is installed. The gap that remains is an external signal for which Ruby
has installed a trap: MRI runs the trap, rbgo still dies. That needs
os/signal.Notifyplus a delivery point in the interpreter loop and is left asfollow-up rather than smuggled in; it is stated in the code comment on
vm.selfSignal, not only here.rb_signal_execgives the disposition table: no handler →SIGINTbecomesInterruptviarb_interrupt(),HUP/QUIT/TERM/ALRM/USR1/USR2becomeSignalExceptionviarb_threadptr_signal_raise, anything else raises nothing;Qundef("EXIT") →rb_threadptr_signal_exit; otherwisesignal_execrunsthe command with the signal number.
trap_handlergives the keyword table anddefault_handlergives the managed set (which is why"DEFAULT"onSIGTERMrestores the raise, not the OS default).
eval.c rb_ec_cleanupandeval_error.c exiting_splitgive the terminalhalf.
exiting_splitsplits an uncaught exception three ways:SystemExit→exit with
@statusand no message;SignalException→ die by@signo,with a message only for
SIGSEGVor for a subclass (!rb_obj_is_instance_of(errinfo, rb_eSignal));anything else → status 1 with a message.
rb_ec_cleanupreturns that status tomain()and, last of all, callsruby_default_signal(signaled)—SIG_DFLplusraise(sig)— so the process dies by the signal withWIFSIGNALEDset.That asymmetry is why an uncaught
InterruptprintsInterrupton stderr and anuncaught bare
SignalExceptionprints nothing.process.c:rb_f_exit/rb_f_exit_bang/rb_f_abort, andInit_process'srb_define_module_function(rb_mProcess, "exit", …)— which iswhy
Process.exit(5)is public.One last thing came from probing the shapes no corpus file exercises, on the
principle that a disposition table hides its last error where nothing looks:
Signal.trap(:EXIT)must report its previous handler asnil, not as adisposition name, because
trap()opens withif (sig == 0) oldfunc = SIG_ERR;andSIG_ERRmatches none of the three armsof the
oldcmdswitch'scase 0. Two other probes (two pids in oneProcess.kill,kill(0)on a nonexistent pid) already matched MRI.A premise in #676 that the measurement overturned
#676 says "the VM half is already correct — only the front-end's last step is
missing". It is not.
vm.Rundeliberately discards the terminalSystemExit(internal/vm/vm.go,isSystemExitbranch) and returns a nilerror, so no front-end change alone could ever have recovered the status.
Runnow records it on the VM while still returning cleanly, so an embedded host with
no process of its own is unaffected and the CLI can do what
ruby_run_nodedoes.Before / after, 20 runs per witness against MRI 4.0.5
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25], comparedbyte-for-byte on stdout, stderr and exit status, 20 runs of each program.
exit 3exit falseexit 0/exit trueexit!(4)abort "boom"boomon fd 2abortraise "x"Process.exit(5)Process.exit!(6)Process.abort "x"SystemExitat_exit {…}; exit 3begin; exit 3; ensure …at_exit { puts }; exit!(4)at_exit { exit 7 }; exit 3at_exit { exit 7 }The racy witnesses, as rates over 20 runs — and the rate is the first
premise the measurement overturned (#691 reports "2 runs in 3"; it is 10–20%
here, because the race is asynchronous delivery against the program's own exit):
Process.kill(:TERM, Process.pid)Process.kill(:INT, …)Interrupt, exits 0Signal.trap(:TERM){…}then self-killSignal.trap(:TERM,"IGNORE")then self-killSIGTERMWIFSIGNALEDSIGINTInterrupton fd 230 of 32 spellings are now byte-identical where 9 were. The two that are not are
pre-existing and out of scope: the
SyntaxErrorwording for an unparseableprogram (the status, 1, matches), and the innermost backtrace frame of an
uncaught
Interrupt(<main>where MRI saysProcess.kill, because rbgo pushesno frame for a native method).
Corpus
Ratchet corpus pinned
87b1631,TIMEOUT=25, 2-way, on an isolated copy so asibling agent's
spec_helper.rbedits cannot enter the measurement. Bothreadings were taken back to back in one run, under comparable load (1-minute
load average 5.24 at the start of the before pass, 5.66 at the start of the
after pass, 8.81 at the end).
22759 → 22818. Eight files moved, none downward. The MRI reading through
the same shim is beside each.
core/signal/trap_spec.rbcore/exception/signal_exception_spec.rbcore/signal/signame_spec.rbcore/exception/interrupt_spec.rbcore/signal/list_spec.rbcore/exception/signm_spec.rbcore/exception/signo_spec.rbcore/random/bytes_spec.rbThe last row is not a win and is not claimed as one.
core/random/bytes_spec.rbis flaky: six runs of each binary give 9, 8, 9, 9, 9, 8 on
origin/mainand9, 9, 9, 8, 9, 9 here. It is unrelated to this change and it is the ±1 that has
been read as measurement noise in this ratchet before — so the movement this
change is actually accountable for is +58, and the +59 total carries one
flaky example on top of it.
Six of the seven signal files are now at the MRI reading.
trap_specis oneshort, and that one wants
Thread#group(internal/vm/thread.go), not a signal.The sweep-integrity guard is the corrected form, enforced by the measuring tool
itself: no row with an empty key column, distinct filenames == input count
(2206 == 2206), and a floor on the measurement, because a line-count check passes
green on 2206 rows that are all empty and total 0. It also separates a TIMEOUT
from a zero and from a missing
RBGO_RESULTline, which is how the next sectionwas found.
Three more premises the measurement overturned
exit". Four of the nine are a third category:
core/kernel/exit_spec.rb,core/process/exit_spec.rbandcore/mutex/lock_spec.rbTIME OUT, beforeand after, and they still do. They are worth 24 examples against MRI (12 + 8
signal_exception_spec.rb,interrupt_spec.rb,signm_spec.rbandsigno_spec.rblive undercore/exception/, notcore/signal/.defect withholds. 58 of it is here; 24 is the timeout cause below; the last one is
Thread#group.The timeout cause, isolated — and it is one cause for all three files, not
three:
MRI prints
main saw SystemExit; rbgo hangs for ever. An uncaughtSystemExitin a non-main thread must reach the main thread and interrupt a blocking
sleep(shared/process/exit.rb:53-79, whichcore/kernel/exit_spec.rbandcore/process/exit_spec.rbboth include — 12 + 8 examples).core/mutex/lock_spec.rb(4 examples) is the same family, not a mutex defect:its
:52example spinsloop { lock.synchronize {} }inside a Fiber and relieson
t2.raisefrom another thread to break the loop. Recursive and cross-fiberdeadlock detection are already correct here — both raise a
ThreadErrormatching/deadlock/, verified against MRI — so what hangs is again asynchronouscross-thread interruption:
Thread#raisemust reach a running thread.Owning file
internal/vm/thread.go, outside this change's file cluster,reported rather than reached for.
Coverage, by CI's own recipe
Both profiles were produced and resolved in the tree that owns them (running
origin/main's profile through the branch's sources invents false 0.0% entries),and with CI's exact command —
-coverpkg=$(go list ./internal/... | paste -sd, -)over
./..., not over./internal/..., which is a different number.The two below-100 lists are identical function for function and percent for
percent. The 99.9% is a host artefact of measuring on darwin without
-race(CI's ubuntu lane prints
100.0%for the same deficit, because the gate rounds toone decimal — two of the entries are at 0.0%), and
origin/mainreads exactlythe same 99.9% here, so the gate's outcome is unchanged by this PR.
The full measured function set was diffed too, because a function that LEAVES
the set appears in neither below-100 list and the list diff stays clean while code
silently stops being measured. 27 functions enter, all at 100%. One leaves:
signal.go:signalName, the old trap's name normaliser, now dead code replaced bytrapSignmandsignm2signo— a deliberate deletion, named here so it is notread as silent orphaning.
Tests
Every new row fails on
origin/mainwith the production change reverted, and therevert compiles (
if sendToOS := true; !sendToOS && …indefineProcessKillkeepsselfused; removing the branch outright does notcompile, so it would have tested nothing).
internal/vm/signal_disposition_internal_test.go— every dispositiontrap_handlercan install, plus the two where MRI really does callkill(2),read through the
procKillseam because the alternative is killing the testprocess. It also pins the negatives: a foreign pid and a process-group kill
are not short-circuited;
#to_intis never called on the trap path while#to_stris;trap(sig, nil)reports backnilwhere"IGNORE"reports"IGNORE", even though both discard the signal.internal/vm/exit_status_internal_test.go—exiting_splitas a table,including that only a subclass prints, and the negatives that a rescued
SystemExitrecords no terminal exit and a raisingat_exitdoes not becomethe status.
cmd/rbgo/exit_status_test.go— the only witness for the status itself. Itre-execs the compiled test binary with
WT37_EXIT_SRCset, so theprecondition is established by the test rather than by an external variable, no
Go toolchain is needed, and it runs in every lane. It asserts
WIFSIGNALEDaswell as the number, because "killed by 15" and "exited 143" give a shell the
same
$?.TestSignalModuleininternal/vm/puppet_cli_features_test.gohad five rowspinning rbgo's own divergence under a comment claiming they were pinned to MRI:
Signal.signame("x")answeringnilwhere MRI raisesTypeError,Signal.signamewith no argument answeringnil,Signal.trap(987654)inventing a slot,
Signal.trap(:QUIT)with no block recording"DEFAULT", andan object with only
#to_sbeing accepted. Each replacement was measured againstMRI 4.0.5 on this host and each row says so.
wasip1 and Windows
GOOS=wasip1 GOARCH=wasm go build ./...passes. The build tag is not anafterthought:
internal/vm/signal.gohas no OS call at all — the disposition table andselfSignalare pure Go — andProcess.killis only registered underregisterProcessPosix, so a wasm program reaches neither. No tag needed.cmd/rbgo/die_signal_unsupported.go(wasm || windows) is the one new taggedfile, and the two targets share it for different reasons. Neither wasip1 nor
js/wasm has
kill(2)or a settable disposition, so there is no way to be killedby a signal there. Windows has no
syscall.Killin Go either, and MRI's ownanswer is a different mechanism — the C runtime's
raise()— whose observableeffect on a waiting parent is not the POSIX
WIFSIGNALEDthis code would beimitating. There is no Windows host here to measure that on, so rather than
adapt an assertion to a guess, the platform-specific part is left unclaimed: the
caller exits
128+signo, the same number a POSIX shell reports. The twoend-to-end rows that assert death by a signal, and the VM-level tests that
drive
Process.kill(registered only underregisterProcessPosix), arePOSIX-only and say so in their build tags. Coverage is measured on the ubuntu
lane, which still runs them.
darwin/arm64,linux/amd64,linux/arm64,windows/amd64andwasip1/wasmall build, and
go vetis clean forwindows/amd64.The closed-world path was checked end to end rather than only compiled, since
its
mainchanged too:rbgo build --closedonputs "running"; exit 3links abinary that prints
runningand exits 3. Before this change it exited 0.Process.exit/exit!/abortare registered unconditionally, asInit_processdeclares them, so the wasm closed-world main gets the statustoo.
Left undone, with reasons
SignalException— no host hereto measure it, so the gap is named in
cmd/rbgo/die_signal_unsupported.gorather than guessed at.os/signal.Notifyplus a delivery point in the interpreter loop. MRI does notserve the "no Ruby frame" case either; what it does serve and rbgo does not is
an external signal for a trapped disposition.
exit!still runsensureblocks. MRI's_exit(2)skips them. Skippingat_exitis done; skippingensureneeds an unwinding path the exceptionmachinery does not have. The test row that records this says it is a known
divergence rather than asserting MRI's answer, so nobody reads it as a spec.
Signal.list's numbers are this table's own, not the host's. They areright on Darwin and right everywhere for the 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. Pre-existing, recorded in the
siglistcomment,not fixed here: a per-GOOS table needs a build tag on a target (wasip1) that
has no signal constants at all. It does not affect any spec measured above,
because each is self-consistent (
e.signo.should == Signal.list['TERM']).Outside the named file cluster
Flagged for veto, both minimal and both unavoidable:
internal/vm/vm.go— two fields (trapList,terminalExit,exitImmediate)and four lines in
Run's recover to record the terminalSystemExit. This isthe premise overturn above: the discard lives here, so the fix cannot not.
cmd/rbgo/stream_test.go— one line, becauserunnow also returns themachine.
cmd/rbgo/exit_status_test.gointroduces aTestMainin packagemain; if PR#684's
wasm_build_test.goadds one too, they will conflict and the re-execguard is the one to keep.
🤖 Generated with Claude Code