Bug #22133
closedRuby's default SIGINT handling ignores `Thread.handle_interrupt` masking.
Description
Ruby's default SIGINT handling currently bypasses Thread.handle_interrupt masking. When a process receives SIGINT with the default Ruby handler installed, CRuby calls rb_interrupt() directly. That can raise Interrupt immediately inside code wrapped by Thread.handle_interrupt(SignalException => :never), including while blocked in operations like Thread::Queue#pop.
This is inconsistent with other asynchronous exception delivery paths, such as Thread#raise, and with other default signal exceptions such as SIGTERM. Those paths enqueue a pending interrupt on the target thread, allowing Thread.handle_interrupt to defer delivery until the configured point.
Background¶
This was discovered while working on Async/Async::Container signal handling. Async relies on Thread.handle_interrupt and scheduler-level interruption boundaries to keep signal delivery deterministic. The current default SIGINT path means a Ctrl-C can escape a masked section and interrupt internal synchronization code, which can break graceful shutdown behavior.
A minimal reproduction is:
waiting = Thread::Queue.new
release = Thread::Queue.new
inner = false
Thread.new do
waiting.pop
Process.kill(:INT, Process.pid)
release.push(true)
end
begin
Thread.handle_interrupt(SignalException => :never) do
begin
waiting.push(true)
release.pop
rescue Interrupt
inner = true
raise
end
end
rescue Interrupt
puts "outer"
end
puts inner
Expected output:
Current affected behavior can produce inner == true, meaning the Interrupt was delivered inside the masked region.
Proposed fix¶
Route default SIGINT through the same pending-interrupt mechanism as other default signal exceptions, while preserving the traditional exception class and no-message Interrupt object. Concretely, the PR replaces the direct rb_interrupt() default SIGINT path with a helper that enqueues an Interrupt on the main thread and wakes that thread.
This makes default SIGINT respect Thread.handle_interrupt(SignalException => :never) in the same way as default SIGTERM/SignalException and Thread#raise.
Pull request¶
PR: https://github.com/ruby/ruby/pull/17533
The PR includes:
- A failing regression test for default SIGINT with
Thread.handle_interrupt. - The implementation change routing default SIGINT through the pending interrupt queue.
- Ruby/spec coverage for default SIGINT ->
Interruptand default SIGTERM ->SignalExceptionbeing maskable byThread.handle_interrupt.
Once accepted, I would like to request backports for supported Ruby branches where this default SIGINT behavior is present.
Updated by ioquatix (Samuel Williams) 22 days ago
- Related to Feature #6762: Control interrupt timing added
Updated by kosaki (Motohiro KOSAKI) 22 days ago
I haven't read the code yet, but what you're saying seems reasonable.
Updated by Eregon (Benoit Daloze) 21 days ago
Seems like a clear bugfix.
TruffleRuby already behaves like this.
Updated by ko1 (Koichi Sasada) 12 days ago
There are no objection, so let's fix it.
Updated by headius (Charles Nutter) 12 days ago
JRuby does not by default trap SIGINT to raise Interrupt, since that would interfere with the JVM's own clean shutdown connected to SIGINT by default. Users can opt into that behavior if they want.
If a JRuby user traps SIGINT and chooses to raise Interrupt, they would be subject to Thread.handle_interrupt controls like any other Ruby exception.
Updated by Anonymous 11 days ago
- Status changed from Open to Closed
Applied in changeset git|ef67c377508ea3a07516c1fa413a038b5268fc1c.
Respect handle_interrupt for default SIGINT. (#17533)
[[Bug #22133]]
Updated by k0kubun (Takashi Kokubun) 8 days ago
- Backport changed from 3.3: REQUIRED, 3.4: REQUIRED, 4.0: REQUIRED to 3.3: REQUIRED, 3.4: REQUIRED, 4.0: DONE
ruby_4_0 10b2ee7f1c0b860ad0e9b55619c52743a5818f0e merged revision(s) ef67c377508ea3a07516c1fa413a038b5268fc1c.