Skip to content
Open
1 change: 1 addition & 0 deletions async-container.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 1 addition & 7 deletions lib/async/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
36 changes: 9 additions & 27 deletions lib/async/container/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -117,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.
Expand Down Expand Up @@ -186,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...")
Expand Down
3 changes: 2 additions & 1 deletion lib/async/container/forked.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,15 @@ 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.
Signal.trap(:INT){::Thread.current.raise(Interrupt)}
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
Expand Down
39 changes: 14 additions & 25 deletions lib/async/container/generic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
require "async/clock"

require_relative "group"
require_relative "keyed"
require_relative "statistics"
require_relative "policy"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 = {}
Expand Down
2 changes: 1 addition & 1 deletion lib/async/container/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 0 additions & 53 deletions lib/async/container/keyed.rb

This file was deleted.

2 changes: 1 addition & 1 deletion lib/async/container/threaded.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/async/container/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
55 changes: 55 additions & 0 deletions test/async/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions test/async/container/channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down
Loading