Skip to content

fix: keep the library usable on a PHP without ext-swoole - #962

Merged
abnegate merged 2 commits into
feat-query-libfrom
fix/swoole-optional-event-context
Sep 10, 2026
Merged

fix: keep the library usable on a PHP without ext-swoole#962
abnegate merged 2 commits into
feat-query-libfrom
fix/swoole-optional-event-context

Conversation

@abnegate

@abnegate abnegate commented Sep 10, 2026

Copy link
Copy Markdown
Member

Problem

ext-swoole is not in this package's composer.json require block, so a consumer can resolve utopia-php/database onto a PHP that does not have it. Two call sites referenced Swoole classes unguarded. A missing class is a fatal Error, not a catchable exception, so the requirement was invisible until it crashed at runtime.

1. Database::getEventContext() — backs silent() and areEventsSilenced().

This is worse than the originally reported deleteDocuments() path. Database::create() is itself wrapped in a silent() call, so the first operation any swoole-less consumer performs crashes:

Uncaught Error: Class "Swoole\Coroutine" not found in src/Database/Database.php:1463
#0 src/Database/Database.php(1441): Utopia\Database\Database->getEventContext()
#1 src/Database/Traits/Databases.php(31): Utopia\Database\Database->silent(Object(Closure))
#2 Utopia\Database\Database->create()

2. Connection::hasError() — found while confirming the fix actually made the library usable. It reaches Swoole\Database\DetectsLostConnections on the error path of the reconnecting Utopia\Database\PDO wrapper, so without the extension any query exception becomes a fatal. Same defect class. Flagging it explicitly since it is beyond the literal scope of the report — happy to split it out if you would rather it landed separately.

Fix

Option 1 from the report: guard the coroutine id lookup with \extension_loaded('swoole'), matching the guard already used for the coroutine sleep in withRetries(). Without the extension the event context is the same -1 sentinel already used outside a coroutine.

Connection::hasError() needed more than a guard, per review:

  • extension_loaded() is not sufficient there. DetectsLostConnections comes from Swoole's PHP-land library, not the extension core, and swoole.enable_library=Off switches that library off while leaving the extension loaded — extension_loaded() returns true while class_exists() returns false. It is now gated on class_exists(). The same is not true of Swoole\Coroutine, which is extension-core and still resolves with the library off, so extension_loaded() remains correct in Database.php.
  • The fallback list was inadequate. Enumerating what Swoole actually matches shows it supplies 24 needles, while the local list held exactly oneMax connect timeout reached, the single signature Swoole does not supply. Guarding alone would have left swoole-less consumers with 1 of 25 signatures, rethrowing server has gone away and Lost connection instead of reconnecting, silently dropping the retry contract for exactly the consumers this change exists to serve. Those 24 needles now live here, with Swoole's check kept ahead of them so upstream additions still apply where the library is present.

Option 2 (declaring ext-swoole in require) was not taken — it would turn an optional dependency into a hard one for every consumer. ext-swoole is instead documented under suggest, so the optionality is visible rather than implied.

Test

tests/unit/SwooleAbsentTest.php covers both environments an optional extension produces:

  1. Absent — the fixture runs in a subprocess started with -n, which skips php.ini and every conf.d file and so loads no shared extension. An extension cannot be unloaded from a running interpreter, so a subprocess is the only honest way to reach this branch. Swoole is shared both in this repo's test image (php:8.5-cli-alpine + docker-php-ext-enable) and in any pecl install, so the subprocess genuinely lacks it. The test skips with a loud message if some build has it compiled in statically, rather than passing vacuously.
  2. Present, library off — the same fixture under -d swoole.enable_library=Off, which the -n subprocess cannot reach.

The fixture asserts outcomes, not the absence of a throw: deleted=3 / remaining=0 for the delete path, and five real lost-connection signatures detected plus one unrelated message not detected, so it cannot pass by matching everything.

Verified red, each guard independently:

Reverted Result
Both guards RED — fatal at Database.php:1463
Database guard only RED — fatal at Database.php:1463
Connection guard only RED — fatal at Connection.php:28
Needle list (single needle) RED — lostDetected=1, expected 5
class_existsextension_loaded, library off RED — fatal at Connection.php:28
Nothing GREEN — 2 tests, 10 assertions

Checks

  • vendor/bin/phpunit --testsuite unit — OK, 1595 tests, 6169 assertions. The run exercises Connection::hasError with swoole present ("Lost connection detected. Re-preparing statement…"), confirming detection is not regressed.
  • vendor/bin/phpstan analyse — no errors
  • vendor/bin/pint --test — passed

Not verified

End-to-end on utopia-php/monorepo — reproducing test (audit) needs Docker plus MariaDB. The reproduction here matches the reported stack exactly (Class "Swoole\Coroutine" not foundgetEventContext()silent()deleteDocuments()), but the monorepo lane has not been run green against this branch.

Origin

Surfaced on utopia-php/monorepo PR #206: dropping the seemingly unused ext-swoole dev requirement from packages/audit/composer.json failed test (audit) with 17 Class "Swoole\Coroutine" not found errors via cleanup()deleteDocuments().

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • 0.69.x

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: ce0c8310-2162-4acf-9005-f9edc0e203ee

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the optional-dependency failures are guarded and the revised tests validate observable behavior.

Summary

  • Guards coroutine-context lookup when ext-swoole is absent.
  • Uses Swoole lost-connection detection only when its helper class exists.
  • Adds local lost-connection message detection for the fallback path.
  • Documents ext-swoole as optional and adds subprocess coverage for both missing-extension scenarios.

Reviews (2) · Last reviewed commit: "fix: detect lost connections without Swo..."

Comment thread src/Database/Connection.php Outdated
Comment thread src/Database/Connection.php Outdated
Comment thread tests/unit/SwooleAbsentTest.php Outdated
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 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Both P1s were real and are fixed in a69c106. I reproduced each before and after rather than taking them on description.

Issue 1 — helper availability unchecked. Valid. Swoole\Database\DetectsLostConnections comes from Swoole's PHP-land library, not the extension core, and swoole.enable_library=Off switches that library off while leaving the extension loaded:

$ php -d swoole.enable_library=Off -r '...'
extension_loaded(swoole):                bool(true)
class_exists(DetectsLostConnections):    bool(false)

Under that setting the previous guard still fataled on every query-error path. Now gated on class_exists(). Worth noting the same is not true of Swoole\Coroutine in Database.php — that is an extension-core class and still resolves with the library off, so extension_loaded() remains the correct guard there. I checked rather than assumed.

Issue 2 — lost connections stop reconnecting. Valid, and the more serious of the two. I enumerated what Swoole actually matches by feeding candidate messages through causedByLostConnection: it supplies 24 needles, and the local fallback held exactly oneMax connect timeout reached, which is the single signature Swoole does not supply. So swoole-less consumers kept 1 of 25 signatures; server has gone away and Lost connection were rethrown instead of reconnecting. That silently dropped the retry contract for precisely the consumers this PR exists to serve. The other 24 needles now live here, with Swoole's check kept ahead of them so upstream additions still apply where the library is present.

Issue 3 — test mirrors runtime configuration. Partly taken. I dropped the swoole=0 assertion; you are right that it is a configuration marker, not an outcome. I kept the swoole=1 skip branch, because it is doing different work: it is the only thing preventing a vacuous pass on a build with swoole compiled in statically, where the subprocess would exercise the present-extension path while still reporting green. It gates the test rather than asserting behaviour. Happy to reshape it if you would rather see that expressed another way.

You were also right that the fixture proved nothing here — a boom exception returns false whether or not the fallback list works. It now drives five real lost-connection signatures plus one unrelated message (so it cannot pass by matching everything), and a second test runs the fixture under swoole.enable_library=Off, which the -n subprocess cannot reach.

Both fixes verified red first:

Check Before After
No swoole, lost-connection signatures detected 1 of 5 5 of 5
swoole.enable_library=Off fatal at Connection.php:28 5 of 5
Unrelated exception misclassified no no

Local: unit suite 1595 tests / 6169 assertions OK, PHPStan clean, Pint passed.

@abnegate
abnegate merged commit 2adc28f into feat-query-lib Sep 10, 2026
39 of 40 checks passed
@abnegate
abnegate deleted the fix/swoole-optional-event-context branch September 10, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant