From 56915581620ba77d1cdecdcecad50c0b036904bb Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 7 Sep 2026 14:04:04 +0200 Subject: [PATCH] fix: refuse to start on a ZTS build with Zend signals FrankenPHP has its own SAPI and never calls zend_signal_startup(), which the cli and embed SAPIs of php-src do, so zend_signal_globals_id stays 0. Registering the ini entries of the signal globals then resolves their address through ts_resource(0), which in ZTS returns the tsrm_tls_entry of the calling thread itself, so the write lands on the thread id stored there. TSRM stops recognizing the thread from then on: every lookup appends a new set of globals and tail-recurses, copying the constant, function and class tables each time. The server never finishes booting and its memory grows by hundreds of megabytes per second until it is killed, with no error and no log line. docs/compile.md and the Dockerfiles pass --disable-zend-signals, but PHP enables Zend signals by default, so building PHP by hand without that flag is enough to hit this. Check the flag PHP already reports through frankenphp_get_config() and fail with an actionable error instead. Non-ZTS builds are unaffected: their ini entries address the globals directly, TSRM is not involved. --- frankenphp.go | 21 +++++++++++++++++++-- phpconfig_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 phpconfig_test.go diff --git a/frankenphp.go b/frankenphp.go index 8b19dd2285..3f7bbdf582 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -46,6 +46,7 @@ var ( ErrInvalidRequest = errors.New("not a FrankenPHP request") ErrAlreadyStarted = errors.New("FrankenPHP is already started") ErrInvalidPHPVersion = errors.New("FrankenPHP is only compatible with PHP 8.2+") + ErrZendSignals = errors.New(`FrankenPHP is not compatible with Zend Signals, recompile PHP with the "--disable-zend-signals" configuration option`) ErrMainThreadCreation = errors.New("error creating the main thread") ErrScriptExecution = errors.New("error during PHP script execution") ErrNotRunning = errors.New("server is not registered, you must first call frankenphp.Init() with the WithServer() option") @@ -156,6 +157,21 @@ func Config() PHPConfig { } } +// checkPHPConfig rejects the PHP builds FrankenPHP cannot run on +func checkPHPConfig(config PHPConfig) error { + if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) { + return ErrInvalidPHPVersion + } + + // FrankenPHP never calls zend_signal_startup(), so in ZTS the ini entries + // of the signal globals overwrite the TSRM entry of each thread + if config.ZTS && config.ZendSignals { + return ErrZendSignals + } + + return nil +} + func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { maxProcs := runtime.GOMAXPROCS(0) * 2 maxThreadsFromWorkers := 0 @@ -294,9 +310,10 @@ func Init(options ...Option) error { config := Config() - if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) { + if err := checkPHPConfig(config); err != nil { shutdown() - return ErrInvalidPHPVersion + + return err } if config.ZTS { diff --git a/phpconfig_test.go b/phpconfig_test.go new file mode 100644 index 0000000000..465c834a7a --- /dev/null +++ b/phpconfig_test.go @@ -0,0 +1,36 @@ +package frankenphp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckPHPConfig(t *testing.T) { + supported := PHPConfig{Version: PHPVersion{MajorVersion: 8, MinorVersion: 2}, ZTS: true} + + t.Run("a supported build is accepted", func(t *testing.T) { + require.NoError(t, checkPHPConfig(supported)) + }) + + t.Run("PHP older than 8.2 is rejected", func(t *testing.T) { + old := supported + old.Version.MinorVersion = 1 + assert.ErrorIs(t, checkPHPConfig(old), ErrInvalidPHPVersion) + }) + + t.Run("Zend signals are rejected in ZTS", func(t *testing.T) { + signals := supported + signals.ZendSignals = true + assert.ErrorIs(t, checkPHPConfig(signals), ErrZendSignals) + }) + + t.Run("Zend signals are allowed without ZTS", func(t *testing.T) { + // without ZTS the ini entries address the globals directly + signals := supported + signals.ZTS = false + signals.ZendSignals = true + require.NoError(t, checkPHPConfig(signals)) + }) +}