Signal handling: CRuby-compatible SignalException conversion + SIG_DFL re-raise#910
Merged
Merged
Conversation
…L re-raise
Implements CRuby's signal semantics (previously only SIGINT converted, and
an uncaught SignalException exited 1 instead of dying by the signal):
1. Default-convert set = CRuby's (verified on 4.0.2): HUP, INT->Interrupt,
QUIT, ALRM, TERM, USR1, USR2 are caught by the async stub and raised as
rescuable SignalExceptions at the next poll point. signo_to_error gains
SIGALRM. (PIPE/CHLD/CONT/WINCH stay unconverted, as in CRuby.)
2. Uncaught SignalException (or Interrupt) re-raises with SIG_DFL: both the
top-level handle_error and the fork-child error path restore SIG_DFL,
unblock, and kill(self, signo), so the parent sees a *signal* death
($?.signaled? / termsig) — including `raise SignalException, 'SIGKILL'`.
Reporting matches CRuby: Interrupt prints the error report, a plain
SignalException dies silently. MonorubyErr::signal_exception_signo
extracts (signo, is_interrupt) from @__signo / the conversion message;
SignalException#signo derives from the message for runtime-materialized
exceptions (built without #initialize).
3. Process.kill to self drains the pending bitmap synchronously (a
self-signal is delivered before kill(2) returns), so the exception
raises *inside* the kill call as CRuby does — `-> { Process.kill(...)
}.should.raise(SignalException)` now passes.
4. Kernel#sleep is signal-interruptible: nanosleep + EINTR -> poll, instead
of std::thread::sleep (which silently retries EINTR, so a signaled
sleeper dozed the full interval and converted at some later, racy poll).
A trap handler resumes the remaining sleep; a conversion aborts it.
5. The pending-signal bitmap moves from per-Codegen JIT data to a
process-global static (signals and sigaction are process-wide). With
per-Codegen storage, a second Codegen (another test thread) re-pointing
the handlers at its bitmap meant signals recorded in one bitmap were
polled from another and lost — the source of flaky fork tests. Both
arch stubs now OR into the global's absolute address.
6. Child-status observation fixes surfaced by the specs: IO.popen(String)
no longer interposes sh for plain commands (a wrapping shell reports a
signal-killed child as normal exit 128+signo, destroying signaled?);
POSIX shell builtins (exit, exec, ...) still route through sh, matching
CRuby's posix_sh_cmds. Backticks now set $? (previously left nil).
SignalException.new(name, msg) raises ArgumentError as in CRuby.
Results: core/exception/signal_exception_spec 16/16 pass, signm/signo pass
(their spec/tags entries are removed; tags 10 -> 7 files); interrupt_spec
down to 1 failure (backtrace frame granularity). core/exception: 248
examples, 5 failures, 0 errors. All CI spec categories stay 0/0;
thread/process/io/kernel/socket categories complete with no hang. Full lib
suite 1828 passed; new subprocess integration tests in
tests/signal_handling.rs (in-process self-signal tests would signal the
shared cargo-test process).
Known limitation (doc'd): a process blocked in a Rust EINTR-retrying
read/write survives a single SIGTERM (bit set, poll never reached); use
`timeout -k` when killing genuinely hung monoruby processes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
…ll (CRuby parity) trap_non_callable_raises_at_delivery encoded the old deferred behavior (NoMethodError raised at some later allocation poll). With self-directed signals drained synchronously, the error surfaces inside the Process.kill call itself — which is exactly what CRuby 4.0.2 does (`-e:2:in 'Process.kill': undefined method 'call' ...`). Move the rescue around the kill call accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
… conversion stub) The hang watchdog (MONORUBY_HANG_WATCHDOG_SEC) drives its abort via setitimer(2)+SIGALRM. With SIGALRM now in the default-conversion install set, Codegen::new could re-sigaction ALRM to the conversion stub after watchdog::init had installed its handler — the watchdog then never fired and the hung process died by raw SIGALRM (or lingered), breaking tests/watchdog.rs. Skip ALRM in the default-install loop while the watchdog is armed; if the watchdog arms after codegen init, its own sigaction overwrites the stub, so it wins under either init order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
A signal delivered in the window before (re-)entering nanosleep — e.g. a parent's SIGTERM landing between fork return and the child's sleep call — has its pending bit set already, so it will never EINTR the upcoming nanosleep. The sleeper then dozed the full interval and the surrounding block could end without reaching a poll point (a fork child exited 0 instead of dying signal-terminated; the residual process_kill_with_string flake under full-suite parallelism). Check the pending bitmap at the top of the sleep loop and run the poll before blocking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
The direct-exec path split with `split(' ')`, so consecutive (or leading)
spaces produced empty-string argv entries. Exposed by the IO.popen
direct-exec change: mspec's ruby_exe interpolates `"#{opts} --disable=x"`
with empty opts, yielding `monoruby --disable=rubyopt file` (double
space) -> argv ["", "--disable=rubyopt", file] -> monoruby treats "" as
the script -> LoadError, exit 1 (command_line/feature_spec failures on
CI). Use split_whitespace(), which collapses runs and trims, matching
shell word splitting. command_line: 175 examples, 0 failures, 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #910 +/- ##
==========================================
- Coverage 90.50% 90.49% -0.02%
==========================================
Files 189 189
Lines 123266 123474 +208
==========================================
+ Hits 111562 111732 +170
- Misses 11704 11742 +38 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Summary
Implements CRuby's signal semantics. Previously only SIGINT converted to a Ruby exception, and an uncaught
SignalExceptionexited 1 — soProcess.kill(:TERM, Process.pid)killed the process outright, and$?.signaled?/termsignever saw signal deaths.Changes
Default-convert set = CRuby's (verified on 4.0.2):
HUP, INT→Interrupt, QUIT, ALRM, TERM, USR1, USR2are caught by the async stub and raised as rescuableSignalExceptions at the next poll point. (PIPE/CHLD/CONT/WINCHstay unconverted, as in CRuby.)Uncaught
SignalExceptionre-raises withSIG_DFL— both top-levelhandle_errorand the fork-child error path restoreSIG_DFL, unblock, andkill(self, signo), so the parent sees a signal death ($?.signaled?/termsig), includingraise SignalException, 'SIGKILL'. Reporting matches CRuby:Interruptprints the report; plainSignalExceptiondies silently.Process.killto self drains synchronously — a self-signal is delivered beforekill(2)returns, so the exception raises inside thekillcall (-> { Process.kill(...) }.should.raise(SignalException)passes).Kernel#sleepis signal-interruptible —nanosleep+ EINTR→poll instead ofstd::thread::sleep(which silently retries EINTR; a signaled sleeper dozed the full interval and converted at a later, racy poll). A trap handler resumes the remaining sleep; a conversion aborts it.Pending-signal bitmap → process-global static — signals and
sigactionare process-wide; per-Codegen JIT-data storage meant a second Codegen (another test thread) re-pointing the handlers at its bitmap silently lost signals recorded there while polls read another. Both arch stubs now OR into the global's absolute address. This was the root of the flaky fork tests.Child-status observation fixes surfaced by the specs:
IO.popen(String)no longer interposesshfor plain commands — a wrapping shell reports a signal-killed child as normal exit128+signo, destroyingsignaled?(this is what made mspec'sruby_exeexit-status assertions fail). POSIX shell builtins (exit,exec, …) still route throughsh, matching CRuby'sposix_sh_cmds.$?(previously left nil).SignalException.new(name, msg)raisesArgumentError;#signoderives from the message for runtime-materialized exceptions.Results
core/exception/signal_exception_speccore/exception/signm_spec/signo_speccore/exception/interrupt_speccore/exceptioncategoryspec/tagsentries removed (10 → 7 files).thread/process/io/kernel/socketcategories complete with no hang.tests/signal_handling.rs(8 tests — in-process self-signal tests would signal the shared cargo-test process).$?,SIGKILLraise, popen builtins, backtick$?): all MATCH.Known limitation (documented)
A process blocked in a Rust EINTR-retrying read/write survives a single SIGTERM (the pending bit is set but the poll is never reached). Healthy execution converts promptly; when killing a genuinely hung monoruby, use
timeout -k(KILL fallback). Follow-up: CRuby-style interruptible blocking I/O.🤖 Generated with Claude Code
Generated by Claude Code