Custom Go extensions are unregistered after `--watch` hot-reload (functions vanish in worker mode)
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,
--watchenabled - Custom extension built with
xcaddy --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 --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. touchany watched.phpfile (triggers one hot-reload).- Request the same page again →
false(fatalCall 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_leninregister_internal_extensions— keep them for the process lifetime so each reboot re-registers. - Make the storage GC-safe: today
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.
Source: php/frankenphp