From 9a33998b3187310975060f2a4eaf596867b2da36 Mon Sep 17 00:00:00 2001 From: "William T. Nelson" <35801+wtn@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:42:00 -0500 Subject: [PATCH] Avoid draining pool in gardener cleanup. Co-authored-by: Claude --- lib/async/pool/controller.rb | 19 ++++++++++++- test/async/pool/controller.rb | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/async/pool/controller.rb b/lib/async/pool/controller.rb index fc2bea2..7e4d673 100644 --- a/lib/async/pool/controller.rb +++ b/lib/async/pool/controller.rb @@ -260,7 +260,7 @@ def start_gardener end ensure @gardener = nil - self.close + self.terminate end end @@ -351,6 +351,23 @@ def available_resource private + # Force-retire all resources immediately, including busy ones, without waiting for them to be released. Unlike {drain}, this never blocks, so it is safe to call during cancellation when busy resources will never be released. + def terminate + # Re-read `@resources.first` each iteration: `retire` -> `resource.close` can yield, so the set of resources may change mid-teardown. + while (resource, _usage = @resources.first) + begin + retire(resource) + rescue => error + Console.warn(self, "Failed to retire resource during teardown!", resource: resource, exception: error) + + # Ensure progress so the loop cannot spin forever on a resource that failed to retire: + @resources.delete(resource) + end + end + + @available.clear + end + # Acquire an existing resource with zero usage. # If there are resources that are in use, wait until they are released. def acquire_existing_resource diff --git a/test/async/pool/controller.rb b/test/async/pool/controller.rb index 979bde0..f895254 100644 --- a/test/async/pool/controller.rb +++ b/test/async/pool/controller.rb @@ -301,6 +301,59 @@ expect(events).to be == [:acquire, :close, :release, :closed] end + + it "does not deadlock when Sync scope exits with unreleased resources" do + thread = Thread.new do + Sync do + policy = proc{|pool| pool.prune(0)} + pool = subject.new(Async::Pool::Resource, policy: policy) + + # Acquire a resource (starts the gardener) but don't release it + pool.acquire + + # Let gardener start and enter its wait loop + sleep 0.05 + end + end + + result = thread.join(5) + thread.kill if result.nil? + + expect(result).not.to be_nil + end + + it "force-retires all resources even if closing one raises" do + closed = [] + + failing_resource = Class.new(Async::Pool::Resource) do + define_method(:close) do + closed << self + raise "Resource failed to close!" + end + end + + thread = Thread.new do + Sync do + policy = proc{|pool| pool.prune(0)} + pool = subject.new(failing_resource, policy: policy) + + # Acquire two resources (concurrency 1 each) but don't release them: + pool.acquire + pool.acquire + + # Let the gardener start and enter its wait loop: + sleep 0.05 + end + end + + result = thread.join(5) + thread.kill if result.nil? + + # Teardown must force-retire every resource, even though closing the + # first one raises: + expect(result).not.to be_nil + expect(closed.size).to be == 2 + end end with "#to_s" do