Skip to content

Commit f7a4123

Browse files
Use async-signals context for controller signals.
Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4
1 parent 187b055 commit f7a4123

9 files changed

Lines changed: 178 additions & 208 deletions

File tree

async-container.gemspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ Gem::Specification.new do |spec|
2424

2525
spec.required_ruby_version = ">= 3.3"
2626

27-
spec.add_dependency "async", "~> 2.22"
28-
spec.add_dependency "async-signals", "~> 0.1"
27+
spec.add_dependency "async", "~> 2.41"
28+
spec.add_dependency "async-signals", "~> 0.3"
2929
end

lib/async/container.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
# Copyright, 2017-2025, by Samuel Williams.
55

66
require_relative "container/controller"
7+
8+
# This is a workaround for Ruby's inconsistent handling of SIGINT.
9+
# See <https://github.com/ruby/ruby/pull/17533> for more details.
10+
::Signal.trap(:INT){::Thread.current.raise(Interrupt)}

lib/async/container/controller.rb

Lines changed: 134 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
require_relative "statistics"
1010
require_relative "notify"
1111
require_relative "policy"
12-
require_relative "events"
1312

13+
require "async"
1414
require "async/signals"
1515

1616
module Async
@@ -63,9 +63,20 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop:
6363
@graceful_stop = graceful_stop
6464

6565
@container = nil
66-
@events = Events.new
66+
@events = ::Thread::Queue.new
6767
@signals = Async::Signals::Handlers.new
6868

69+
# Serializes lifecycle transitions such as start, restart and reload. `Container#stop` (which can also take time) is performed outside this guard, so that live container events are not blocked by the stop operation (e.g. restarting).
70+
@guard = ::Thread::Mutex.new
71+
72+
@signals.trap(SIGINT) do |signal, context|
73+
context.raise(Interrupt)
74+
end
75+
76+
@signals.trap(SIGTERM) do |signal, context|
77+
context.raise(Interrupt)
78+
end
79+
6980
self.trap(SIGHUP) do
7081
self.restart
7182
rescue SetupError => error
@@ -77,16 +88,6 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop:
7788
rescue SetupError => error
7889
Console.error(self, error)
7990
end
80-
81-
self.trap(SIGINT) do
82-
self.stop
83-
:stop
84-
end
85-
86-
self.trap(SIGTERM) do
87-
self.stop
88-
:stop
89-
end
9091
end
9192

9293
# The notify client used by the controller.
@@ -149,12 +150,12 @@ def create_container
149150
# Whether the controller has a running container.
150151
# @returns [Boolean]
151152
def running?
152-
!!@container
153+
@guard.synchronize{!!@container}
153154
end
154155

155156
# Wait for the underlying container to start.
156157
def wait
157-
@container&.wait
158+
@guard.synchronize{@container}&.wait
158159
end
159160

160161
# Spawn container instances into the given container.
@@ -167,131 +168,157 @@ def setup(container)
167168

168169
# Start the container unless it's already running.
169170
# @returns [Generic] The container.
170-
def start
171-
unless @container
172-
Console.info(self, "Controller starting...")
173-
174-
if self.restart == :event
175-
return
176-
end
177-
end
178-
179-
Console.info(self, "Controller started.")
180-
181-
return @container
171+
def restart
172+
self.start(restart: true)
182173
end
183174

184175
# Stop the container if it's running.
185-
# @parameter graceful [Boolean] Whether to give the children instances time to shut down or to kill them immediately.
176+
# @parameter graceful [Boolean | Numeric] Whether to give the children instances time to shut down or to kill them immediately.
186177
def stop(graceful = @graceful_stop)
187-
@container&.stop(graceful)
188-
@container = nil
178+
container = nil
179+
180+
@guard.synchronize do
181+
if container = @container
182+
@container = nil
183+
end
184+
end
185+
186+
container&.stop(graceful)
189187
end
190188

191189
# Restart the container. A new container is created, and if successful, any old container is terminated gracefully.
192190
# This is equivalent to a blue-green deployment.
193-
def restart
194-
if @container
195-
@notify&.restarting!
196-
197-
Console.info(self, "Restarting container...")
198-
else
199-
Console.info(self, "Starting container...")
200-
end
201-
202-
container = self.create_container
191+
def start(restart: false)
192+
old_container = nil
203193

204-
begin
205-
self.setup(container)
206-
rescue => error
207-
@notify&.error!(error.to_s)
194+
@guard.synchronize do
195+
if @container && restart
196+
@notify&.restarting!
197+
198+
Console.info(self, "Restarting container...")
199+
else
200+
Console.info(self, "Starting container...")
201+
end
208202

209-
raise SetupError, container
210-
end
211-
212-
# Wait for all child processes to enter the ready state.
213-
Console.info(self, "Waiting for startup...")
214-
215-
if container.wait_until_ready(@events) == :event
216-
return :event
217-
end
218-
219-
Console.info(self, "Finished startup.")
220-
221-
if container.failed?
222-
@notify&.error!("Container failed to start!")
203+
container = self.create_container
204+
205+
begin
206+
self.setup(container)
207+
rescue => error
208+
@notify&.error!(error.to_s)
209+
210+
raise SetupError, container
211+
end
223212

224-
raise SetupError, container
213+
# Wait for all child processes to enter the ready state.
214+
Console.info(self, "Waiting for startup...")
215+
216+
container.wait_until_ready
217+
218+
Console.info(self, "Finished startup.")
219+
220+
if container.failed?
221+
@notify&.error!("Container failed to start!")
222+
223+
raise SetupError, container
224+
end
225+
226+
# The following swap should be atomic:
227+
old_container = @container
228+
@container = container
229+
container = nil
230+
231+
@notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.")
232+
rescue => error
233+
raise
234+
ensure
235+
# If we are leaving this function with an exception, kill the container:
236+
if container
237+
Console.warn(self, "Stopping failed container...", exception: error)
238+
container.stop(false)
239+
end
225240
end
226241

227-
# The following swap should be atomic:
228-
old_container = @container
229-
@container = container
230-
container = nil
231-
232242
if old_container
233243
Console.info(self, "Stopping old container...")
234-
old_container&.stop(@graceful_stop)
235-
end
236-
237-
@notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.")
238-
rescue => error
239-
raise
240-
ensure
241-
# If we are leaving this function with an exception, kill the container:
242-
if container
243-
Console.warn(self, "Stopping failed container...", exception: error)
244-
container.stop(false)
244+
old_container.stop(@graceful_stop)
245245
end
246246
end
247247

248248
# Reload the existing container. Children instances will be reloaded using `SIGHUP`.
249249
def reload
250-
@notify&.reloading!
251-
252-
Console.info(self){"Reloading container: #{@container}..."}
253-
254-
begin
255-
self.setup(@container)
256-
rescue
257-
raise SetupError, @container
250+
@guard.synchronize do
251+
@notify&.reloading!
252+
253+
Console.info(self){"Reloading container: #{@container}..."}
254+
255+
begin
256+
self.setup(@container)
257+
rescue
258+
raise SetupError, @container
259+
end
260+
261+
# Wait for all child processes to enter the ready state.
262+
Console.info(self, "Waiting for startup...")
263+
@container.wait_until_ready
264+
Console.info(self, "Finished startup.")
265+
266+
if @container.failed?
267+
@notify.error!("Container failed to reload!")
268+
269+
raise SetupError, @container
270+
else
271+
@notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.")
272+
end
258273
end
259-
260-
# Wait for all child processes to enter the ready state.
261-
Console.info(self, "Waiting for startup...")
262-
@container.wait_until_ready
263-
Console.info(self, "Finished startup.")
264-
265-
if @container.failed?
266-
@notify.error!("Container failed to reload!")
274+
end
275+
276+
private def wait_for_container
277+
while true
278+
container = @guard.synchronize{@container}
267279

268-
raise SetupError, @container
269-
else
270-
@notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.")
280+
if container.nil?
281+
@events.close
282+
return
283+
end
284+
285+
container.wait
286+
287+
@guard.synchronize do
288+
# If this is still the active container, it completed naturally. Clear it and close the event queue so the controller run loop can finish. If it was replaced by a restart, keep waiting for the new active container.
289+
if @container.equal?(container)
290+
@container = nil
291+
@events.close
292+
return
293+
end
294+
end
271295
end
272296
end
273297

274298
# Enter the controller run loop.
275-
def run
299+
# @parameter signals [#install] The signal backend to use while running the controller.
300+
def run(signals: Async::Signals.default)
276301
@notify&.status!("Initializing controller...")
277302

278-
Async::Signals.install(@signals) do
279-
self.start
280-
281-
while event = @events.pop(timeout: 0)
282-
return if event.call == :stop
283-
end
284-
285-
while @container&.running?
286-
@container.wait(@events)
303+
signals.install(@signals) do
304+
Sync do |task|
305+
self.start
306+
307+
waiter = task.async{wait_for_container}
287308

288-
while event = @events.pop(timeout: 0)
289-
return if event.call == :stop
309+
while event = @events.pop
310+
event.call
290311
end
312+
rescue Async::Cancel
313+
# Graceful shutdown:
314+
self.stop
315+
ensure
316+
# Forced shutdown:
317+
self.stop(false)
291318
end
292319
end
293-
ensure
294-
self.stop(false)
320+
rescue Interrupt
321+
# Ignore - normal shutdown - can propagate from top level Sync.
295322
end
296323
end
297324
end

lib/async/container/events.rb

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)