From 4f6c81ba7b3679b6bd1a289be75e39002ae58b63 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 24 May 2026 22:13:41 +0700 Subject: [PATCH 1/3] fix #1902 --- cli_test.go | 18 +++++++++++++++ frankenphp.c | 46 ++++++++++++++++++++++++++++++++++++++ testdata/command-pcntl.php | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 testdata/command-pcntl.php diff --git a/cli_test.go b/cli_test.go index f9ee03fea2..82fbbf7987 100644 --- a/cli_test.go +++ b/cli_test.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/exec" + "runtime" "testing" "github.com/dunglas/frankenphp" @@ -45,6 +46,23 @@ func TestExecuteCLICode(t *testing.T) { assert.Equal(t, stdoutStderrStr, `Hello World`) } +// Regression test for https://github.com/php/frankenphp/issues/1902. A +// long-running CLI script that installs pcntl_signal handlers must +// receive its own signals reliably +func TestExecuteScriptCLISignals(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("pcntl is not available on Windows") + } + 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", "testdata/command-pcntl.php") + stdoutStderr, err := cmd.CombinedOutput() + assert.NoError(t, err, "output: %s", stdoutStderr) + assert.Contains(t, string(stdoutStderr), "ok") +} + func ExampleExecuteScriptCLI() { if len(os.Args) <= 1 { log.Println("Usage: my-program script.php") diff --git a/frankenphp.c b/frankenphp.c index 1b852e52c7..eae9863bbe 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -178,6 +178,46 @@ static void frankenphp_fork_child(void) { static void frankenphp_register_atfork(void) { pthread_atfork(frankenphp_fork_prepare, NULL, frankenphp_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 + * pthread. Caddy's signal.Notify keeps working via runtime.ensureSigM. */ +static void frankenphp_fill_cli_signal_set(sigset_t *s) { + sigemptyset(s); +#ifdef SIGHUP + sigaddset(s, SIGHUP); +#endif +#ifdef SIGINT + sigaddset(s, SIGINT); +#endif +#ifdef SIGQUIT + sigaddset(s, SIGQUIT); +#endif +#ifdef SIGTERM + sigaddset(s, SIGTERM); +#endif +#ifdef SIGUSR1 + sigaddset(s, SIGUSR1); +#endif +#ifdef SIGUSR2 + sigaddset(s, SIGUSR2); +#endif +#ifdef SIGALRM + sigaddset(s, SIGALRM); +#endif +#ifdef SIGCHLD + sigaddset(s, SIGCHLD); +#endif +} + +__attribute__((constructor)) static void frankenphp_libpreinit(void) { + sigset_t set; + frankenphp_fill_cli_signal_set(&set); + /* Single-threaded at this point (constructors run before Go's runtime), + * so sigprocmask is sufficient and portable. */ + sigprocmask(SIG_BLOCK, &set, NULL); +} #endif /* Best-effort force-kill for stuck PHP threads. @@ -1689,6 +1729,12 @@ static void *execute_script_cli(void *arg) { void *exit_status; bool eval = (bool)arg; +#ifndef PHP_WIN32 + sigset_t cli_signals; + frankenphp_fill_cli_signal_set(&cli_signals); + pthread_sigmask(SIG_UNBLOCK, &cli_signals, NULL); +#endif + /* * The SAPI name "cli" is hardcoded into too many programs... let's usurp it. */ diff --git a/testdata/command-pcntl.php b/testdata/command-pcntl.php new file mode 100644 index 0000000000..740b570645 --- /dev/null +++ b/testdata/command-pcntl.php @@ -0,0 +1,45 @@ + 0, 'SIGUSR2' => 0, 'SIGALRM' => 0]; + +pcntl_signal(SIGUSR1, function () use (&$received) { $received['SIGUSR1']++; }); +pcntl_signal(SIGUSR2, function () use (&$received) { $received['SIGUSR2']++; }); +pcntl_signal(SIGALRM, function () use (&$received) { $received['SIGALRM']++; }); + +$pid = getmypid(); +$attempts = 0; +$deadline = microtime(true) + 1.5; +while (microtime(true) < $deadline) { + posix_kill($pid, SIGUSR1); + posix_kill($pid, SIGUSR2); + $attempts += 2; + usleep(500); +} + +pcntl_alarm(1); +$alarmDeadline = microtime(true) + 1.5; +while (microtime(true) < $alarmDeadline && $received['SIGALRM'] === 0) { + usleep(1000); +} + +if ($received['SIGUSR1'] === 0 || $received['SIGUSR2'] === 0) { + fwrite(STDERR, "missed user signals: " . json_encode($received) . " of $attempts attempts\n"); + exit(1); +} +if ($received['SIGALRM'] === 0) { + fwrite(STDERR, "missed SIGALRM from pcntl_alarm\n"); + exit(1); +} + +echo "ok\n"; +exit(0); From 86c62b55f2ee7a91f3bdba5af28974c4e4f62db3 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 24 May 2026 22:30:38 +0700 Subject: [PATCH 2/3] fix tests --- cli_test.go | 4 ++++ testdata/command-pcntl.php | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cli_test.go b/cli_test.go index 82fbbf7987..964bb49907 100644 --- a/cli_test.go +++ b/cli_test.go @@ -59,6 +59,10 @@ func TestExecuteScriptCLISignals(t *testing.T) { cmd := exec.Command("internal/testcli/testcli", "testdata/command-pcntl.php") stdoutStderr, err := cmd.CombinedOutput() + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 2 { + t.Skipf("pcntl/posix not available: %s", stdoutStderr) + } assert.NoError(t, err, "output: %s", stdoutStderr) assert.Contains(t, string(stdoutStderr), "ok") } diff --git a/testdata/command-pcntl.php b/testdata/command-pcntl.php index 740b570645..966e1e2668 100644 --- a/testdata/command-pcntl.php +++ b/testdata/command-pcntl.php @@ -3,9 +3,11 @@ // Long-running CLI script that uses pcntl signals, // simulating queue processors like Laravel Horizon or Symfony Messenger. -if (!extension_loaded('pcntl')) { - fwrite(STDERR, "pcntl extension required\n"); - exit(2); +foreach (['pcntl_async_signals', 'pcntl_signal', 'pcntl_alarm', 'posix_kill'] as $fn) { + if (!function_exists($fn)) { + fwrite(STDERR, "missing $fn (pcntl/posix not fully loaded)\n"); + exit(2); + } } pcntl_async_signals(true); From 831a6a8b29658a0301d9b7bf91ff94a69771c3ac Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 1 Jul 2026 21:37:29 +0700 Subject: [PATCH 3/3] more accurate signal blocking --- frankenphp.c | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index eae9863bbe..da0561812f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -182,21 +182,11 @@ static void frankenphp_register_atfork(void) { /* 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 - * pthread. Caddy's signal.Notify keeps working via runtime.ensureSigM. */ + * pthread. Caddy's `signal.Notify` keeps working via `runtime.ensureSigM`. + * Limited to async-notify signals: Go's minitSignalMask re-unblocks anything + * flagged _SigKill/_SigThrow/_SigUnblock on every M anyway. */ static void frankenphp_fill_cli_signal_set(sigset_t *s) { sigemptyset(s); -#ifdef SIGHUP - sigaddset(s, SIGHUP); -#endif -#ifdef SIGINT - sigaddset(s, SIGINT); -#endif -#ifdef SIGQUIT - sigaddset(s, SIGQUIT); -#endif -#ifdef SIGTERM - sigaddset(s, SIGTERM); -#endif #ifdef SIGUSR1 sigaddset(s, SIGUSR1); #endif @@ -206,9 +196,6 @@ static void frankenphp_fill_cli_signal_set(sigset_t *s) { #ifdef SIGALRM sigaddset(s, SIGALRM); #endif -#ifdef SIGCHLD - sigaddset(s, SIGCHLD); -#endif } __attribute__((constructor)) static void frankenphp_libpreinit(void) {