From ed81f9babb9a3b61acb4ef92b04ffef9a843542a Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:02:40 +0100 Subject: [PATCH] fix(pools): release the reserved slot when connection creation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pop() reserves capacity by incrementing connectionsCreated before the connection exists, then releases it in a catch. The catch was scoped to \Exception, so anything else escaping createConnection() kept the slot. The init callback is caller supplied, so an Error is entirely reachable: a TypeError from the callback, or a failure inside an adapter. Each leaked slot is permanent for the life of the process, and they accumulate one failed pop at a time. Once connectionsCreated reaches size the pool refuses to create anything and reports itself empty while nothing is actually in use — "Pool 'x' is empty (size 24, active 0, idle 0)". Only a restart recovers it, which is why an upstream outage that should degrade into slow acquisition instead ratchets a process into permanent failure that outlives the outage. Catch \Throwable, and move the release into a finally guarded by a $reserved flag so the reservation is balanced on every path out of the block, not only the ones enumerated in a catch. destroyConnection() already used \Throwable for the same reason a few lines below. The added test drains a size-2 pool with five pops against an init callback that throws a TypeError. Before this change count() drops to 0 with nothing handed out; after it stays at 2. Reported previously as utopia-php/pools#34, which was auto-closed because that repository is a read-only mirror. Co-Authored-By: Claude Opus 5 (1M context) --- packages/pools/src/Pools/Pool.php | 17 ++++++++--- .../tests/Pools/Scopes/PoolTestScope.php | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/pools/src/Pools/Pool.php b/packages/pools/src/Pools/Pool.php index d56d7cc23..f57c756ee 100644 --- a/packages/pools/src/Pools/Pool.php +++ b/packages/pools/src/Pools/Pool.php @@ -376,19 +376,28 @@ public function pop(): Connection }); if ($shouldCreateConnections) { + $reserved = true; + try { $connection = $this->createConnection(); $this->pool->synchronized(function () use ($connection): void { $this->active[$connection->getID()] = $connection; }); + $reserved = false; + return $connection; - } catch (\Exception $e) { - $this->pool->synchronized(function (): void { - $this->connectionsCreated--; - }); + } catch (\Throwable $e) { + // Throwable, not Exception: the init callback is caller + // supplied, so an Error holds the slot just the same. // Don't throw immediately - fall through to try getting // an existing connection from the pool $lastException = $e; + } finally { + if ($reserved) { + $this->pool->synchronized(function (): void { + $this->connectionsCreated--; + }); + } } } diff --git a/packages/pools/tests/Pools/Scopes/PoolTestScope.php b/packages/pools/tests/Pools/Scopes/PoolTestScope.php index eb46b8930..240ca2cd1 100644 --- a/packages/pools/tests/Pools/Scopes/PoolTestScope.php +++ b/packages/pools/tests/Pools/Scopes/PoolTestScope.php @@ -341,6 +341,35 @@ public function testPopRetriesAfterConnectionCreationFailure(): void }); } + public function testPopReleasesReservedSlotWhenCreationThrowsError(): void + { + $this->execute(function (): void { + // pop() reserves capacity before the connection exists. An Error + // escaping the catch used to keep that slot, draining the pool one + // failed pop at a time. + $pool = new Pool($this->getAdapter(), 'test-error-leak', 2, function (): string { + throw new \TypeError('Connection init failed'); + }); + $pool->setReconnectAttempts(1); + $pool->setReconnectSleep(0); + $pool->setRetryAttempts(1); + $pool->setRetrySleep(0); + + // More attempts than slots, so a kept reservation shows up as lost + // capacity by the end. + for ($i = 0; $i < 5; $i++) { + try { + $pool->pop(); + $this->fail('Should have thrown'); + } catch (Exception $e) { + $this->assertInstanceOf(\TypeError::class, $e->getPrevious()); + } + } + + $this->assertSame(2, $pool->count()); + }); + } + public function testPoolEmptyErrorIncludesActiveCount(): void { $this->execute(function (): void {