#2572·frankenphp

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

Author: amenophisCreated Jul 27, 2026Updated Aug 21, 2026

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

c
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

  1. Build a FrankenPHP binary with any trivial custom extension exposing one function, e.g. //export_php:function hello(): string.
  2. Run in worker mode with --watch.
  3. Request a page that calls var_dump(function_exists('hello'));true.
  4. touch any watched .php file (triggers one hot-reload).
  5. 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.