From 1268aee8d8f71a065464ca55d322b6025aed772e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 15:41:41 +1200 Subject: [PATCH 1/9] Document child signal handling. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- lib/async/container/forked.rb | 3 ++- lib/async/container/threaded.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index 613a66c..21fe621 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -101,6 +101,7 @@ def exec(*arguments, ready: true, **options) def self.fork(**options) # $stderr.puts fork: caller self.new(**options) do |process| + # 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. @@ -108,7 +109,7 @@ def self.fork(**options) Signal.trap(:TERM){::Thread.current.raise(Interrupt)} # Same as SIGINT. Signal.trap(:HUP){::Thread.current.raise(Restart)} - # This could be a configuration option: + # 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 yield Instance.for(process) rescue Interrupt diff --git a/lib/async/container/threaded.rb b/lib/async/container/threaded.rb index b1be095..5224519 100644 --- a/lib/async/container/threaded.rb +++ b/lib/async/container/threaded.rb @@ -114,7 +114,7 @@ def exec(*arguments, ready: true, **options) def self.fork(**options) self.new(**options) do |thread| ::Thread.new do - # This could be a configuration option (see forked implementation too): + # Reset `SignalException` delivery because CRuby inherits the `Thread.handle_interrupt` mask stack across `Thread.new`. Async deliberately masks signal exceptions, and signal-driven thread control should be delivered promptly: ::Thread.handle_interrupt(SignalException => :immediate) do yield Instance.for(thread) end From 9913c30c5064dc412dce21708b1e42ce44a2069e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 27 Jun 2026 15:41:46 +1200 Subject: [PATCH 2/9] Cover idempotent channel close behavior. Assisted-By: devx/3236e566-7538-432e-a30a-2bdf37265ed4 --- test/async/container/channel.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/async/container/channel.rb b/test/async/container/channel.rb index 1bd7076..89df2a7 100644 --- a/test/async/container/channel.rb +++ b/test/async/container/channel.rb @@ -30,6 +30,32 @@ expect(channel.receive).to be_nil end + with "#close" do + it "can close more than once" do + channel.close + + expect do + channel.close + end.not.to raise_exception + end + + it "can close the input end after it was already closed" do + channel.in.close + + expect do + channel.close_read + end.not.to raise_exception + end + + it "can close the output end after it was already closed" do + channel.out.close + + expect do + channel.close_write + end.not.to raise_exception + end + end + with "timeout" do let(:channel) {subject.new(timeout: 0.001)} From 818bfd3d957db10299957c408445e892a8a316b3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 13:05:28 +1200 Subject: [PATCH 3/9] Adopt `async-signals` graceful defaults. (#68) --- async-container.gemspec | 1 + lib/async/container/controller.rb | 3 ++ test/async/container.rb | 55 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/async-container.gemspec b/async-container.gemspec index da9d466..d51e7a8 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.6" end diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index 75b1a95..b9a67ed 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -10,6 +10,9 @@ require_relative "notify" require_relative "policy" +# This sets up graceful handling of SIGINT and SIGTERM. +require "async/signals/graceful" + module Async module Container # The default graceful stop policy for controllers. 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 9e82c588cd896cc15f177ad9e366d99943f4cdc4 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 13:27:53 +1200 Subject: [PATCH 4/9] Add test for `Controller#start` idempotency. (#69) --- lib/async/container/controller.rb | 7 ++++++- test/async/container/controller.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index b9a67ed..ca0349c 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -120,13 +120,18 @@ def setup(container) end # Start the container unless it's already running. + # @returns [Boolean] Whether a new container was started. def start unless @container Console.info(self, "Controller starting...") self.restart + + Console.info(self, "Controller started.") + + return true end - Console.info(self, "Controller started.") + return false end # Stop the container if it's running. diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index 273b016..f1611c4 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -139,6 +139,21 @@ def controller.setup(container) expect(controller.container).to be_nil end + it "doesn't restart a container if it's already running" do + expect(controller).to receive(:setup) + + expect(controller.start).to be == true + expect(controller).to be(:running?) + + container = controller.container + + # Starting again is idempotent: it returns false and the container is not replaced. + expect(controller.start).to be == false + expect(controller.container).to be_equal(container) + ensure + controller.stop + end + it "can spawn a reactor" do def controller.setup(container) container.async do |task| From fc7a89376a6aecc878a172aeeccf91f8a3a3e999 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 13:45:13 +1200 Subject: [PATCH 5/9] Make policy stop test deterministic. (#70) --- test/async/container/policy.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/async/container/policy.rb b/test/async/container/policy.rb index fd63799..1619efe 100644 --- a/test/async/container/policy.rb +++ b/test/async/container/policy.rb @@ -254,14 +254,26 @@ 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! - exit(1) + + # Only the first worker exits, and only once all workers are ready, so exactly one `child_exit` drives the stop. Otherwise multiple simultaneous exits can race the `stopping?` check and inflate `stop_count`. + if i.zero? + trigger.first.gets + exit(1) + else + sleep + end end end + container.wait_until_ready + + trigger.last.puts("exit") + container.wait expect(stop_policy.stop_count).to be == 1 From 4db57a08d64297ce2cb0d4a851159ffe187cbd43 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 13:46:29 +1200 Subject: [PATCH 6/9] Fix `@namespace` usage. --- lib/async/container.rb | 8 +------- lib/async/container/version.rb | 2 ++ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/async/container.rb b/lib/async/container.rb index bd0d47a..1d42c50 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -4,10 +4,4 @@ # Copyright, 2017-2025, by Samuel Williams. require_relative "container/controller" - -# @namespace -module Async - # @namespace - module Container - end -end +require_relative "container/version" \ No newline at end of file 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 03b1fa425d7ad19c179ba234e120d45cd7f2c96d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 1 Jul 2026 18:30:13 +1200 Subject: [PATCH 7/9] Add tests for controller restart behaviour. (#71) --- test/async/container/controller.rb | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb index f1611c4..e73e014 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -84,6 +84,49 @@ def controller.setup(container) end end + with "#restart" do + it "replaces the running container with a new one" do + def controller.setup(container) + container.spawn do |instance| + instance.ready! + sleep + end + end + + controller.start + first = controller.container + + expect(first).not.to be_nil + expect(controller).to be(:running?) + + # A restart is a blue-green swap: a new container is created and the old one is stopped. + controller.restart + + expect(controller.container).not.to be_equal(first) + expect(controller).to be(:running?) + ensure + controller.stop(false) + end + + it "starts a new container if not already running" do + def controller.setup(container) + container.spawn do |instance| + instance.ready! + sleep + end + end + + expect(controller).not.to be(:running?) + + controller.restart + + expect(controller).to be(:running?) + expect(controller.container).not.to be_nil + ensure + controller.stop(false) + end + end + with "notify" do before do @notify_server = Async::Container::Notify::Server.open From 2e9234b0a4a2d58b96d424cd6e196c5fd660c274 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 2 Jul 2026 18:25:58 +1200 Subject: [PATCH 8/9] Remove incomplete reload/keyed reconciliation. (#73) --- lib/async/container/controller.rb | 26 ---------- lib/async/container/generic.rb | 39 ++++++--------- lib/async/container/keyed.rb | 53 -------------------- releases.md | 4 ++ test/async/container/controller.rb | 80 ------------------------------ 5 files changed, 18 insertions(+), 184 deletions(-) delete mode 100644 lib/async/container/keyed.rb diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index ca0349c..4accfc8 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -194,32 +194,6 @@ def restart 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 - 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 - # Enter the controller run loop, trapping `SIGINT` and `SIGTERM`. def run @notify&.status!("Initializing controller...") diff --git a/lib/async/container/generic.rb b/lib/async/container/generic.rb index d7e1551..0364517 100644 --- a/lib/async/container/generic.rb +++ b/lib/async/container/generic.rb @@ -8,7 +8,6 @@ require "async/clock" require_relative "group" -require_relative "keyed" require_relative "statistics" require_relative "policy" @@ -79,7 +78,7 @@ def to_s # Look up a child process by key. # A key could be a symbol, a file path, or something else which the child instance represents. def [] key - @keyed[key]&.value + @keyed[key] end # Statistics relating to the behavior of children instances. @@ -210,13 +209,13 @@ def stop(timeout = true) # Spawn a child instance into the container. # @parameter name [String] The name of the child instance. # @parameter restart [Boolean] Whether to restart the child instance if it fails. - # @parameter key [Symbol] A key used for reloading child instances. + # @parameter key [Symbol] An optional key used to look up (via {[]}) and reuse the child instance. # @parameter health_check_timeout [Numeric | Nil] The maximum time a child instance can run without updating its state, before it is terminated as unhealthy. # @parameter startup_timeout [Numeric | Nil] The maximum time a child instance can run without becoming ready, before it is terminated as unhealthy. def spawn(name: nil, restart: false, key: nil, health_check_timeout: nil, startup_timeout: nil, &block) name ||= UNNAMED - if mark?(key) + if reuse?(key) Console.debug(self, "Reusing existing child.", child: {key: key, name: name}) return false end @@ -326,32 +325,22 @@ def async(**options, &block) end end - # Reload the container's keyed instances. + # Re-run the given block against the container. + # + # Existing keyed children are reused (see {spawn}), so re-running setup will not + # duplicate them. Reconciliation of children whose keys are no longer configured + # (i.e. stopping obsolete children) is not currently supported and will be revisited. def reload - @keyed.each_value(&:clear!) - yield - - dirty = false - - @keyed.delete_if do |key, value| - value.stop? && (dirty = true) - end - - return dirty end - # Mark the container's keyed instance which ensures that it won't be discarded. - def mark?(key) + # Whether a child instance already exists for the given key, in which case it can be reused rather than spawned again. + def reuse?(key) if key - if value = @keyed[key] - value.mark! - - return true - end + @keyed.key?(key) + else + false end - - return false end # Whether a child instance exists for the given key. @@ -366,7 +355,7 @@ def key?(key) # Register the child (value) as running. def insert(key, child) if key - @keyed[key] = Keyed.new(key, child) + @keyed[key] = child end state = {} diff --git a/lib/async/container/keyed.rb b/lib/async/container/keyed.rb deleted file mode 100644 index a0de5b4..0000000 --- a/lib/async/container/keyed.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2020-2025, by Samuel Williams. - -module Async - module Container - # Tracks a key/value pair such that unmarked keys can be identified and cleaned up. - # This helps implement persistent processes that start up child processes per directory or configuration file. If those directories and/or configuration files are removed, the child process can then be cleaned up automatically, because those key/value pairs will not be marked when reloading the container. - class Keyed - # Initialize the keyed instance - # - # @parameter key [Object] The key. - # @parameter value [Object] The value. - def initialize(key, value) - @key = key - @value = value - @marked = true - end - - # @attribute [Object] The key value, normally a symbol or a file-system path. - attr :key - - # @attribute [Object] The value, normally a child instance. - attr :value - - # @returns [Boolean] True if the instance has been marked, during reloading the container. - def marked? - @marked - end - - # Mark the instance. This will indiciate that the value is still in use/active. - def mark! - @marked = true - end - - # Clear the instance. This is normally done before reloading a container. - def clear! - @marked = false - end - - # Stop the instance if it was not marked. - # - # @returns [Boolean] True if the instance was stopped. - def stop? - unless @marked - @value.stop - return true - end - end - end - end -end diff --git a/releases.md b/releases.md index 73d4f2f..f0c5bd6 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - **Removed** `Controller#reload` and the container-level keyed reconciliation (`Keyed`, mark/sweep, and stopping of obsolete keyed children on reload). This functionality was incomplete and not correctly wired, and will be revisited with a simpler design. Keyed `spawn(key:)` registration and `container[key]` lookup are retained, and `Container#reload` now simply re-runs the given block (reusing existing keyed children). + ## 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/controller.rb b/test/async/container/controller.rb index e73e014..4d85c4c 100644 --- a/test/async/container/controller.rb +++ b/test/async/container/controller.rb @@ -44,46 +44,6 @@ def read_graceful_stop(value) end end - with "#reload" do - it "can reuse keyed child" do - input, output = IO.pipe - - controller.instance_variable_set(:@output, output) - - def controller.setup(container) - container.spawn(key: "test") do |instance| - instance.ready! - - @output.write(".") - @output.flush - - sleep(0.2) - end - - container.spawn do |instance| - instance.ready! - - sleep(0.1) - - @output.write(",") - @output.flush - end - end - - controller.start - - expect(controller.state_string).to be == "running" - - expect(input.read(2)).to be == ".," - - controller.reload - - expect(input.read(1)).to be == "," - - controller.wait - end - end - with "#restart" do it "replaces the running container with a new one" do def controller.setup(container) @@ -127,46 +87,6 @@ def controller.setup(container) end end - with "notify" do - before do - @notify_server = Async::Container::Notify::Server.open - @notify_client = Async::Container::Notify::Socket.new(@notify_server.path) - @notify = @notify_server.bind - end - - after do - @notify&.close - end - - let(:controller) {subject.new(notify: @notify_client)} - - it "sends status with ready notification on reload" do - def controller.setup(container) - container.spawn do |instance| - instance.ready! - sleep(0.1) - end - end - - controller.start - - # Drain the start ready message: - @notify.wait_until_ready - - controller.reload - - # Capture messages until we find the reload ready notification: - while message = @notify.receive - break if message[:ready] - end - - expect(message).to have_keys( - ready: be == true, - status: be =~ /Running/ - ) - end - end - with "#start" do it "can start up a container" do expect(controller).to receive(:setup) From de92338913510d3bde5ece737f13202899d4e3f8 Mon Sep 17 00:00:00 2001 From: Ayush Newatia Date: Thu, 16 Jul 2026 21:30:50 +0100 Subject: [PATCH 9/9] Allow the user to set the graceful stop timeout using an environment variable --- lib/async/container/group.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/container/group.rb b/lib/async/container/group.rb index def3182..ad43ac3 100644 --- a/lib/async/container/group.rb +++ b/lib/async/container/group.rb @@ -160,7 +160,7 @@ def stop(graceful = true) self.interrupt if graceful == true - graceful = DEFAULT_GRACEFUL_TIMEOUT + graceful = ENV.fetch("ASYNC_CONTAINER_GRACEFUL_STOP_TIMEOUT", DEFAULT_GRACEFUL_TIMEOUT).to_f end clock = Clock.start