Description
A custom Go extension registered via frankenphp.RegisterExtension / the //export_php:function generator works on first boot, but its functions/classes disappear after the first --watch hot-reload in worker mode.
function_exists('my_ext_func') returns true right after startup and false after any watched file change, until the process is fully restarted (docker compose up --force-recreate, etc.).
frankenphp php-cli and any fresh process are always fine — only the long-running frankenphp run --watch process loses the extension after a reload. This makes it impossible to develop an app that relies on a custom extension while --watch is on, since editing any watched PHP file silently drops the extension.
Environment
- FrankenPHP v1.12.6 (PHP 8.5.8, ZTS), worker mode,
--watch enabled
- Custom extension built with
xcaddy --with <module> using the extension generator (//export_php:function ...), registered via the generated frankenphp.RegisterExtension(&C.<name>_module_entry) in an init()
Root cause
The registered-module list is consumed once and never restored on the main-thread reboot that --watch performs.
1. register_internal_extensions() registers the modules, then clears the list — so it only ever registers on the first php_module_startup:
https://github.com/php/frankenphp/blob/v1.12.6/frankenphp.c#L1948-L1964
int register_internal_extensions(void) {
// ... original hook ...
for (int i = 0; i < modules_len; i++) {
if (zend_register_internal_module(modules[i]) == NULL)
return FAILURE;
}
modules = NULL; // <-- list cleared after the first module startup
modules_len = 0;
return SUCCESS;
}
The Go side mirrors this single-shot assumption: registerExtensions() is guarded by sync.Once and sets extensions = nil after the first call (ext.go).
2. A watched file change reboots the PHP main thread, which re-runs php_module_startup (and therefore register_internal_extensions):
RestartWorkers() → mainThread.rebootAllThreads() → C.frankenphp_new_main_thread(...)
https://github.com/php/frankenphp/blob/v1.12.6/phpmainthread.go#L186
3. On this second register_internal_extensions() call, modules_len == 0, so the loop is a no-op → the custom modules are not re-registered → their functions/classes are gone in the rebooted workers.
This matches the observed behavior exactly: first boot OK, every hot-reload afterwards drops the extension, and only a full process restart recovers it.
Minimal reproduction
- Build a FrankenPHP binary with any trivial custom extension exposing one function, e.g.
//export_php:function hello(): string.
- Run in worker mode with
--watch.
- Request a page that calls
var_dump(function_exists('hello')); → true.
touch any watched .php file (triggers one hot-reload).
- Request the same page again →
false (fatal Call to undefined function hello() if the code calls it).
Suggested fix
Re-register the custom modules on every php_module_startup instead of consuming the list once:
- Don't zero
modules / modules_len in register_internal_extensions — keep them for the process lifetime so each reboot re-registers.
- Make the storage GC-safe: today
modules aliases the backing array of the Go extensions slice that registerExtensions() nils (extensions = nil), so after a reboot the pointer may dangle into freed Go memory. Copy the entries into C-owned storage in register_extensions (or keep the Go slice alive for the process lifetime).
Happy to open a PR if this direction looks right.
Description
A custom Go extension registered via
frankenphp.RegisterExtension/ the//export_php:functiongenerator works on first boot, but its functions/classes disappear after the first--watchhot-reload in worker mode.function_exists('my_ext_func')returnstrueright after startup andfalseafter any watched file change, until the process is fully restarted (docker compose up --force-recreate, etc.).frankenphp php-cliand any fresh process are always fine — only the long-runningfrankenphp run --watchprocess loses the extension after a reload. This makes it impossible to develop an app that relies on a custom extension while--watchis on, since editing any watched PHP file silently drops the extension.Environment
--watchenabledxcaddy --with <module>using the extension generator (//export_php:function ...), registered via the generatedfrankenphp.RegisterExtension(&C.<name>_module_entry)in aninit()Root cause
The registered-module list is consumed once and never restored on the main-thread reboot that
--watchperforms.1.
register_internal_extensions()registers the modules, then clears the list — so it only ever registers on the firstphp_module_startup:https://github.com/php/frankenphp/blob/v1.12.6/frankenphp.c#L1948-L1964
The Go side mirrors this single-shot assumption:
registerExtensions()is guarded bysync.Onceand setsextensions = nilafter the first call (ext.go).2. A watched file change reboots the PHP main thread, which re-runs
php_module_startup(and thereforeregister_internal_extensions):RestartWorkers()→mainThread.rebootAllThreads()→C.frankenphp_new_main_thread(...)https://github.com/php/frankenphp/blob/v1.12.6/phpmainthread.go#L186
3. On this second
register_internal_extensions()call,modules_len == 0, so the loop is a no-op → the custom modules are not re-registered → their functions/classes are gone in the rebooted workers.This matches the observed behavior exactly: first boot OK, every hot-reload afterwards drops the extension, and only a full process restart recovers it.
Minimal reproduction
//export_php:function hello(): string.--watch.var_dump(function_exists('hello'));→true.touchany watched.phpfile (triggers one hot-reload).false(fatalCall to undefined function hello()if the code calls it).Suggested fix
Re-register the custom modules on every
php_module_startupinstead of consuming the list once:modules/modules_leninregister_internal_extensions— keep them for the process lifetime so each reboot re-registers.modulesaliases the backing array of the Goextensionsslice thatregisterExtensions()nils (extensions = nil), so after a reboot the pointer may dangle into freed Go memory. Copy the entries into C-owned storage inregister_extensions(or keep the Go slice alive for the process lifetime).Happy to open a PR if this direction looks right.