From d190ee0c12d513aeccd8eba21888b4db45632197 Mon Sep 17 00:00:00 2001 From: Sergey Nikolaev Date: Thu, 21 May 2026 10:23:35 +0700 Subject: [PATCH] fix(client): restore async pool capacity after request failures Replace broken async HTTP clients in the ConnectionPool before throw/retry so repeated failures do not drain the pool and deadlock on the next get(). Related issue: https://github.com/manticoresoftware/manticoresearch/issues/4373 --- src/ManticoreSearch/Client.php | 30 ++++---- .../Network/ManticoreSearch/ClientTest.php | 76 +++++++++++++++++++ 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/ManticoreSearch/Client.php b/src/ManticoreSearch/Client.php index 957e6c2..d421e03 100644 --- a/src/ManticoreSearch/Client.php +++ b/src/ManticoreSearch/Client.php @@ -82,13 +82,7 @@ public function __construct(?string $url = null, ?string $authToken = null) { } $this->setServerUrl($url); $this->setAuthToken($authToken); - $this->connectionPool = new ConnectionPool( - function () { - $client = new HttpClient($this->host, $this->port); - $client->set(['timeout' => -1]); - return $client; - } - ); + $this->connectionPool = new ConnectionPool(fn() => $this->makeHttpClient()); $this->buddyVersion = Buddy::getVersion(); $this->clientMap = new Map; } @@ -98,16 +92,19 @@ function () { * @return void */ public function __clone() { - $this->connectionPool = new ConnectionPool( - function () { - $client = new HttpClient($this->host, $this->port); - $client->set(['timeout' => -1]); - return $client; - } - ); + $this->connectionPool = new ConnectionPool(fn() => $this->makeHttpClient()); $this->clientMap = new Map; } + /** + * @return HttpClient + */ + protected function makeHttpClient(): HttpClient { + $client = new HttpClient($this->host, $this->port); + $client->set(['timeout' => -1]); + return $client; + } + /** * Set server URL of Manticore searchd to send requests to * @param string $url it supports http:// prefixed and not @@ -368,14 +365,15 @@ protected function runAsyncRequest(string $path, string $request, array $headers $client->setData($request); $client->execute("/$path"); if ($client->errCode) { + $error = "Error while async request: {$client->errCode}: {$client->errMsg}"; + $client->close(); + $this->connectionPool->put($this->makeHttpClient()); /** @phpstan-ignore-next-line */ if ($client->errCode !== 104 || $try >= 3) { - $error = "Error while async request: {$client->errCode}: {$client->errMsg}"; throw new ManticoreSearchClientError($error); } Buddy::debug('Client: connection reset by peer, repeat: ' . (++$try)); - $client->close(); goto request; } $result = $client->body; diff --git a/test/BuddyCore/Network/ManticoreSearch/ClientTest.php b/test/BuddyCore/Network/ManticoreSearch/ClientTest.php index 31eb6ff..e250565 100755 --- a/test/BuddyCore/Network/ManticoreSearch/ClientTest.php +++ b/test/BuddyCore/Network/ManticoreSearch/ClientTest.php @@ -73,4 +73,80 @@ public function testResponseUrlSetOk(): void { // $this->client->setServerUrl($url); // } + + public function testAsyncFailuresDoNotDrainConnectionPool(): void { + $executor = trim((string)shell_exec('command -v manticore-executor')); + if ($executor === '') { + $this->markTestSkipped('manticore-executor is required for the coroutine deadlock regression test'); + } + + $repoRoot = dirname(__DIR__, 4); + $autoloadCandidates = [ + $repoRoot . '/vendor/autoload.php', + dirname($repoRoot, 2) . '/autoload.php', + ]; + $autoload = ''; + foreach ($autoloadCandidates as $candidate) { + if (is_file($candidate)) { + $autoload = $candidate; + break; + } + } + $versionFile = $repoRoot . '/test/src/MOCK_APP_VERSION'; + if ($autoload === '' || !is_file($versionFile)) { + $this->markTestSkipped('Required test bootstrap files are missing'); + } + + $scriptFile = tempnam(sys_get_temp_dir(), 'buddy-core-deadlock-'); + if ($scriptFile === false) { + throw new \RuntimeException('Failed to create temporary script file'); + } + + $script = <<<'PHP' +sendRequest('SHOW STATUS'); + echo "ok {$i}\n"; + } catch (Throwable $e) { + echo "err {$i}: " . $e->getMessage() . "\n"; + } + } + echo "completed\n"; +}); +PHP; + $script = str_replace( + ['__AUTOLOAD__', '__VERSION_FILE__'], + [addslashes($autoload), addslashes($versionFile)], + $script + ); + file_put_contents($scriptFile, $script); + + try { + $output = []; + $returnVar = 0; + exec($executor . ' ' . escapeshellarg($scriptFile) . ' 2>&1', $output, $returnVar); + $stdout = implode(PHP_EOL, $output); + $this->assertStringContainsString('completed', $stdout); + $this->assertStringNotContainsString('[FATAL ERROR]', $stdout); + $this->assertStringNotContainsString('all coroutines (count: 1) are asleep - deadlock!', $stdout); + $this->assertStringNotContainsString('Channel::~Channel()', $stdout); + } finally { + @unlink($scriptFile); + } + } + }