From 922de2de5e53cb13b56b41e79ee6e79032e586a0 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 29 Jul 2026 15:24:32 +0700 Subject: [PATCH 01/30] feat: register phpinfo info entries under frankenphp extension --- caddy/caddy.go | 9 +++++++++ cli.go | 2 ++ frankenphp.c | 16 +++++++++++++++- frankenphp.go | 19 ++++++++++++++++++- frankenphp.h | 3 +++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 9cbc219f3d..328b596c62 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -9,6 +9,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/dunglas/frankenphp" ) const ( @@ -26,6 +27,14 @@ func init() { caddy.RegisterModule(FrankenPHPModule{}) caddy.RegisterModule(FrankenPHPAdmin{}) + // Report Caddy version in phpinfo() + simpleVersion, fullVersion := caddy.Version() + if fullVersion != "" { + frankenphp.AddPhpinfoEntry("Caddy Version", fullVersion) + } else if simpleVersion != "" { + frankenphp.AddPhpinfoEntry("Caddy Version", simpleVersion) + } + httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile) diff --git a/cli.go b/cli.go index 96821a2392..a91a8c5df2 100644 --- a/cli.go +++ b/cli.go @@ -9,6 +9,7 @@ import "unsafe" func ExecuteScriptCLI(script string, args []string) int { // Ensure extensions are registered before CLI execution registerExtensions() + initPhpinfoEntries() cScript := C.CString(script) defer C.free(unsafe.Pointer(cScript)) @@ -22,6 +23,7 @@ func ExecuteScriptCLI(script string, args []string) int { func ExecutePHPCode(phpCode string) int { // Ensure extensions are registered before CLI execution registerExtensions() + initPhpinfoEntries() cCode := C.CString(phpCode) defer C.free(unsafe.Pointer(cCode)) diff --git a/frankenphp.c b/frankenphp.c index a47b6d80a7..ef9aebd1c6 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -6,6 +6,7 @@ #include #include #include +#include #ifdef HAVE_PHP_SESSION #include #endif @@ -107,6 +108,8 @@ frankenphp_config frankenphp_get_config() { }; } +const char **frankenphp_phpinfo_entries = NULL; + bool should_filter_var = 0; bool original_user_abort_setting = 0; frankenphp_interned_strings_t frankenphp_strings = {0}; @@ -1101,6 +1104,17 @@ PHP_MINIT_FUNCTION(frankenphp) { return SUCCESS; } +PHP_MINFO_FUNCTION(frankenphp) { + php_info_print_table_start(); + php_info_print_table_row(2, "Version", TOSTRING(FRANKENPHP_VERSION)); + if (frankenphp_phpinfo_entries) { + for (int i = 0; frankenphp_phpinfo_entries[i] != NULL; i += 2) { + php_info_print_table_row(2, frankenphp_phpinfo_entries[i], frankenphp_phpinfo_entries[i + 1]); + } + } + php_info_print_table_end(); +} + static zend_module_entry frankenphp_module = { STANDARD_MODULE_HEADER, "frankenphp", @@ -1109,7 +1123,7 @@ static zend_module_entry frankenphp_module = { NULL, /* shutdown */ NULL, /* request initialization */ NULL, /* request shutdown */ - NULL, /* information */ + PHP_MINFO(frankenphp), /* information */ TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; diff --git a/frankenphp.go b/frankenphp.go index ad2dedc42a..ef813e7610 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -37,7 +37,7 @@ import ( "time" "unsafe" // debug on Linux - //_ "github.com/ianlancetaylor/cgosymbolizer" + // _ "github.com/ianlancetaylor/cgosymbolizer" ) type contextKeyStruct struct{} @@ -156,6 +156,22 @@ func Config() PHPConfig { } } +var phpinfoEntries []*C.char + +func AddPhpinfoEntry(key, value string) { + cKey := C.CString(key) + cValue := C.CString(value) + phpinfoEntries = append(phpinfoEntries, cKey, cValue) +} + +func initPhpinfoEntries() { + if len(phpinfoEntries) == 0 { + return + } + phpinfoEntries = append(phpinfoEntries, nil) + C.frankenphp_phpinfo_entries = (**C.char)(unsafe.Pointer(&phpinfoEntries[0])) +} + func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { maxProcs := runtime.GOMAXPROCS(0) * 2 maxThreadsFromWorkers := 0 @@ -250,6 +266,7 @@ func Init(options ...Option) error { signal.Ignore(syscall.SIGPIPE) registerExtensions() + initPhpinfoEntries() opt := &opt{} for _, o := range options { diff --git a/frankenphp.h b/frankenphp.h index db32a82fe0..c046bfff9e 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -74,6 +74,9 @@ typedef struct { #define STRINGIFY(x) #x #define TOSTRING(x) STRINGIFY(x) +/* phpinfo entries from Go - null-terminated array of key, value, key, value, ... */ +extern const char **frankenphp_phpinfo_entries; + typedef struct go_string { size_t len; char *data; From ce102e52907801ce3bcdde0f5daa4995dcb5939c Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 29 Jul 2026 16:53:58 +0700 Subject: [PATCH 02/30] report e-dant/watcher, dunglas/caddy-cbrotli, libbrotli and dunglas/mercure versions --- caddy/br.go | 29 +++++++++++++++++++++++++++++ mercure.go | 12 ++++++++++++ watcher.go | 13 +++++++++++++ 3 files changed, 54 insertions(+) diff --git a/caddy/br.go b/caddy/br.go index 6522cb67a4..90bcf60575 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -2,4 +2,33 @@ package caddy +// #include +import "C" + +import ( + "fmt" + "runtime/debug" + + "github.com/dunglas/frankenphp" +) + var brotli = true + +func init() { + brotliVer := C.BrotliEncoderVersion() + if brotliVer != 0 { + major := int(brotliVer >> 24) + minor := int((brotliVer >> 12) & 0xfff) + patch := int(brotliVer & 0xfff) + frankenphp.AddPhpinfoEntry("libbrotli", fmt.Sprintf("%d.%d.%d", major, minor, patch)) + } + + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/dunglas/caddy-cbrotli" { + frankenphp.AddPhpinfoEntry("dunglas/caddy-cbrotli", dep.Version) + break + } + } + } +} diff --git a/mercure.go b/mercure.go index d7cf33609e..80c4370034 100644 --- a/mercure.go +++ b/mercure.go @@ -8,11 +8,23 @@ package frankenphp import "C" import ( "log/slog" + "runtime/debug" "unsafe" "github.com/dunglas/mercure" ) +func init() { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/dunglas/mercure" { + AddPhpinfoEntry("dunglas/mercure", dep.Version) + break + } + } + } +} + type mercureContext struct { mercureHub *mercure.Hub } diff --git a/watcher.go b/watcher.go index cfe133e5ab..587178900b 100644 --- a/watcher.go +++ b/watcher.go @@ -3,12 +3,25 @@ package frankenphp import ( + "runtime/debug" "sync/atomic" "github.com/dunglas/frankenphp/internal/watcher" watcherGo "github.com/e-dant/watcher/watcher-go" ) +func init() { + // watcher doesn't expose the version, so get it from go.mod + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/e-dant/watcher" { + AddPhpinfoEntry("e-dant/watcher", dep.Version) + break + } + } + } +} + type hotReloadOpt struct { hotReload []*watcher.PatternGroup } From 032a742d54302dfcf754495d6cedadbeaed43b50 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 29 Jul 2026 17:42:52 +0700 Subject: [PATCH 03/30] keep go array, only convert to c array in init function --- caddy/caddy.go | 4 ++-- frankenphp.c | 2 +- frankenphp.go | 40 ++++++++++++++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 328b596c62..f203f8f659 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -30,9 +30,9 @@ func init() { // Report Caddy version in phpinfo() simpleVersion, fullVersion := caddy.Version() if fullVersion != "" { - frankenphp.AddPhpinfoEntry("Caddy Version", fullVersion) + frankenphp.AddPhpinfoEntry("caddy", fullVersion) } else if simpleVersion != "" { - frankenphp.AddPhpinfoEntry("Caddy Version", simpleVersion) + frankenphp.AddPhpinfoEntry("caddy", simpleVersion) } httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) diff --git a/frankenphp.c b/frankenphp.c index ef9aebd1c6..cb65b51720 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1106,7 +1106,7 @@ PHP_MINIT_FUNCTION(frankenphp) { PHP_MINFO_FUNCTION(frankenphp) { php_info_print_table_start(); - php_info_print_table_row(2, "Version", TOSTRING(FRANKENPHP_VERSION)); + php_info_print_table_row(2, "frankenphp", TOSTRING(FRANKENPHP_VERSION)); if (frankenphp_phpinfo_entries) { for (int i = 0; frankenphp_phpinfo_entries[i] != NULL; i += 2) { php_info_print_table_row(2, frankenphp_phpinfo_entries[i], frankenphp_phpinfo_entries[i + 1]); diff --git a/frankenphp.go b/frankenphp.go index ef813e7610..555ca0a688 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -30,6 +30,7 @@ import ( "os" "os/signal" "runtime" + "sort" "strings" "sync" "sync/atomic" @@ -156,20 +157,47 @@ func Config() PHPConfig { } } -var phpinfoEntries []*C.char +type phpinfoEntry struct { + key, value string +} + +var ( + phpinfoEntries []phpinfoEntry + cPhpinfoArr []*C.char +) func AddPhpinfoEntry(key, value string) { - cKey := C.CString(key) - cValue := C.CString(value) - phpinfoEntries = append(phpinfoEntries, cKey, cValue) + phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) } func initPhpinfoEntries() { + for _, cstr := range cPhpinfoArr { + if cstr != nil { + C.free(unsafe.Pointer(cstr)) + } + } + if cPhpinfoArr != nil { + C.free(unsafe.Pointer(&cPhpinfoArr[0])) + cPhpinfoArr = nil + C.frankenphp_phpinfo_entries = nil + } + if len(phpinfoEntries) == 0 { return } - phpinfoEntries = append(phpinfoEntries, nil) - C.frankenphp_phpinfo_entries = (**C.char)(unsafe.Pointer(&phpinfoEntries[0])) + + sort.Slice(phpinfoEntries, func(i, j int) bool { + return phpinfoEntries[i].key < phpinfoEntries[j].key + }) + + n := 2*len(phpinfoEntries) + 1 + cPhpinfoArr = (*[1 << 28]*C.char)(C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0)))))[:n:n] + for i, e := range phpinfoEntries { + cPhpinfoArr[2*i] = C.CString(e.key) + cPhpinfoArr[2*i+1] = C.CString(e.value) + } + cPhpinfoArr[n-1] = nil + C.frankenphp_phpinfo_entries = &cPhpinfoArr[0] } func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { From 98aadc42ffa686178e14347ff804f71a92bc0abf Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 29 Jul 2026 17:43:21 +0700 Subject: [PATCH 04/30] clang-format --- frankenphp.c | 3 ++- frankenphp.h | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index cb65b51720..acf625018e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1109,7 +1109,8 @@ PHP_MINFO_FUNCTION(frankenphp) { php_info_print_table_row(2, "frankenphp", TOSTRING(FRANKENPHP_VERSION)); if (frankenphp_phpinfo_entries) { for (int i = 0; frankenphp_phpinfo_entries[i] != NULL; i += 2) { - php_info_print_table_row(2, frankenphp_phpinfo_entries[i], frankenphp_phpinfo_entries[i + 1]); + php_info_print_table_row(2, frankenphp_phpinfo_entries[i], + frankenphp_phpinfo_entries[i + 1]); } } php_info_print_table_end(); diff --git a/frankenphp.h b/frankenphp.h index c046bfff9e..f23f81a385 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -74,7 +74,8 @@ typedef struct { #define STRINGIFY(x) #x #define TOSTRING(x) STRINGIFY(x) -/* phpinfo entries from Go - null-terminated array of key, value, key, value, ... */ +/* phpinfo entries from Go - null-terminated array of key, value, key, value, + * ... */ extern const char **frankenphp_phpinfo_entries; typedef struct go_string { From 87a0874e27e02fe8bbc26beee65335d1e3c7ecee Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 29 Jul 2026 18:40:26 +0700 Subject: [PATCH 05/30] why is this missing in CI? @dunglas --- caddy/br.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/caddy/br.go b/caddy/br.go index 90bcf60575..48d7741c3e 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -2,11 +2,7 @@ package caddy -// #include -import "C" - import ( - "fmt" "runtime/debug" "github.com/dunglas/frankenphp" @@ -15,14 +11,6 @@ import ( var brotli = true func init() { - brotliVer := C.BrotliEncoderVersion() - if brotliVer != 0 { - major := int(brotliVer >> 24) - minor := int((brotliVer >> 12) & 0xfff) - patch := int(brotliVer & 0xfff) - frankenphp.AddPhpinfoEntry("libbrotli", fmt.Sprintf("%d.%d.%d", major, minor, patch)) - } - if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/dunglas/caddy-cbrotli" { From f06d1468935e57a20d99f5c06d5155fed3d4a675 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 4 Aug 2026 18:05:32 +0200 Subject: [PATCH 06/30] rename method --- caddy/br.go | 2 +- caddy/caddy.go | 4 ++-- cli.go | 4 ++-- frankenphp.go | 6 +++--- mercure.go | 2 +- watcher.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/caddy/br.go b/caddy/br.go index 48d7741c3e..2efe385381 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -14,7 +14,7 @@ func init() { if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/dunglas/caddy-cbrotli" { - frankenphp.AddPhpinfoEntry("dunglas/caddy-cbrotli", dep.Version) + frankenphp.AddPHPInfoEntry("dunglas/caddy-cbrotli", dep.Version) break } } diff --git a/caddy/caddy.go b/caddy/caddy.go index f203f8f659..70a242ac51 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -30,9 +30,9 @@ func init() { // Report Caddy version in phpinfo() simpleVersion, fullVersion := caddy.Version() if fullVersion != "" { - frankenphp.AddPhpinfoEntry("caddy", fullVersion) + frankenphp.AddPHPInfoEntry("caddy", fullVersion) } else if simpleVersion != "" { - frankenphp.AddPhpinfoEntry("caddy", simpleVersion) + frankenphp.AddPHPInfoEntry("caddy", simpleVersion) } httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) diff --git a/cli.go b/cli.go index a91a8c5df2..2b1592294d 100644 --- a/cli.go +++ b/cli.go @@ -9,7 +9,7 @@ import "unsafe" func ExecuteScriptCLI(script string, args []string) int { // Ensure extensions are registered before CLI execution registerExtensions() - initPhpinfoEntries() + initPHPInfoEntries() cScript := C.CString(script) defer C.free(unsafe.Pointer(cScript)) @@ -23,7 +23,7 @@ func ExecuteScriptCLI(script string, args []string) int { func ExecutePHPCode(phpCode string) int { // Ensure extensions are registered before CLI execution registerExtensions() - initPhpinfoEntries() + initPHPInfoEntries() cCode := C.CString(phpCode) defer C.free(unsafe.Pointer(cCode)) diff --git a/frankenphp.go b/frankenphp.go index 555ca0a688..42e3e0d4c4 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -166,11 +166,11 @@ var ( cPhpinfoArr []*C.char ) -func AddPhpinfoEntry(key, value string) { +func AddPHPInfoEntry(key, value string) { phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) } -func initPhpinfoEntries() { +func initPHPInfoEntries() { for _, cstr := range cPhpinfoArr { if cstr != nil { C.free(unsafe.Pointer(cstr)) @@ -294,7 +294,7 @@ func Init(options ...Option) error { signal.Ignore(syscall.SIGPIPE) registerExtensions() - initPhpinfoEntries() + initPHPInfoEntries() opt := &opt{} for _, o := range options { diff --git a/mercure.go b/mercure.go index 80c4370034..a06842bb7f 100644 --- a/mercure.go +++ b/mercure.go @@ -18,7 +18,7 @@ func init() { if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/dunglas/mercure" { - AddPhpinfoEntry("dunglas/mercure", dep.Version) + AddPHPInfoEntry("dunglas/mercure", dep.Version) break } } diff --git a/watcher.go b/watcher.go index 587178900b..b738d02546 100644 --- a/watcher.go +++ b/watcher.go @@ -15,7 +15,7 @@ func init() { if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/e-dant/watcher" { - AddPhpinfoEntry("e-dant/watcher", dep.Version) + AddPHPInfoEntry("e-dant/watcher", dep.Version) break } } From fdbf473866e6a147a3df753e7c2721d56fa6a4fe Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 11 Aug 2026 18:32:59 +0200 Subject: [PATCH 07/30] test for phpinfo as plaintext --- cli_test.go | 17 +++++++++++++++++ frankenphp.c | 1 + 2 files changed, 18 insertions(+) diff --git a/cli_test.go b/cli_test.go index 964bb49907..c3a1aed1f2 100644 --- a/cli_test.go +++ b/cli_test.go @@ -46,6 +46,23 @@ func TestExecuteCLICode(t *testing.T) { assert.Equal(t, stdoutStderrStr, `Hello World`) } +// The CLI must print phpinfo() as plain text, like the CLI SAPI does. +func TestExecuteCLICodePHPInfoAsText(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + cmd := exec.Command("internal/testcli/testcli", "-r", "phpinfo();") + stdoutStderr, err := cmd.CombinedOutput() + assert.NoError(t, err) + + stdoutStderrStr := string(stdoutStderr) + + assert.Contains(t, stdoutStderrStr, "PHP Version => ") + assert.NotContains(t, stdoutStderrStr, "") +} + // Regression test for https://github.com/php/frankenphp/issues/1902. A // long-running CLI script that installs pcntl_signal handlers must // receive its own signals reliably diff --git a/frankenphp.c b/frankenphp.c index acf625018e..4331f9cb9c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1872,6 +1872,7 @@ static void *execute_script_cli(void *arg) { php_embed_module.name = "cli"; php_embed_module.pretty_name = "PHP CLI embedded in FrankenPHP"; php_embed_module.register_server_variables = sapi_cli_register_variables; + php_embed_module.phpinfo_as_text = 1; php_embed_init(cli_argc, cli_argv); From ef483ee514459a08dc77d56008238765f35d53f8 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 11 Aug 2026 19:04:24 +0200 Subject: [PATCH 08/30] suggestion by @dunglas - also include all go modules and go version --- frankenphp.c | 36 ++++++++++++++-- frankenphp.go | 102 +++++++++++++++++++++++++++++++++++---------- frankenphp.h | 4 ++ frankenphp_test.go | 1 + 4 files changed, 118 insertions(+), 25 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 4331f9cb9c..bdafc88568 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -109,6 +109,7 @@ frankenphp_config frankenphp_get_config() { } const char **frankenphp_phpinfo_entries = NULL; +const char **frankenphp_go_modules = NULL; bool should_filter_var = 0; bool original_user_abort_setting = 0; @@ -1104,16 +1105,43 @@ PHP_MINIT_FUNCTION(frankenphp) { return SUCCESS; } +static void frankenphp_print_info_rows(const char **entries) { + for (int i = 0; entries[i] != NULL; i += 2) { + php_info_print_table_row(2, entries[i], entries[i + 1]); + } +} + PHP_MINFO_FUNCTION(frankenphp) { php_info_print_table_start(); php_info_print_table_row(2, "frankenphp", TOSTRING(FRANKENPHP_VERSION)); if (frankenphp_phpinfo_entries) { - for (int i = 0; frankenphp_phpinfo_entries[i] != NULL; i += 2) { - php_info_print_table_row(2, frankenphp_phpinfo_entries[i], - frankenphp_phpinfo_entries[i + 1]); - } + frankenphp_print_info_rows(frankenphp_phpinfo_entries); } php_info_print_table_end(); + + if (frankenphp_go_modules == NULL) { + return; + } + + /* The list of Go modules is long, collapse it by default when rendering + * HTML */ + if (sapi_module.phpinfo_as_text) { + php_info_print_table_start(); + php_info_print_table_header(1, "Go modules"); + php_info_print_table_end(); + } else { + php_printf("
Go " + "modules\n"); + } + + php_info_print_table_start(); + php_info_print_table_header(2, "Module", "Version"); + frankenphp_print_info_rows(frankenphp_go_modules); + php_info_print_table_end(); + + if (!sapi_module.phpinfo_as_text) { + php_printf("
\n"); + } } static zend_module_entry frankenphp_module = { diff --git a/frankenphp.go b/frankenphp.go index 42e3e0d4c4..c00ea17ab6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -30,6 +30,7 @@ import ( "os" "os/signal" "runtime" + "runtime/debug" "sort" "strings" "sync" @@ -162,42 +163,101 @@ type phpinfoEntry struct { } var ( - phpinfoEntries []phpinfoEntry - cPhpinfoArr []*C.char + phpinfoEntries []phpinfoEntry + goModuleEntries []phpinfoEntry + cPhpinfoArr []*C.char + cGoModulesArr []*C.char ) +// Report the Go toolchain and every Go module linked into the binary. Caddy +// modules, FrankenPHP extensions written in Go and even the standard library +// itself. The list is verbose, so it's displayed in a collapsed section. +func init() { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return + } + + AddPHPInfoEntry("Go", buildInfo.GoVersion) + + goModuleEntries = make([]phpinfoEntry, 0, len(buildInfo.Deps)) + for _, dep := range buildInfo.Deps { + goModuleEntries = append(goModuleEntries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) + } +} + +// goModuleVersion returns the version of the given module, taking "replace" +// directives into account. +func goModuleVersion(module *debug.Module) string { + if module.Replace == nil { + return module.Version + } + + if module.Replace.Version == "" { + // Replaced by a local directory + return module.Replace.Path + } + + return module.Replace.Path + " " + module.Replace.Version +} + +// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). func AddPHPInfoEntry(key, value string) { phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) } func initPHPInfoEntries() { - for _, cstr := range cPhpinfoArr { + freeCEntries(cPhpinfoArr) + freeCEntries(cGoModulesArr) + + cPhpinfoArr = newCEntries(phpinfoEntries) + cGoModulesArr = newCEntries(goModuleEntries) + + C.frankenphp_phpinfo_entries = firstCEntry(cPhpinfoArr) + C.frankenphp_go_modules = firstCEntry(cGoModulesArr) +} + +// newCEntries converts entries to a null-terminated C array of key, value, key, +// value, ... sorted by key. The returned slice is backed by memory allocated by +// C, free it with freeCEntries(). +func newCEntries(entries []phpinfoEntry) []*C.char { + if len(entries) == 0 { + return nil + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + n := 2*len(entries) + 1 + arr := (*[1 << 28]*C.char)(C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0)))))[:n:n] + for i, e := range entries { + arr[2*i] = C.CString(e.key) + arr[2*i+1] = C.CString(e.value) + } + arr[n-1] = nil + + return arr +} + +func freeCEntries(arr []*C.char) { + for _, cstr := range arr { if cstr != nil { C.free(unsafe.Pointer(cstr)) } } - if cPhpinfoArr != nil { - C.free(unsafe.Pointer(&cPhpinfoArr[0])) - cPhpinfoArr = nil - C.frankenphp_phpinfo_entries = nil - } - if len(phpinfoEntries) == 0 { - return + if arr != nil { + C.free(unsafe.Pointer(&arr[0])) } +} - sort.Slice(phpinfoEntries, func(i, j int) bool { - return phpinfoEntries[i].key < phpinfoEntries[j].key - }) - - n := 2*len(phpinfoEntries) + 1 - cPhpinfoArr = (*[1 << 28]*C.char)(C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0)))))[:n:n] - for i, e := range phpinfoEntries { - cPhpinfoArr[2*i] = C.CString(e.key) - cPhpinfoArr[2*i+1] = C.CString(e.value) +func firstCEntry(arr []*C.char) **C.char { + if arr == nil { + return nil } - cPhpinfoArr[n-1] = nil - C.frankenphp_phpinfo_entries = &cPhpinfoArr[0] + + return &arr[0] } func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { diff --git a/frankenphp.h b/frankenphp.h index f23f81a385..19fb9e1fe7 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -78,6 +78,10 @@ typedef struct { * ... */ extern const char **frankenphp_phpinfo_entries; +/* Go modules linked into the binary, same layout, displayed in a section + * collapsed by default */ +extern const char **frankenphp_go_modules; + typedef struct go_string { size_t len; char *data; diff --git a/frankenphp_test.go b/frankenphp_test.go index 409e644634..9d81c11c18 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -454,6 +454,7 @@ func testPhpInfo(t *testing.T, opts *testOptions) { assert.Contains(t, body, "frankenphp") assert.Contains(t, body, fmt.Sprintf("i=%d", i)) + assert.Contains(t, body, runtime.Version()) }, opts) } From b95c9663c2a35204c80b498c4d397c2ade28575c Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 11 Aug 2026 19:10:47 +0200 Subject: [PATCH 09/30] don't capitalise Go version in PHPInfo entry, nothing else is capitalised --- frankenphp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frankenphp.go b/frankenphp.go index c00ea17ab6..f7171634fd 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -178,7 +178,7 @@ func init() { return } - AddPHPInfoEntry("Go", buildInfo.GoVersion) + AddPHPInfoEntry("go", buildInfo.GoVersion) goModuleEntries = make([]phpinfoEntry, 0, len(buildInfo.Deps)) for _, dep := range buildInfo.Deps { From df91cab52a502b37a49195e2678bbd62a1765f0e Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 21 Aug 2026 12:45:47 +0200 Subject: [PATCH 10/30] amend cli test --- cli_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli_test.go b/cli_test.go index bf6932a2dc..d47331e772 100644 --- a/cli_test.go +++ b/cli_test.go @@ -59,8 +59,13 @@ func TestExecuteCLICodePHPInfoAsText(t *testing.T) { stdoutStderrStr := string(stdoutStderr) assert.Contains(t, stdoutStderrStr, "PHP Version => ") + assert.Contains(t, stdoutStderrStr, "frankenphp => ") + assert.Contains(t, stdoutStderrStr, "go => go") + assert.Contains(t, stdoutStderrStr, "Go modules") + assert.Contains(t, stdoutStderrStr, "Module => Version") assert.NotContains(t, stdoutStderrStr, "") + assert.NotContains(t, stdoutStderrStr, "
") } // `-i` (and any other invocation without a script) is only supported since PHP From 1bffc57152d18144652809839ed2c638c7568647 Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 21 Aug 2026 14:29:14 +0200 Subject: [PATCH 11/30] hook frankenphp_module into cli execution too --- frankenphp.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index d8311df7a0..c4161f60fe 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1815,6 +1815,19 @@ static void *execute_script_cli(void *arg) { #endif } +static int (*previous_php_register_internal_extensions_func)(void) = NULL; + +/* frankenphp_module is passed to php_module_startup() by our own SAPI, but the + * CLI SAPIs take no additional modules: hook their module startup instead */ +static int register_frankenphp_module(void) { + if (previous_php_register_internal_extensions_func() != SUCCESS) { + return FAILURE; + } + + return zend_register_internal_module(&frankenphp_module) == NULL ? FAILURE + : SUCCESS; +} + int frankenphp_execute_script_cli(char *script, int argc, char **argv, bool eval) { pthread_t thread; @@ -1824,6 +1837,10 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, cli_exec_args_t args = { .script = script, .argc = argc, .argv = argv, .eval = eval}; + previous_php_register_internal_extensions_func = + php_register_internal_extensions_func; + php_register_internal_extensions_func = register_frankenphp_module; + /* * Start the script in a dedicated thread to prevent conflicts between Go and * PHP signal handlers From c3950da6e96a321a5cf5d6e2f8af47c00396dc44 Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 21 Aug 2026 14:40:43 +0200 Subject: [PATCH 12/30] make sure tests set display_errors=1 when they rely on it --- frankenphp_test.go | 5 +++++ server_test.go | 1 + worker_test.go | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frankenphp_test.go b/frankenphp_test.go index 764a5f0402..f564eb349d 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -576,6 +576,11 @@ func TestException_worker(t *testing.T) { testException(t, &testOptions{workerScript: "exception.php"}) } func testException(t *testing.T, opts *testOptions) { + if opts.phpIni == nil { + opts.phpIni = map[string]string{} + } + opts.phpIni["display_errors"] = "1" + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { body, _ := testGet(fmt.Sprintf("http://example.com/exception.php?i=%d", i), handler, t) diff --git a/server_test.go b/server_test.go index f297db7c29..3ad2032bbe 100644 --- a/server_test.go +++ b/server_test.go @@ -102,6 +102,7 @@ func TestServer(t *testing.T) { server2, _ := frankenphp.NewServer(testDataDir) initServers( t, + frankenphp.WithPhpIni(map[string]string{"display_errors": "1"}), frankenphp.WithServer(server1), frankenphp.WithServer(server2), frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server1)), diff --git a/worker_test.go b/worker_test.go index dc423294f6..1a0f1d0ad0 100644 --- a/worker_test.go +++ b/worker_test.go @@ -76,7 +76,7 @@ func TestCannotCallHandleRequestInNonWorkerMode(t *testing.T) { body, _ := io.ReadAll(resp.Body) assert.Contains(t, string(body), "Fatal error: Uncaught RuntimeException: frankenphp_handle_request() called while not in worker mode") - }, nil) + }, &testOptions{phpIni: map[string]string{"display_errors": "1", "html_errors": "1"}}) } func TestWorkerEnv(t *testing.T) { From 0296a8984bdfc4fd67b037629628cee698d64ca4 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 5 Sep 2026 23:51:16 +0200 Subject: [PATCH 13/30] fix cli metadata without server runtime hooks --- frankenphp.c | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 715dd66698..a01200ebca 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1171,6 +1171,20 @@ static zend_module_entry frankenphp_module = { TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; +/* CLI exposes the same metadata, but must keep PHP's native functions and + * avoid initializing hooks that depend on the server runtime. */ +static zend_module_entry frankenphp_cli_module = { + STANDARD_MODULE_HEADER, + "frankenphp", + NULL, /* function table */ + NULL, /* initialization */ + NULL, /* shutdown */ + NULL, /* request initialization */ + NULL, /* request shutdown */ + PHP_MINFO(frankenphp), /* information */ + TOSTRING(FRANKENPHP_VERSION), + STANDARD_MODULE_PROPERTIES}; + static int frankenphp_startup(sapi_module_struct *sapi_module) { php_import_environment_variables = get_full_env; @@ -1825,8 +1839,9 @@ static int register_frankenphp_module(void) { return FAILURE; } - return zend_register_internal_module(&frankenphp_module) == NULL ? FAILURE - : SUCCESS; + return zend_register_internal_module(&frankenphp_cli_module) == NULL + ? FAILURE + : SUCCESS; } int frankenphp_execute_script_cli(char *script, int argc, char **argv, From 8019e98e6e14fa583a3383b3eff0b7da17412320 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 5 Sep 2026 23:51:28 +0200 Subject: [PATCH 14/30] restore extension registration hooks after cli execution --- frankenphp.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index a01200ebca..608bf74a24 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1863,14 +1863,19 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, */ err = pthread_create(&thread, NULL, execute_script_cli, &args); if (err != 0) { + php_register_internal_extensions_func = + previous_php_register_internal_extensions_func; return err; } err = pthread_join(thread, &exit_status); if (err != 0) { + /* The CLI thread may still be using the hook; do not restore it yet. */ return err; } + php_register_internal_extensions_func = + previous_php_register_internal_extensions_func; return (intptr_t)exit_status; } From 083ba46b70f8c076d5a4eb7f142c8996938149c5 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 5 Sep 2026 23:51:40 +0200 Subject: [PATCH 15/30] respect module replacements in component version entries --- caddy/br.go | 2 +- frankenphp.go | 7 +++++++ mercure.go | 2 +- watcher.go | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/caddy/br.go b/caddy/br.go index 2efe385381..c6991ca1af 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -14,7 +14,7 @@ func init() { if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/dunglas/caddy-cbrotli" { - frankenphp.AddPHPInfoEntry("dunglas/caddy-cbrotli", dep.Version) + frankenphp.AddPHPInfoModule("dunglas/caddy-cbrotli", dep) break } } diff --git a/frankenphp.go b/frankenphp.go index 1f68c1f456..fa02f3ee4b 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -202,10 +202,17 @@ func goModuleVersion(module *debug.Module) string { } // AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). +// Call it during package initialization before Init. func AddPHPInfoEntry(key, value string) { phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) } +// AddPHPInfoModule adds a component's Go module version to the frankenphp section +// of phpinfo(). Call it during package initialization before Init. +func AddPHPInfoModule(key string, module *debug.Module) { + AddPHPInfoEntry(key, goModuleVersion(module)) +} + func initPHPInfoEntries() { freeCEntries(cPhpinfoArr) freeCEntries(cGoModulesArr) diff --git a/mercure.go b/mercure.go index 33599a5a64..41ae854680 100644 --- a/mercure.go +++ b/mercure.go @@ -18,7 +18,7 @@ func init() { if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/dunglas/mercure" { - AddPHPInfoEntry("dunglas/mercure", dep.Version) + AddPHPInfoModule("dunglas/mercure", dep) break } } diff --git a/watcher.go b/watcher.go index b738d02546..474418aa00 100644 --- a/watcher.go +++ b/watcher.go @@ -11,11 +11,11 @@ import ( ) func init() { - // watcher doesn't expose the version, so get it from go.mod + // watcher doesn't expose the version, so get it from the build info. if buildInfo, ok := debug.ReadBuildInfo(); ok { for _, dep := range buildInfo.Deps { if dep.Path == "github.com/e-dant/watcher" { - AddPHPInfoEntry("e-dant/watcher", dep.Version) + AddPHPInfoModule("e-dant/watcher", dep) break } } From f8eabf65d5b0212aec91db0cea27c7ad600a8a1f Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 5 Sep 2026 23:51:52 +0200 Subject: [PATCH 16/30] include main module in phpinfo module inventory --- frankenphp.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index fa02f3ee4b..d17065cf85 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -180,10 +180,18 @@ func init() { AddPHPInfoEntry("go", buildInfo.GoVersion) - goModuleEntries = make([]phpinfoEntry, 0, len(buildInfo.Deps)) + goModuleEntries = buildGoModuleEntries(buildInfo) +} + +func buildGoModuleEntries(buildInfo *debug.BuildInfo) []phpinfoEntry { + entries := make([]phpinfoEntry, 0, len(buildInfo.Deps)+1) + if buildInfo.Main.Path != "" { + entries = append(entries, phpinfoEntry{buildInfo.Main.Path, goModuleVersion(&buildInfo.Main)}) + } for _, dep := range buildInfo.Deps { - goModuleEntries = append(goModuleEntries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) + entries = append(entries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) } + return entries } // goModuleVersion returns the version of the given module, taking "replace" From 5ea3d90f3da41d0fd2bca553d47c0d08619877ca Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 6 Sep 2026 00:04:49 +0200 Subject: [PATCH 17/30] test cli behavior across startup and shutdown --- cli_linux_test.go | 118 ++++++++++++++ cli_test.go | 283 ++++++++++++++++++++++++++++++++++ go.mod | 2 +- testdata/command-detached.php | 43 ++++++ 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 cli_linux_test.go create mode 100644 testdata/command-detached.php diff --git a/cli_linux_test.go b/cli_linux_test.go new file mode 100644 index 0000000000..3354545f7c --- /dev/null +++ b/cli_linux_test.go @@ -0,0 +1,118 @@ +//go:build linux + +package frankenphp_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestExecuteScriptCLIDetachedChild(t *testing.T) { + const helperEnv = "FRANKENPHP_TEST_DETACHED_CHILD" + dir := os.Getenv(helperEnv) + if dir == "" { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + self, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestExecuteScriptCLIDetachedChild$", "-test.v") + cmd.Env = append(os.Environ(), helperEnv+"="+t.TempDir()) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 77 { + t.Skipf("pcntl/posix unavailable: %s", output) + } + require.NoError(t, err, "%s", output) + return + } + + // PDEATHSIG and subreapers are Linux-specific. Isolate adoption from other tests. + require.NoError(t, unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)) + input, release, err := os.Pipe() + require.NoError(t, err) + defer func() { _ = input.Close() }() + pid := 0 + t.Cleanup(func() { + // EOF also releases a child whose PID was not reported before a parent failure. + _ = release.Close() + if pid > 0 { + _ = unix.Kill(pid, unix.SIGKILL) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + var status unix.WaitStatus + _, err := unix.Wait4(-1, &status, unix.WNOHANG, nil) + if errors.Is(err, unix.ECHILD) { + return + } + if err != nil && !errors.Is(err, unix.EINTR) { + t.Errorf("reaping detached child: %v", err) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Error("detached child cleanup timed out") + }) + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + ready := filepath.Join(dir, "ready") + _, err = os.Lstat(ready) + require.ErrorIs(t, err, os.ErrNotExist, "readiness path must not already exist") + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "testdata/command-detached.php") + // PHP's emulated and native CLIs expose different script argv layouts. + cmd.Env = append(os.Environ(), "FRANKENPHP_TEST_DETACHED_READY="+ready) + cmd.Stdin = input + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 2 { + // The fixture checks extensions before forking, so nothing needs reaping. + t.Logf("%s", output) + os.Exit(77) + } + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "CHILD=") { + pid, _ = strconv.Atoi(strings.TrimPrefix(line, "CHILD=")) + } + } + require.NoError(t, err, "CLI parent: %s", output) + require.Greater(t, pid, 0, "no child PID: %s", output) + + // CombinedOutput has waited for the actual CLI parent exit, not just readiness. + // The CLI joins its PHP thread before exiting, so this also covers Linux's + // PDEATHSIG on the forking thread's exit rather than the whole process's exit. + _, writeErr := release.WriteString("survived\n") + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var status unix.WaitStatus + got, err := unix.Wait4(pid, &status, unix.WNOHANG, nil) + if errors.Is(err, unix.EINTR) { + continue + } + require.NoError(t, err) + if got == pid { + pid = 0 // Reaped: cleanup must not signal a potentially reused PID. + require.True(t, status.Exited(), "detached child terminated by signal %d (%s)", status.Signal(), status.Signal()) + require.Equal(t, 0, status.ExitStatus(), "detached child failed") + require.NoError(t, writeErr) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("detached child did not finish after CLI parent exited") +} diff --git a/cli_test.go b/cli_test.go index 3aeacfdf96..ca94992c63 100644 --- a/cli_test.go +++ b/cli_test.go @@ -1,15 +1,20 @@ package frankenphp_test import ( + "context" "errors" + "fmt" "log" "os" "os/exec" + "path/filepath" "runtime" "testing" + "time" "github.com/dunglas/frankenphp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExecuteScriptCLI(t *testing.T) { @@ -115,6 +120,284 @@ func TestExecuteScriptCLISignals(t *testing.T) { assert.Contains(t, string(stdoutStderr), "ok") } +func TestExecuteCLIEnvironment(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + t.Setenv("FRANKENPHP_CLI_ENVIRONMENT_TEST", "inherited") + for _, tt := range []struct { + name string + code string + want string + }{ + { + name: "getenv named", + code: `echo json_encode([getenv($name), getenv($name, true)]);`, + want: `["inherited","inherited"]`, + }, + { + name: "getenv all", + code: ` +$env = getenv(); +$localEnv = getenv(null, true); +echo json_encode([is_array($env), $env[$name], is_array($localEnv), $localEnv[$name]]);`, + want: `[true,"inherited",true,"inherited"]`, + }, + { + name: "putenv", + code: ` +$results = [putenv($name . "=changed=value"), getenv($name), getenv($name, true), getenv()[$name]]; +$results[] = putenv($name . "="); +$results[] = getenv($name); +$results[] = array_key_exists($name, getenv()); +$results[] = putenv($name); +$results[] = getenv($name); +$results[] = getenv($name, true); +$results[] = array_key_exists($name, getenv()); +echo json_encode($results);`, + want: `[true,"changed=value","changed=value","changed=value",true,"",true,true,false,false,false]`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-n", "-r", `$name = "FRANKENPHP_CLI_ENVIRONMENT_TEST"; `+tt.code) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "CLI timed out; output: %s", output) + require.NoError(t, err, "output: %s", output) + require.Equal(t, tt.want, string(output)) + }) + } +} + +func TestExecuteCLIHTTPFunctionsUnavailable(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + for _, tt := range []struct { + name string + args string + }{ + {"getallheaders", ""}, + {"apache_request_headers", ""}, + {"fastcgi_finish_request", ""}, + {"frankenphp_request_headers", ""}, + {"frankenphp_response_headers", ""}, + {"apache_response_headers", ""}, + {"frankenphp_finish_request", ""}, + {"frankenphp_handle_request", "static function () {}"}, + {"headers_send", "103"}, + {"mercure_publish", "'https://example.com/topic', 'test'"}, + {"frankenphp_log", "'CLI feature detection'"}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + // Frameworks call these after feature detection. Exposing HTTP + // callbacks in CLI can access missing Go threads or a foreign SAPI context. + code := fmt.Sprintf(` +$function = %q; +if (function_exists($function)) { + $function(%s); +} +var_export(function_exists($function)); +`, tt.name, tt.args) + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-r", code) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "output: %s", output) + require.NoError(t, err, "output: %s", output) + require.Equal(t, "false", string(output)) + }) + } +} + +func TestExecuteCLINativeHTTPFunctions(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-r", ` +if (PHP_SAPI !== 'cli') { + throw new RuntimeException('Expected ordinary CLI'); +} +foreach (['header', 'header_remove', 'headers_list', 'headers_sent', + 'http_response_code', 'flush', 'connection_status', + 'connection_aborted', 'ignore_user_abort'] as $function) { + if (!function_exists($function)) { + throw new RuntimeException('Missing native function: ' . $function); + } +} +header('X-CLI-Test: test'); +header_remove('X-CLI-Test'); +headers_list(); +headers_sent(); +http_response_code(204); +flush(); +connection_status(); +connection_aborted(); +ignore_user_abort(false); +// Older PHP versions use the embed SAPI rather than the native CLI SAPI. +if (PHP_VERSION_ID >= 80600) { + foreach (['dl', 'cli_set_process_title', 'cli_get_process_title'] as $function) { + if (!function_exists($function)) { + throw new RuntimeException('Missing native CLI function: ' . $function); + } + } +} +echo 'ok'; +`) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "output: %s", output) + require.NoError(t, err, "output: %s", output) + require.Equal(t, "ok", string(output)) +} + +func TestExecuteScriptCLILifecycle(t *testing.T) { + const childEnv = "FRANKENPHP_CLI_LIFECYCLE_CHILD" + if scenario := os.Getenv(childEnv); scenario != "" { + calls := 1 + switch scenario { + case "repeated": + calls = 2 + case "rejected-then-script": + // Missing -r code is rejected before PHP startup by the pre-8.6 + // emulation, but still installs the module registration hook. + args := []string{"cli-lifecycle", "-n", "-r"} + if status := frankenphp.ExecuteScriptCLI(args[0], args); status == 0 { + t.Fatal("CLI accepted -r without code") + } + default: + t.Fatalf("unknown CLI lifecycle scenario %q", scenario) + } + + for i := 1; i <= calls; i++ { + code := fmt.Sprintf("file_put_contents('cli-lifecycle-script-%d', 'executed'); exit(%d);", i, 20+i) + args := []string{"cli-lifecycle", "-n", "-r", code} + if status := frankenphp.ExecuteScriptCLI(args[0], args); status != 20+i { + t.Fatalf("CLI call %d returned %d, want %d", i, status, 20+i) + } + } + // Older CLI emulation closes standard streams at shutdown. Use the + // process exit status rather than the test runner's final output. + os.Exit(0) + } + + for _, scenario := range []string{"repeated", "rejected-then-script"} { + t.Run(scenario, func(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + // Keep PHP's process-global CLI lifecycle out of server tests, and + // bound both a recursive-hook crash and a hung child. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestExecuteScriptCLILifecycle$") + cmd.Env = append(os.Environ(), childEnv+"="+scenario) + // File markers survive pre-8.6 CLI shutdown closing standard streams. + cmd.Dir = t.TempDir() + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI lifecycle child failed: %v (context: %v)\n%s", err, ctx.Err(), output) + } + calls := 1 + if scenario == "repeated" { + calls = 2 + } + for i := 1; i <= calls; i++ { + marker := filepath.Join(cmd.Dir, fmt.Sprintf("cli-lifecycle-script-%d", i)) + if content, err := os.ReadFile(marker); err != nil || string(content) != "executed" { + t.Fatalf("CLI lifecycle child did not execute script %d: marker %q, error %v\n%s", i, content, err, output) + } + } + }) + } +} + +func TestExecuteCLIOpcacheReset(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + for _, tt := range []struct { + name string + enableCLI string + code string + want string + }{ + { + name: "disabled", + enableCLI: "0", + code: `echo json_encode([ini_get('opcache.enable_cli'), opcache_reset()]);`, + want: `["0",false]`, + }, + { + name: "enabled", + enableCLI: "1", + // Native reset schedules a restart at request shutdown, not an + // immediate cache flush. Inspecting the pending flag needs no fixture. + code: ` +$before = opcache_get_status(false); +$reset = opcache_reset(); +$after = opcache_get_status(false); +echo json_encode([ini_get('opcache.enable_cli'), $before['opcache_enabled'], + $before['restart_pending'], $reset, $after['restart_pending']]);`, + want: `["1",true,false,true,true]`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + // The emulated CLI (PHP < 8.6) does not parse -d or -n. Use an + // isolated INI instead, including an empty scan directory. + iniPath := filepath.Join(t.TempDir(), "php.ini") + t.Setenv("PHPRC", iniPath) + t.Setenv("PHP_INI_SCAN_DIR", t.TempDir()) + ini := "opcache.enable=1\nopcache.enable_cli=" + tt.enableCLI + "\n" + + "opcache.file_cache_only=0\nopcache.restrict_api=\nopcache.jit=disable\n" + code := ` +if (!extension_loaded('Zend OPcache')) { + fwrite(STDERR, "OPcache is not loaded\n"); + exit(77); +} +` + tt.code + + // PHP 8.5+ includes OPcache; older builds may link it statically + // or provide a shared extension. Do not load a static extension twice. + for _, shared := range []bool{false, true} { + config := ini + if shared { + config += "zend_extension=opcache\n" + } + require.NoError(t, os.WriteFile(iniPath, []byte(config), 0o600)) + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-r", code) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + cancel() + if exitError, ok := errors.AsType[*exec.ExitError](err); ok && exitError.ExitCode() == 77 { + if shared { + t.Skipf("OPcache is unavailable, including as a shared extension: %s", output) + } + continue + } + require.NoError(t, err, "output: %s", output) + require.Equal(t, tt.want, string(output)) + return + } + }) + } +} + func ExampleExecuteScriptCLI() { if len(os.Args) <= 1 { log.Println("Usage: my-program script.php") diff --git a/go.mod b/go.mod index 552a12442b..381d981e6b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/prometheus/client_golang v1.24.1 github.com/stretchr/testify v1.12.1 golang.org/x/net v0.58.0 + golang.org/x/sys v0.47.0 ) require ( @@ -58,7 +59,6 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/testdata/command-detached.php b/testdata/command-detached.php new file mode 100644 index 0000000000..bd612c0ef2 --- /dev/null +++ b/testdata/command-detached.php @@ -0,0 +1,43 @@ + "$1" && IFS= read -r result && [ "$result" = survived ]', 'detached', $ready]); + exit(1); +} + +printf("CHILD=%d\n", $pid); +$deadline = microtime(true) + 5; +do { + if (is_file($ready)) { + exit(0); + } + usleep(1000); +} while (microtime(true) < $deadline); + +fwrite(STDERR, "detached child did not exec within 5s\n"); +exit(1); From c6044285aa2d061ae247fb836d6f73d0d7fcd270 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 6 Sep 2026 00:04:59 +0200 Subject: [PATCH 18/30] test phpinfo module metadata and escaped rendering --- frankenphp_test.go | 13 +++++ types_test.go | 117 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/frankenphp_test.go b/frankenphp_test.go index 00eaa38080..8b133615b7 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -27,6 +27,7 @@ import ( "os/user" "path/filepath" "runtime" + "runtime/debug" "strconv" "strings" "sync" @@ -458,6 +459,17 @@ func testSession(t *testing.T, opts *testOptions) { }, opts) } +const phpInfoTestComponent = "test/component<&>" + +func init() { + // Register before any Init call, as required by AddPHPInfoModule's contract. + frankenphp.AddPHPInfoModule(phpInfoTestComponent, &debug.Module{ + Path: "example.com/component", + Version: "v1.0.0", + Replace: &debug.Module{Path: "example.com/fork<&>", Version: "v2.0.0"}, + }) +} + func TestPhpInfo_module(t *testing.T) { testPhpInfo(t, nil) } func TestPhpInfo_worker(t *testing.T) { testPhpInfo(t, &testOptions{workerScript: "phpinfo.php"}) } func testPhpInfo(t *testing.T, opts *testOptions) { @@ -472,6 +484,7 @@ func testPhpInfo(t *testing.T, opts *testOptions) { assert.Contains(t, body, "frankenphp") assert.Contains(t, body, fmt.Sprintf("i=%d", i)) assert.Contains(t, body, runtime.Version()) + assert.Contains(t, body, `test/component<&> example.com/fork<&> v2.0.0 `) }, opts) } diff --git a/types_test.go b/types_test.go index a08f90725e..e89bc4a516 100644 --- a/types_test.go +++ b/types_test.go @@ -2,6 +2,8 @@ package frankenphp import ( "log/slog" + "runtime/debug" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -145,3 +147,118 @@ func TestNestedMixedArray(t *testing.T) { assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion") }) } + +func TestBuildGoModuleEntries(t *testing.T) { + deps := []*debug.Module{ + {Path: "example.com/dependency", Version: "v1.2.3"}, + { + Path: "example.com/replaced", + Version: "v1.0.0", + Replace: &debug.Module{Path: "example.com/fork", Version: "v1.4.0"}, + }, + { + Path: "example.com/local", + Version: "v1.0.0", + Replace: &debug.Module{Path: "../local"}, + }, + } + wantDeps := []phpinfoEntry{ + {"example.com/dependency", "v1.2.3"}, + {"example.com/replaced", "example.com/fork v1.4.0"}, + {"example.com/local", "../local"}, + } + + for _, tt := range []struct { + name string + info debug.BuildInfo + want []phpinfoEntry + }{ + { + name: "direct caddy main", + info: debug.BuildInfo{ + Main: debug.Module{Path: "github.com/dunglas/frankenphp/caddy", Version: "v1.12.7"}, + }, + want: []phpinfoEntry{{"github.com/dunglas/frankenphp/caddy", "v1.12.7"}}, + }, + { + name: "development main", + info: debug.BuildInfo{Main: debug.Module{Path: "caddy", Version: "(devel)"}}, + want: []phpinfoEntry{{"caddy", "(devel)"}}, + }, + { + name: "main without version", + info: debug.BuildInfo{Main: debug.Module{Path: "example.com/app"}}, + want: []phpinfoEntry{{"example.com/app", ""}}, + }, + { + name: "main absent", + }, + { + name: "generated command", + info: debug.BuildInfo{Path: "command-line-arguments"}, + }, + { + name: "main without path", + info: debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, + }, + { + name: "replaced main", + info: debug.BuildInfo{Main: debug.Module{ + Path: "example.com/app", + Version: "v1.0.0", + Replace: &debug.Module{Path: "example.com/app-fork", Version: "v1.1.0"}, + }}, + want: []phpinfoEntry{{"example.com/app", "example.com/app-fork v1.1.0"}}, + }, + { + name: "locally replaced main", + info: debug.BuildInfo{Main: debug.Module{ + Path: "example.com/app", + Version: "v1.0.0", + Replace: &debug.Module{Path: "../app"}, + }}, + want: []phpinfoEntry{{"example.com/app", "../app"}}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if got := buildGoModuleEntries(&tt.info); !slices.Equal(got, tt.want) { + t.Fatalf("without dependencies: got %v, want %v", got, tt.want) + } + + tt.info.Deps = deps + want := append(slices.Clone(tt.want), wantDeps...) + if got := buildGoModuleEntries(&tt.info); !slices.Equal(got, want) { + t.Fatalf("with dependencies: got %v, want %v", got, want) + } + }) + } +} + +func TestAddPHPInfoModule(t *testing.T) { + // Keep this test serial and isolate registrations from the runtime tests. + previous := phpinfoEntries + t.Cleanup(func() { phpinfoEntries = previous }) + + const key, path = "test/component", "example.com/component" + for _, tt := range []struct { + name string + replace *debug.Module + want string + }{ + {name: "unreplaced", want: "v1.2.3"}, + {name: "same module version replacement", replace: &debug.Module{Path: path, Version: "v1.2.4"}, want: path + " v1.2.4"}, + {name: "fork version replacement", replace: &debug.Module{Path: "example.com/fork", Version: "v2.0.0"}, want: "example.com/fork v2.0.0"}, + {name: "local path replacement", replace: &debug.Module{Path: "../local-component"}, want: "../local-component"}, + } { + t.Run(tt.name, func(t *testing.T) { + phpinfoEntries = nil + AddPHPInfoModule(key, &debug.Module{Path: path, Version: "v1.2.3", Replace: tt.replace}) + if len(phpinfoEntries) != 1 { + t.Fatalf("registered %d entries, want 1", len(phpinfoEntries)) + } + if got := phpinfoEntries[0]; got != (phpinfoEntry{key, tt.want}) { + t.Errorf("component entry = %#v, want key %q and version %q", got, key, tt.want) + } + }) + } +} From acaad7ecf0abec59510de7d82624135f67322c42 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 6 Sep 2026 00:40:57 +0200 Subject: [PATCH 19/30] reword comment to shut copilot up --- cli_test.go | 2 +- frankenphp.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cli_test.go b/cli_test.go index ca94992c63..742449b2e4 100644 --- a/cli_test.go +++ b/cli_test.go @@ -73,7 +73,7 @@ func TestExecuteCLICodePHPInfoAsText(t *testing.T) { } // `-i` (and any other invocation without a script) is only supported since PHP -// 8.6, where the real CLI SAPI is reused. older versions must fail cleanly. +// 8.6, where the real CLI SAPI is reused. Older versions must fail cleanly. func TestExecuteCLIPHPInfo(t *testing.T) { if _, err := os.Stat("internal/testcli/testcli"); err != nil { t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") diff --git a/frankenphp.go b/frankenphp.go index d17065cf85..2bad167983 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -169,9 +169,8 @@ var ( cGoModulesArr []*C.char ) -// Report the Go toolchain and every Go module linked into the binary. Caddy -// modules, FrankenPHP extensions written in Go and even the standard library -// itself. The list is verbose, so it's displayed in a collapsed section. +// Report the Go toolchain and Go module versions. +// The list is verbose, so it's displayed in a collapsed section. func init() { buildInfo, ok := debug.ReadBuildInfo() if !ok { From 78136e71dceed2de4f9e0272e2be0167512d60b4 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 13:52:24 +0200 Subject: [PATCH 20/30] fix: zero phpinfo arrays before Go pointer writes --- frankenphp.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frankenphp.go b/frankenphp.go index 4c4e77bad5..f636ac0272 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -14,6 +14,7 @@ package frankenphp // #include // #include +// #include // #include "frankenphp.h" // #include // #include @@ -245,7 +246,11 @@ func newCEntries(entries []phpinfoEntry) []*C.char { }) n := 2*len(entries) + 1 - arr := (*[1 << 28]*C.char)(C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0)))))[:n:n] + size := C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0))) + ptr := C.malloc(size) + // Zero in C before Go pointer writes: the GC write barrier may scan old values. + C.memset(ptr, 0, size) + arr := (*[1 << 28]*C.char)(ptr)[:n:n] for i, e := range entries { arr[2*i] = C.CString(e.key) arr[2*i+1] = C.CString(e.value) From 2a4d1d391d57c3703cb4f4601d1cb1e534c76566 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 14:12:21 +0200 Subject: [PATCH 21/30] fix windows stream closing issue --- cli_test.go | 18 ++++++++++++++---- emulate_php_cli.c | 27 +++++++++++++-------------- testdata/command-detached.php | 5 ++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/cli_test.go b/cli_test.go index 742449b2e4..1e1d489f70 100644 --- a/cli_test.go +++ b/cli_test.go @@ -280,14 +280,21 @@ func TestExecuteScriptCLILifecycle(t *testing.T) { } for i := 1; i <= calls; i++ { - code := fmt.Sprintf("file_put_contents('cli-lifecycle-script-%d', 'executed'); exit(%d);", i, 20+i) + code := fmt.Sprintf(` +if (fstat(STDIN) === false) { + exit(1); +} +fwrite(STDOUT, "cli stdout %[1]d\n"); +fwrite(STDERR, "cli stderr %[1]d\n"); +file_put_contents('cli-lifecycle-script-%[1]d', 'executed'); +exit(%[2]d);`, i, 20+i) args := []string{"cli-lifecycle", "-n", "-r", code} if status := frankenphp.ExecuteScriptCLI(args[0], args); status != 20+i { t.Fatalf("CLI call %d returned %d, want %d", i, status, 20+i) } + fmt.Fprintf(os.Stdout, "host stdout %d\n", i) + fmt.Fprintf(os.Stderr, "host stderr %d\n", i) } - // Older CLI emulation closes standard streams at shutdown. Use the - // process exit status rather than the test runner's final output. os.Exit(0) } @@ -303,7 +310,6 @@ func TestExecuteScriptCLILifecycle(t *testing.T) { defer cancel() cmd := exec.CommandContext(ctx, self, "-test.run=^TestExecuteScriptCLILifecycle$") cmd.Env = append(os.Environ(), childEnv+"="+scenario) - // File markers survive pre-8.6 CLI shutdown closing standard streams. cmd.Dir = t.TempDir() cmd.WaitDelay = time.Second output, err := cmd.CombinedOutput() @@ -315,6 +321,10 @@ func TestExecuteScriptCLILifecycle(t *testing.T) { calls = 2 } for i := 1; i <= calls; i++ { + // Both PHP and its host must retain usable stdio across shutdown. + for _, stream := range []string{"cli stdout", "cli stderr", "host stdout", "host stderr"} { + assert.Contains(t, string(output), fmt.Sprintf("%s %d\n", stream, i)) + } marker := filepath.Join(cmd.Dir, fmt.Sprintf("cli-lifecycle-script-%d", i)) if content, err := os.ReadFile(marker); err != nil || string(content) != "executed" { t.Fatalf("CLI lifecycle child did not execute script %d: marker %q, error %v\n%s", i, content, err, output) diff --git a/emulate_php_cli.c b/emulate_php_cli.c index 38d77260e3..e40ef8d729 100644 --- a/emulate_php_cli.c +++ b/emulate_php_cli.c @@ -54,15 +54,22 @@ static void register_server_variable_filtered(const char *key, char **val, * Parts based on CGI SAPI Module by Rasmus Lerdorf, Stig * Bakken and Zeev Suraski */ -static void cli_register_file_handles(bool no_close) /* {{{ */ +static void cli_register_file_handles(void) /* {{{ */ { php_stream *s_in, *s_out, *s_err; - php_stream_context *sc_in = NULL, *sc_out = NULL, *sc_err = NULL; zend_constant ic, oc, ec; - s_in = php_stream_open_wrapper_ex("php://stdin", "rb", 0, NULL, sc_in); - s_out = php_stream_open_wrapper_ex("php://stdout", "wb", 0, NULL, sc_out); - s_err = php_stream_open_wrapper_ex("php://stderr", "wb", 0, NULL, sc_err); + s_in = php_stream_fopen_from_file(stdin, "rb"); + s_out = php_stream_fopen_from_file(stdout, "wb"); + s_err = php_stream_fopen_from_file(stderr, "wb"); + + /* Borrow stdio without duplicating or closing it between executions. */ + if (s_in) + s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE; + if (s_out) + s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE; + if (s_err) + s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE; if (s_in == NULL || s_out == NULL || s_err == NULL) { if (s_in) @@ -74,14 +81,6 @@ static void cli_register_file_handles(bool no_close) /* {{{ */ return; } - if (no_close) { - s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE; - s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE; - s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE; - } - - /*s_in_process = s_in;*/ - php_stream_to_zval(s_in, &ic.value); php_stream_to_zval(s_out, &oc.value); php_stream_to_zval(s_err, &ec.value); @@ -170,7 +169,7 @@ void *emulate_script_cli(void *arg) { php_embed_init(cli_args->argc, cli_args->argv); - cli_register_file_handles(false); + cli_register_file_handles(); zend_first_try { if (eval) { /* evaluate script as literal PHP code (php-cli -r "...") */ diff --git a/testdata/command-detached.php b/testdata/command-detached.php index bd612c0ef2..e40f24a1c9 100644 --- a/testdata/command-detached.php +++ b/testdata/command-detached.php @@ -23,10 +23,9 @@ if (posix_setsid() === -1) { exit(1); } - fclose(STDOUT); - fclose(STDERR); + // Redirect after exec: the emulated CLI keeps process stdio open. // Keep stdin for the Go test's token, sent only after this CLI parent exits. - pcntl_exec('/bin/sh', ['-c', 'set -C; printf ready > "$1" && IFS= read -r result && [ "$result" = survived ]', 'detached', $ready]); + pcntl_exec('/bin/sh', ['-c', 'exec >/dev/null 2>&1; set -C; printf ready > "$1" && IFS= read -r result && [ "$result" = survived ]', 'detached', $ready]); exit(1); } From 44cfc28a4bd833e7746289eab6615f70b9071d42 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 14:20:32 +0200 Subject: [PATCH 22/30] fix linux amd64 race tests --- .github/workflows/docker.yaml | 4 +++- caddy/caddy_test.go | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 3c285d677f..94d77e67fe 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -109,7 +109,9 @@ jobs: include: - race: "" - platform: linux/amd64 - race: "-race" # The Go race detector is only supported on amd64 + # The Go race detector is only enabled on amd64. PHP's inherited PIE + # flags require external linking for runtime/cgo in pure-Go test packages. + race: "-race -ldflags=-linkmode=external" exclude: # arm/v6 is only available for Alpine: https://github.com/docker-library/golang/issues/502 - variant: php-${{ needs.prepare.outputs.php82_version }}-trixie diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index b7d6c231eb..331b13ee5d 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -1219,12 +1220,19 @@ func testSingleIniConfiguration(tester *caddytest.Tester, key string, value stri } func TestOsEnv(t *testing.T) { + // This is not a reload test: avoid the previous config's listener, which + // Caddy may still be shutting down after FrankenPHP unregisters its server. + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) + require.NoError(t, listener.Close()) + tester := caddytest.NewTester(t) - initServer(t, tester, ` + tester.InitServer(` { skip_install_trust admin localhost:2999 - http_port `+testPort+` + http_port `+port+` frankenphp { num_threads 2 @@ -1233,7 +1241,7 @@ func TestOsEnv(t *testing.T) { } } - localhost:`+testPort+` { + localhost:`+port+` { route { root ../testdata php @@ -1242,7 +1250,7 @@ func TestOsEnv(t *testing.T) { `, "caddyfile") tester.AssertGetResponse( - "http://localhost:"+testPort+"/env/env.php?keys[]=ENV1&keys[]=ENV2", + "http://localhost:"+port+"/env/env.php?keys[]=ENV1&keys[]=ENV2", http.StatusOK, "ENV1=value1,ENV2=value2", ) From 1b7a97393e805b22f901a407bf7df0f8aa9e4326 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 14:38:36 +0200 Subject: [PATCH 23/30] satisfy go fmt --- cli_test.go | 6 ++++-- frankenphp_test.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cli_test.go b/cli_test.go index 1e1d489f70..c28333f337 100644 --- a/cli_test.go +++ b/cli_test.go @@ -292,8 +292,10 @@ exit(%[2]d);`, i, 20+i) if status := frankenphp.ExecuteScriptCLI(args[0], args); status != 20+i { t.Fatalf("CLI call %d returned %d, want %d", i, status, 20+i) } - fmt.Fprintf(os.Stdout, "host stdout %d\n", i) - fmt.Fprintf(os.Stderr, "host stderr %d\n", i) + _, err := fmt.Fprintf(os.Stdout, "host stdout %d\n", i) + require.NoError(t, err) + _, err = fmt.Fprintf(os.Stderr, "host stderr %d\n", i) + require.NoError(t, err) } os.Exit(0) } diff --git a/frankenphp_test.go b/frankenphp_test.go index 8b133615b7..3d6e501cc8 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -1247,6 +1247,24 @@ func FuzzResponseHeaders(f *testing.F) { // fuzzer-controlled, since unbounded native recursion (no depth guard) is // the interesting bug class here, not the value shapes themselves. func FuzzPersistZvalRoundtrip(f *testing.F) { + // Check the compiled-in hook once, not in every seed's concurrent requests. + func() { + require.NoError(f, frankenphp.Init()) + defer frankenphp.Shutdown() + + root, err := fastabs.FastAbs("./testdata") + require.NoError(f, err) + req := httptest.NewRequest("GET", "http://example.com/fuzz-persist-roundtrip.php", nil) + req, err = frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(root, false)) + require.NoError(f, err) + w := httptest.NewRecorder() + require.NoError(f, frankenphp.ServeHTTP(w, req)) + require.Equal(f, http.StatusOK, w.Code) + if w.Body.String() == "SKIP" { + f.Skip("FRANKENPHP_TEST not set; skipping persistent_zval roundtrip fuzzing") + } + }() + f.Add(0, 1) f.Add(1, 1) f.Add(10, 2) From 6d5a4fd22ba0178887bc9b0637d6351007e06a2e Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 19:14:33 +0200 Subject: [PATCH 24/30] suggestions --- caddy/caddy.go | 8 ++------ caddy/phpinfo_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ cli_test.go | 25 +++++++++++++++++++++++++ frankenphp.c | 15 ++++++++++----- 4 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 caddy/phpinfo_test.go diff --git a/caddy/caddy.go b/caddy/caddy.go index ed1ad5fde0..475eb70bc1 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -28,12 +28,8 @@ func init() { caddy.RegisterModule(&FrankenPHPAdmin{}) // Report Caddy version in phpinfo() - simpleVersion, fullVersion := caddy.Version() - if fullVersion != "" { - frankenphp.AddPHPInfoEntry("caddy", fullVersion) - } else if simpleVersion != "" { - frankenphp.AddPHPInfoEntry("caddy", simpleVersion) - } + _, fullVersion := caddy.Version() + frankenphp.AddPHPInfoEntry("caddy", fullVersion) httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) diff --git a/caddy/phpinfo_test.go b/caddy/phpinfo_test.go new file mode 100644 index 0000000000..62d969fb3d --- /dev/null +++ b/caddy/phpinfo_test.go @@ -0,0 +1,41 @@ +package caddy_test + +import ( + "html" + "io" + "net/http" + "regexp" + "testing" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddytest" + "github.com/stretchr/testify/require" +) + +func TestPHPInfoCaddyVersion(t *testing.T) { + tester := caddytest.NewTester(t) + initServer(t, tester, ` + { + skip_install_trust + admin localhost:2999 + } + + http://localhost:`+testPort+` { + php_server { + root ../testdata + } + } + `, "caddyfile") + + resp, err := tester.Client.Get("http://localhost:" + testPort + "/phpinfo.php") + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + row := regexp.MustCompile(`caddy (.*?) `).FindSubmatch(body) + _, fullVersion := caddy.Version() + require.Len(t, row, 2, "phpinfo must include the Caddy version row") + require.Equal(t, html.EscapeString(fullVersion), string(row[1])) +} diff --git a/cli_test.go b/cli_test.go index c28333f337..63d6357c54 100644 --- a/cli_test.go +++ b/cli_test.go @@ -173,6 +173,31 @@ echo json_encode($results);`, } } +func TestExecuteCLIExtensionDetection(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-r", ` +if (extension_loaded('frankenphp')) { + frankenphp_handle_request(static function () {}); +} +echo json_encode([ + extension_loaded('frankenphp'), + in_array('frankenphp', get_loaded_extensions(), true), + extension_loaded('frankenphp-cli'), + in_array('frankenphp-cli', get_loaded_extensions(), true), +]); +`) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "output: %s", output) + require.NoError(t, err, "output: %s", output) + require.Equal(t, "[false,false,true,true]", string(output)) +} + func TestExecuteCLIHTTPFunctionsUnavailable(t *testing.T) { if _, err := os.Stat("internal/testcli/testcli"); err != nil { t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") diff --git a/frankenphp.c b/frankenphp.c index 608bf74a24..bd82d975a1 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1171,11 +1171,12 @@ static zend_module_entry frankenphp_module = { TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; -/* CLI exposes the same metadata, but must keep PHP's native functions and - * avoid initializing hooks that depend on the server runtime. */ +/* CLI exposes the same metadata under a distinct name so extension detection + * does not advertise server functions. Keep PHP's native functions and avoid + * initializing hooks that depend on the server runtime. */ static zend_module_entry frankenphp_cli_module = { STANDARD_MODULE_HEADER, - "frankenphp", + "frankenphp-cli", NULL, /* function table */ NULL, /* initialization */ NULL, /* shutdown */ @@ -1853,8 +1854,12 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, cli_exec_args_t args = { .script = script, .argc = argc, .argv = argv, .eval = eval}; - previous_php_register_internal_extensions_func = - php_register_internal_extensions_func; + /* A failed join can leave our hook installed. Do not save it as its own + * predecessor on the next call. */ + if (previous_php_register_internal_extensions_func == NULL) { + previous_php_register_internal_extensions_func = + php_register_internal_extensions_func; + } php_register_internal_extensions_func = register_frankenphp_module; /* From c64dd5b9d42a6f5937c20216a61d30358c5699c6 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 8 Sep 2026 20:59:47 +0200 Subject: [PATCH 25/30] safety fix for userland closing non-duplicate streams --- emulate_php_cli.c | 33 ++++++++++++++++++++++----------- frankenphp.c | 2 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/emulate_php_cli.c b/emulate_php_cli.c index e40ef8d729..59f702f6f5 100644 --- a/emulate_php_cli.c +++ b/emulate_php_cli.c @@ -17,6 +17,7 @@ #endif #include #include +#include #include #include #include @@ -46,6 +47,24 @@ static void register_server_variable_filtered(const char *key, char **val, } } +static php_stream *cli_open_standard_stream(const char *path, const char *mode, + FILE *file) { + php_stream *stream = php_stream_open_wrapper(path, mode, 0, NULL); + php_socket_t fd; + + /* PHP uses the process's stdin/stdout/stderr on the first open and duplicates + * them on later opens. Keep the originals open for the next CLI execution, + * but let PHP close the duplicates. */ + if (stream && + php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT, (void **)&fd, 0) == + SUCCESS && + fd == (php_socket_t)fileno(file)) { + stream->flags |= PHP_STREAM_FLAG_NO_CLOSE; + } + + return stream; +} + /* * CLI code is adapted from * https://github.com/php/php-src/blob/master/sapi/cli/php_cli.c Copyright (c) @@ -59,17 +78,9 @@ static void cli_register_file_handles(void) /* {{{ */ php_stream *s_in, *s_out, *s_err; zend_constant ic, oc, ec; - s_in = php_stream_fopen_from_file(stdin, "rb"); - s_out = php_stream_fopen_from_file(stdout, "wb"); - s_err = php_stream_fopen_from_file(stderr, "wb"); - - /* Borrow stdio without duplicating or closing it between executions. */ - if (s_in) - s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE; - if (s_out) - s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE; - if (s_err) - s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE; + s_in = cli_open_standard_stream("php://stdin", "rb", stdin); + s_out = cli_open_standard_stream("php://stdout", "wb", stdout); + s_err = cli_open_standard_stream("php://stderr", "wb", stderr); if (s_in == NULL || s_out == NULL || s_err == NULL) { if (s_in) diff --git a/frankenphp.c b/frankenphp.c index bd82d975a1..104f32544f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1856,7 +1856,7 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, /* A failed join can leave our hook installed. Do not save it as its own * predecessor on the next call. */ - if (previous_php_register_internal_extensions_func == NULL) { + if (php_register_internal_extensions_func != register_frankenphp_module) { previous_php_register_internal_extensions_func = php_register_internal_extensions_func; } From 873ca4a8ab9e0b4fe1448562ba59deedf578e20e Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 9 Sep 2026 16:23:40 +0200 Subject: [PATCH 26/30] pin instead of manual c memory management --- frankenphp.go | 60 ++++++++++++++------------------------------------- types_test.go | 19 ++++++++++++++++ 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index f636ac0272..44204d2ad0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -14,7 +14,6 @@ package frankenphp // #include // #include -// #include // #include "frankenphp.h" // #include // #include @@ -167,8 +166,7 @@ type phpinfoEntry struct { var ( phpinfoEntries []phpinfoEntry goModuleEntries []phpinfoEntry - cPhpinfoArr []*C.char - cGoModulesArr []*C.char + phpinfoPinner runtime.Pinner ) // Report the Go toolchain and Go module versions. @@ -223,20 +221,17 @@ func AddPHPInfoModule(key string, module *debug.Module) { } func initPHPInfoEntries() { - freeCEntries(cPhpinfoArr) - freeCEntries(cGoModulesArr) - - cPhpinfoArr = newCEntries(phpinfoEntries) - cGoModulesArr = newCEntries(goModuleEntries) - - C.frankenphp_phpinfo_entries = firstCEntry(cPhpinfoArr) - C.frankenphp_go_modules = firstCEntry(cGoModulesArr) + // Replace the previous runtime's tables before PHP starts using them. + C.frankenphp_phpinfo_entries = nil + C.frankenphp_go_modules = nil + phpinfoPinner.Unpin() + C.frankenphp_phpinfo_entries = pinPHPInfoEntries(phpinfoEntries, &phpinfoPinner) + C.frankenphp_go_modules = pinPHPInfoEntries(goModuleEntries, &phpinfoPinner) } -// newCEntries converts entries to a null-terminated C array of key, value, key, -// value, ... sorted by key. The returned slice is backed by memory allocated by -// C, free it with freeCEntries(). -func newCEntries(entries []phpinfoEntry) []*C.char { +// pinPHPInfoEntries sorts entries and pins a null-terminated array of key, value +// pointers, along with the null-terminated strings they point to. +func pinPHPInfoEntries(entries []phpinfoEntry, pinner *runtime.Pinner) **C.char { if len(entries) == 0 { return nil } @@ -245,38 +240,15 @@ func newCEntries(entries []phpinfoEntry) []*C.char { return entries[i].key < entries[j].key }) - n := 2*len(entries) + 1 - size := C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0))) - ptr := C.malloc(size) - // Zero in C before Go pointer writes: the GC write barrier may scan old values. - C.memset(ptr, 0, size) - arr := (*[1 << 28]*C.char)(ptr)[:n:n] + arr := make([]*C.char, 2*len(entries)+1) for i, e := range entries { - arr[2*i] = C.CString(e.key) - arr[2*i+1] = C.CString(e.value) - } - arr[n-1] = nil - - return arr -} - -func freeCEntries(arr []*C.char) { - for _, cstr := range arr { - if cstr != nil { - C.free(unsafe.Pointer(cstr)) + for j, s := range []string{e.key, e.value} { + data := unsafe.StringData(s + "\x00") + pinner.Pin(data) + arr[2*i+j] = (*C.char)(unsafe.Pointer(data)) } } - - if arr != nil { - C.free(unsafe.Pointer(&arr[0])) - } -} - -func firstCEntry(arr []*C.char) **C.char { - if arr == nil { - return nil - } - + pinner.Pin(&arr[0]) return &arr[0] } diff --git a/types_test.go b/types_test.go index e89bc4a516..ae838d840b 100644 --- a/types_test.go +++ b/types_test.go @@ -2,9 +2,11 @@ package frankenphp import ( "log/slog" + "runtime" "runtime/debug" "slices" "testing" + "unsafe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -148,6 +150,23 @@ func TestNestedMixedArray(t *testing.T) { }) } +func TestPinPHPInfoEntries(t *testing.T) { + var pinner runtime.Pinner + defer pinner.Unpin() + + require.Nil(t, pinPHPInfoEntries(nil, &pinner)) + entries := []phpinfoEntry{{"z", "last"}, {"a", ""}, {"", "first"}} + ptr := pinPHPInfoEntries(entries, &pinner) + runtime.GC() + + arr := unsafe.Slice(ptr, 2*len(entries)+1) + for i, want := range []string{"", "first", "a", "", "z", "last"} { + got := unsafe.Slice((*byte)(unsafe.Pointer(arr[i])), len(want)+1) + assert.Equal(t, want+"\x00", string(got)) + } + assert.Nil(t, arr[len(arr)-1]) +} + func TestBuildGoModuleEntries(t *testing.T) { deps := []*debug.Module{ {Path: "example.com/dependency", Version: "v1.2.3"}, From d055b385e0cc1f97e483fc24960f37a6184769f8 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 9 Sep 2026 16:34:02 +0200 Subject: [PATCH 27/30] switch to caddy's simpleVersion --- caddy/caddy.go | 4 ++-- caddy/phpinfo_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 475eb70bc1..daab10c296 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -28,8 +28,8 @@ func init() { caddy.RegisterModule(&FrankenPHPAdmin{}) // Report Caddy version in phpinfo() - _, fullVersion := caddy.Version() - frankenphp.AddPHPInfoEntry("caddy", fullVersion) + simpleVersion, _ := caddy.Version() + frankenphp.AddPHPInfoEntry("caddy", simpleVersion) httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) diff --git a/caddy/phpinfo_test.go b/caddy/phpinfo_test.go index 62d969fb3d..1023008d39 100644 --- a/caddy/phpinfo_test.go +++ b/caddy/phpinfo_test.go @@ -35,7 +35,7 @@ func TestPHPInfoCaddyVersion(t *testing.T) { require.NoError(t, err) row := regexp.MustCompile(`caddy (.*?) `).FindSubmatch(body) - _, fullVersion := caddy.Version() + simpleVersion, _ := caddy.Version() require.Len(t, row, 2, "phpinfo must include the Caddy version row") - require.Equal(t, html.EscapeString(fullVersion), string(row[1])) + require.Equal(t, html.EscapeString(simpleVersion), string(row[1])) } From 90a1fb0d00aa3d79f62d3e5246c9d202290c46dc Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 9 Sep 2026 17:36:40 +0200 Subject: [PATCH 28/30] move phpinfo helpers into phpinfo.go --- frankenphp.go | 95 ---------------------------------------------- phpinfo.go | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 95 deletions(-) create mode 100644 phpinfo.go diff --git a/frankenphp.go b/frankenphp.go index 44204d2ad0..1c14883024 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -30,8 +30,6 @@ import ( "os" "os/signal" "runtime" - "runtime/debug" - "sort" "strings" "sync" "sync/atomic" @@ -159,99 +157,6 @@ func Config() PHPConfig { } } -type phpinfoEntry struct { - key, value string -} - -var ( - phpinfoEntries []phpinfoEntry - goModuleEntries []phpinfoEntry - phpinfoPinner runtime.Pinner -) - -// Report the Go toolchain and Go module versions. -// The list is verbose, so it's displayed in a collapsed section. -func init() { - buildInfo, ok := debug.ReadBuildInfo() - if !ok { - return - } - - AddPHPInfoEntry("go", buildInfo.GoVersion) - - goModuleEntries = buildGoModuleEntries(buildInfo) -} - -func buildGoModuleEntries(buildInfo *debug.BuildInfo) []phpinfoEntry { - entries := make([]phpinfoEntry, 0, len(buildInfo.Deps)+1) - if buildInfo.Main.Path != "" { - entries = append(entries, phpinfoEntry{buildInfo.Main.Path, goModuleVersion(&buildInfo.Main)}) - } - for _, dep := range buildInfo.Deps { - entries = append(entries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) - } - return entries -} - -// goModuleVersion returns the version of the given module, taking "replace" -// directives into account. -func goModuleVersion(module *debug.Module) string { - if module.Replace == nil { - return module.Version - } - - if module.Replace.Version == "" { - // Replaced by a local directory - return module.Replace.Path - } - - return module.Replace.Path + " " + module.Replace.Version -} - -// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). -// Call it during package initialization before Init. -func AddPHPInfoEntry(key, value string) { - phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) -} - -// AddPHPInfoModule adds a component's Go module version to the frankenphp section -// of phpinfo(). Call it during package initialization before Init. -func AddPHPInfoModule(key string, module *debug.Module) { - AddPHPInfoEntry(key, goModuleVersion(module)) -} - -func initPHPInfoEntries() { - // Replace the previous runtime's tables before PHP starts using them. - C.frankenphp_phpinfo_entries = nil - C.frankenphp_go_modules = nil - phpinfoPinner.Unpin() - C.frankenphp_phpinfo_entries = pinPHPInfoEntries(phpinfoEntries, &phpinfoPinner) - C.frankenphp_go_modules = pinPHPInfoEntries(goModuleEntries, &phpinfoPinner) -} - -// pinPHPInfoEntries sorts entries and pins a null-terminated array of key, value -// pointers, along with the null-terminated strings they point to. -func pinPHPInfoEntries(entries []phpinfoEntry, pinner *runtime.Pinner) **C.char { - if len(entries) == 0 { - return nil - } - - sort.Slice(entries, func(i, j int) bool { - return entries[i].key < entries[j].key - }) - - arr := make([]*C.char, 2*len(entries)+1) - for i, e := range entries { - for j, s := range []string{e.key, e.value} { - data := unsafe.StringData(s + "\x00") - pinner.Pin(data) - arr[2*i+j] = (*C.char)(unsafe.Pointer(data)) - } - } - pinner.Pin(&arr[0]) - return &arr[0] -} - // checkPHPConfig rejects the PHP builds FrankenPHP cannot run on func checkPHPConfig(config PHPConfig) error { if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) { diff --git a/phpinfo.go b/phpinfo.go new file mode 100644 index 0000000000..6ebb185efe --- /dev/null +++ b/phpinfo.go @@ -0,0 +1,103 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "runtime" + "runtime/debug" + "sort" + "unsafe" +) + +type phpinfoEntry struct { + key, value string +} + +var ( + phpinfoEntries []phpinfoEntry + goModuleEntries []phpinfoEntry + phpinfoPinner runtime.Pinner +) + +// Report the Go toolchain and Go module versions. +// The list is verbose, so it's displayed in a collapsed section. +func init() { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return + } + + AddPHPInfoEntry("go", buildInfo.GoVersion) + + goModuleEntries = buildGoModuleEntries(buildInfo) +} + +func buildGoModuleEntries(buildInfo *debug.BuildInfo) []phpinfoEntry { + entries := make([]phpinfoEntry, 0, len(buildInfo.Deps)+1) + if buildInfo.Main.Path != "" { + entries = append(entries, phpinfoEntry{buildInfo.Main.Path, goModuleVersion(&buildInfo.Main)}) + } + for _, dep := range buildInfo.Deps { + entries = append(entries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) + } + return entries +} + +// goModuleVersion returns the version of the given module, taking "replace" +// directives into account. +func goModuleVersion(module *debug.Module) string { + if module.Replace == nil { + return module.Version + } + + if module.Replace.Version == "" { + // Replaced by a local directory + return module.Replace.Path + } + + return module.Replace.Path + " " + module.Replace.Version +} + +// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). +// Call it during package initialization before Init. +func AddPHPInfoEntry(key, value string) { + phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) +} + +// AddPHPInfoModule adds a component's Go module version to the frankenphp section +// of phpinfo(). Call it during package initialization before Init. +func AddPHPInfoModule(key string, module *debug.Module) { + AddPHPInfoEntry(key, goModuleVersion(module)) +} + +func initPHPInfoEntries() { + // Replace the previous runtime's tables before PHP starts using them. + C.frankenphp_phpinfo_entries = nil + C.frankenphp_go_modules = nil + phpinfoPinner.Unpin() + C.frankenphp_phpinfo_entries = pinPHPInfoEntries(phpinfoEntries, &phpinfoPinner) + C.frankenphp_go_modules = pinPHPInfoEntries(goModuleEntries, &phpinfoPinner) +} + +// pinPHPInfoEntries sorts entries and pins a null-terminated array of key, value +// pointers, along with the null-terminated strings they point to. +func pinPHPInfoEntries(entries []phpinfoEntry, pinner *runtime.Pinner) **C.char { + if len(entries) == 0 { + return nil + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + arr := make([]*C.char, 2*len(entries)+1) + for i, e := range entries { + for j, s := range []string{e.key, e.value} { + data := unsafe.StringData(s + "\x00") + pinner.Pin(data) + arr[2*i+j] = (*C.char)(unsafe.Pointer(data)) + } + } + pinner.Pin(&arr[0]) + return &arr[0] +} From f991a3a0aa3b38f34b678cf0e0fbe805bb937b4f Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 9 Sep 2026 17:37:32 +0200 Subject: [PATCH 29/30] centralize phpinfo module version lookup --- caddy/br.go | 15 ++-------- frankenphp_test.go | 8 +---- mercure.go | 10 +------ phpinfo.go | 74 ++++++++++++++++++++++++++++------------------ types_test.go | 36 +++++++++++++++------- watcher.go | 11 +------ 6 files changed, 77 insertions(+), 77 deletions(-) diff --git a/caddy/br.go b/caddy/br.go index c6991ca1af..791a94f991 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -2,21 +2,10 @@ package caddy -import ( - "runtime/debug" - - "github.com/dunglas/frankenphp" -) +import "github.com/dunglas/frankenphp" var brotli = true func init() { - if buildInfo, ok := debug.ReadBuildInfo(); ok { - for _, dep := range buildInfo.Deps { - if dep.Path == "github.com/dunglas/caddy-cbrotli" { - frankenphp.AddPHPInfoModule("dunglas/caddy-cbrotli", dep) - break - } - } - } + frankenphp.AddPHPInfoModule("dunglas/caddy-cbrotli", "github.com/dunglas/caddy-cbrotli") } diff --git a/frankenphp_test.go b/frankenphp_test.go index 3d6e501cc8..b137bd5012 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -27,7 +27,6 @@ import ( "os/user" "path/filepath" "runtime" - "runtime/debug" "strconv" "strings" "sync" @@ -462,12 +461,7 @@ func testSession(t *testing.T, opts *testOptions) { const phpInfoTestComponent = "test/component<&>" func init() { - // Register before any Init call, as required by AddPHPInfoModule's contract. - frankenphp.AddPHPInfoModule(phpInfoTestComponent, &debug.Module{ - Path: "example.com/component", - Version: "v1.0.0", - Replace: &debug.Module{Path: "example.com/fork<&>", Version: "v2.0.0"}, - }) + frankenphp.AddPHPInfoEntry(phpInfoTestComponent, "example.com/fork<&> v2.0.0") } func TestPhpInfo_module(t *testing.T) { testPhpInfo(t, nil) } diff --git a/mercure.go b/mercure.go index 41ae854680..ece35fe349 100644 --- a/mercure.go +++ b/mercure.go @@ -8,21 +8,13 @@ package frankenphp import "C" import ( "log/slog" - "runtime/debug" "unsafe" "github.com/dunglas/mercure" ) func init() { - if buildInfo, ok := debug.ReadBuildInfo(); ok { - for _, dep := range buildInfo.Deps { - if dep.Path == "github.com/dunglas/mercure" { - AddPHPInfoModule("dunglas/mercure", dep) - break - } - } - } + AddPHPInfoModule("dunglas/mercure", "github.com/dunglas/mercure") } type mercureContext struct { diff --git a/phpinfo.go b/phpinfo.go index 6ebb185efe..b24cc53597 100644 --- a/phpinfo.go +++ b/phpinfo.go @@ -5,7 +5,9 @@ import "C" import ( "runtime" "runtime/debug" + "slices" "sort" + "sync" "unsafe" ) @@ -14,22 +16,49 @@ type phpinfoEntry struct { } var ( - phpinfoEntries []phpinfoEntry - goModuleEntries []phpinfoEntry - phpinfoPinner runtime.Pinner + phpinfoMu sync.Mutex + phpinfoEntries []phpinfoEntry + phpinfoModules []phpinfoEntry + phpinfoPinner runtime.Pinner ) -// Report the Go toolchain and Go module versions. -// The list is verbose, so it's displayed in a collapsed section. -func init() { - buildInfo, ok := debug.ReadBuildInfo() - if !ok { - return - } +// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). +func AddPHPInfoEntry(key, value string) { + phpinfoMu.Lock() + defer phpinfoMu.Unlock() + phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) +} - AddPHPInfoEntry("go", buildInfo.GoVersion) +// AddPHPInfoModule adds a component's Go module version to the frankenphp section +// of phpinfo(). +func AddPHPInfoModule(key, modulePath string) { + phpinfoMu.Lock() + defer phpinfoMu.Unlock() + phpinfoModules = append(phpinfoModules, phpinfoEntry{key, modulePath}) +} + +func collectPHPInfoEntries(buildInfo *debug.BuildInfo) (entries, modules []phpinfoEntry) { + phpinfoMu.Lock() + entries = slices.Clone(phpinfoEntries) + components := slices.Clone(phpinfoModules) + phpinfoMu.Unlock() - goModuleEntries = buildGoModuleEntries(buildInfo) + if buildInfo == nil { + return entries, nil + } + + entries = append(entries, phpinfoEntry{"go", buildInfo.GoVersion}) + modules = buildGoModuleEntries(buildInfo) + versions := make(map[string]string, len(modules)) + for _, module := range modules { + versions[module.key] = module.value + } + for _, component := range components { + if version, ok := versions[component.value]; ok { + entries = append(entries, phpinfoEntry{component.key, version}) + } + } + return entries, modules } func buildGoModuleEntries(buildInfo *debug.BuildInfo) []phpinfoEntry { @@ -43,8 +72,6 @@ func buildGoModuleEntries(buildInfo *debug.BuildInfo) []phpinfoEntry { return entries } -// goModuleVersion returns the version of the given module, taking "replace" -// directives into account. func goModuleVersion(module *debug.Module) string { if module.Replace == nil { return module.Version @@ -58,25 +85,16 @@ func goModuleVersion(module *debug.Module) string { return module.Replace.Path + " " + module.Replace.Version } -// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). -// Call it during package initialization before Init. -func AddPHPInfoEntry(key, value string) { - phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) -} - -// AddPHPInfoModule adds a component's Go module version to the frankenphp section -// of phpinfo(). Call it during package initialization before Init. -func AddPHPInfoModule(key string, module *debug.Module) { - AddPHPInfoEntry(key, goModuleVersion(module)) -} - func initPHPInfoEntries() { + buildInfo, _ := debug.ReadBuildInfo() + entries, modules := collectPHPInfoEntries(buildInfo) + // Replace the previous runtime's tables before PHP starts using them. C.frankenphp_phpinfo_entries = nil C.frankenphp_go_modules = nil phpinfoPinner.Unpin() - C.frankenphp_phpinfo_entries = pinPHPInfoEntries(phpinfoEntries, &phpinfoPinner) - C.frankenphp_go_modules = pinPHPInfoEntries(goModuleEntries, &phpinfoPinner) + C.frankenphp_phpinfo_entries = pinPHPInfoEntries(entries, &phpinfoPinner) + C.frankenphp_go_modules = pinPHPInfoEntries(modules, &phpinfoPinner) } // pinPHPInfoEntries sorts entries and pins a null-terminated array of key, value diff --git a/types_test.go b/types_test.go index ae838d840b..8024523110 100644 --- a/types_test.go +++ b/types_test.go @@ -255,10 +255,20 @@ func TestBuildGoModuleEntries(t *testing.T) { func TestAddPHPInfoModule(t *testing.T) { // Keep this test serial and isolate registrations from the runtime tests. - previous := phpinfoEntries - t.Cleanup(func() { phpinfoEntries = previous }) + previousEntries, previousModules := phpinfoEntries, phpinfoModules + phpinfoEntries, phpinfoModules = nil, nil + t.Cleanup(func() { phpinfoEntries, phpinfoModules = previousEntries, previousModules }) const key, path = "test/component", "example.com/component" + AddPHPInfoEntry("custom", "value") + AddPHPInfoModule(key, path) + AddPHPInfoModule("missing", "example.com/missing") + AddPHPInfoModule("main", "example.com/app") + + entries, modules := collectPHPInfoEntries(nil) + require.Equal(t, []phpinfoEntry{{"custom", "value"}}, entries) + require.Empty(t, modules) + for _, tt := range []struct { name string replace *debug.Module @@ -270,14 +280,20 @@ func TestAddPHPInfoModule(t *testing.T) { {name: "local path replacement", replace: &debug.Module{Path: "../local-component"}, want: "../local-component"}, } { t.Run(tt.name, func(t *testing.T) { - phpinfoEntries = nil - AddPHPInfoModule(key, &debug.Module{Path: path, Version: "v1.2.3", Replace: tt.replace}) - if len(phpinfoEntries) != 1 { - t.Fatalf("registered %d entries, want 1", len(phpinfoEntries)) - } - if got := phpinfoEntries[0]; got != (phpinfoEntry{key, tt.want}) { - t.Errorf("component entry = %#v, want key %q and version %q", got, key, tt.want) - } + entries, modules := collectPHPInfoEntries(&debug.BuildInfo{ + GoVersion: "go1.26.0", + Main: debug.Module{Path: "example.com/app", Version: "v3.0.0"}, + Deps: []*debug.Module{ + {Path: path, Version: "v1.2.3", Replace: tt.replace}, + {Path: "example.com/other", Version: "v4.0.0"}, + }, + }) + require.Equal(t, []phpinfoEntry{ + {"custom", "value"}, {"go", "go1.26.0"}, {key, tt.want}, {"main", "v3.0.0"}, + }, entries) + require.Equal(t, []phpinfoEntry{ + {"example.com/app", "v3.0.0"}, {path, tt.want}, {"example.com/other", "v4.0.0"}, + }, modules) }) } } diff --git a/watcher.go b/watcher.go index 474418aa00..84aa5ad1c4 100644 --- a/watcher.go +++ b/watcher.go @@ -3,7 +3,6 @@ package frankenphp import ( - "runtime/debug" "sync/atomic" "github.com/dunglas/frankenphp/internal/watcher" @@ -11,15 +10,7 @@ import ( ) func init() { - // watcher doesn't expose the version, so get it from the build info. - if buildInfo, ok := debug.ReadBuildInfo(); ok { - for _, dep := range buildInfo.Deps { - if dep.Path == "github.com/e-dant/watcher" { - AddPHPInfoModule("e-dant/watcher", dep) - break - } - } - } + AddPHPInfoModule("e-dant/watcher", "github.com/e-dant/watcher") } type hotReloadOpt struct { From 2b6244b2cc3eb471b70d5a14d91bbe01785ff1ea Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 9 Sep 2026 17:38:36 +0200 Subject: [PATCH 30/30] lazily collect phpinfo metadata through minfo callback --- cli.go | 1 - frankenphp.c | 15 +++++++-------- frankenphp.go | 1 - frankenphp.h | 10 +++------- frankenphp_test.go | 8 ++++++++ phpinfo.go | 15 +++++++-------- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/cli.go b/cli.go index 9e02c8497e..a96153a14a 100644 --- a/cli.go +++ b/cli.go @@ -9,7 +9,6 @@ import "unsafe" func ExecuteScriptCLI(script string, args []string) int { // Ensure extensions are registered before CLI execution registerExtensions() - initPHPInfoEntries() cScript := C.CString(script) defer C.free(unsafe.Pointer(cScript)) diff --git a/frankenphp.c b/frankenphp.c index 104f32544f..e769dd044f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -114,9 +114,6 @@ frankenphp_config frankenphp_get_config() { }; } -const char **frankenphp_phpinfo_entries = NULL; -const char **frankenphp_go_modules = NULL; - bool should_filter_var = 0; bool original_user_abort_setting = 0; frankenphp_interned_strings_t frankenphp_strings = {0}; @@ -1126,15 +1123,17 @@ static void frankenphp_print_info_rows(const char **entries) { } } -PHP_MINFO_FUNCTION(frankenphp) { +PHP_MINFO_FUNCTION(frankenphp) { go_frankenphp_phpinfo(); } + +void frankenphp_print_phpinfo(const char **entries, const char **modules) { php_info_print_table_start(); php_info_print_table_row(2, "frankenphp", TOSTRING(FRANKENPHP_VERSION)); - if (frankenphp_phpinfo_entries) { - frankenphp_print_info_rows(frankenphp_phpinfo_entries); + if (entries) { + frankenphp_print_info_rows(entries); } php_info_print_table_end(); - if (frankenphp_go_modules == NULL) { + if (modules == NULL) { return; } @@ -1151,7 +1150,7 @@ PHP_MINFO_FUNCTION(frankenphp) { php_info_print_table_start(); php_info_print_table_header(2, "Module", "Version"); - frankenphp_print_info_rows(frankenphp_go_modules); + frankenphp_print_info_rows(modules); php_info_print_table_end(); if (!sapi_module.phpinfo_as_text) { diff --git a/frankenphp.go b/frankenphp.go index 1c14883024..9f45f5722f 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -268,7 +268,6 @@ func Init(options ...Option) error { signal.Ignore(syscall.SIGPIPE) registerExtensions() - initPHPInfoEntries() opt := &opt{} for _, o := range options { diff --git a/frankenphp.h b/frankenphp.h index 7ec4e17d56..014dd0beb1 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -47,13 +47,9 @@ typedef struct { #define STRINGIFY(x) #x #define TOSTRING(x) STRINGIFY(x) -/* phpinfo entries from Go - null-terminated array of key, value, key, value, - * ... */ -extern const char **frankenphp_phpinfo_entries; - -/* Go modules linked into the binary, same layout, displayed in a section - * collapsed by default */ -extern const char **frankenphp_go_modules; +/* Print null-terminated arrays of key, value pairs supplied by Go. + * The pointers are only used for the duration of this call. */ +void frankenphp_print_phpinfo(const char **entries, const char **modules); typedef struct go_string { size_t len; diff --git a/frankenphp_test.go b/frankenphp_test.go index b137bd5012..c6f74d1667 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -468,7 +468,14 @@ func TestPhpInfo_module(t *testing.T) { testPhpInfo(t, nil) } func TestPhpInfo_worker(t *testing.T) { testPhpInfo(t, &testOptions{workerScript: "phpinfo.php"}) } func testPhpInfo(t *testing.T, opts *testOptions) { var logOnce sync.Once + var registerOnce sync.Once + lateKey := fmt.Sprintf("%s/%d", t.Name(), time.Now().UnixNano()) runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + registerOnce.Do(func() { + body, _ := testGet("http://example.com/phpinfo.php", handler, t) + assert.NotContains(t, body, lateKey) + frankenphp.AddPHPInfoEntry(lateKey, "registered after phpinfo") + }) body, _ := testGet(fmt.Sprintf("http://example.com/phpinfo.php?i=%d", i), handler, t) logOnce.Do(func() { @@ -478,6 +485,7 @@ func testPhpInfo(t *testing.T, opts *testOptions) { assert.Contains(t, body, "frankenphp") assert.Contains(t, body, fmt.Sprintf("i=%d", i)) assert.Contains(t, body, runtime.Version()) + assert.Contains(t, body, ``+lateKey+` registered after phpinfo `) assert.Contains(t, body, `test/component<&> example.com/fork<&> v2.0.0 `) }, opts) } diff --git a/phpinfo.go b/phpinfo.go index b24cc53597..9b1e839cf1 100644 --- a/phpinfo.go +++ b/phpinfo.go @@ -19,7 +19,6 @@ var ( phpinfoMu sync.Mutex phpinfoEntries []phpinfoEntry phpinfoModules []phpinfoEntry - phpinfoPinner runtime.Pinner ) // AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). @@ -85,16 +84,16 @@ func goModuleVersion(module *debug.Module) string { return module.Replace.Path + " " + module.Replace.Version } -func initPHPInfoEntries() { +//export go_frankenphp_phpinfo +func go_frankenphp_phpinfo() { buildInfo, _ := debug.ReadBuildInfo() entries, modules := collectPHPInfoEntries(buildInfo) - // Replace the previous runtime's tables before PHP starts using them. - C.frankenphp_phpinfo_entries = nil - C.frankenphp_go_modules = nil - phpinfoPinner.Unpin() - C.frankenphp_phpinfo_entries = pinPHPInfoEntries(entries, &phpinfoPinner) - C.frankenphp_go_modules = pinPHPInfoEntries(modules, &phpinfoPinner) + // PHP consumes these tables synchronously on the calling PHP thread. + // Each call owns its pins, so concurrent phpinfo() calls share no C pointers. + var pinner runtime.Pinner + defer pinner.Unpin() + C.frankenphp_print_phpinfo(pinPHPInfoEntries(entries, &pinner), pinPHPInfoEntries(modules, &pinner)) } // pinPHPInfoEntries sorts entries and pins a null-terminated array of key, value