diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 3c285d677f..098a02e176 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
@@ -217,7 +219,7 @@ jobs:
# which replaced it with "containerimage.digest" and "containerimage.descriptor"
docker run --platform="${PLATFORM}" --rm \
"$(jq -r ".\"builder-${VARIANT}\" | .\"containerimage.config.digest\" // .\"containerimage.digest\"" <<< "${METADATA}")" \
- sh -c "./go.sh test ${RACE} -v $(./go.sh list ./... | grep -v github.com/dunglas/frankenphp/internal/testext | grep -v github.com/dunglas/frankenphp/internal/extgen | tr '\n' ' ') && cd caddy && ../go.sh test ${RACE} -v ./..."
+ sh -c "./go.sh test ${RACE} -timeout 30m -v $(./go.sh list ./... | grep -v github.com/dunglas/frankenphp/internal/testext | grep -v github.com/dunglas/frankenphp/internal/extgen | tr '\n' ' ') && cd caddy && ../go.sh test ${RACE} -timeout 30m -v ./..."
env:
METADATA: ${{ steps.build.outputs.metadata }}
PLATFORM: ${{ matrix.platform }}
diff --git a/caddy/caddy.go b/caddy/caddy.go
index 24c5011900..aa7979b1a3 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,10 @@ func init() {
caddy.RegisterModule(&FrankenPHPModule{})
caddy.RegisterModule(&FrankenPHPAdmin{})
+ // Report Caddy version in phpinfo()
+ simpleVersion, _ := caddy.Version()
+ frankenphp.AddPHPInfoEntry("Caddy", simpleVersion)
+
httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption)
httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile)
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",
)
diff --git a/caddy/frankenphp/main.go b/caddy/frankenphp/main.go
index 6b9d40561f..7b411b0512 100644
--- a/caddy/frankenphp/main.go
+++ b/caddy/frankenphp/main.go
@@ -6,8 +6,6 @@ import (
// plug in Caddy modules here.
_ "github.com/caddyserver/caddy/v2/modules/standard"
_ "github.com/dunglas/frankenphp/caddy"
- _ "github.com/dunglas/mercure/caddy"
- _ "github.com/dunglas/vulcain/caddy"
)
func main() {
diff --git a/caddy/frankenphp/mercure.go b/caddy/frankenphp/mercure.go
new file mode 100644
index 0000000000..69fb08142a
--- /dev/null
+++ b/caddy/frankenphp/mercure.go
@@ -0,0 +1,5 @@
+//go:build !nomercure
+
+package main
+
+import _ "github.com/dunglas/mercure/caddy"
diff --git a/caddy/frankenphp/vulcain.go b/caddy/frankenphp/vulcain.go
new file mode 100644
index 0000000000..4fe04347a3
--- /dev/null
+++ b/caddy/frankenphp/vulcain.go
@@ -0,0 +1,5 @@
+//go:build !novulcain
+
+package main
+
+import _ "github.com/dunglas/vulcain/caddy"
diff --git a/caddy/phpinfo_test.go b/caddy/phpinfo_test.go
new file mode 100644
index 0000000000..11579cd7f5
--- /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)
+ simpleVersion, _ := caddy.Version()
+ require.Len(t, row, 2, "phpinfo must include the Caddy version row")
+ require.Equal(t, html.EscapeString(simpleVersion), string(row[1]))
+}
diff --git a/cli_linux_test.go b/cli_linux_test.go
new file mode 100644
index 0000000000..111ff1dcec
--- /dev/null
+++ b/cli_linux_test.go
@@ -0,0 +1,151 @@
+//go:build linux
+
+package frankenphp_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+func TestExecuteScriptCLIPhpInfoForkChild(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(context.Background(), 8*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "testdata/command-phpinfo-fork.php")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.WaitDelay = time.Second
+ var output bytes.Buffer
+ cmd.Stdout = &output
+ cmd.Stderr = &output
+ require.NoError(t, cmd.Start())
+ pid := cmd.Process.Pid
+ err := cmd.Wait()
+ if err != nil {
+ // kill the whole group: the parent may be stuck on a wedged child
+ _ = unix.Kill(-pid, unix.SIGKILL)
+ }
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ t.Fatalf("fork child did not finish: %s", output.String())
+ }
+ var exitError *exec.ExitError
+ if errors.As(err, &exitError) && exitError.ExitCode() == 2 {
+ t.Skipf("pcntl unavailable: %s", output.String())
+ }
+ require.NoError(t, err, "%s", output.String())
+ require.Contains(t, output.String(), "parent-ok")
+ require.Contains(t, output.String(), "child-safe",
+ "a fork child must not call into Go to render FrankenPHP phpinfo data")
+}
+
+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-only: isolate them in a child process
+ 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 releases a child whose PID never got reported
+ _ = 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")
+ 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 {
+ // exit 2: nothing was forked, nothing to reap
+ 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)
+
+ // the CLI joined its PHP thread before exiting: the child must survive the
+ // forking thread's exit, not just the process's
+ _, 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 // don't signal a possibly 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 56d88a92d9..fb4dc62ffa 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) {
@@ -45,8 +50,30 @@ 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.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
-// 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`")
@@ -93,6 +120,321 @@ 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 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`")
+ }
+
+ 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(`
+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)
+ }
+ _, 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)
+ }
+
+ 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)
+ 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++ {
+ // 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)
+ }
+ }
+ })
+ }
+}
+
+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/emulate_php_cli.c b/emulate_php_cli.c
index f33f360359..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)
@@ -54,15 +73,14 @@ 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 = 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)
@@ -74,14 +92,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);
@@ -165,10 +175,12 @@ void *emulate_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;
+ /* the CLI SAPI prints phpinfo() as plain text, not as HTML */
+ php_embed_module.phpinfo_as_text = 1;
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/ext.go b/ext.go
index b993bf83df..c7e7dc374a 100644
--- a/ext.go
+++ b/ext.go
@@ -10,6 +10,8 @@ import (
var (
extensions []*C.zend_module_entry
registerOnce sync.Once
+ // keep the array alive while C holds a raw pointer to it
+ registeredExtensions []*C.zend_module_entry
)
// RegisterExtension registers a new PHP extension.
@@ -23,6 +25,7 @@ func registerExtensions() {
}
registerOnce.Do(func() {
+ registeredExtensions = extensions
C.register_extensions((**C.zend_module_entry)(unsafe.Pointer(&extensions[0])), C.int(len(extensions)))
extensions = nil
})
diff --git a/frankenphp.c b/frankenphp.c
index 2378ac8ff6..eb39fa287e 100644
--- a/frankenphp.c
+++ b/frankenphp.c
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#ifdef HAVE_PHP_SESSION
#include
#endif
@@ -153,6 +154,8 @@ static pid_t fork_parent_pid = 0;
static void frankenphp_fork_prepare(void) { fork_parent_pid = getpid(); }
+static void frankenphp_mark_fork_child(void) { is_forked_child = true; }
+
#if defined(FRANKENPHP_KQUEUE_PARENT_DEATH)
/* Watcher thread for platforms without a kernel parent-death signal.
* Blocks in kevent() until the parent exits, then force-kills this child. */
@@ -175,7 +178,7 @@ static void *frankenphp_parent_death_watcher(void *arg) {
#endif
static void frankenphp_fork_child(void) {
- is_forked_child = true;
+ frankenphp_mark_fork_child();
#if defined(__linux__)
// if the parent process dies between fork() and this prctl()
if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0 ||
@@ -209,6 +212,10 @@ static void frankenphp_register_atfork(void) {
pthread_atfork(frankenphp_fork_prepare, NULL, frankenphp_fork_child);
}
+static void frankenphp_register_cli_atfork(void) {
+ pthread_atfork(NULL, NULL, frankenphp_mark_fork_child);
+}
+
/* pcntl signals delivered to a Go M segfault on PCNTL_G (no TSRM there)
* Block these in a constructor so Go's schedinit captures
* the mask and every M inherits it; execute_script_cli unblocks on its own
@@ -1116,6 +1123,68 @@ 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) {
+#ifndef PHP_WIN32
+ if (UNEXPECTED(is_forked_child)) {
+ return;
+ }
+#endif
+
+ struct go_frankenphp_collect_phpinfo_return data =
+ go_frankenphp_collect_phpinfo();
+ const char **entries = (const char **)data.r0;
+ const char **modules = (const char **)data.r1;
+ bool bailed_out = false;
+
+ zend_try {
+ /* no Go in here: printing may bailout, and a bailout must never
+ * unwind a Go frame */
+ php_info_print_table_start();
+ php_info_print_table_row(2, "FrankenPHP", TOSTRING(FRANKENPHP_VERSION));
+ if (entries) {
+ frankenphp_print_info_rows(entries);
+ }
+ php_info_print_table_end();
+
+ if (modules != NULL) {
+ /* the Go module list is long: collapse it in 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(modules);
+ php_info_print_table_end();
+
+ if (!sapi_module.phpinfo_as_text) {
+ php_printf(" \n");
+ }
+ }
+ }
+ zend_catch { bailed_out = true; }
+ zend_end_try();
+
+ /* the bailout is caught: safe to hand the tables back to Go */
+ go_frankenphp_release_phpinfo(data.r0, data.r1);
+
+ if (bailed_out) {
+ /* re-raise the caught bailout */
+ zend_bailout();
+ }
+}
+
static zend_module_entry frankenphp_module = {
STANDARD_MODULE_HEADER,
"frankenphp",
@@ -1124,7 +1193,20 @@ 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};
+
+/* same phpinfo section in CLI, without the server functions and hooks */
+static zend_module_entry frankenphp_cli_module = {
+ STANDARD_MODULE_HEADER,
+ "frankenphp-cli",
+ NULL, /* function table */
+ NULL, /* initialization */
+ NULL, /* shutdown */
+ NULL, /* request initialization */
+ NULL, /* request shutdown */
+ PHP_MINFO(frankenphp), /* information */
TOSTRING(FRANKENPHP_VERSION),
STANDARD_MODULE_PROPERTIES};
@@ -1773,6 +1855,25 @@ static void *execute_script_cli(void *arg) {
#endif
}
+static int (*previous_php_register_internal_extensions_func)(void) = NULL;
+
+/* the CLI SAPIs take no extra modules: hook their module startup */
+static int register_frankenphp_module(void) {
+ if (previous_php_register_internal_extensions_func() != SUCCESS) {
+ return FAILURE;
+ }
+
+#ifndef PHP_WIN32
+ /* mark only: a CLI child must survive its PHP thread's exit */
+ static pthread_once_t cli_atfork_once = PTHREAD_ONCE_INIT;
+ pthread_once(&cli_atfork_once, frankenphp_register_cli_atfork);
+#endif
+
+ return zend_register_internal_module(&frankenphp_cli_module) == NULL
+ ? FAILURE
+ : SUCCESS;
+}
+
int frankenphp_execute_script_cli(char *script, int argc, char **argv,
bool eval) {
pthread_t thread;
@@ -1782,20 +1883,33 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv,
cli_exec_args_t args = {
.script = script, .argc = argc, .argv = argv, .eval = eval};
+ /* a failed join leaves the hook installed: don't save it as its own
+ * predecessor */
+ if (php_register_internal_extensions_func != register_frankenphp_module) {
+ 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
*/
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 inside the hook */
return err;
}
+ php_register_internal_extensions_func =
+ previous_php_register_internal_extensions_func;
return (intptr_t)exit_status;
}
diff --git a/frankenphp.go b/frankenphp.go
index 3f7bbdf582..9f45f5722f 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{}
diff --git a/frankenphp_test.go b/frankenphp_test.go
index 322fccf281..47eb44c26b 100644
--- a/frankenphp_test.go
+++ b/frankenphp_test.go
@@ -458,19 +458,53 @@ func testSession(t *testing.T, opts *testOptions) {
}, opts)
}
+const phpInfoTestComponent = "test/component<&>"
+
+func init() {
+ frankenphp.AddPHPInfoEntry(phpInfoTestComponent, "example.com/fork<&> 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) {
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() {
t.Log(body)
})
- assert.Contains(t, body, "frankenphp")
+ 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)
+}
+
+func TestPhpInfoForkChild_module(t *testing.T) { testPhpInfoForkChild(t, nil) }
+func TestPhpInfoForkChild_worker(t *testing.T) {
+ testPhpInfoForkChild(t, &testOptions{workerScript: "phpinfo-fork.php"})
+}
+func testPhpInfoForkChild(t *testing.T, opts *testOptions) {
+ if opts == nil {
+ opts = &testOptions{}
+ }
+ opts.nbParallelRequests = 1
+ runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
+ body, _ := testGet("http://example.com/phpinfo-fork.php", handler, t)
+ if body == "pcntl-unavailable" {
+ t.Skip("pcntl/posix not fully loaded")
+ }
+ require.Equal(t, "child-safe", body)
}, opts)
}
@@ -579,6 +613,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)
@@ -1228,6 +1267,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)
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/internal/testext/exttest.go b/internal/testext/exttest.go
index 8e9f74cac8..9830e5c496 100644
--- a/internal/testext/exttest.go
+++ b/internal/testext/exttest.go
@@ -14,6 +14,7 @@ import "C"
import (
"io"
"net/http/httptest"
+ "runtime"
"testing"
"unsafe"
@@ -26,6 +27,27 @@ func testRegisterExtension(t *testing.T) {
frankenphp.RegisterExtension(unsafe.Pointer(&C.module1_entry))
frankenphp.RegisterExtension(unsafe.Pointer(&C.module2_entry))
+ // race the GC against C reading the raw array
+ stop := make(chan struct{})
+ go func() {
+ var sink [][]unsafe.Pointer
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ runtime.GC()
+ for i := 0; i < 20000; i++ {
+ sink = append(sink, make([]unsafe.Pointer, 2))
+ if len(sink) > 100000 {
+ sink = nil
+ }
+ }
+ }
+ }()
+ defer close(stop)
+
err := frankenphp.Init()
require.Nil(t, err)
defer frankenphp.Shutdown()
diff --git a/phpinfo.go b/phpinfo.go
new file mode 100644
index 0000000000..c62b41b922
--- /dev/null
+++ b/phpinfo.go
@@ -0,0 +1,199 @@
+package frankenphp
+
+// #include "frankenphp.h"
+import "C"
+import (
+ "runtime"
+ "runtime/debug"
+ "slices"
+ "sort"
+ "sync"
+ "unsafe"
+)
+
+type phpinfoEntry struct {
+ key, value string
+}
+
+var (
+ phpinfoMu sync.Mutex
+ phpinfoEntries []phpinfoEntry
+
+ phpinfoDirty = true
+ phpinfoBuildMu sync.Mutex
+ phpinfoCurrent *phpinfoTable
+ phpinfoRetired []*phpinfoTable
+)
+
+// one pinned generation of the phpinfo tables, held at a fixed address while
+// C code may still be reading it
+type phpinfoTable struct {
+ pinner runtime.Pinner
+ entries, modules **C.char
+ readers int
+ retired bool
+}
+
+func (t *phpinfoTable) matches(entries, modules **C.char) bool {
+ return t.entries == entries && t.modules == modules
+}
+
+// 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})
+ phpinfoDirty = true
+}
+
+func collectPHPInfoEntries(buildInfo *debug.BuildInfo) (entries, modules []phpinfoEntry) {
+ phpinfoMu.Lock()
+ entries = slices.Clone(phpinfoEntries)
+ phpinfoMu.Unlock()
+
+ if buildInfo == nil {
+ return entries, nil
+ }
+
+ entries = append(entries, phpinfoEntry{"Go", buildInfo.GoVersion})
+ modules = buildGoModuleEntries(buildInfo)
+ moduleAliases := map[string]string{
+ "github.com/dunglas/mercure": "dunglas/mercure",
+ "github.com/e-dant/watcher": "e-dant/watcher",
+ "github.com/dunglas/caddy-cbrotli": "dunglas/caddy-cbrotli",
+ }
+ for _, module := range modules {
+ if alias, ok := moduleAliases[module.key]; ok {
+ entries = append(entries, phpinfoEntry{alias, module.value})
+ }
+ }
+ return entries, modules
+}
+
+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
+}
+
+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
+}
+
+// The caller must hand the borrowed tables back to
+// go_frankenphp_release_phpinfo, bailout included, or they stay pinned.
+//
+//export go_frankenphp_collect_phpinfo
+func go_frankenphp_collect_phpinfo() (**C.char, **C.char) {
+ phpinfoBuildMu.Lock()
+ defer phpinfoBuildMu.Unlock()
+
+ phpinfoMu.Lock()
+ dirty := phpinfoDirty
+ phpinfoDirty = false
+ phpinfoMu.Unlock()
+
+ if !dirty && phpinfoCurrent != nil {
+ phpinfoCurrent.readers++
+ return phpinfoCurrent.entries, phpinfoCurrent.modules
+ }
+
+ buildInfo, _ := debug.ReadBuildInfo()
+ entries, modules := collectPHPInfoEntries(buildInfo)
+
+ table := new(phpinfoTable)
+ table.entries = pinPHPInfoEntries(entries, &table.pinner)
+ table.modules = pinPHPInfoEntries(modules, &table.pinner)
+ table.readers = 1
+
+ // readers of the previous table may still be printing it
+ retirePHPInfoTableLocked(phpinfoCurrent)
+ phpinfoCurrent = table
+
+ return table.entries, table.modules
+}
+
+// go_frankenphp_release_phpinfo ends a collect borrow; the table is unpinned
+// once retired and its last reader is done.
+//
+//export go_frankenphp_release_phpinfo
+func go_frankenphp_release_phpinfo(entries, modules **C.char) {
+ phpinfoBuildMu.Lock()
+ defer phpinfoBuildMu.Unlock()
+
+ table := phpinfoCurrent
+ if table == nil || !table.matches(entries, modules) {
+ table = nil
+ for _, t := range phpinfoRetired {
+ if t.matches(entries, modules) {
+ table = t
+ break
+ }
+ }
+ }
+ if table == nil {
+ return
+ }
+
+ if table.readers > 0 {
+ table.readers--
+ }
+ unpinPHPInfoTableLocked(table)
+}
+
+func retirePHPInfoTableLocked(table *phpinfoTable) {
+ if table == nil || table.retired {
+ return
+ }
+
+ table.retired = true
+ phpinfoRetired = append(phpinfoRetired, table)
+ unpinPHPInfoTableLocked(table)
+}
+
+func unpinPHPInfoTableLocked(table *phpinfoTable) {
+ if !table.retired || table.readers > 0 {
+ return
+ }
+
+ table.pinner.Unpin()
+ phpinfoRetired = slices.DeleteFunc(phpinfoRetired, func(t *phpinfoTable) bool {
+ return t == table
+ })
+}
+
+// pinPHPInfoEntries returns a sorted, NUL-terminated key/value array for C.
+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]
+}
diff --git a/phpinfo_test.go b/phpinfo_test.go
new file mode 100644
index 0000000000..d9beaaac26
--- /dev/null
+++ b/phpinfo_test.go
@@ -0,0 +1,80 @@
+package frankenphp
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// rebuilds must not accumulate pinned tables: every borrow is released
+func TestPHPInfoTablesAreRetiredAfterRelease(t *testing.T) {
+ for i := 0; i < 500; i++ {
+ AddPHPInfoEntry("retired-key", strings.Repeat("v", 1024))
+ entries, modules := go_frankenphp_collect_phpinfo()
+ go_frankenphp_release_phpinfo(entries, modules)
+ }
+
+ phpinfoBuildMu.Lock()
+ current, retired, readers := phpinfoCurrent, len(phpinfoRetired), -1
+ if current != nil {
+ readers = current.readers
+ }
+ phpinfoBuildMu.Unlock()
+
+ assert.NotNil(t, current)
+ assert.Empty(t, retired, "every retired generation must have been unpinned")
+ assert.Zero(t, readers, "every borrow must have been released")
+}
+
+// reader accounting must survive concurrent rebuilds
+func TestPHPInfoConcurrentBorrowRelease(t *testing.T) {
+ entries, modules := go_frankenphp_collect_phpinfo()
+ go_frankenphp_release_phpinfo(entries, modules)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 200; j++ {
+ entries, modules := go_frankenphp_collect_phpinfo()
+ go_frankenphp_release_phpinfo(entries, modules)
+ }
+ }()
+ }
+ for i := 0; i < 100; i++ {
+ AddPHPInfoEntry("concurrent-rebuild", "during")
+ }
+ wg.Wait()
+
+ phpinfoBuildMu.Lock()
+ defer phpinfoBuildMu.Unlock()
+
+ assert.Empty(t, phpinfoRetired)
+ if phpinfoCurrent != nil {
+ assert.Zero(t, phpinfoCurrent.readers)
+ }
+}
+
+// without retirement, 2000 cycles pin 140+ MB
+func TestPHPInfoLateRegistrationDoesNotAccumulatePinnedTables(t *testing.T) {
+ runtime.GC()
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+
+ value := strings.Repeat("v", 1024)
+ for i := 0; i < 2000; i++ {
+ AddPHPInfoEntry(fmt.Sprintf("heap-key-%d", i), value)
+ entries, modules := go_frankenphp_collect_phpinfo()
+ go_frankenphp_release_phpinfo(entries, modules)
+ }
+
+ runtime.GC()
+ runtime.ReadMemStats(&after)
+
+ assert.Less(t, after.HeapAlloc-before.HeapAlloc, uint64(32<<20))
+}
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/testdata/command-detached.php b/testdata/command-detached.php
new file mode 100644
index 0000000000..e40f24a1c9
--- /dev/null
+++ b/testdata/command-detached.php
@@ -0,0 +1,42 @@
+/dev/null 2>&1; set -C; printf ready > "$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);
diff --git a/testdata/command-phpinfo-fork.php b/testdata/command-phpinfo-fork.php
new file mode 100644
index 0000000000..9030a1f6e0
--- /dev/null
+++ b/testdata/command-phpinfo-fork.php
@@ -0,0 +1,27 @@
+ ") ? "child-frankenphp\n" : "child-safe\n");
+ exit(0);
+}
+
+$waited = pcntl_waitpid($pid, $status);
+if ($waited !== $pid || !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) {
+ fwrite(STDERR, "child failed\n");
+ exit(1);
+}
+
+fwrite(STDERR, "parent-ok\n");
diff --git a/testdata/phpinfo-fork.php b/testdata/phpinfo-fork.php
new file mode 100644
index 0000000000..028baecd02
--- /dev/null
+++ b/testdata/phpinfo-fork.php
@@ -0,0 +1,40 @@
+FrankenPHP ') ? 3 : 0);
+ }
+
+ $deadline = microtime(true) + 3;
+ do {
+ $waited = pcntl_waitpid($pid, $status, WNOHANG);
+ if ($waited === $pid) {
+ echo pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0
+ ? "child-safe"
+ : "child-unsafe";
+ return;
+ }
+ usleep(1000);
+ } while (microtime(true) < $deadline);
+
+ posix_kill($pid, SIGKILL);
+ pcntl_waitpid($pid, $status);
+ echo "child-timeout";
+};
diff --git a/types_test.go b/types_test.go
index a08f90725e..ab7338d21e 100644
--- a/types_test.go
+++ b/types_test.go
@@ -2,7 +2,11 @@ package frankenphp
import (
"log/slog"
+ "runtime"
+ "runtime/debug"
+ "slices"
"testing"
+ "unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -145,3 +149,155 @@ func TestNestedMixedArray(t *testing.T) {
assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion")
})
}
+
+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"},
+ {
+ 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 TestCollectPHPInfoEntries(t *testing.T) {
+ // Keep this test serial and isolate registrations from the runtime tests.
+ previousEntries := phpinfoEntries
+ phpinfoEntries = nil
+ t.Cleanup(func() { phpinfoEntries = previousEntries })
+
+ const key, path = "dunglas/mercure", "github.com/dunglas/mercure"
+ AddPHPInfoEntry("custom", "value")
+
+ entries, modules := collectPHPInfoEntries(nil)
+ require.Equal(t, []phpinfoEntry{{"custom", "value"}}, entries)
+ require.Empty(t, modules)
+
+ entries, modules = collectPHPInfoEntries(&debug.BuildInfo{GoVersion: "go1.26.0"})
+ require.Equal(t, []phpinfoEntry{{"custom", "value"}, {"Go", "go1.26.0"}}, entries)
+ require.Empty(t, modules)
+
+ 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) {
+ entries, modules := collectPHPInfoEntries(&debug.BuildInfo{
+ GoVersion: "go1.26.0",
+ Main: debug.Module{Path: "github.com/e-dant/watcher", Version: "v3.0.0"},
+ Deps: []*debug.Module{
+ {Path: path, Version: "v1.2.3", Replace: tt.replace},
+ {Path: "example.com/other", Version: "v4.0.0"},
+ {Path: "github.com/dunglas/caddy-cbrotli", Version: "v1.0.0"},
+ },
+ })
+ require.Equal(t, []phpinfoEntry{
+ {"custom", "value"}, {"Go", "go1.26.0"}, {"e-dant/watcher", "v3.0.0"}, {key, tt.want},
+ {"dunglas/caddy-cbrotli", "v1.0.0"},
+ }, entries)
+ require.Equal(t, []phpinfoEntry{
+ {"github.com/e-dant/watcher", "v3.0.0"}, {path, tt.want}, {"example.com/other", "v4.0.0"},
+ {"github.com/dunglas/caddy-cbrotli", "v1.0.0"},
+ }, modules)
+ })
+ }
+}
diff --git a/worker_test.go b/worker_test.go
index 10c2b669ae..8ef48bc569 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) {