Skip to content

Commit ed4e292

Browse files
Add scoped signal handling.
Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4
1 parent 9913c30 commit ed4e292

4 files changed

Lines changed: 204 additions & 0 deletions

File tree

lib/async/container.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Released under the MIT License.
44
# Copyright, 2017-2025, by Samuel Williams.
55

6+
require_relative "container/signals"
67
require_relative "container/controller"
78

89
# @namespace

lib/async/container/signals.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
module Async
7+
module Container
8+
# Represents a collection of process signal handlers which enqueue events.
9+
class Signals
10+
# Represents a trapped signal event.
11+
class Event
12+
# Initialize the signal event.
13+
# @parameter signal [Symbol | String | Integer] The signal that was received.
14+
# @parameter handler [Proc] The handler to invoke when the event is applied.
15+
def initialize(signal, handler)
16+
@signal = signal
17+
@handler = handler
18+
end
19+
20+
# @attribute [Symbol | String | Integer] The signal that was received.
21+
attr :signal
22+
23+
# Apply the signal event by invoking its handler.
24+
def apply
25+
@handler.call
26+
end
27+
end
28+
29+
# Initialize the signal handler collection.
30+
# @parameter events [Thread::Queue] The queue used to receive signal events.
31+
def initialize(events = ::Thread::Queue.new)
32+
@events = events
33+
@handlers = {}
34+
end
35+
36+
# @attribute [Thread::Queue] The queue used to receive signal events.
37+
attr :events
38+
39+
# Register a signal handler.
40+
# If no block is provided, the signal will be ignored while trapped.
41+
# @parameter signal [Symbol | String | Integer] The signal to trap.
42+
def trap(signal, &block)
43+
@handlers[signal] = block
44+
end
45+
46+
# Ignore a signal while trapped.
47+
# @parameter signal [Symbol | String | Integer] The signal to ignore.
48+
def ignore(signal)
49+
trap(signal)
50+
end
51+
52+
# Wait for the next signal event.
53+
# @returns [Event] The next signal event.
54+
def wait
55+
@events.pop
56+
end
57+
58+
# Install the registered signal handlers for the duration of the block.
59+
# @yields {|signals| ...} The block to run while signal handlers are installed.
60+
def trapped
61+
previous = {}
62+
63+
@handlers.each do |signal, handler|
64+
previous[signal] = install(signal, handler)
65+
end
66+
67+
yield self
68+
ensure
69+
previous&.each do |signal, handler|
70+
::Signal.trap(signal, handler)
71+
end
72+
end
73+
74+
private
75+
76+
def install(signal, handler)
77+
if handler
78+
::Signal.trap(signal) do
79+
@events << Event.new(signal, handler)
80+
end
81+
else
82+
::Signal.trap(signal, "IGNORE")
83+
end
84+
end
85+
end
86+
end
87+
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::Container::Signals` for installing scoped signal traps that enqueue signal events.
6+
37
## v0.37.0
48

59
- Rename `ASYNC_CONTAINER_GRACEFUL_TIMEOUT` to `ASYNC_CONTAINER_GRACEFUL_STOP` and apply it at the controller level as `GRACEFUL_STOP`. `Group#stop` now only applies the shutdown policy it is given.

test/async/container/signals.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "async/container/signals"
7+
8+
describe Async::Container::Signals do
9+
let(:events) {::Thread::Queue.new}
10+
let(:signals) {subject.new(events)}
11+
12+
with Async::Container::Signals::Event do
13+
it "applies the handler" do
14+
applied = false
15+
16+
event = Async::Container::Signals::Event.new(:USR1, proc{applied = true})
17+
18+
expect(event.signal).to be == :USR1
19+
20+
event.apply
21+
22+
expect(applied).to be == true
23+
end
24+
end
25+
26+
with "#events" do
27+
it "exposes the event queue" do
28+
expect(signals.events).to be == events
29+
end
30+
end
31+
32+
with "#wait" do
33+
it "waits for queued events" do
34+
event = Async::Container::Signals::Event.new(:USR1, proc{})
35+
36+
events << event
37+
38+
expect(signals.wait).to be == event
39+
end
40+
end
41+
42+
with "#trapped" do
43+
it "queues trapped signal events" do
44+
applied = false
45+
46+
signals.trap(:USR1) do
47+
applied = true
48+
end
49+
50+
signals.trapped do
51+
::Process.kill(:USR1, ::Process.pid)
52+
53+
event = signals.wait
54+
55+
expect(event.signal).to be == :USR1
56+
57+
event.apply
58+
end
59+
60+
expect(applied).to be == true
61+
end
62+
63+
it "ignores signals without a handler" do
64+
signals.trap(:USR1)
65+
66+
signals.trapped do
67+
::Process.kill(:USR1, ::Process.pid)
68+
69+
expect do
70+
events.pop(true)
71+
end.to raise_exception(ThreadError)
72+
end
73+
end
74+
75+
it "can ignore signals explicitly" do
76+
signals.ignore(:USR1)
77+
78+
signals.trapped do
79+
::Process.kill(:USR1, ::Process.pid)
80+
81+
expect do
82+
events.pop(true)
83+
end.to raise_exception(ThreadError)
84+
end
85+
end
86+
87+
it "restores previous signal handlers" do
88+
previous = ::Thread::Queue.new
89+
original = ::Signal.trap(:USR1) do
90+
previous << :handled
91+
end
92+
93+
begin
94+
signals.ignore(:USR1)
95+
96+
signals.trapped do
97+
::Process.kill(:USR1, ::Process.pid)
98+
99+
expect do
100+
previous.pop(true)
101+
end.to raise_exception(ThreadError)
102+
end
103+
104+
::Process.kill(:USR1, ::Process.pid)
105+
106+
expect(previous.pop).to be == :handled
107+
ensure
108+
::Signal.trap(:USR1, original)
109+
end
110+
end
111+
end
112+
end

0 commit comments

Comments
 (0)