Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/async.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
require_relative "kernel/async"
require_relative "kernel/sync"
require_relative "kernel/barrier"
require_relative "async/sigint"
35 changes: 35 additions & 0 deletions lib/async/sigint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

module Async
# Installs a compatibility SIGINT handler when Ruby's default SIGINT handling does not respect `Thread.handle_interrupt`.
#
# See <https://bugs.ruby-lang.org/issues/22133> for more context.
module SIGINT
# Whether this Ruby needs the compatibility SIGINT handler.
def self.required?
true
end

# Install the compatibility SIGINT handler, if needed.
def self.install
return unless required?

previous = ::Signal.trap(:INT, "DEFAULT")

if previous == "DEFAULT"
::Signal.trap(:INT) do
::Thread.main.raise(::Interrupt)
end
else
::Signal.trap(:INT, previous)
end
end

self.install
end

private_constant :SIGINT
end
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releases

## Unreleased

- Install a compatibility SIGINT handler on affected CRuby versions when SIGINT is still using the default handler, so `Thread.handle_interrupt` can defer interrupts.

## v2.41.0

- **Fixed**: Protect initial task from Interrupt exceptions.
Expand Down
92 changes: 92 additions & 0 deletions test/async/sigint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "open3"
require "rbconfig"

describe "Async::SIGINT" do
let(:ruby_binary) {RbConfig.ruby}
let(:load_path) {File.expand_path("../../lib", __dir__)}

def capture_ruby(*arguments)
Open3.capture3(ruby_binary, "-I#{load_path}", *arguments)
end

it "requires the SIGINT compatibility handler" do
script = <<~RUBY
require "async/sigint"

sigint = Async.const_get(:SIGINT)

puts sigint.required?
RUBY

output, error, status = capture_ruby("-e", script)

expect(error).to be == ""
expect(status).to be(:success?)
expect(output.lines(chomp: true)).to be == ["true"]
end

it "does not replace an existing SIGINT handler" do
script = <<~RUBY
handled = Thread::Queue.new

Signal.trap(:INT) do
handled.push(true)
end

require "async/sigint"

Process.kill(:INT, Process.pid)

puts handled.pop
RUBY

output, error, status = capture_ruby("-e", script)

expect(error).to be == ""
expect(status).to be(:success?)
expect(output.lines(chomp: true)).to be == ["true"]
end

it "defers SIGINT while signal exceptions are masked" do
script = <<~RUBY
require "async"

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
RUBY

output, error, status = capture_ruby("-e", script)

expect(error).to be == ""
expect(status).to be(:success?)
expect(output.lines(chomp: true)).to be == ["outer", "false"]
end
end
Loading