Skip to content

Commit 870dd5b

Browse files
Add graceful default signal handlers. (#4)
1 parent bdce203 commit 870dd5b

3 files changed

Lines changed: 127 additions & 0 deletions

File tree

lib/async/signals/graceful.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
module Async
7+
module Signals
8+
# Installs default signal handlers for graceful shutdown.
9+
#
10+
# Ruby's built-in handling for `SIGINT` can bypass `Thread.handle_interrupt`, which makes graceful shutdown unreliable for event loops and other code that needs to defer interruption while cleaning up. This file also maps `SIGTERM` to `Interrupt`, so both termination signals follow the same graceful shutdown path when they are still using Ruby's default handler.
11+
#
12+
# See <https://bugs.ruby-lang.org/issues/22133> for more details.
13+
module Graceful
14+
SIGNALS = ["INT", "TERM"].freeze
15+
16+
SIGNALS.each do |signal|
17+
previous = ::Signal.trap(signal) do
18+
::Thread.main.raise(::Interrupt)
19+
end
20+
21+
unless previous == "DEFAULT"
22+
::Signal.trap(signal, previous)
23+
end
24+
end
25+
end
26+
end
27+
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Add `async/signals/graceful` for installing default `SIGINT` and `SIGTERM` handlers that raise `Interrupt`.
6+
37
## v0.5.0
48

59
- Change `Async::Signals.default` to select process signal handling only on the main thread when no fiber scheduler is installed.

test/async/signals/graceful.rb

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "open3"
7+
require "rbconfig"
8+
9+
describe "Async::Signals::Graceful" do
10+
def ruby(script)
11+
::Open3.capture3(::RbConfig.ruby, "-Ilib", "-e", script)
12+
end
13+
14+
def expect_success(script)
15+
stdout, stderr, status = ruby(script)
16+
17+
expect(status).to be(:success?)
18+
expect(stderr).to be == ""
19+
20+
stdout
21+
end
22+
23+
it "installs graceful SIGINT handling" do
24+
stdout = expect_success(<<~RUBY)
25+
require "async/signals/graceful"
26+
27+
begin
28+
::Thread.handle_interrupt(::Interrupt => :never) do
29+
::Process.kill(:INT, ::Process.pid)
30+
puts "inner"
31+
end
32+
33+
sleep 1
34+
rescue ::Interrupt
35+
puts "outer"
36+
end
37+
RUBY
38+
39+
expect(stdout).to be == "inner\nouter\n"
40+
end
41+
42+
it "installs graceful SIGTERM handling" do
43+
stdout = expect_success(<<~RUBY)
44+
require "async/signals/graceful"
45+
46+
begin
47+
::Thread.handle_interrupt(::Interrupt => :never) do
48+
::Process.kill(:TERM, ::Process.pid)
49+
puts "inner"
50+
end
51+
52+
sleep 1
53+
rescue ::Interrupt
54+
puts "outer"
55+
end
56+
RUBY
57+
58+
expect(stdout).to be == "inner\nouter\n"
59+
end
60+
61+
it "preserves existing SIGINT handlers" do
62+
stdout = expect_success(<<~RUBY)
63+
events = ::Thread::Queue.new
64+
65+
::Signal.trap(:INT) do
66+
events << "handled"
67+
end
68+
69+
require "async/signals/graceful"
70+
71+
::Process.kill(:INT, ::Process.pid)
72+
73+
puts events.pop(timeout: 1)
74+
RUBY
75+
76+
expect(stdout).to be == "handled\n"
77+
end
78+
79+
it "preserves existing SIGTERM handlers" do
80+
stdout = expect_success(<<~RUBY)
81+
events = ::Thread::Queue.new
82+
83+
::Signal.trap(:TERM) do
84+
events << "handled"
85+
end
86+
87+
require "async/signals/graceful"
88+
89+
::Process.kill(:TERM, ::Process.pid)
90+
91+
puts events.pop(timeout: 1)
92+
RUBY
93+
94+
expect(stdout).to be == "handled\n"
95+
end
96+
end

0 commit comments

Comments
 (0)