Skip to content

signal+exit: a self-directed signal becomes a Ruby exception, and a deliberate exit status reaches the process (#691, #676) - #693

Merged
tannevaled merged 6 commits into
mainfrom
wave37/signals-and-exit
Sep 26, 2026
Merged

tannevaled merged 6 commits into
mainfrom
wave37/signals-and-exit

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

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/ruby tag v3_4_0: signal.c, process.c, eval.c,
eval_error.c, ruby.c.

signal.c rb_f_kill answers the design question. When the target pid is
this 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, calls
signal_enque(sig) and then rb_thread_execute_interrupts() before
returning
. Only SIGSEGV, SIGBUS, SIGKILL, SIGILL, SIGFPE and
SIGSTOP go to kill(2) from that arm, because they cannot be deferred to a
safe point.

So MRI's C handler (Init_signal → install_sighandler) exists to park an
asynchronous signal in signal_buff until the next Ruby safe point. For a
self-directed kill the safe point is the next statement, and signal_enque plus
an 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 signal
disposition. 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: sighandler only fills signal_buff, and if
the VM has already reached rb_ec_cleanup nothing ever runs it — the process
then dies through ruby_default_signal. In rbgo an externally delivered
signal 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.Notify plus a delivery point in the interpreter loop and is left as
follow-up rather than smuggled in; it is stated in the code comment on
vm.selfSignal, not only here.

rb_signal_exec gives the disposition table: no handler → SIGINT becomes
Interrupt via rb_interrupt(), HUP/QUIT/TERM/ALRM/USR1/USR2 become
SignalException via rb_threadptr_signal_raise, anything else raises nothing;
Qundef ("EXIT") → rb_threadptr_signal_exit; otherwise signal_exec runs
the command with the signal number. trap_handler gives the keyword table and
default_handler gives the managed set (which is why "DEFAULT" on SIGTERM
restores the raise, not the OS default).

eval.c rb_ec_cleanup and eval_error.c exiting_split give the terminal
half. exiting_split splits an uncaught exception three ways: SystemExit →
exit with @status and no message; SignalException → die by @signo,
with a message only for SIGSEGV or for a subclass (!rb_obj_is_instance_of(errinfo, rb_eSignal));
anything else → status 1 with a message. rb_ec_cleanup returns that status to
main() and, last of all, calls ruby_default_signal(signaled) — SIG_DFL plus
raise(sig) — so the process dies by the signal with WIFSIGNALED set.
That asymmetry is why an uncaught Interrupt prints Interrupt on stderr and an
uncaught bare SignalException prints nothing.

process.c: rb_f_exit / rb_f_exit_bang / rb_f_abort, and
Init_process's rb_define_module_function(rb_mProcess, "exit", …) — which is
why 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 as nil, not as a
disposition name, because 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. Two other probes (two pids in one
Process.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.Run deliberately discards the terminal
SystemExit (internal/vm/vm.go, isSystemExit branch) and returns a nil
error, so no front-end change alone could ever have recovered the status. Run
now 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_node does.

Before / after, 20 runs per witness against MRI 4.0.5

ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25], compared
byte-for-byte on stdout, stderr and exit status, 20 runs of each program.

program MRI before after
exit 3 3 0 ×20 3 ×20
exit false 1 0 ×20 1 ×20
exit 0 / exit true 0 0 ×20 0 ×20
exit!(4) 4 0 ×20 4 ×20
abort "boom" 1 + boom on fd 2 0 ×20 1 ×20
abort 1 0 ×20 1 ×20
raise "x" 1 1 ×20 1 ×20
Process.exit(5) 5 1, NoMethodError ×20 5 ×20
Process.exit!(6) 6 1, NoMethodError ×20 6 ×20
Process.abort "x" 1 1, NoMethodError ×20 1 ×20
rescued SystemExit 0 0 ×20 0 ×20
at_exit {…}; exit 3 3 0 ×20 3 ×20
begin; exit 3; ensure … 3 0 ×20 3 ×20
at_exit { puts }; exit!(4) 4, nothing printed 0, printed ×20 4, nothing ×20
at_exit { exit 7 }; exit 3 7 3 ×20 7 ×20
at_exit { exit 7 } 7 0 ×20 7 ×20

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):

program MRI before after
rescued Process.kill(:TERM, Process.pid) raises, exits 0 3/20 died 143, 17/20 no raise at all 20/20 identical
rescued Process.kill(:INT, …) raises Interrupt, exits 0 3/20 died 130, 17/20 no raise 20/20 identical
Signal.trap(:TERM){…} then self-kill runs the trap 3/20 died 143, 17/20 trap never ran 20/20 identical
Signal.trap(:TERM,"IGNORE") then self-kill nothing 2/20 died 143 20/20 identical
uncaught self SIGTERM killed by 15 1/20 correct 20/20 identical, WIFSIGNALED
uncaught self SIGINT killed by 2 + Interrupt on fd 2 0/20 20/20 status, message shape now matches

30 of 32 spellings are now byte-identical where 9 were. The two that are not are
pre-existing and out of scope: the SyntaxError wording for an unparseable
program (the status, 1, matches), and the innermost backtrace frame of an
uncaught Interrupt (<main> where MRI says Process.kill, because rbgo pushes
no frame for a native method).

Corpus

Ratchet corpus pinned 87b1631, TIMEOUT=25, 2-way, on an isolated copy so a
sibling agent's spec_helper.rb edits cannot enter the measurement. Both
readings 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.

file before after MRI/shim
core/signal/trap_spec.rb no result 0 35 36
core/exception/signal_exception_spec.rb no result 0 12 12
core/signal/signame_spec.rb 2 6 6
core/exception/interrupt_spec.rb no result 0 3 3
core/signal/list_spec.rb 2 4 4
core/exception/signm_spec.rb no result 0 1 1
core/exception/signo_spec.rb no result 0 1 1
core/random/bytes_spec.rb 8 9 —

The last row is not a win and is not claimed as one. core/random/bytes_spec.rb
is flaky: six runs of each binary give 9, 8, 9, 9, 9, 8 on origin/main and
9, 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_spec is one
short, 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_RESULT line, which is how the next section
was found.

Three more premises the measurement overturned

  1. Process.kill(:TERM, Process.pid) kills the process instead of raising SignalException — ~77 examples behind one defect #691 lists nine files "reporting nothing at all — not failures, a silent
    exit". Four of the nine are a third category: core/kernel/exit_spec.rb,
    core/process/exit_spec.rb and core/mutex/lock_spec.rb TIME OUT, before
    and after, and they still do. They are worth 24 examples against MRI (12 + 8
      1. and they are not this defect.
  2. The paths in Process.kill(:TERM, Process.pid) kills the process instead of raising SignalException — ~77 examples behind one defect #691 are wrong: signal_exception_spec.rb, interrupt_spec.rb,
    signm_spec.rb and signo_spec.rb live under core/exception/, not
    core/signal/.
  3. The "≈77 examples" is the MRI ceiling across those files, not what this
    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:

ready = false
t = Thread.new { Thread.pass until ready; exit 42 }
begin; ready = true; sleep
rescue SystemExit; puts "main saw SystemExit"; end

MRI prints main saw SystemExit; rbgo hangs for ever. An uncaught SystemExit
in a non-main thread must reach the main thread and interrupt a blocking
sleep
(shared/process/exit.rb:53-79, which core/kernel/exit_spec.rb and
core/process/exit_spec.rb both include — 12 + 8 examples).

core/mutex/lock_spec.rb (4 examples) is the same family, not a mutex defect:
its :52 example spins loop { lock.synchronize {} } inside a Fiber and relies
on t2.raise from another thread to break the loop. Recursive and cross-fiber
deadlock detection are already correct here — both raise a ThreadError matching
/deadlock/, verified against MRI — so what hangs is again asynchronous
cross-thread interruption
: Thread#raise must 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.

origin/main this branch
functions below 100% 16 16
new below-100 entries — none
percentages changed — none
printed total 99.9% 99.9%

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 to
one decimal — two of the entries are at 0.0%), and origin/main reads exactly
the 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 by
trapSignm and signm2signo — a deliberate deletion, named here so it is not
read as silent orphaning.

Tests

Every new row fails on origin/main with the production change reverted, and the
revert compiles (if sendToOS := true; !sendToOS && … in
defineProcessKill keeps self used; removing the branch outright does not
compile, so it would have tested nothing).

  • internal/vm/signal_disposition_internal_test.go — every disposition
    trap_handler can install, plus the two where MRI really does call kill(2),
    read through the procKill seam because the alternative is killing the test
    process. It also pins the negatives: a foreign pid and a process-group kill
    are not short-circuited; #to_int is never called on the trap path while
    #to_str is; trap(sig, nil) reports back nil where "IGNORE" reports
    "IGNORE", even though both discard the signal.
  • internal/vm/exit_status_internal_test.go — exiting_split as a table,
    including that only a subclass prints, and the negatives that a rescued
    SystemExit records no terminal exit and a raising at_exit does not become
    the status.
  • cmd/rbgo/exit_status_test.go — 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 $?.

TestSignalModule in internal/vm/puppet_cli_features_test.go had five rows
pinning rbgo's own divergence
under a comment claiming they were pinned to MRI:
Signal.signame("x") answering nil where MRI raises TypeError,
Signal.signame with no argument answering nil, Signal.trap(987654)
inventing a slot, Signal.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 each row says so.

wasip1 and Windows

GOOS=wasip1 GOARCH=wasm go build ./... passes. The build tag is not an
afterthought:

  • internal/vm/signal.go has no OS call at all — the disposition table and
    selfSignal are pure Go — and Process.kill is only registered under
    registerProcessPosix, so a wasm program reaches neither. No tag needed.
  • cmd/rbgo/die_signal_unsupported.go (wasm || windows) is the one new tagged
    file, 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 killed
    by a signal there. Windows has no syscall.Kill in Go either, 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 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 still runs them.

darwin/arm64, linux/amd64, linux/arm64, windows/amd64 and wasip1/wasm
all build, and go vet is clean for windows/amd64.

The closed-world path was checked end to end rather than only compiled, since
its main changed too: rbgo build --closed on puts "running"; exit 3 links a
binary that prints running and exits 3. Before this change it exited 0.

  • Process.exit/exit!/abort are registered unconditionally, as
    Init_process declares them, so the wasm closed-world main gets the status
    too.

Left undone, with reasons

  • What MRI does on Windows for an uncaught SignalException — no host here
    to measure it, so the gap is named in
    cmd/rbgo/die_signal_unsupported.go rather than guessed at.
  • External signal delivery to an installed trap — needs
    os/signal.Notify plus a delivery point in the interpreter loop. MRI does not
    serve the "no Ruby frame" case either; what it does serve and rbgo does not is
    an external signal for a trapped disposition.
  • exit! still runs ensure blocks. MRI's _exit(2) skips them. Skipping
    at_exit is done; skipping ensure needs an unwinding path the exception
    machinery 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 are
    right 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 siglist comment,
    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 terminal SystemExit. This is
    the premise overturn above: the discard lives here, so the fix cannot not.
  • cmd/rbgo/stream_test.go — one line, because run now also returns the
    machine.

cmd/rbgo/exit_status_test.go introduces a TestMain in package main; if PR
#684's wasm_build_test.go adds one too, they will conflict and the re-exec
guard is the one to keep.

🤖 Generated with Claude Code

tannevaled and others added 6 commits September 26, 2026 18:34
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process.kill(:TERM, Process.pid) kills the process instead of raising SignalException — ~77 examples behind one defect

1 participant