Skip to content

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

Description

@amenophis

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

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions