Skip to content

fix(runtime): real SIGINT/SIGBREAK/SIGHUP + process.kill on Windows#6631

Merged
proggeramlug merged 1 commit into
mainfrom
fix/win-signals
Jul 20, 2026
Merged

fix(runtime): real SIGINT/SIGBREAK/SIGHUP + process.kill on Windows#6631
proggeramlug merged 1 commit into
mainfrom
fix/win-signals

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Fixes two Windows defects from the 2026-07-18 audit of crates/perry-runtime/src/os/signal.rs:

  1. process.on('SIGINT'|...) was a #[cfg(not(unix))] no-op — no console control handler was ever installed, so Ctrl+C never reached JS and graceful shutdown was impossible on Windows.
  2. process.kill(pid, sig) silently discarded its arguments and returned true.

Mechanism

Signal delivery mirrors the Unix structure in the same file (per-signal pending/listeners atomics + main-thread drain):

  • SetConsoleCtrlHandler installs win_console_ctrl_handler on first listener registration (set_process_signal_listener_count > 0 for a console-mapped signal). It stays installed for the process lifetime; a per-slot listener-count check inside the handler restores default behavior when the last listener is removed (no unregister, so there is no install/remove race).
  • Handler-thread → main-thread marshal: the control handler runs on a thread Windows injects, never the JS main thread. It only bumps the mapped slot's pending atomic and calls js_notify_main_thread() (atomics + condvar, documented safe from any thread) — no JS heap access, no JS calls. The existing platform-neutral drain (js_process_signal_draintake_pending_process_signals()emit_process_event) then fires the JS listeners on the main thread with its thread-local arena, exactly as on Unix. has_active_process_signal_listeners() gains the same pending-gated keep-alive as Unix, so a delivered signal holds the loop open until its callbacks run, while mere listener registration stays ref-neutral.

Node/libuv parity mapping:

Console event JS event
CTRL_C_EVENT 'SIGINT'
CTRL_BREAK_EVENT 'SIGBREAK'
CTRL_CLOSE_EVENT 'SIGHUP' (handler thread then parks, libuv-style, so the main thread gets the full ~5s grace window instead of dying the instant the handler returns)
'SIGTERM' registerable but source-less (Node allows it; it never fires from the console)

Handler TRUE/FALSE semantics: returns TRUE (handled) iff at least one JS listener is registered for the mapped signal at that moment; otherwise FALSE so default termination is preserved — plain Ctrl+C on a program with no listener still kills it, and removing the last listener restores default behavior.

process.kill(pid, sig) mirrors libuv's uv_kill (src/win/process.c), via a JS-free testable core win_process_kill:

  • sig 0 → existence probe: OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess; only STILL_ACTIVE counts as alive (an exited process whose pid is pinned by an open handle reports ESRCH).
  • SIGHUP/SIGINT/SIGQUIT/SIGKILL/SIGTERMOpenProcess(PROCESS_TERMINATE) + TerminateProcess(h, 1), including libuv's already-exited special case (ERROR_ACCESS_DENIED + !STILL_ACTIVEESRCH).
  • Other signals in 0..NSIGENOSYS; out of range → EINVAL. pid 0 targets the current process via the pseudo-handle (no CloseHandle); real handles are always closed.
  • Failures throw through the same convention as the Unix arm (shared throw_kill_error_code): Error("kill <CODE>") with .code/.syscall = "kill"ESRCH for a nonexistent pid, EPERM for access denied.

Win32 access uses the crate's existing binding style: windows-sys 0.61 (added the Win32_System_Threading feature; Win32_System_Console/Win32_Foundation were already enabled).

Tests

#[cfg(windows)] unit tests in os/signal.rs (run on this Windows 11 x64 / MSVC box via cargo test -p perry-runtime --release signal):

  • win_kill_terminates_child_and_sig0_tracks_liveness — spawns cmd /c pause, probes alive (sig 0 → Ok), SIGTERM-kills it, asserts exit code 1, then asserts sig-0 and terminate both report ESRCH while the Child handle pins the pid (no pid-reuse flake window).
  • win_kill_reports_esrch_for_nonexistent_pid, win_kill_rejects_unsupported_signals (ENOSYS/EINVAL).
  • win_console_ctrl_handler_semantics — drives the control handler directly: FALSE with no listeners (for all mapped + unmapped events), TRUE with a SIGINT listener, exactly one pending SIGINT drained via take_pending_process_signals(), keep-alive gate flips accordingly, and removing the last listener restores FALSE + drops undelivered pendings.
  • win_sigterm_registration_is_accepted_and_inert.

A live GenerateConsoleCtrlEvent round-trip is deliberately not a unit test: group 0 would hit every process sharing the test console (cargo, the shell), and a fresh process group needs a compiled child binary — too flaky for CI.

Manual verification

Live console delivery (not exercised by unit tests; needs a perry-compiled child so the ctrl event lands in our handler, in its own process group so nothing else on the console is hit):

  1. Compile:
    // sigtest.ts
    process.on("SIGBREAK", () => { console.log("got SIGBREAK"); process.exit(42); });
    process.on("SIGINT",   () => { console.log("got SIGINT");   process.exit(43); });
    console.log("armed");
    setTimeout(() => process.exit(7), 15000); // listeners are ref-neutral
    cargo run --release -- sigtest.ts -o sigtest.exe
  2. CTRL_BREAK round-trip (python, same console):
    import subprocess, signal, time
    p = subprocess.Popen(["sigtest.exe"], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
    time.sleep(1); p.send_signal(signal.CTRL_BREAK_EVENT)
    assert p.wait(timeout=20) == 42
  3. Interactive Ctrl+C in a real console → prints got SIGINT, exits 43; comment out the SIGINT listener → Ctrl+C terminates immediately (handler returns FALSE, default preserved).

Step 1–3 were not run end-to-end for this PR (they need a full compiler build on a box where main's pre-existing Windows breakage is patched); the handler's mapping/return/marshal logic is covered deterministically by the unit tests above.

Notes

Summary by CodeRabbit

  • New Features
    • Added Windows support for console signal handling, including pending signal delivery and listener management.
    • Added Windows process termination support with behavior aligned to standard Node.js semantics.
  • Bug Fixes
    • Improved error reporting for invalid, unsupported, or nonexistent process targets on Windows.
    • Prevented stale or undelivered signals from triggering unexpectedly or keeping the event loop active.
  • Tests
    • Added coverage for Windows process liveness checks, signal handling, error codes, and console control behavior.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f2b03546-d073-408a-808d-b840d5affb45

📥 Commits

Reviewing files that changed from the base of the PR and between f8bd710 and b5a9974.

📒 Files selected for processing (2)
  • crates/perry-runtime/Cargo.toml
  • crates/perry-runtime/src/os/signal.rs
📝 Walkthrough

Walkthrough

Windows console control events now feed process-signal listeners, and process.kill uses Win32 process APIs with Node-style error handling. Windows-specific tests cover signal delivery, listener state, liveness probing, termination errors, and unsupported signals.

Changes

Windows process signals

Layer / File(s) Summary
Windows console signal delivery
crates/perry-runtime/src/os/signal.rs
Adds console-control signal slots, handler installation, listener gating, pending-signal draining, and main-thread notification on Windows.
Windows process.kill implementation
crates/perry-runtime/Cargo.toml, crates/perry-runtime/src/os/signal.rs
Enables Windows threading APIs and implements process probing, termination, Win32-to-Node errno mapping, and shared kill-error construction.
Windows signal and kill validation
crates/perry-runtime/src/os/signal.rs
Tests liveness probes, nonexistent processes, unsupported signals, handler semantics, pending-signal draining, and SIGTERM registration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: andrewtdiz

Sequence Diagram(s)

sequenceDiagram
  participant ConsoleControlHandler
  participant WinSignalSlot
  participant JSMainThread
  ConsoleControlHandler->>WinSignalSlot: increment pending signal count
  ConsoleControlHandler->>JSMainThread: notify main thread
  JSMainThread->>WinSignalSlot: drain pending signals
Loading
sequenceDiagram
  participant js_process_kill
  participant win_process_kill
  participant WindowsProcessAPI
  js_process_kill->>win_process_kill: pass pid and signal
  win_process_kill->>WindowsProcessAPI: probe or terminate process
  WindowsProcessAPI-->>win_process_kill: return Win32 status
  win_process_kill-->>js_process_kill: return Node errno code
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main runtime fix for Windows signal handling and process.kill behavior.
Description check ✅ Passed The description covers the summary, concrete changes, and test/verification details, with only optional template sections missing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/win-signals

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@proggeramlug
proggeramlug merged commit 44389d2 into main Jul 20, 2026
27 checks passed
@proggeramlug
proggeramlug deleted the fix/win-signals branch July 20, 2026 22:55
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.

1 participant