From 9b2a53ccd757f7bc9c35928c55719b673b212a3a Mon Sep 17 00:00:00 2001 From: Claudear <262350598+claudear@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:53:19 +0000 Subject: [PATCH] fix(pools): release reserved slot when connection creation throws any Throwable pop() reserves a capacity slot (connectionsCreated++) before creating a connection, then decrements it on failure. The catch only handled \Exception, so a \Throwable that is not an \Exception (e.g. a \TypeError from the init callback) propagated without releasing the reserved slot. Each such failure permanently shrank the pool's usable capacity. Once the leaked count reached `size`, pop() would refuse to create new connections and every request failed with "Pool '...' is empty (size N, active 0, idle 0)" even though nothing was actually in use. Catch \Throwable so the reserved slot is always released, matching the \Throwable handling already used in destroyConnection(). Co-Authored-By: Claude Opus 4.8 --- src/Pools/Pool.php | 6 +++- tests/Pools/Scopes/PoolTestScope.php | 44 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Pools/Pool.php b/src/Pools/Pool.php index d56d7cc..04947f6 100644 --- a/src/Pools/Pool.php +++ b/src/Pools/Pool.php @@ -382,7 +382,11 @@ public function pop(): Connection $this->active[$connection->getID()] = $connection; }); return $connection; - } catch (\Exception $e) { + } catch (\Throwable $e) { + // Catch every Throwable, not just Exception: the slot was reserved + // (connectionsCreated++) before creating, so an Error (e.g. TypeError + // from the init callback) must release it too. Otherwise the reserved + // capacity leaks and the pool permanently reports itself as empty. $this->pool->synchronized(function (): void { $this->connectionsCreated--; }); diff --git a/tests/Pools/Scopes/PoolTestScope.php b/tests/Pools/Scopes/PoolTestScope.php index eb46b89..bd479f0 100644 --- a/tests/Pools/Scopes/PoolTestScope.php +++ b/tests/Pools/Scopes/PoolTestScope.php @@ -341,6 +341,50 @@ public function testPopRetriesAfterConnectionCreationFailure(): void }); } + public function testPopReleasesReservedSlotWhenCreationThrowsError(): void + { + $this->execute(function (): void { + // The init callback throws a Throwable that is NOT an Exception (e.g. a + // TypeError). pop() reserves a capacity slot (connectionsCreated++) before + // creating, so it must release that slot on ANY failure, not just Exception. + // Otherwise the reserved slots leak and the pool permanently reports itself + // as empty ("size N, active 0, idle 0") even though nothing is in use. + $pool = new Pool($this->getAdapter(), 'test-error-leak', 2, function () { + throw new \TypeError('Connection init failed'); + }); + $pool->setReconnectAttempts(1); + $pool->setReconnectSleep(0); + $pool->setRetryAttempts(1); + $pool->setRetrySleep(0); + + // Exhaust every creation attempt; each one must fully unwind. + for ($i = 0; $i < 5; $i++) { + try { + $pool->pop(); + $this->fail('Should have thrown'); + } catch (Exception | \TypeError) { + // expected: creation always fails + } + } + + // No connection was ever handed out, so all capacity must still be available. + $this->assertSame(2, $pool->count()); + $this->assertSame(0, \count($this->readActive($pool))); + }); + } + + /** + * @param Pool $pool + * @return array + */ + private function readActive(Pool $pool): array + { + $reflection = new \ReflectionProperty(Pool::class, 'active'); + /** @var array $active */ + $active = $reflection->getValue($pool); + return $active; + } + public function testPoolEmptyErrorIncludesActiveCount(): void { $this->execute(function (): void {