From be9b1fc50589283c8f4f6965c9e0bae8cde8e6b9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 10 Sep 2026 12:19:33 +1200 Subject: [PATCH 1/2] fix: keep the library usable on a PHP without ext-swoole ext-swoole is not in composer.json's require block, so a consumer may resolve this package onto a PHP that does not have it. Two call sites referenced Swoole classes unguarded, and a missing class there is a fatal Error rather than a catchable exception, so the failure surfaced as a crash at runtime instead of at resolution time. getEventContext() backs silent() and areEventsSilenced(). Database::create() is already inside a silent() call, so the first operation any swoole-less consumer performed crashed, and deleteDocuments() crashed for the same reason further in. Connection::hasError() reached Swoole's lost-connection detector on the error path of the reconnecting PDO wrapper, which turned any query exception into a fatal. Both now check extension_loaded('swoole') first, matching the guard already used for the coroutine sleep in withRetries(); without the extension the event context is the same -1 sentinel used outside a coroutine, and lost connections fall back to the local message list. Extensions cannot be unloaded from a running interpreter, so the regression test drives the create/silent/deleteDocuments path in a subprocess started with -n, which loads no shared extension. Swoole is shared both in the test image and in any pecl install, so the subprocess genuinely lacks it; the test skips loudly if a build has it compiled in statically. Reverting either guard turns it red. Co-Authored-By: Claude Opus 5 --- composer.json | 1 + src/Database/Connection.php | 2 +- src/Database/Database.php | 4 +++ tests/unit/Support/swoole-absent.php | 53 ++++++++++++++++++++++++++++ tests/unit/SwooleAbsentTest.php | 43 ++++++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Support/swoole-absent.php create mode 100644 tests/unit/SwooleAbsentTest.php diff --git a/composer.json b/composer.json index 147da1ecd8..1597bba016 100755 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ }, "suggest": { "ext-redis": "Needed to support Redis Cache Adapter", + "ext-swoole": "Needed to scope lifecycle hook silencing per coroutine and to detect lost connections", "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", "mongodb/mongodb": "Needed to support MongoDB Database Adapter" }, diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 024aecc266..3c9021869d 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -25,7 +25,7 @@ class Connection */ public static function hasError(Throwable $e): bool { - if (DetectsLostConnections::causedByLostConnection($e)) { + if (\extension_loaded('swoole') && DetectsLostConnections::causedByLostConnection($e)) { return true; } diff --git a/src/Database/Database.php b/src/Database/Database.php index b3c4aec3b8..99e8e1a1ac 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1460,6 +1460,10 @@ protected function areEventsSilenced(): bool private function getEventContext(): int { + if (! \extension_loaded('swoole')) { + return -1; + } + $context = Coroutine::getCid(); return \is_int($context) ? $context : -1; diff --git a/tests/unit/Support/swoole-absent.php b/tests/unit/Support/swoole-absent.php new file mode 100644 index 0000000000..6b435436a7 --- /dev/null +++ b/tests/unit/Support/swoole-absent.php @@ -0,0 +1,53 @@ +setDatabase('utopiaTests')->setNamespace('swoole_absent'); +$database->create(); +$database->getAuthorization()->addRole(Role::any()->toString()); + +echo 'create=ok' . PHP_EOL; + +echo 'silent=' . $database->silent(fn () => 'ok') . PHP_EOL; + +$database->createCollection(new Collection( + id: 'logs', + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, +)); + +foreach (['a', 'b', 'c'] as $id) { + $database->createDocument('logs', new Document(['$id' => $id])); +} + +$deleted = $database->deleteDocuments('logs', [Query::limit(10)]); + +echo 'deleted=' . $deleted . PHP_EOL; +echo 'remaining=' . \count($database->find('logs', [Query::limit(10)])) . PHP_EOL; +echo 'hasError=' . (Connection::hasError(new RuntimeException('boom')) ? '1' : '0') . PHP_EOL; diff --git a/tests/unit/SwooleAbsentTest.php b/tests/unit/SwooleAbsentTest.php new file mode 100644 index 0000000000..a020043b15 --- /dev/null +++ b/tests/unit/SwooleAbsentTest.php @@ -0,0 +1,43 @@ +&1'; + + \exec($command, $lines, $status); + + $output = \implode(PHP_EOL, $lines); + + if (\str_contains($output, 'swoole=1')) { + $this->markTestSkipped('swoole is statically compiled into ' . PHP_BINARY . ', so its absence cannot be exercised'); + } + + $this->assertSame(0, $status, "Fixture exited {$status} without swoole:" . PHP_EOL . $output); + + $this->assertStringContainsString('swoole=0', $output, $output); + $this->assertStringContainsString('create=ok', $output, $output); + $this->assertStringContainsString('silent=ok', $output, $output); + $this->assertStringContainsString('deleted=3', $output, $output); + $this->assertStringContainsString('remaining=0', $output, $output); + $this->assertStringContainsString('hasError=0', $output, $output); + } +} From a69c1060e75e26c2c6bb027d2a9a7e3d45b8bf54 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 10 Sep 2026 12:29:34 +1200 Subject: [PATCH 2/2] fix: detect lost connections without Swoole's PHP-land library Guarding the DetectsLostConnections call on extension_loaded('swoole') left two holes, both raised in review on the previous commit. The class comes from Swoole's PHP-land library rather than the extension core, and swoole.enable_library=Off switches that library off while the extension stays loaded. extension_loaded() therefore does not establish that the class can be called, and the guard still fataled on every query-error path under that setting. class_exists() is the condition that actually holds. The local fallback list also carried a single needle, 'Max connect timeout reached', which is the one signature Swoole does not supply. Consumers without the extension kept 1 of 25 lost-connection signatures, so 'server has gone away' and 'Lost connection' were rethrown instead of reconnecting, silently dropping the retry contract for exactly the consumers this change exists to serve. The remaining 24 needles Swoole matches are now held here. Swoole's check is kept ahead of them so upstream additions still apply wherever the library is present. The fixture asserted only that an unrelated exception was not classified as a lost connection, which held either way. It now drives five real signatures plus one unrelated message, and a second test runs it under swoole.enable_library=Off, which the -n subprocess cannot reach. Co-Authored-By: Claude Opus 5 --- src/Database/Connection.php | 26 +++++++++++- tests/unit/Support/swoole-absent.php | 14 ++++++- tests/unit/SwooleAbsentTest.php | 59 +++++++++++++++++++++------- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 3c9021869d..3d58f2c3fa 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -15,6 +15,30 @@ class Connection */ protected static array $errors = [ 'Max connect timeout reached', + 'server has gone away', + 'no connection to the server', + 'Lost connection', + 'is dead or not enabled', + 'Error while sending', + 'decryption failed or bad record mac', + 'server closed the connection unexpectedly', + 'SSL connection has been closed unexpectedly', + 'Error writing data to the connection', + 'Resource deadlock avoided', + 'Transaction() on null', + 'child connection forced to terminate due to client_idle_limit', + 'query_wait_timeout', + 'reset by peer', + 'Physical connection is not usable', + 'TCP Provider: Error code 0x68', + 'ORA-03114', + 'Packets out of order. Expected', + 'Adaptive Server connection failed', + 'Communication link failure', + 'connection is no longer usable', + 'Login timeout expired', + 'running with the --read-only option so it cannot execute this statement', + 'SQLSTATE[HY000] [2002] Connection refused', ]; /** @@ -25,7 +49,7 @@ class Connection */ public static function hasError(Throwable $e): bool { - if (\extension_loaded('swoole') && DetectsLostConnections::causedByLostConnection($e)) { + if (\class_exists(DetectsLostConnections::class) && DetectsLostConnections::causedByLostConnection($e)) { return true; } diff --git a/tests/unit/Support/swoole-absent.php b/tests/unit/Support/swoole-absent.php index 6b435436a7..05814cc140 100644 --- a/tests/unit/Support/swoole-absent.php +++ b/tests/unit/Support/swoole-absent.php @@ -50,4 +50,16 @@ echo 'deleted=' . $deleted . PHP_EOL; echo 'remaining=' . \count($database->find('logs', [Query::limit(10)])) . PHP_EOL; -echo 'hasError=' . (Connection::hasError(new RuntimeException('boom')) ? '1' : '0') . PHP_EOL; +$lost = 0; +foreach ([ + 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', + 'Lost connection to MySQL server during query', + 'SQLSTATE[08006] server closed the connection unexpectedly', + 'Max connect timeout reached', + 'Communication link failure', +] as $message) { + $lost += Connection::hasError(new RuntimeException($message)) ? 1 : 0; +} + +echo 'lostDetected=' . $lost . PHP_EOL; +echo 'unrelatedDetected=' . (Connection::hasError(new RuntimeException('syntax error near FROM')) ? '1' : '0') . PHP_EOL; diff --git a/tests/unit/SwooleAbsentTest.php b/tests/unit/SwooleAbsentTest.php index a020043b15..70b34ca473 100644 --- a/tests/unit/SwooleAbsentTest.php +++ b/tests/unit/SwooleAbsentTest.php @@ -7,25 +7,35 @@ class SwooleAbsentTest extends TestCase { /** - * ext-swoole is optional: it is absent from composer.json's require block, so a - * consumer may run the library on a PHP that does not have it. The lifecycle-hook - * context lookup and the lost-connection check both reach Swoole classes, and an - * unguarded reference there is a fatal Error rather than a caught exception. - * - * An extension cannot be unloaded from a running interpreter, so this drives a - * subprocess started with -n, which skips php.ini and every conf.d file and - * therefore loads no shared extension. Swoole ships as a shared extension both in - * the test image and in every environment that installs it through pecl. + * @param array $flags + * @return array{status: int, output: string} */ - public function testDatabaseOperatesWithoutSwoole(): void + private function runFixture(array $flags): array { - $fixture = __DIR__ . '/Support/swoole-absent.php'; + $command = \escapeshellarg(PHP_BINARY); - $command = \escapeshellarg(PHP_BINARY) . ' -n ' . \escapeshellarg($fixture) . ' 2>&1'; + foreach ($flags as $flag) { + $command .= ' ' . $flag; + } + + $command .= ' ' . \escapeshellarg(__DIR__ . '/Support/swoole-absent.php') . ' 2>&1'; \exec($command, $lines, $status); - $output = \implode(PHP_EOL, $lines); + return ['status' => $status, 'output' => \implode(PHP_EOL, $lines)]; + } + + /** + * ext-swoole is optional: it is absent from composer.json's require block, so a + * consumer may run the library on a PHP that does not have it. An extension cannot + * be unloaded from a running interpreter, so this drives a subprocess started with + * -n, which skips php.ini and every conf.d file and therefore loads no shared + * extension. Swoole ships as a shared extension in the test image and in every + * environment that installs it through pecl. + */ + public function testDatabaseOperatesWithoutSwoole(): void + { + ['status' => $status, 'output' => $output] = $this->runFixture(['-n']); if (\str_contains($output, 'swoole=1')) { $this->markTestSkipped('swoole is statically compiled into ' . PHP_BINARY . ', so its absence cannot be exercised'); @@ -33,11 +43,30 @@ public function testDatabaseOperatesWithoutSwoole(): void $this->assertSame(0, $status, "Fixture exited {$status} without swoole:" . PHP_EOL . $output); - $this->assertStringContainsString('swoole=0', $output, $output); $this->assertStringContainsString('create=ok', $output, $output); $this->assertStringContainsString('silent=ok', $output, $output); $this->assertStringContainsString('deleted=3', $output, $output); $this->assertStringContainsString('remaining=0', $output, $output); - $this->assertStringContainsString('hasError=0', $output, $output); + $this->assertStringContainsString('lostDetected=5', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); + } + + /** + * Swoole\Database\DetectsLostConnections comes from Swoole's PHP-land library, which + * swoole.enable_library=Off switches off while leaving the extension loaded. Lost + * connections must still be recognised, so detection cannot rest on that class. + */ + public function testLostConnectionsAreDetectedWithoutSwooleLibrary(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('swoole is not loaded, so its library cannot be switched off'); + } + + ['status' => $status, 'output' => $output] = $this->runFixture(['-d swoole.enable_library=Off']); + + $this->assertSame(0, $status, "Fixture exited {$status} with the swoole library disabled:" . PHP_EOL . $output); + + $this->assertStringContainsString('lostDetected=5', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); } }