Skip to content

Commit 3f5a6e8

Browse files
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.
1 parent 2e33427 commit 3f5a6e8

3 files changed

Lines changed: 62 additions & 2 deletions

File tree

docs/compile.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ cd php-*/
4444

4545
Then, run the `configure` script with the options needed for your platform.
4646
The following `./configure` flags are mandatory, but you can add others, for example, to compile extensions or additional features.
47+
`--disable-zend-signals` is one of them: FrankenPHP does not start Zend signals, and refuses to run on a ZTS build that enables them.
4748

4849
#### Linux and FreeBSD
4950

frankenphp.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ var (
4646
ErrInvalidRequest = errors.New("not a FrankenPHP request")
4747
ErrAlreadyStarted = errors.New("FrankenPHP is already started")
4848
ErrInvalidPHPVersion = errors.New("FrankenPHP is only compatible with PHP 8.2+")
49+
ErrZendSignals = errors.New(`PHP was built with Zend signals but FrankenPHP does not start them, which corrupts the state of PHP threads, recompile PHP with the "--disable-zend-signals" configuration option to fix this issue`)
4950
ErrMainThreadCreation = errors.New("error creating the main thread")
5051
ErrScriptExecution = errors.New("error during PHP script execution")
5152
ErrNotRunning = errors.New("server is not registered, you must first call frankenphp.Init() with the WithServer() option")
@@ -156,6 +157,26 @@ func Config() PHPConfig {
156157
}
157158
}
158159

160+
// checkPHPConfig rejects the PHP builds FrankenPHP cannot run on
161+
func checkPHPConfig(config PHPConfig) error {
162+
if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) {
163+
return ErrInvalidPHPVersion
164+
}
165+
166+
// FrankenPHP has its own SAPI and never calls zend_signal_startup(), so
167+
// zend_signal_globals_id stays 0. In ZTS, the ini entries of the signal
168+
// globals then resolve their address through ts_resource(0), which
169+
// returns the TSRM entry of the thread itself, and writing a value there
170+
// overwrites the thread id it holds. TSRM stops recognizing the thread
171+
// and allocates a new set of globals on every lookup, so PHP never
172+
// finishes booting and memory grows without bound
173+
if config.ZTS && config.ZendSignals {
174+
return ErrZendSignals
175+
}
176+
177+
return nil
178+
}
179+
159180
func calculateMaxThreads(opt *opt) (numWorkers int, _ error) {
160181
maxProcs := runtime.GOMAXPROCS(0) * 2
161182
maxThreadsFromWorkers := 0
@@ -294,9 +315,10 @@ func Init(options ...Option) error {
294315

295316
config := Config()
296317

297-
if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) {
318+
if err := checkPHPConfig(config); err != nil {
298319
shutdown()
299-
return ErrInvalidPHPVersion
320+
321+
return err
300322
}
301323

302324
if config.ZTS {

phpconfig_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package frankenphp
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestCheckPHPConfig(t *testing.T) {
11+
supported := PHPConfig{Version: PHPVersion{MajorVersion: 8, MinorVersion: 2}, ZTS: true}
12+
13+
t.Run("a supported build is accepted", func(t *testing.T) {
14+
require.NoError(t, checkPHPConfig(supported))
15+
})
16+
17+
t.Run("PHP older than 8.2 is rejected", func(t *testing.T) {
18+
old := supported
19+
old.Version.MinorVersion = 1
20+
assert.ErrorIs(t, checkPHPConfig(old), ErrInvalidPHPVersion)
21+
})
22+
23+
t.Run("Zend signals are rejected in ZTS", func(t *testing.T) {
24+
signals := supported
25+
signals.ZendSignals = true
26+
assert.ErrorIs(t, checkPHPConfig(signals), ErrZendSignals)
27+
})
28+
29+
t.Run("Zend signals are allowed without ZTS", func(t *testing.T) {
30+
// the ini entries of the signal globals are addressed directly
31+
// there, TSRM is not involved
32+
signals := supported
33+
signals.ZTS = false
34+
signals.ZendSignals = true
35+
require.NoError(t, checkPHPConfig(signals))
36+
})
37+
}

0 commit comments

Comments
 (0)