From 04979d0c9550d4427a3339221cdea66387b6ad06 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 12:37:58 +1200 Subject: [PATCH] Install default SIGINT handler. --- lib/async.rb | 1 + lib/async/sigint.rb | 35 +++++++++++++++++ releases.md | 4 ++ test/async/sigint.rb | 92 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 lib/async/sigint.rb create mode 100644 test/async/sigint.rb diff --git a/lib/async.rb b/lib/async.rb index d8dfb16b..e537c07f 100644 --- a/lib/async.rb +++ b/lib/async.rb @@ -11,3 +11,4 @@ require_relative "kernel/async" require_relative "kernel/sync" require_relative "kernel/barrier" +require_relative "async/sigint" diff --git a/lib/async/sigint.rb b/lib/async/sigint.rb new file mode 100644 index 00000000..54fa2afa --- /dev/null +++ b/lib/async/sigint.rb @@ -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 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 diff --git a/releases.md b/releases.md index 7d787214..e5769d91 100644 --- a/releases.md +++ b/releases.md @@ -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. diff --git a/test/async/sigint.rb b/test/async/sigint.rb new file mode 100644 index 00000000..1e858442 --- /dev/null +++ b/test/async/sigint.rb @@ -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