From a3ec1ffb97d861559dd95e50718fb5973154cda6 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 12 Nov 2026 08:51:07 +0100 Subject: [PATCH] fix: keep Go's preemption signal off the PHP threads The Go runtime preempts goroutines with SIGURG, sent to the thread that runs them. A PHP thread runs Go code during each callback, and the signal may only land once the thread is back in PHP and entering a system call, which then fails with EINTR: stream_select() returns false with a warning, a poll context throws. Blocking the signal when the thread starts does not hold, Go unblocks it at the first call into Go. Blocked after that call, it stays blocked, since the thread keeps its M. --- frankenphp.c | 14 ++++++++++++++ frankenphp_test.go | 17 +++++++++++++++++ testdata/signal-mask.php | 11 +++++++++++ 3 files changed, 42 insertions(+) create mode 100644 testdata/signal-mask.php diff --git a/frankenphp.c b/frankenphp.c index ca7ec27b14..08241e2122 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1874,6 +1874,20 @@ static void *php_thread(void *arg) { * grace period can wake it from a busy PHP loop or blocking syscall. */ frankenphp_register_thread_for_kill(thread_index); +#if !defined(PHP_WIN32) && defined(SIGURG) + /* That was the first call of this thread into Go, which unblocks, for + * good, the signal its runtime preempts goroutines with. The signal is + * sent to a thread while it runs Go code, and may only land once the + * thread is back in PHP and entering a system call, which then fails with + * EINTR: stream_select() returns false, a poll context throws. Blocked + * again from here on, the Go code this thread runs is preempted at its + * safe points only, which the callbacks of a PHP thread reach quickly. */ + sigset_t preemption; + sigemptyset(&preemption); + sigaddset(&preemption, SIGURG); + pthread_sigmask(SIG_BLOCK, &preemption, NULL); +#endif + bool thread_is_healthy = true; bool has_attempted_shutdown = false; diff --git a/frankenphp_test.go b/frankenphp_test.go index c792c3642f..e57738cfa9 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -191,6 +191,23 @@ func TestEnvVarsInPhpIni(t *testing.T) { }) } +// The Go runtime preempts goroutines with SIGURG, which would interrupt the +// system call a PHP thread sleeps in when it lands there +func TestPreemptionSignalIsBlocked_module(t *testing.T) { testPreemptionSignalIsBlocked(t, nil) } +func TestPreemptionSignalIsBlocked_worker(t *testing.T) { + testPreemptionSignalIsBlocked(t, &testOptions{workerScript: "signal-mask.php"}) +} +func testPreemptionSignalIsBlocked(t *testing.T, opts *testOptions) { + if runtime.GOOS != "linux" { + t.Skip("the signal mask of a thread is read from /proc") + } + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/signal-mask.php", handler, t) + assert.Equal(t, "blocked", body) + }, opts) +} + func TestFinishRequest_module(t *testing.T) { testFinishRequest(t, &testOptions{}) } func TestFinishRequest_worker(t *testing.T) { testFinishRequest(t, &testOptions{workerScript: "finish-request.php"}) diff --git a/testdata/signal-mask.php b/testdata/signal-mask.php new file mode 100644 index 0000000000..82526bdf60 --- /dev/null +++ b/testdata/signal-mask.php @@ -0,0 +1,11 @@ +> 22 & 1 ? 'blocked' : 'not blocked'; +};