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) {