Skip to content

Signal handling: CRuby-compatible SignalException conversion + SIG_DFL re-raise#910

Merged
sisshiki1969 merged 5 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c
Jul 14, 2026
Merged

Signal handling: CRuby-compatible SignalException conversion + SIG_DFL re-raise#910
sisshiki1969 merged 5 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c

Conversation

@sisshiki1969

Copy link
Copy Markdown
Owner

Summary

Implements CRuby's signal semantics. Previously only SIGINT converted to a Ruby exception, and an uncaught SignalException exited 1 — so Process.kill(:TERM, Process.pid) killed the process outright, and $?.signaled?/termsig never saw signal deaths.

Changes

  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. (PIPE/CHLD/CONT/WINCH stay unconverted, as in CRuby.)

  2. Uncaught SignalException re-raises with SIG_DFL — both 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 report; plain SignalException dies silently.

  3. Process.kill to self drains synchronously — a self-signal is delivered before kill(2) returns, so the exception raises inside the kill call (-> { Process.kill(...) }.should.raise(SignalException) passes).

  4. Kernel#sleep is signal-interruptiblenanosleep + EINTR→poll instead of std::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.

  5. Pending-signal bitmap → process-global static — signals and sigaction are 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.

  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? (this is what made mspec's ruby_exe exit-status assertions fail). 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; #signo derives from the message for runtime-materialized exceptions.

Results

Spec Before After
core/exception/signal_exception_spec process death (tagged) 16/16 pass
core/exception/signm_spec / signo_spec process death (tagged) pass
core/exception/interrupt_spec 3 failures 1 failure (backtrace frame granularity — follow-up)
core/exception category 248 examples, 5 failures, 0 errors
  • 3 spec/tags entries removed (10 → 7 files).
  • All CI spec categories stay 0 failures / 0 errors; thread/process/io/kernel/socket categories complete with no hang.
  • Full lib suite 1828 passed; new subprocess integration tests in tests/signal_handling.rs (8 tests — in-process self-signal tests would signal the shared cargo-test process).
  • 7-point CRuby diff battery (rescue payloads, fork $?, SIGKILL raise, 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

claude added 5 commits July 14, 2026 03:30
…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

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.18519% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.49%. Comparing base (9406ad2) to head (7e768c0).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
monoruby/src/globals/error.rs 19.04% 17 Missing ⚠️
monoruby/src/builtins/kernel.rs 76.66% 14 Missing ⚠️
monoruby/src/executor.rs 7.69% 12 Missing ⚠️
monoruby/src/main.rs 33.33% 4 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sisshiki1969 sisshiki1969 merged commit a515779 into master Jul 14, 2026
2 of 4 checks passed
@sisshiki1969 sisshiki1969 deleted the claude/cruby-4-0-2-specs-olt61c branch July 14, 2026 05:00
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.

2 participants