From 02ce566fb3cfd4741756d43b50710ff810d1dfed Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 16:20:40 +1200 Subject: [PATCH 01/25] Add scoped signal handling. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container.rb | 1 + lib/async/container/signals.rb | 87 +++++++++++++++++++++++++ releases.md | 4 ++ test/async/container/signals.rb | 112 ++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 lib/async/container/signals.rb create mode 100644 test/async/container/signals.rb diff --git a/lib/async/container.rb b/lib/async/container.rb index bd0d47a..d5ff84d 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -3,6 +3,7 @@ # Released under the MIT License. # Copyright, 2017-2025, by Samuel Williams. +require_relative "container/signals" require_relative "container/controller" # @namespace diff --git a/lib/async/container/signals.rb b/lib/async/container/signals.rb new file mode 100644 index 0000000..0523b3c --- /dev/null +++ b/lib/async/container/signals.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Async + module Container + # Represents a collection of process signal handlers which enqueue events. + class Signals + # Represents a trapped signal event. + class Event + # Initialize the signal event. + # @parameter signal [Symbol | String | Integer] The signal that was received. + # @parameter handler [Proc] The handler to invoke when the event is applied. + def initialize(signal, handler) + @signal = signal + @handler = handler + end + + # @attribute [Symbol | String | Integer] The signal that was received. + attr :signal + + # Call the signal event by invoking its handler. + def call + @handler.call + end + end + + # Initialize the signal handler collection. + # @parameter events [Thread::Queue] The queue used to receive signal events. + def initialize(events = ::Thread::Queue.new) + @events = events + @handlers = {} + end + + # @attribute [Thread::Queue] The queue used to receive signal events. + attr :events + + # Register a signal handler. + # If no block is provided, the signal will be ignored while trapped. + # @parameter signal [Symbol | String | Integer] The signal to trap. + def trap(signal, &block) + @handlers[signal] = block + end + + # Ignore a signal while trapped. + # @parameter signal [Symbol | String | Integer] The signal to ignore. + def ignore(signal) + trap(signal) + end + + # Wait for the next signal event. + # @returns [Event] The next signal event. + def wait + @events.pop + end + + # Install the registered signal handlers for the duration of the block. + # @yields {|signals| ...} The block to run while signal handlers are installed. + def trapped + previous = {} + + @handlers.each do |signal, handler| + previous[signal] = install(signal, handler) + end + + yield self + ensure + previous&.each do |signal, handler| + ::Signal.trap(signal, handler) + end + end + + private + + def install(signal, handler) + if handler + ::Signal.trap(signal) do + @events << Event.new(signal, handler) + end + else + ::Signal.trap(signal, "IGNORE") + end + end + end + end +end diff --git a/releases.md b/releases.md index 73d4f2f..4f9bc30 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `Async::Container::Signals` for installing scoped signal traps that enqueue signal events. + ## v0.37.0 - 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. diff --git a/test/async/container/signals.rb b/test/async/container/signals.rb new file mode 100644 index 0000000..d357b16 --- /dev/null +++ b/test/async/container/signals.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/container/signals" + +describe Async::Container::Signals do + let(:events) {::Thread::Queue.new} + let(:signals) {subject.new(events)} + + with Async::Container::Signals::Event do + it "calls the handler" do + applied = false + + event = Async::Container::Signals::Event.new(:USR1, proc{applied = true}) + + expect(event.signal).to be == :USR1 + + event.call + + expect(applied).to be == true + end + end + + with "#events" do + it "exposes the event queue" do + expect(signals.events).to be == events + end + end + + with "#wait" do + it "waits for queued events" do + event = Async::Container::Signals::Event.new(:USR1, proc{}) + + events << event + + expect(signals.wait).to be == event + end + end + + with "#trapped" do + it "queues trapped signal events" do + applied = false + + signals.trap(:USR1) do + applied = true + end + + signals.trapped do + ::Process.kill(:USR1, ::Process.pid) + + event = signals.wait + + expect(event.signal).to be == :USR1 + + event.call + end + + expect(applied).to be == true + end + + it "ignores signals without a handler" do + signals.trap(:USR1) + + signals.trapped do + ::Process.kill(:USR1, ::Process.pid) + + expect do + events.pop(true) + end.to raise_exception(ThreadError) + end + end + + it "can ignore signals explicitly" do + signals.ignore(:USR1) + + signals.trapped do + ::Process.kill(:USR1, ::Process.pid) + + expect do + events.pop(true) + end.to raise_exception(ThreadError) + end + end + + it "restores previous signal handlers" do + previous = ::Thread::Queue.new + original = ::Signal.trap(:USR1) do + previous << :handled + end + + begin + signals.ignore(:USR1) + + signals.trapped do + ::Process.kill(:USR1, ::Process.pid) + + expect do + previous.pop(true) + end.to raise_exception(ThreadError) + end + + ::Process.kill(:USR1, ::Process.pid) + + expect(previous.pop).to be == :handled + ensure + ::Signal.trap(:USR1, original) + end + end + end +end From ded924ce9e2dc8c9dbf00b036782f31ca4402f80 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 17:56:34 +1200 Subject: [PATCH 02/25] Integrate controller events with child waits. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container.rb | 1 + lib/async/container/controller.rb | 101 ++++++++++++++---------------- lib/async/container/events.rb | 59 +++++++++++++++++ lib/async/container/generic.rb | 14 +++-- lib/async/container/group.rb | 34 ++++++---- lib/async/container/signals.rb | 8 ++- test/async/container/signals.rb | 19 ++++++ 7 files changed, 162 insertions(+), 74 deletions(-) create mode 100644 lib/async/container/events.rb diff --git a/lib/async/container.rb b/lib/async/container.rb index d5ff84d..893cb33 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -3,6 +3,7 @@ # Released under the MIT License. # Copyright, 2017-2025, by Samuel Williams. +require_relative "container/events" require_relative "container/signals" require_relative "container/controller" diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 75b1a95..a12fffe 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -9,6 +9,8 @@ require_relative "statistics" require_relative "notify" require_relative "policy" +require_relative "events" +require_relative "signals" module Async module Container @@ -41,10 +43,29 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: @graceful_stop = graceful_stop @container = nil - @signals = {} + @events = Events.new + @signals = Signals.new(@events) self.trap(SIGHUP) do self.restart + rescue SetupError => error + Console.error(self, error) + end + + self.trap(SIGUSR1) do + self.reload + rescue SetupError => error + Console.error(self, error) + end + + self.trap(SIGINT) do + self.stop + :stop + end + + self.trap(SIGTERM) do + self.stop + :stop end end @@ -80,7 +101,7 @@ def to_s # @parameters signal [Symbol] The signal to trap, e.g. `:INT`. # @parameters block [Proc] The signal handler to invoke. def trap(signal, &block) - @signals[signal] = block + @signals.trap(signal, &block) end # Create a policy for managing child lifecycle events. @@ -117,13 +138,19 @@ def setup(container) end # Start the container unless it's already running. + # @returns [Generic] The container. def start unless @container Console.info(self, "Controller starting...") - self.restart + + if self.restart(@events) == :event + return + end end Console.info(self, "Controller started.") + + return @container end # Stop the container if it's running. @@ -135,7 +162,7 @@ def stop(graceful = @graceful_stop) # Restart the container. A new container is created, and if successful, any old container is terminated gracefully. # This is equivalent to a blue-green deployment. - def restart + def restart(events = nil) if @container @notify&.restarting! @@ -156,7 +183,11 @@ def restart # Wait for all child processes to enter the ready state. Console.info(self, "Waiting for startup...") - container.wait_until_ready + + if container.wait_until_ready(events) == :event + return :event + end + Console.info(self, "Finished startup.") if container.failed? @@ -195,7 +226,7 @@ def reload begin self.setup(@container) rescue - raise SetupError, container + raise SetupError, @container end # Wait for all child processes to enter the ready state. @@ -212,66 +243,28 @@ def reload end end - # Enter the controller run loop, trapping `SIGINT` and `SIGTERM`. + # Enter the controller run loop. def run @notify&.status!("Initializing controller...") - with_signal_handlers do + @signals.trapped do self.start + while event = @events.pop(timeout: 0) + return if event.call == :stop + end + while @container&.running? - begin - @container.wait - rescue SignalException => exception - if handler = @signals[exception.signo] - begin - handler.call - rescue SetupError => error - Console.error(self, error) - end - else - raise - end + @container.wait(@events) + + while event = @events.pop(timeout: 0) + return if event.call == :stop end end end - rescue Interrupt - self.stop - rescue Terminate - self.stop(false) ensure self.stop(false) end - - private def with_signal_handlers - # I thought this was the default... but it doesn't always raise an exception unless you do this explicitly. - - interrupt_action = Signal.trap(:INT) do - # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. - # $stderr.puts "Received INT signal, interrupting...", caller - ::Thread.current.raise(Interrupt) - end - - # SIGTERM behaves the same as SIGINT by default. - terminate_action = Signal.trap(:TERM) do - # $stderr.puts "Received TERM signal, interrupting...", caller - ::Thread.current.raise(Interrupt) # Same as SIGINT - end - - hangup_action = Signal.trap(:HUP) do - # $stderr.puts "Received HUP signal, restarting...", caller - ::Thread.current.raise(Restart) - end - - ::Thread.handle_interrupt(SignalException => :never) do - yield - end - ensure - # Restore the interrupt handler: - Signal.trap(:INT, interrupt_action) - Signal.trap(:TERM, terminate_action) - Signal.trap(:HUP, hangup_action) - end end end end diff --git a/lib/async/container/events.rb b/lib/async/container/events.rb new file mode 100644 index 0000000..4041c30 --- /dev/null +++ b/lib/async/container/events.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Async + module Container + # Represents a queue of events that can wake `IO.select`. + class Events + # Initialize the event queue. + def initialize + @queue = ::Thread::Queue.new + @input, @output = ::IO.pipe + @io = @input + end + + # @attribute [IO] The readable end of the event pipe. + attr :input + + # @attribute [IO] The readable end used to wait for events. + attr :io + + # Enqueue an event and wake any waiter. + # @parameter event [Object] The event to enqueue. + def <<(event) + @queue << event + + begin + @output.write_nonblock(".") + rescue ::IO::WaitWritable + # The pipe is already full, so any select waiter is already awake: + end + + return self + end + + # Remove and return the next queued event. + # @parameter non_block [Boolean] Whether to raise if no event is ready. + # @parameter timeout [Numeric | Nil] The maximum time to wait for an event. + def pop(non_block = false, timeout: nil) + if timeout + event = @queue.pop(non_block, timeout: timeout) + else + event = @queue.pop(non_block) + end + + return unless event + + @input.read_nonblock(1) + + return event + rescue ::IO::WaitReadable + return event if event + + raise + end + end + end +end diff --git a/lib/async/container/generic.rb b/lib/async/container/generic.rb index d7e1551..40229d3 100644 --- a/lib/async/container/generic.rb +++ b/lib/async/container/generic.rb @@ -106,13 +106,13 @@ def stopping? # Sleep until some state change occurs or the specified duration elapses. # # @parameter duration [Numeric] the maximum amount of time to sleep for. - def sleep(duration = nil) - @group.sleep(duration) + def sleep(duration = nil, events = nil) + @group.sleep(duration, events) end # Wait until all spawned tasks are completed. - def wait - @group.wait + def wait(events = nil) + @group.wait(events) end # Gracefully interrupt all child instances. @@ -135,7 +135,7 @@ def status?(flag) # Wait until all the children instances have indicated that they are ready. # @returns [Boolean] The children all became ready. - def wait_until_ready + def wait_until_ready(events = nil) while true Console.debug(self) do |buffer| buffer.puts "Waiting for ready:" @@ -144,7 +144,9 @@ def wait_until_ready end end - self.sleep + if self.sleep(nil, events) == :event + return :event + end if self.status?(:ready) Console.debug(self) do |buffer| diff --git a/lib/async/container/group.rb b/lib/async/container/group.rb index def3182..e950c05 100644 --- a/lib/async/container/group.rb +++ b/lib/async/container/group.rb @@ -57,14 +57,16 @@ def empty? end # Sleep for at most the specified duration until some state change occurs. - def sleep(duration) - self.wait_for_children(duration) + def sleep(duration, events = nil) + self.wait_for_children(duration, events) end # Begin any outstanding queued processes and wait for them indefinitely. - def wait + def wait(events = nil) with_health_checks do |duration| - self.wait_for_children(duration) + if self.wait_for_children(duration, events) == :event + return + end end end @@ -211,30 +213,40 @@ def wait_for(channel) protected - def wait_for_children(duration = nil) + def wait_for_children(duration = nil, events = nil) # This log is a bit noisy and doesn't really provide a lot of useful information: Console.debug(self, "Waiting for children...", duration: duration, running: @running) unless @running.empty? # Maybe consider using a proper event loop here: - if ready = self.select(duration) + if ready = self.select(duration, events) + if events && ready.delete(events.io) + result = :event + end + ready.each do |io| if fiber = @running[io] # This method can be re-entered. While resuming a fiber, a policy hook may be invoked, which may invoke operations on the container. In that case, select may be called again on the same set of waiting fibers. On returning, those fibers may have already completed and removed themselves from @running, so we need to check for that. fiber.resume end end + + return result end end end # Wait for a child process to exit OR a signal to be received. - def select(duration) - ::Thread.handle_interrupt(SignalException => :immediate) do - readable, _, _ = ::IO.select(@running.keys, nil, nil, duration) - - return readable + def select(duration, events = nil) + waiting = @running.keys + + if events + waiting << events.io end + + readable, _, _ = ::IO.select(waiting, nil, nil, duration) + + return readable end end end diff --git a/lib/async/container/signals.rb b/lib/async/container/signals.rb index 0523b3c..3510d42 100644 --- a/lib/async/container/signals.rb +++ b/lib/async/container/signals.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require_relative "events" + module Async module Container # Represents a collection of process signal handlers which enqueue events. @@ -27,13 +29,13 @@ def call end # Initialize the signal handler collection. - # @parameter events [Thread::Queue] The queue used to receive signal events. - def initialize(events = ::Thread::Queue.new) + # @parameter events [Events] The queue used to receive signal events. + def initialize(events = Events.new) @events = events @handlers = {} end - # @attribute [Thread::Queue] The queue used to receive signal events. + # @attribute [Events] The queue used to receive signal events. attr :events # Register a signal handler. diff --git a/test/async/container/signals.rb b/test/async/container/signals.rb index d357b16..39a04be 100644 --- a/test/async/container/signals.rb +++ b/test/async/container/signals.rb @@ -23,6 +23,25 @@ end end + with Async::Container::Events do + let(:events) {Async::Container::Events.new} + + it "wakes IO.select when an event is queued" do + event = Object.new + + events << event + + readable, _, _ = IO.select([events.io], nil, nil, 0) + + expect(readable).to be == [events.io] + expect(events.pop(timeout: 0)).to be == event + end + + it "returns nil when no event is queued" do + expect(events.pop(timeout: 0)).to be_nil + end + end + with "#events" do it "exposes the event queue" do expect(signals.events).to be == events From 48a4ff8c580f2b6a15cedcfc069598dc4cc09ce5 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 21:49:23 +1200 Subject: [PATCH 03/25] Apply RuboCop formatting. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index a12fffe..1fe5e28 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -51,18 +51,18 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: rescue SetupError => error Console.error(self, error) end - + self.trap(SIGUSR1) do self.reload rescue SetupError => error Console.error(self, error) end - + self.trap(SIGINT) do self.stop :stop end - + self.trap(SIGTERM) do self.stop :stop @@ -149,7 +149,7 @@ def start end Console.info(self, "Controller started.") - + return @container end From 9e750776045eaf21185742eb6223d70314240baa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 21:59:01 +1200 Subject: [PATCH 04/25] Preallocate signal trap events. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/signals.rb | 18 +++++++++++------- test/async/container/signals.rb | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/async/container/signals.rb b/lib/async/container/signals.rb index 3510d42..9f6dea9 100644 --- a/lib/async/container/signals.rb +++ b/lib/async/container/signals.rb @@ -32,7 +32,7 @@ def call # @parameter events [Events] The queue used to receive signal events. def initialize(events = Events.new) @events = events - @handlers = {} + @traps = {} end # @attribute [Events] The queue used to receive signal events. @@ -42,7 +42,11 @@ def initialize(events = Events.new) # If no block is provided, the signal will be ignored while trapped. # @parameter signal [Symbol | String | Integer] The signal to trap. def trap(signal, &block) - @handlers[signal] = block + if block + @traps[signal] = Event.new(signal, block).freeze + else + @traps[signal] = nil + end end # Ignore a signal while trapped. @@ -62,8 +66,8 @@ def wait def trapped previous = {} - @handlers.each do |signal, handler| - previous[signal] = install(signal, handler) + @traps.each do |signal, event| + previous[signal] = install(signal, event) end yield self @@ -75,10 +79,10 @@ def trapped private - def install(signal, handler) - if handler + def install(signal, event) + if event ::Signal.trap(signal) do - @events << Event.new(signal, handler) + @events << event end else ::Signal.trap(signal, "IGNORE") diff --git a/test/async/container/signals.rb b/test/async/container/signals.rb index 39a04be..38efebb 100644 --- a/test/async/container/signals.rb +++ b/test/async/container/signals.rb @@ -79,6 +79,20 @@ expect(applied).to be == true end + it "reuses a frozen event for trapped signals" do + signals.trap(:USR1){} + + signals.trapped do + ::Process.kill(:USR1, ::Process.pid) + event = signals.wait + + expect(event.frozen?).to be == true + + ::Process.kill(:USR1, ::Process.pid) + expect(signals.wait).to be == event + end + end + it "ignores signals without a handler" do signals.trap(:USR1) From db74350539626f957a6da0a5f947585fd1f7605d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 22:05:56 +1200 Subject: [PATCH 05/25] Use controller events for restart waits. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 1fe5e28..82a3c3f 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -143,7 +143,7 @@ def start unless @container Console.info(self, "Controller starting...") - if self.restart(@events) == :event + if self.restart == :event return end end @@ -162,7 +162,7 @@ def stop(graceful = @graceful_stop) # Restart the container. A new container is created, and if successful, any old container is terminated gracefully. # This is equivalent to a blue-green deployment. - def restart(events = nil) + def restart if @container @notify&.restarting! @@ -184,7 +184,7 @@ def restart(events = nil) # Wait for all child processes to enter the ready state. Console.info(self, "Waiting for startup...") - if container.wait_until_ready(events) == :event + if container.wait_until_ready(@events) == :event return :event end From 76c2402163c2c945bbd84667ceb7444957c0dca0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 28 Jun 2026 11:48:18 +1200 Subject: [PATCH 06/25] Use async-signals for controller traps. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- async-container.gemspec | 1 + lib/async/container.rb | 1 - lib/async/container/controller.rb | 36 ++++++- lib/async/container/signals.rb | 93 ------------------ releases.md | 2 +- test/async/container/controller.rb | 21 +++++ test/async/container/events.rb | 39 ++++++++ test/async/container/signals.rb | 145 ----------------------------- 8 files changed, 94 insertions(+), 244 deletions(-) delete mode 100644 lib/async/container/signals.rb create mode 100644 test/async/container/events.rb delete mode 100644 test/async/container/signals.rb diff --git a/async-container.gemspec b/async-container.gemspec index da9d466..b36dc2d 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -25,4 +25,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "async", "~> 2.22" + spec.add_dependency "async-signals", "~> 0.1" end diff --git a/lib/async/container.rb b/lib/async/container.rb index 893cb33..6b2a408 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -4,7 +4,6 @@ # Copyright, 2017-2025, by Samuel Williams. require_relative "container/events" -require_relative "container/signals" require_relative "container/controller" # @namespace diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 82a3c3f..ec6a099 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -10,7 +10,8 @@ require_relative "notify" require_relative "policy" require_relative "events" -require_relative "signals" + +require "async/signals" module Async module Container @@ -29,6 +30,25 @@ module Container # Manages the life-cycle of one or more containers in order to support a persistent system. # e.g. a web server, job server or some other long running system. class Controller + # Represents a trapped process signal as a queued controller event. + class SignalEvent + # Initialize the signal event. + # @parameter signal [Symbol | String | Integer] The signal that was received. + # @parameter handler [Proc] The handler to invoke when the event is processed. + def initialize(signal, handler) + @signal = signal + @handler = handler + end + + # @attribute [Symbol | String | Integer] The signal that was received. + attr :signal + + # Process the signal event by invoking the registered handler. + def call + @handler.call + end + end + SIGHUP = Signal.list["HUP"] SIGINT = Signal.list["INT"] SIGTERM = Signal.list["TERM"] @@ -44,7 +64,7 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: @container = nil @events = Events.new - @signals = Signals.new(@events) + @signals = Async::Signals::Handlers.new self.trap(SIGHUP) do self.restart @@ -101,7 +121,15 @@ def to_s # @parameters signal [Symbol] The signal to trap, e.g. `:INT`. # @parameters block [Proc] The signal handler to invoke. def trap(signal, &block) - @signals.trap(signal, &block) + if block + event = SignalEvent.new(signal, block).freeze + + @signals.trap(signal) do + @events << event + end + else + @signals.ignore(signal) + end end # Create a policy for managing child lifecycle events. @@ -247,7 +275,7 @@ def reload def run @notify&.status!("Initializing controller...") - @signals.trapped do + Async::Signals.install(@signals) do self.start while event = @events.pop(timeout: 0) diff --git a/lib/async/container/signals.rb b/lib/async/container/signals.rb deleted file mode 100644 index 9f6dea9..0000000 --- a/lib/async/container/signals.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "events" - -module Async - module Container - # Represents a collection of process signal handlers which enqueue events. - class Signals - # Represents a trapped signal event. - class Event - # Initialize the signal event. - # @parameter signal [Symbol | String | Integer] The signal that was received. - # @parameter handler [Proc] The handler to invoke when the event is applied. - def initialize(signal, handler) - @signal = signal - @handler = handler - end - - # @attribute [Symbol | String | Integer] The signal that was received. - attr :signal - - # Call the signal event by invoking its handler. - def call - @handler.call - end - end - - # Initialize the signal handler collection. - # @parameter events [Events] The queue used to receive signal events. - def initialize(events = Events.new) - @events = events - @traps = {} - end - - # @attribute [Events] The queue used to receive signal events. - attr :events - - # Register a signal handler. - # If no block is provided, the signal will be ignored while trapped. - # @parameter signal [Symbol | String | Integer] The signal to trap. - def trap(signal, &block) - if block - @traps[signal] = Event.new(signal, block).freeze - else - @traps[signal] = nil - end - end - - # Ignore a signal while trapped. - # @parameter signal [Symbol | String | Integer] The signal to ignore. - def ignore(signal) - trap(signal) - end - - # Wait for the next signal event. - # @returns [Event] The next signal event. - def wait - @events.pop - end - - # Install the registered signal handlers for the duration of the block. - # @yields {|signals| ...} The block to run while signal handlers are installed. - def trapped - previous = {} - - @traps.each do |signal, event| - previous[signal] = install(signal, event) - end - - yield self - ensure - previous&.each do |signal, handler| - ::Signal.trap(signal, handler) - end - end - - private - - def install(signal, event) - if event - ::Signal.trap(signal) do - @events << event - end - else - ::Signal.trap(signal, "IGNORE") - end - end - end - end -end diff --git a/releases.md b/releases.md index 4f9bc30..b14db43 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `Async::Container::Signals` for installing scoped signal traps that enqueue signal events. + - Use `async-signals` to coordinate controller signal traps while queueing signal events through the controller event loop. ## v0.37.0 diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index 273b016..f0ca366 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -210,6 +210,27 @@ def controller.setup(container) with "signals" do include_context Async::Container::AController, "dots" + it "queues trapped signal events" do + controller = Async::Container::Controller.new(notify: nil) + applied = false + + controller.trap(:USR1) do + applied = true + end + + Async::Signals.install(controller.instance_variable_get(:@signals)) do + Process.kill(:USR1, Process.pid) + + event = controller.instance_variable_get(:@events).pop(timeout: 1) + + expect(event.signal).to be == :USR1 + + event.call + end + + expect(applied).to be == true + end + it "restarts children when receiving SIGHUP" do expect(input.read(1)).to be == "." diff --git a/test/async/container/events.rb b/test/async/container/events.rb new file mode 100644 index 0000000..cfbd663 --- /dev/null +++ b/test/async/container/events.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/container/controller" + +describe Async::Container::Controller::SignalEvent do + it "calls the handler" do + applied = false + + event = subject.new(:USR1, proc{applied = true}) + + expect(event.signal).to be == :USR1 + + event.call + + expect(applied).to be == true + end +end + +describe Async::Container::Events do + let(:events) {subject.new} + + it "wakes IO.select when an event is queued" do + event = Object.new + + events << event + + readable, _, _ = IO.select([events.io], nil, nil, 0) + + expect(readable).to be == [events.io] + expect(events.pop(timeout: 0)).to be == event + end + + it "returns nil when no event is queued" do + expect(events.pop(timeout: 0)).to be_nil + end +end diff --git a/test/async/container/signals.rb b/test/async/container/signals.rb deleted file mode 100644 index 38efebb..0000000 --- a/test/async/container/signals.rb +++ /dev/null @@ -1,145 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "async/container/signals" - -describe Async::Container::Signals do - let(:events) {::Thread::Queue.new} - let(:signals) {subject.new(events)} - - with Async::Container::Signals::Event do - it "calls the handler" do - applied = false - - event = Async::Container::Signals::Event.new(:USR1, proc{applied = true}) - - expect(event.signal).to be == :USR1 - - event.call - - expect(applied).to be == true - end - end - - with Async::Container::Events do - let(:events) {Async::Container::Events.new} - - it "wakes IO.select when an event is queued" do - event = Object.new - - events << event - - readable, _, _ = IO.select([events.io], nil, nil, 0) - - expect(readable).to be == [events.io] - expect(events.pop(timeout: 0)).to be == event - end - - it "returns nil when no event is queued" do - expect(events.pop(timeout: 0)).to be_nil - end - end - - with "#events" do - it "exposes the event queue" do - expect(signals.events).to be == events - end - end - - with "#wait" do - it "waits for queued events" do - event = Async::Container::Signals::Event.new(:USR1, proc{}) - - events << event - - expect(signals.wait).to be == event - end - end - - with "#trapped" do - it "queues trapped signal events" do - applied = false - - signals.trap(:USR1) do - applied = true - end - - signals.trapped do - ::Process.kill(:USR1, ::Process.pid) - - event = signals.wait - - expect(event.signal).to be == :USR1 - - event.call - end - - expect(applied).to be == true - end - - it "reuses a frozen event for trapped signals" do - signals.trap(:USR1){} - - signals.trapped do - ::Process.kill(:USR1, ::Process.pid) - event = signals.wait - - expect(event.frozen?).to be == true - - ::Process.kill(:USR1, ::Process.pid) - expect(signals.wait).to be == event - end - end - - it "ignores signals without a handler" do - signals.trap(:USR1) - - signals.trapped do - ::Process.kill(:USR1, ::Process.pid) - - expect do - events.pop(true) - end.to raise_exception(ThreadError) - end - end - - it "can ignore signals explicitly" do - signals.ignore(:USR1) - - signals.trapped do - ::Process.kill(:USR1, ::Process.pid) - - expect do - events.pop(true) - end.to raise_exception(ThreadError) - end - end - - it "restores previous signal handlers" do - previous = ::Thread::Queue.new - original = ::Signal.trap(:USR1) do - previous << :handled - end - - begin - signals.ignore(:USR1) - - signals.trapped do - ::Process.kill(:USR1, ::Process.pid) - - expect do - previous.pop(true) - end.to raise_exception(ThreadError) - end - - ::Process.kill(:USR1, ::Process.pid) - - expect(previous.pop).to be == :handled - ensure - ::Signal.trap(:USR1, original) - end - end - end -end From 8001225268d07d3b0bff70d21be2779f16e7e6b3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 28 Jun 2026 11:56:01 +1200 Subject: [PATCH 07/25] Avoid exceptions when waking events. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/events.rb | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/async/container/events.rb b/lib/async/container/events.rb index 4041c30..d37b747 100644 --- a/lib/async/container/events.rb +++ b/lib/async/container/events.rb @@ -25,34 +25,21 @@ def initialize def <<(event) @queue << event - begin - @output.write_nonblock(".") - rescue ::IO::WaitWritable - # The pipe is already full, so any select waiter is already awake: - end + # If the pipe is full, any select waiter is already awake: + @output.write_nonblock(".", exception: false) return self end # Remove and return the next queued event. - # @parameter non_block [Boolean] Whether to raise if no event is ready. - # @parameter timeout [Numeric | Nil] The maximum time to wait for an event. - def pop(non_block = false, timeout: nil) - if timeout - event = @queue.pop(non_block, timeout: timeout) - else - event = @queue.pop(non_block) - end + def pop(...) + event = @queue.pop(...) return unless event - @input.read_nonblock(1) + @input.read_nonblock(1, exception: false) return event - rescue ::IO::WaitReadable - return event if event - - raise end end end From 187b0556c37f9d98c03dacd568f89c3d3d56cc9f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 28 Jun 2026 21:26:16 +1200 Subject: [PATCH 08/25] Move version constant. --- lib/async/container.rb | 8 -------- lib/async/container/version.rb | 2 ++ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/async/container.rb b/lib/async/container.rb index 6b2a408..bfc07fa 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -3,12 +3,4 @@ # Released under the MIT License. # Copyright, 2017-2025, by Samuel Williams. -require_relative "container/events" require_relative "container/controller" - -# @namespace -module Async - # @namespace - module Container - end -end diff --git a/lib/async/container/version.rb b/lib/async/container/version.rb index cd71a1a..62925ef 100644 --- a/lib/async/container/version.rb +++ b/lib/async/container/version.rb @@ -3,7 +3,9 @@ # Released under the MIT License. # Copyright, 2017-2026, by Samuel Williams. +# @namespace module Async + # @namespace module Container VERSION = "0.37.0" end From f7a4123695fd3460dee9832489c3b041c3f31587 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 01:21:20 +1200 Subject: [PATCH 09/25] Use async-signals context for controller signals. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- async-container.gemspec | 4 +- lib/async/container.rb | 4 + lib/async/container/controller.rb | 241 ++++++++++++++++------------- lib/async/container/events.rb | 46 ------ lib/async/container/generic.rb | 14 +- lib/async/container/group.rb | 30 +--- readme.md | 8 +- test/async/container/controller.rb | 20 +++ test/async/container/events.rb | 19 --- 9 files changed, 178 insertions(+), 208 deletions(-) delete mode 100644 lib/async/container/events.rb diff --git a/async-container.gemspec b/async-container.gemspec index b36dc2d..808f67f 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -24,6 +24,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "async", "~> 2.22" - spec.add_dependency "async-signals", "~> 0.1" + spec.add_dependency "async", "~> 2.41" + spec.add_dependency "async-signals", "~> 0.3" end diff --git a/lib/async/container.rb b/lib/async/container.rb index bfc07fa..cbb41ab 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -4,3 +4,7 @@ # Copyright, 2017-2025, by Samuel Williams. require_relative "container/controller" + +# This is a workaround for Ruby's inconsistent handling of SIGINT. +# See for more details. +::Signal.trap(:INT){::Thread.current.raise(Interrupt)} diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index ec6a099..5babcdf 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -9,8 +9,8 @@ require_relative "statistics" require_relative "notify" require_relative "policy" -require_relative "events" +require "async" require "async/signals" module Async @@ -63,9 +63,20 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: @graceful_stop = graceful_stop @container = nil - @events = Events.new + @events = ::Thread::Queue.new @signals = Async::Signals::Handlers.new + # 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). + @guard = ::Thread::Mutex.new + + @signals.trap(SIGINT) do |signal, context| + context.raise(Interrupt) + end + + @signals.trap(SIGTERM) do |signal, context| + context.raise(Interrupt) + end + self.trap(SIGHUP) do self.restart rescue SetupError => error @@ -77,16 +88,6 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: rescue SetupError => error Console.error(self, error) end - - self.trap(SIGINT) do - self.stop - :stop - end - - self.trap(SIGTERM) do - self.stop - :stop - end end # The notify client used by the controller. @@ -149,12 +150,12 @@ def create_container # Whether the controller has a running container. # @returns [Boolean] def running? - !!@container + @guard.synchronize{!!@container} end # Wait for the underlying container to start. def wait - @container&.wait + @guard.synchronize{@container}&.wait end # Spawn container instances into the given container. @@ -167,131 +168,157 @@ def setup(container) # Start the container unless it's already running. # @returns [Generic] The container. - def start - unless @container - Console.info(self, "Controller starting...") - - if self.restart == :event - return - end - end - - Console.info(self, "Controller started.") - - return @container + def restart + self.start(restart: true) end # Stop the container if it's running. - # @parameter graceful [Boolean] Whether to give the children instances time to shut down or to kill them immediately. + # @parameter graceful [Boolean | Numeric] Whether to give the children instances time to shut down or to kill them immediately. def stop(graceful = @graceful_stop) - @container&.stop(graceful) - @container = nil + container = nil + + @guard.synchronize do + if container = @container + @container = nil + end + end + + container&.stop(graceful) end # Restart the container. A new container is created, and if successful, any old container is terminated gracefully. # This is equivalent to a blue-green deployment. - def restart - if @container - @notify&.restarting! - - Console.info(self, "Restarting container...") - else - Console.info(self, "Starting container...") - end - - container = self.create_container + def start(restart: false) + old_container = nil - begin - self.setup(container) - rescue => error - @notify&.error!(error.to_s) + @guard.synchronize do + if @container && restart + @notify&.restarting! + + Console.info(self, "Restarting container...") + else + Console.info(self, "Starting container...") + end - raise SetupError, container - end - - # Wait for all child processes to enter the ready state. - Console.info(self, "Waiting for startup...") - - if container.wait_until_ready(@events) == :event - return :event - end - - Console.info(self, "Finished startup.") - - if container.failed? - @notify&.error!("Container failed to start!") + container = self.create_container + + begin + self.setup(container) + rescue => error + @notify&.error!(error.to_s) + + raise SetupError, container + end - raise SetupError, container + # Wait for all child processes to enter the ready state. + Console.info(self, "Waiting for startup...") + + container.wait_until_ready + + Console.info(self, "Finished startup.") + + if container.failed? + @notify&.error!("Container failed to start!") + + raise SetupError, container + end + + # The following swap should be atomic: + old_container = @container + @container = container + container = nil + + @notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.") + rescue => error + raise + ensure + # If we are leaving this function with an exception, kill the container: + if container + Console.warn(self, "Stopping failed container...", exception: error) + container.stop(false) + end end - # The following swap should be atomic: - old_container = @container - @container = container - container = nil - if old_container Console.info(self, "Stopping old container...") - old_container&.stop(@graceful_stop) - end - - @notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.") - rescue => error - raise - ensure - # If we are leaving this function with an exception, kill the container: - if container - Console.warn(self, "Stopping failed container...", exception: error) - container.stop(false) + old_container.stop(@graceful_stop) end end # Reload the existing container. Children instances will be reloaded using `SIGHUP`. def reload - @notify&.reloading! - - Console.info(self){"Reloading container: #{@container}..."} - - begin - self.setup(@container) - rescue - raise SetupError, @container + @guard.synchronize do + @notify&.reloading! + + Console.info(self){"Reloading container: #{@container}..."} + + begin + self.setup(@container) + rescue + raise SetupError, @container + end + + # Wait for all child processes to enter the ready state. + Console.info(self, "Waiting for startup...") + @container.wait_until_ready + Console.info(self, "Finished startup.") + + if @container.failed? + @notify.error!("Container failed to reload!") + + raise SetupError, @container + else + @notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.") + end end - - # Wait for all child processes to enter the ready state. - Console.info(self, "Waiting for startup...") - @container.wait_until_ready - Console.info(self, "Finished startup.") - - if @container.failed? - @notify.error!("Container failed to reload!") + end + + private def wait_for_container + while true + container = @guard.synchronize{@container} - raise SetupError, @container - else - @notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.") + if container.nil? + @events.close + return + end + + container.wait + + @guard.synchronize do + # 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. + if @container.equal?(container) + @container = nil + @events.close + return + end + end end end # Enter the controller run loop. - def run + # @parameter signals [#install] The signal backend to use while running the controller. + def run(signals: Async::Signals.default) @notify&.status!("Initializing controller...") - Async::Signals.install(@signals) do - self.start - - while event = @events.pop(timeout: 0) - return if event.call == :stop - end - - while @container&.running? - @container.wait(@events) + signals.install(@signals) do + Sync do |task| + self.start + + waiter = task.async{wait_for_container} - while event = @events.pop(timeout: 0) - return if event.call == :stop + while event = @events.pop + event.call end + rescue Async::Cancel + # Graceful shutdown: + self.stop + ensure + # Forced shutdown: + self.stop(false) end end - ensure - self.stop(false) + rescue Interrupt + # Ignore - normal shutdown - can propagate from top level Sync. end end end diff --git a/lib/async/container/events.rb b/lib/async/container/events.rb deleted file mode 100644 index d37b747..0000000 --- a/lib/async/container/events.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -module Async - module Container - # Represents a queue of events that can wake `IO.select`. - class Events - # Initialize the event queue. - def initialize - @queue = ::Thread::Queue.new - @input, @output = ::IO.pipe - @io = @input - end - - # @attribute [IO] The readable end of the event pipe. - attr :input - - # @attribute [IO] The readable end used to wait for events. - attr :io - - # Enqueue an event and wake any waiter. - # @parameter event [Object] The event to enqueue. - def <<(event) - @queue << event - - # If the pipe is full, any select waiter is already awake: - @output.write_nonblock(".", exception: false) - - return self - end - - # Remove and return the next queued event. - def pop(...) - event = @queue.pop(...) - - return unless event - - @input.read_nonblock(1, exception: false) - - return event - end - end - end -end diff --git a/lib/async/container/generic.rb b/lib/async/container/generic.rb index 40229d3..d7e1551 100644 --- a/lib/async/container/generic.rb +++ b/lib/async/container/generic.rb @@ -106,13 +106,13 @@ def stopping? # Sleep until some state change occurs or the specified duration elapses. # # @parameter duration [Numeric] the maximum amount of time to sleep for. - def sleep(duration = nil, events = nil) - @group.sleep(duration, events) + def sleep(duration = nil) + @group.sleep(duration) end # Wait until all spawned tasks are completed. - def wait(events = nil) - @group.wait(events) + def wait + @group.wait end # Gracefully interrupt all child instances. @@ -135,7 +135,7 @@ def status?(flag) # Wait until all the children instances have indicated that they are ready. # @returns [Boolean] The children all became ready. - def wait_until_ready(events = nil) + def wait_until_ready while true Console.debug(self) do |buffer| buffer.puts "Waiting for ready:" @@ -144,9 +144,7 @@ def wait_until_ready(events = nil) end end - if self.sleep(nil, events) == :event - return :event - end + self.sleep if self.status?(:ready) Console.debug(self) do |buffer| diff --git a/lib/async/container/group.rb b/lib/async/container/group.rb index e950c05..5a1eb96 100644 --- a/lib/async/container/group.rb +++ b/lib/async/container/group.rb @@ -57,16 +57,14 @@ def empty? end # Sleep for at most the specified duration until some state change occurs. - def sleep(duration, events = nil) - self.wait_for_children(duration, events) + def sleep(duration) + self.wait_for_children(duration) end # Begin any outstanding queued processes and wait for them indefinitely. - def wait(events = nil) + def wait with_health_checks do |duration| - if self.wait_for_children(duration, events) == :event - return - end + self.wait_for_children(duration) end end @@ -213,38 +211,26 @@ def wait_for(channel) protected - def wait_for_children(duration = nil, events = nil) + def wait_for_children(duration = nil) # This log is a bit noisy and doesn't really provide a lot of useful information: Console.debug(self, "Waiting for children...", duration: duration, running: @running) unless @running.empty? # Maybe consider using a proper event loop here: - if ready = self.select(duration, events) - if events && ready.delete(events.io) - result = :event - end - + if ready = self.select(duration) ready.each do |io| if fiber = @running[io] # This method can be re-entered. While resuming a fiber, a policy hook may be invoked, which may invoke operations on the container. In that case, select may be called again on the same set of waiting fibers. On returning, those fibers may have already completed and removed themselves from @running, so we need to check for that. fiber.resume end end - - return result end end end # Wait for a child process to exit OR a signal to be received. - def select(duration, events = nil) - waiting = @running.keys - - if events - waiting << events.io - end - - readable, _, _ = ::IO.select(waiting, nil, nil, duration) + def select(duration) + readable, _, _ = ::IO.select(@running.keys, nil, nil, duration) return readable end diff --git a/readme.md b/readme.md index 22b9885..dbb8317 100644 --- a/readme.md +++ b/readme.md @@ -28,6 +28,10 @@ Please see the [project documentation](https://socketry.github.io/async-containe Please see the [project releases](https://socketry.github.io/async-container/releases/index) for all releases. +### Unreleased + + - Add `Async::Container::Signals` for installing scoped signal traps that enqueue signal events. + ### v0.37.0 - 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. @@ -64,10 +68,6 @@ Please see the [project releases](https://socketry.github.io/async-container/rel - Add `Async::Container::Generic#stopping?` so that policies can more accurately track the state of the container. -### v0.33.0 - - - Add `Policy#make_statistics` to allow policies to customize statistics initialization. - ## Contributing We welcome contributions to this project. diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index f0ca366..e508fab 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -210,6 +210,26 @@ def controller.setup(container) with "signals" do include_context Async::Container::AController, "dots" + it "uses the provided signal backend" do + signals = Module.new do + def self.install(handlers) + @handlers = handlers + yield + end + + def self.handlers + @handlers + end + end + + def controller.setup(container) + end + + controller.run(signals: signals) + + expect(signals.handlers).to be == controller.instance_variable_get(:@signals) + end + it "queues trapped signal events" do controller = Async::Container::Controller.new(notify: nil) applied = false diff --git a/test/async/container/events.rb b/test/async/container/events.rb index cfbd663..0f0f3c1 100644 --- a/test/async/container/events.rb +++ b/test/async/container/events.rb @@ -18,22 +18,3 @@ expect(applied).to be == true end end - -describe Async::Container::Events do - let(:events) {subject.new} - - it "wakes IO.select when an event is queued" do - event = Object.new - - events << event - - readable, _, _ = IO.select([events.io], nil, nil, 0) - - expect(readable).to be == [events.io] - expect(events.pop(timeout: 0)).to be == event - end - - it "returns nil when no event is queued" do - expect(events.pop(timeout: 0)).to be_nil - end -end From b6d5363aeafe5bc7258fb2c3045e0b228f4b2469 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 01:30:39 +1200 Subject: [PATCH 10/25] Improve controller signal coverage. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- test/async/container/controller.rb | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index e508fab..cc916fa 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -230,6 +230,14 @@ def controller.setup(container) expect(signals.handlers).to be == controller.instance_variable_get(:@signals) end + it "can ignore trapped signals" do + controller.trap(:USR2) + + handlers = controller.instance_variable_get(:@signals).to_h + + expect(handlers.fetch("USR2")).to be_nil + end + it "queues trapped signal events" do controller = Async::Container::Controller.new(notify: nil) applied = false @@ -251,6 +259,65 @@ def controller.setup(container) expect(applied).to be == true end + it "handles setup errors when restarting from a signal" do + def controller.restart + raise Async::Container::SetupError, nil + end + + Async::Signals.install(controller.instance_variable_get(:@signals)) do + Process.kill(:HUP, Process.pid) + + event = controller.instance_variable_get(:@events).pop(timeout: 1) + + expect{event.call}.not.to raise_exception + end + end + + it "handles setup errors when reloading from a signal" do + def controller.reload + raise Async::Container::SetupError, nil + end + + Async::Signals.install(controller.instance_variable_get(:@signals)) do + Process.kill(:USR1, Process.pid) + + event = controller.instance_variable_get(:@events).pop(timeout: 1) + + expect{event.call}.not.to raise_exception + end + end + + it "notifies when reload fails" do + notify = Object.new + + def notify.reloading! + end + + def notify.error!(message) + @message = message + end + + def notify.message + @message + end + + container = Object.new + + def container.wait_until_ready + end + + def container.failed? + true + end + + controller = Async::Container::Controller.new(notify: notify) + + controller.instance_variable_set(:@container, container) + + expect{controller.reload}.to raise_exception(Async::Container::SetupError) + expect(notify.message).to be == "Container failed to reload!" + end + it "restarts children when receiving SIGHUP" do expect(input.read(1)).to be == "." From 6ad1df35443efe026437ed05b3faf0bb89941d87 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 01:47:09 +1200 Subject: [PATCH 11/25] Collect inherited objects after fork. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/forked.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index 21fe621..9fe9ae7 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -111,6 +111,9 @@ def self.fork(**options) # Reset `SignalException` delivery because CRuby inherits the `Thread.handle_interrupt` mask stack across `Thread.new`. Async deliberately masks signal exceptions, and the signal traps above should be delivered promptly: ::Thread.handle_interrupt(SignalException => :immediate) do + # Collect inherited, unreachable parent objects before user code can create a new scheduler or other descriptors which might reuse inherited descriptor numbers. + GC.start + yield Instance.for(process) rescue Interrupt # Graceful exit. From 5a3f2a30f842aa8af2a555f6363f9aaf668d237f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 03:33:12 +1200 Subject: [PATCH 12/25] Revert "Collect inherited objects after fork." This reverts commit 6ad1df35443efe026437ed05b3faf0bb89941d87. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/forked.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index 9fe9ae7..21fe621 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -111,9 +111,6 @@ def self.fork(**options) # Reset `SignalException` delivery because CRuby inherits the `Thread.handle_interrupt` mask stack across `Thread.new`. Async deliberately masks signal exceptions, and the signal traps above should be delivered promptly: ::Thread.handle_interrupt(SignalException => :immediate) do - # Collect inherited, unreachable parent objects before user code can create a new scheduler or other descriptors which might reuse inherited descriptor numbers. - GC.start - yield Instance.for(process) rescue Interrupt # Graceful exit. From d7a6307b462d50c12bc1290d3f2b73edfc412426 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 15:51:37 +1200 Subject: [PATCH 13/25] Use async-signals v0.5 defaults. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- async-container.gemspec | 2 +- lib/async/container.rb | 4 -- lib/async/container/controller.rb | 4 +- releases.md | 1 + test/async/container/controller.rb | 61 +++++++++++++++++++++++++++++- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/async-container.gemspec b/async-container.gemspec index 808f67f..2b23176 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -25,5 +25,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "async", "~> 2.41" - spec.add_dependency "async-signals", "~> 0.3" + spec.add_dependency "async-signals", "~> 0.5" end diff --git a/lib/async/container.rb b/lib/async/container.rb index cbb41ab..bfc07fa 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -4,7 +4,3 @@ # Copyright, 2017-2025, by Samuel Williams. require_relative "container/controller" - -# This is a workaround for Ruby's inconsistent handling of SIGINT. -# See for more details. -::Signal.trap(:INT){::Thread.current.raise(Interrupt)} diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 5babcdf..9224451 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -69,11 +69,11 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: # 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). @guard = ::Thread::Mutex.new - @signals.trap(SIGINT) do |signal, context| + @signals.trap(SIGINT) do |_signal, context| context.raise(Interrupt) end - @signals.trap(SIGTERM) do |signal, context| + @signals.trap(SIGTERM) do |_signal, context| context.raise(Interrupt) end diff --git a/releases.md b/releases.md index b14db43..0dc51fb 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Use `async-signals` to coordinate controller signal traps while queueing signal events through the controller event loop. + - Require `async-signals` v0.5 so controller signal handling is implicit only when running on the main thread without an existing fiber scheduler. ## v0.37.0 diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index cc916fa..dde4f6d 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -225,11 +225,34 @@ def self.handlers def controller.setup(container) end - controller.run(signals: signals) + Sync do + controller.run(signals: signals) + end expect(signals.handlers).to be == controller.instance_variable_get(:@signals) end + it "uses the default ignored signal backend inside an async scheduler" do + original = Async::Signals::Ignore.method(:install) + installed = nil + + Async::Signals::Ignore.define_singleton_method(:install) do |handlers, &block| + installed = handlers + block.call + end + + def controller.setup(container) + end + + Sync do + controller.run + end + + expect(installed).to be == controller.instance_variable_get(:@signals) + ensure + Async::Signals::Ignore.define_singleton_method(:install, original) if original + end + it "can ignore trapped signals" do controller.trap(:USR2) @@ -238,6 +261,42 @@ def controller.setup(container) expect(handlers.fetch("USR2")).to be_nil end + it "raises interrupt for SIGINT" do + context = Object.new + + def context.raise(exception) + @exception = exception + end + + def context.exception + @exception + end + + handlers = controller.instance_variable_get(:@signals).to_h + + handlers.fetch("INT").call(Async::Container::Controller::SIGINT, context) + + expect(context.exception).to be == Interrupt + end + + it "raises interrupt for SIGTERM" do + context = Object.new + + def context.raise(exception) + @exception = exception + end + + def context.exception + @exception + end + + handlers = controller.instance_variable_get(:@signals).to_h + + handlers.fetch("TERM").call(Async::Container::Controller::SIGTERM, context) + + expect(context.exception).to be == Interrupt + end + it "queues trapped signal events" do controller = Async::Container::Controller.new(notify: nil) applied = false From 46fcc004e4d85ec863997423cbe37c39878bdb88 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 18:01:42 +1200 Subject: [PATCH 14/25] Use graceful signal defaults. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- async-container.gemspec | 2 +- lib/async/container.rb | 3 +++ releases.md | 2 +- test/async/container.rb | 55 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/async-container.gemspec b/async-container.gemspec index 2b23176..b83765b 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -25,5 +25,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "async", "~> 2.41" - spec.add_dependency "async-signals", "~> 0.5" + spec.add_dependency "async-signals", "~> 0.6" end diff --git a/lib/async/container.rb b/lib/async/container.rb index bfc07fa..425ccab 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -3,4 +3,7 @@ # Released under the MIT License. # Copyright, 2017-2025, by Samuel Williams. +# This sets up graceful handling of SIGINT and SIGTERM. +require "async/signals/graceful" + require_relative "container/controller" diff --git a/releases.md b/releases.md index 0dc51fb..383d243 100644 --- a/releases.md +++ b/releases.md @@ -3,7 +3,7 @@ ## Unreleased - Use `async-signals` to coordinate controller signal traps while queueing signal events through the controller event loop. - - Require `async-signals` v0.5 so controller signal handling is implicit only when running on the main thread without an existing fiber scheduler. + - Require `async-signals` v0.6 so controller signal handling is implicit only when running on the main thread without an existing fiber scheduler, and loading `async-container` installs graceful default `SIGINT`/`SIGTERM` handling. ## v0.37.0 diff --git a/test/async/container.rb b/test/async/container.rb index a080f58..80383c2 100644 --- a/test/async/container.rb +++ b/test/async/container.rb @@ -4,8 +4,23 @@ # Copyright, 2017-2025, by Samuel Williams. require "async/container" +require "open3" +require "rbconfig" describe Async::Container do + def ruby(script) + ::Open3.capture3(::RbConfig.ruby, "-Ilib", "-e", script) + end + + def expect_success(script) + stdout, stderr, status = ruby(script) + + expect(status).to be(:success?) + expect(stderr).to be == "" + + return stdout + end + with ".processor_count" do it "can get processor count" do expect(Async::Container.processor_count).to be >= 1 @@ -35,6 +50,46 @@ end end + with "graceful signal defaults" do + it "installs graceful SIGINT handling" do + stdout = expect_success(<<~RUBY) + require "async/container" + + begin + ::Thread.handle_interrupt(::Interrupt => :never) do + ::Process.kill(:INT, ::Process.pid) + puts "inner" + end + + sleep 1 + rescue ::Interrupt + puts "outer" + end + RUBY + + expect(stdout).to be == "inner\nouter\n" + end + + it "installs graceful SIGTERM handling" do + stdout = expect_success(<<~RUBY) + require "async/container" + + begin + ::Thread.handle_interrupt(::Interrupt => :never) do + ::Process.kill(:TERM, ::Process.pid) + puts "inner" + end + + sleep 1 + rescue ::Interrupt + puts "outer" + end + RUBY + + expect(stdout).to be == "inner\nouter\n" + end + end + with ".best" do it "can get the best container class" do expect(Async::Container.best_container_class).not.to be_nil From fc315cba0e352ed6a32a72fdbce8612a5be5744d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 18:46:58 +1200 Subject: [PATCH 15/25] Remove obsolete restart exception. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/error.rb | 10 ---------- lib/async/container/forked.rb | 9 ++------- lib/async/container/threaded.rb | 5 ----- releases.md | 1 + test/async/container/policy.rb | 7 +++++++ 5 files changed, 10 insertions(+), 22 deletions(-) diff --git a/lib/async/container/error.rb b/lib/async/container/error.rb index 299058d..71d7213 100644 --- a/lib/async/container/error.rb +++ b/lib/async/container/error.rb @@ -31,16 +31,6 @@ def initialize end end - # Similar to {Interrupt}, but represents `SIGHUP`. - class Restart < SignalException - SIGHUP = Signal.list["HUP"] - - # Create a new restart error. - def initialize - super(SIGHUP) - end - end - # Represents the error which occured when a container failed to start up correctly. class SetupError < Error # Create a new setup error. diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index 21fe621..c83c42f 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -104,13 +104,8 @@ def self.fork(**options) # Fork from `Thread.new` so the child does not inherit the parent fiber scheduler or the current caller's fiber stack. Only this short-lived thread is copied into the child process: ::Thread.new do ::Process.fork do - # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. - Signal.trap(:INT){::Thread.current.raise(Interrupt)} - Signal.trap(:TERM){::Thread.current.raise(Interrupt)} # Same as SIGINT. - Signal.trap(:HUP){::Thread.current.raise(Restart)} - - # Reset `SignalException` delivery because CRuby inherits the `Thread.handle_interrupt` mask stack across `Thread.new`. Async deliberately masks signal exceptions, and the signal traps above should be delivered promptly: - ::Thread.handle_interrupt(SignalException => :immediate) do + # Reset interrupt masking - `Exception` is a fast path: + ::Thread.handle_interrupt(Exception => :immediate) do yield Instance.for(process) rescue Interrupt # Graceful exit. diff --git a/lib/async/container/threaded.rb b/lib/async/container/threaded.rb index 5224519..23c2531 100644 --- a/lib/async/container/threaded.rb +++ b/lib/async/container/threaded.rb @@ -211,11 +211,6 @@ def kill! @thread.kill end - # Raise {Restart} in the child thread. - def restart! - @thread.raise(Restart) - end - # Wait for the thread to exit and return he exit status. # @asynchronous This method may block. # diff --git a/releases.md b/releases.md index 383d243..f9f7d1d 100644 --- a/releases.md +++ b/releases.md @@ -4,6 +4,7 @@ - Use `async-signals` to coordinate controller signal traps while queueing signal events through the controller event loop. - Require `async-signals` v0.6 so controller signal handling is implicit only when running on the main thread without an existing fiber scheduler, and loading `async-container` installs graceful default `SIGINT`/`SIGTERM` handling. + - Remove the obsolete `Async::Container::Restart` signal exception. ## v0.37.0 diff --git a/test/async/container/policy.rb b/test/async/container/policy.rb index fd63799..8bddeee 100644 --- a/test/async/container/policy.rb +++ b/test/async/container/policy.rb @@ -254,14 +254,21 @@ def child_exit(container, child, status, name:, key:, **options) end.new container = Async::Container.best_container_class.new(policy: stop_policy) + trigger = IO.pipe 3.times do |i| container.spawn(name: "worker-#{i}") do |instance| instance.ready! + + trigger.first.gets exit(1) end end + container.wait_until_ready + + trigger.last.puts("exit") + container.wait expect(stop_policy.stop_count).to be == 1 From 2233ea3e5fde2fa17fdf6adda344837d1cf8e3ad Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 19:19:44 +1200 Subject: [PATCH 16/25] Restore fork child interrupt traps. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/forked.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index c83c42f..0dc0333 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -104,6 +104,10 @@ def self.fork(**options) # Fork from `Thread.new` so the child does not inherit the parent fiber scheduler or the current caller's fiber stack. Only this short-lived thread is copied into the child process: ::Thread.new do ::Process.fork do + # Convert process signals into pending interrupts on the surviving fork thread so they respect `Thread.handle_interrupt` in the child: + ::Signal.trap(:INT){::Thread.current.raise(::Interrupt)} + ::Signal.trap(:TERM){::Thread.current.raise(::Interrupt)} + # Reset interrupt masking - `Exception` is a fast path: ::Thread.handle_interrupt(Exception => :immediate) do yield Instance.for(process) From 9e2150e89ada92c83add6d1e84bc9302ebf6b6be Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 19:29:42 +1200 Subject: [PATCH 17/25] Run CI tests verbosely. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e91d97c..0f6263c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,4 +44,4 @@ jobs: - name: Run tests timeout-minutes: 10 - run: bundle exec bake test + run: bundle exec sus --verbose From 94e25a36a70f15d93aa0d17c442cb004791337c3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 19:31:08 +1200 Subject: [PATCH 18/25] Make policy stop test deterministic. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- test/async/container/policy.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/async/container/policy.rb b/test/async/container/policy.rb index 8bddeee..5cdc229 100644 --- a/test/async/container/policy.rb +++ b/test/async/container/policy.rb @@ -260,8 +260,12 @@ def child_exit(container, child, status, name:, key:, **options) container.spawn(name: "worker-#{i}") do |instance| instance.ready! - trigger.first.gets - exit(1) + if i.zero? + trigger.first.gets + exit(1) + else + sleep + end end end From 9986da84364de355727f344a81642169ce337230 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 20:31:01 +1200 Subject: [PATCH 19/25] Use graceful defaults for controller interrupts. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 11 +-------- test/async/container/controller.rb | 36 ------------------------------ 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 9224451..634cb29 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -12,6 +12,7 @@ require "async" require "async/signals" +require "async/signals/graceful" module Async module Container @@ -50,8 +51,6 @@ def call end SIGHUP = Signal.list["HUP"] - SIGINT = Signal.list["INT"] - SIGTERM = Signal.list["TERM"] SIGUSR1 = Signal.list["USR1"] SIGUSR2 = Signal.list["USR2"] @@ -69,14 +68,6 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: # 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). @guard = ::Thread::Mutex.new - @signals.trap(SIGINT) do |_signal, context| - context.raise(Interrupt) - end - - @signals.trap(SIGTERM) do |_signal, context| - context.raise(Interrupt) - end - self.trap(SIGHUP) do self.restart rescue SetupError => error diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index dde4f6d..99f37a1 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -261,42 +261,6 @@ def controller.setup(container) expect(handlers.fetch("USR2")).to be_nil end - it "raises interrupt for SIGINT" do - context = Object.new - - def context.raise(exception) - @exception = exception - end - - def context.exception - @exception - end - - handlers = controller.instance_variable_get(:@signals).to_h - - handlers.fetch("INT").call(Async::Container::Controller::SIGINT, context) - - expect(context.exception).to be == Interrupt - end - - it "raises interrupt for SIGTERM" do - context = Object.new - - def context.raise(exception) - @exception = exception - end - - def context.exception - @exception - end - - handlers = controller.instance_variable_get(:@signals).to_h - - handlers.fetch("TERM").call(Async::Container::Controller::SIGTERM, context) - - expect(context.exception).to be == Interrupt - end - it "queues trapped signal events" do controller = Async::Container::Controller.new(notify: nil) applied = false From 7c0b5f207fcdacf12c77e99ff8f48eff61043281 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 29 Jun 2026 20:46:06 +1200 Subject: [PATCH 20/25] Stop controller on interrupt. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 634cb29..d16e3d8 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -309,7 +309,7 @@ def run(signals: Async::Signals.default) end end rescue Interrupt - # Ignore - normal shutdown - can propagate from top level Sync. + self.stop(false) end end end From b8b72d53f3fbf9005ef6d1f2f04a8fe5d9384768 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 30 Jun 2026 20:08:11 +1200 Subject: [PATCH 21/25] Depend on io-event v1.18 Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- async-container.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/async-container.gemspec b/async-container.gemspec index b83765b..3741248 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.add_dependency "async", "~> 2.41" spec.add_dependency "async-signals", "~> 0.6" + spec.add_dependency "io-event", "~> 1.18" end From b184f2eeac54e3702d095662da4747a30a0fee4b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 02:25:31 +1200 Subject: [PATCH 22/25] Keep controller start idempotent Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 18 +++++++++++++----- test/async/container/controller.rb | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index d16e3d8..9058713 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -181,16 +181,21 @@ def stop(graceful = @graceful_stop) # This is equivalent to a blue-green deployment. def start(restart: false) old_container = nil + new_container = nil @guard.synchronize do - if @container && restart - @notify&.restarting! - - Console.info(self, "Restarting container...") + if @container + if restart + @notify&.restarting! + + Console.info(self, "Restarting container...") + else + return @container + end else Console.info(self, "Starting container...") end - + container = self.create_container begin @@ -217,6 +222,7 @@ def start(restart: false) # The following swap should be atomic: old_container = @container @container = container + new_container = container container = nil @notify&.ready!(size: @container.size, status: "Running with #{@container.size} children.") @@ -234,6 +240,8 @@ def start(restart: false) Console.info(self, "Stopping old container...") old_container.stop(@graceful_stop) end + + return new_container end # Reload the existing container. Children instances will be reloaded using `SIGHUP`. diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index 99f37a1..ea57ab8 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -155,6 +155,27 @@ def controller.setup(container) controller.stop end + it "does not restart an already running container" do + count = 0 + + controller.define_singleton_method(:setup) do |container| + count += 1 + + container.spawn do |instance| + instance.ready! + sleep + end + end + + first = controller.start + second = controller.start + + expect(second).to be == first + expect(count).to be == 1 + + controller.stop(false) + end + it "propagates exceptions" do def controller.setup(container) raise "Boom!" From 3a93a8f9238eee1d0c5cd3c670192a4b297310ab Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 02:26:45 +1200 Subject: [PATCH 23/25] Reopen controller event queue for each run Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 48 ++++++++++++++++++++++++++---- test/async/container/controller.rb | 29 ++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 9058713..9caae92 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -64,6 +64,7 @@ def initialize(notify: Notify.open!, container_class: Container, graceful_stop: @container = nil @events = ::Thread::Queue.new @signals = Async::Signals::Handlers.new + @running = false # 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). @guard = ::Thread::Mutex.new @@ -117,7 +118,7 @@ def trap(signal, &block) event = SignalEvent.new(signal, block).freeze @signals.trap(signal) do - @events << event + enqueue_event(event) end else @signals.ignore(signal) @@ -272,12 +273,44 @@ def reload end end - private def wait_for_container + private def enqueue_event(event) + @events << event + rescue ::ClosedQueueError + # The controller run loop has already stopped. + end + + private def open_event_queue + @guard.synchronize do + if @running + raise RuntimeError, "Controller is already running." + end + + @running = true + @events = ::Thread::Queue.new + end + end + + private def close_event_queue(events) + events.close + end + + private def finish_event_queue(events) + events.close + + @guard.synchronize do + if @events.equal?(events) + @running = false + @events = ::Thread::Queue.new + end + end + end + + private def wait_for_container(events) while true container = @guard.synchronize{@container} if container.nil? - @events.close + close_event_queue(events) return end @@ -287,7 +320,7 @@ def reload # 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. if @container.equal?(container) @container = nil - @events.close + close_event_queue(events) return end end @@ -298,14 +331,15 @@ def reload # @parameter signals [#install] The signal backend to use while running the controller. def run(signals: Async::Signals.default) @notify&.status!("Initializing controller...") + events = open_event_queue signals.install(@signals) do Sync do |task| self.start - waiter = task.async{wait_for_container} + task.async{wait_for_container(events)} - while event = @events.pop + while event = events.pop event.call end rescue Async::Cancel @@ -318,6 +352,8 @@ def run(signals: Async::Signals.default) end rescue Interrupt self.stop(false) + ensure + finish_event_queue(events) if events end end end diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index ea57ab8..2576f68 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -187,6 +187,35 @@ def controller.setup(container) end end + with "#run" do + it "can run the same controller more than once" do + input, output = IO.pipe + + controller.instance_variable_set(:@output, output) + + def controller.setup(container) + container.spawn do |instance| + instance.ready! + + sleep(0.01) + + @output.puts("done") + @output.flush + end + end + + 2.times do + controller.run(signals: Async::Signals::Ignore) + + expect(IO.select([input], nil, nil, 1)).not.to be_nil + expect(input.gets).to be == "done\n" + end + ensure + input&.close + output&.close + end + end + with "graceful controller" do include_context Async::Container::AController, "graceful" From 48a9632932553cd0e578f7ac75e67a36411c9b06 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 11:54:03 +1200 Subject: [PATCH 24/25] Apply RuboCop formatting Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 9caae92..e1f83b6 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -196,7 +196,7 @@ def start(restart: false) else Console.info(self, "Starting container...") end - + container = self.create_container begin From e2ff38a0270681965aa7cfb8900b4aa93ac2e41b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 11:58:53 +1200 Subject: [PATCH 25/25] Use explicit controller stop event Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/controller.rb | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index e1f83b6..79ef3a0 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -50,6 +50,15 @@ def call end end + # Represents the end of the active container lifecycle. + class StopEvent + # Process the stop event. + def call + end + end + + STOP_EVENT = StopEvent.new.freeze + SIGHUP = Signal.list["HUP"] SIGUSR1 = Signal.list["USR1"] SIGUSR2 = Signal.list["USR2"] @@ -275,8 +284,6 @@ def reload private def enqueue_event(event) @events << event - rescue ::ClosedQueueError - # The controller run loop has already stopped. end private def open_event_queue @@ -290,13 +297,11 @@ def reload end end - private def close_event_queue(events) - events.close + private def stop_event_queue(events) + events << STOP_EVENT end private def finish_event_queue(events) - events.close - @guard.synchronize do if @events.equal?(events) @running = false @@ -310,7 +315,7 @@ def reload container = @guard.synchronize{@container} if container.nil? - close_event_queue(events) + stop_event_queue(events) return end @@ -320,7 +325,7 @@ def reload # 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. if @container.equal?(container) @container = nil - close_event_queue(events) + stop_event_queue(events) return end end @@ -341,6 +346,8 @@ def run(signals: Async::Signals.default) while event = events.pop event.call + + break if event.equal?(STOP_EVENT) end rescue Async::Cancel # Graceful shutdown: