Skip to content

fix: keep custom extensions registered after PHP restarts - #2788

Open
aguinaldotupy wants to merge 1 commit into
php:mainfrom
aguinaldotupy:fix/2572-extensions-after-reboot
Open

aguinaldotupy wants to merge 1 commit into
php:mainfrom
aguinaldotupy:fix/2572-extensions-after-reboot

Conversation

@aguinaldotupy

Copy link
Copy Markdown

Prepared with an AI assistant (Claude), which wrote the patch and ran the tests below; I asked for it.

Fixes #2572.

register_internal_extensions() freed the module list after the first php_module_startup(), so every later startup registered no custom extension. Their functions, classes and constants disappeared after a main thread reboot (file watcher, RestartWorkers() and the admin restart endpoint, opcache_reset()) and after Shutdown() and Init(), which Caddy runs on config reloads.

This picks up #2579 by @ousamabenyounes, rebased on main, and follows the review it got there.

Changes

  • register_internal_extensions() keeps the C copy of the module list for the process lifetime, so each startup registers the modules again.
  • register_extensions() returns early once the hook is installed, or when the list is empty. A second call can no longer make the hook its own predecessor.
  • No Go or public API change.

This matches what PHP does for its own static modules: zend_startup() creates module_registry on each startup, and module_destructor() runs MSHUTDOWN, frees the ZTS globals id and resets module_started on each shutdown. Re-registering the same static zend_module_entry is therefore a fresh registration.

Tests

internal/testext now restarts PHP twice, first with RestartWorkers() and then with Shutdown() and Init(), and checks after each restart that both test modules are loaded. ext2 gets a function, a MINIT and module globals (GINIT), so the test covers the ZTS globals id being freed and allocated again. MINIT increments a process-wide counter and exposes it as EXT2_MINIT_CALLS, which must read 1, 2 and then 3. If RestartWorkers() returns without rebooting, the count stays at 1 and the test fails, so no API change is needed to observe the reboot.

RED on main (dev image, PHP 8.5 ZTS debug, go test -race ./internal/testext):

exttest.go:67:
    expected: "ext1: loaded\next2: loaded\next2_answer: 42\nEXT2_MINIT_CALLS: 2\n"
    actual  : "ext1: missing\next2: missing\next2_answer: missing\nEXT2_MINIT_CALLS: missing\n"
exttest.go:72:
    expected: "ext1: loaded\next2: loaded\next2_answer: 42\nEXT2_MINIT_CALLS: 3\n"
    actual  : "ext1: missing\next2: missing\next2_answer: missing\nEXT2_MINIT_CALLS: missing\n"
--- FAIL: TestRegisterExtension (0.08s)

GREEN with the fix:

--- PASS: TestRegisterExtension (0.08s)
ok  	github.com/dunglas/frankenphp/internal/testext	1.099s

Also run in the dev image: gotestsum -- -race ./... for the library (1192 tests, 8 skipped because the image has no pcntl) and for caddy/, the internal/testext test 20 times in fresh processes, golangci-lint (0 issues), go mod tidy -diff, and super-linter slim v9.0.0 with the CI settings (clang-format 22.1.3 included).

Not in this PR

  • A late RegisterExtension() after the first Init() is still ignored silently. Reporting it would need an API change, so it belongs in a separate PR.
  • A registration failure on reboot is no more observable than one at first boot: PHP prints Unable to start builtin modules and php_main() ignores the startup result. Every startup registers the same list into a new registry, so a reboot should not fail where the first boot succeeded. I can handle the startup result in a follow-up if you want it.

@ousamabenyounes

Copy link
Copy Markdown
Contributor

Hi @aguinaldotupy,

Thanks for taking over #2579 and bringing the fix forward! Glad to see this work moving ahead.

Since your PR builds on our original contribution, would you be open to adding me as a co-author?

Thanks again for your work on this!

register_internal_extensions() freed the module list after the first
php_module_startup(), so every later startup registered no custom
extension: their functions, classes and constants were gone after a
main thread reboot (file watcher, the admin `workers/restart` endpoint,
`opcache_reset()`) and after `Shutdown()` and `Init()`, which Caddy runs
on config reloads.

Keep the C copy of the list for the process lifetime and make
register_extensions() install the hook only once. PHP destroys the
module registry and resets `module_started` on every module shutdown,
and registers its own static modules the same way on each startup.

Fixes php#2572

Co-authored-by: ousamabenyounes <benyounes.ousama@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aguinaldotupy
aguinaldotupy force-pushed the fix/2572-extensions-after-reboot branch from e7e7d7f to 44972c0 Compare October 29, 2026 00:01
@aguinaldotupy

Copy link
Copy Markdown
Author

Hi @ousamabenyounes, of course — the fix builds directly on your work in #2579. I've added you as co-author on the commit (Co-authored-by trailer). Thanks for the original patch and the investigation!

This branch has not been deployed

No deployments
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.

Custom Go extensions are unregistered after --watch hot-reload (functions vanish in worker mode)

2 participants