Skip to content

Commit 5a9bc7f

Browse files
feat: Add configurable max_requests for PHP threads (#2292)
PHP-FPM recycles worker processes after a configurable number of requests (`pm.max_requests`), preventing memory leaks from accumulating over time. FrankenPHP keeps PHP threads alive indefinitely, so any leak in PHP extensions (e.g. ZTS builds of profiling tools like Blackfire) or application code compounds over hours/days. In production behind reverse proxies like Cloudflare, this can lead to gradual resource exhaustion and eventually 502 errors with no visible warnings in logs. This PR adds a `max_requests` option in the global `frankenphp` block that automatically restarts PHP threads after a given number of requests, fully cleaning up the thread's memory and state. It applies to both regular (module mode) and worker threads. When a thread reaches the limit it exits the C thread loop, triggering a full cleanup including any memory leaked by extensions. A fresh thread is then booted transparently. Other threads continue serving requests during the restart. This cannot be done from userland PHP: restarting a worker script from PHP only resets PHP-level state, not the underlying C thread-local storage where extension-level leaks accumulate. And in module mode (without workers), there is no userland loop to count requests at all. Default is `0` (unlimited), preserving existing behavior. Usage: ```caddyfile { frankenphp { max_requests 500 } } ``` Changes: - New `max_requests` Caddyfile directive in the global `frankenphp` block - New `WithMaxRequests` functional option - New `Rebooting` and `RebootReady` states in the thread state machine for restart coordination - Regular thread restart in `threadregular.go` - Worker thread restart in `threadworker.go` - Safe shutdown: `shutdown()` waits for in-flight reboots to complete before draining threads - Tests for both module and worker mode (sequential and concurrent), with debug log verification - Updated docs
1 parent 239ad52 commit 5a9bc7f

File tree

12 files changed

+304
-7
lines changed

12 files changed

+304
-7
lines changed

caddy/app.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type FrankenPHPApp struct {
5757
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
5858
// The maximum amount of time an autoscaled thread may be idle before being deactivated
5959
MaxIdleTime time.Duration `json:"max_idle_time,omitempty"`
60+
// EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited)
61+
MaxRequests int `json:"max_requests,omitempty"`
6062

6163
opts []frankenphp.Option
6264
metrics frankenphp.Metrics
@@ -153,6 +155,7 @@ func (f *FrankenPHPApp) Start() error {
153155
frankenphp.WithPhpIni(f.PhpIni),
154156
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
155157
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
158+
frankenphp.WithMaxRequests(f.MaxRequests),
156159
)
157160

158161
for _, w := range f.Workers {
@@ -192,6 +195,7 @@ func (f *FrankenPHPApp) Stop() error {
192195
f.NumThreads = 0
193196
f.MaxWaitTime = 0
194197
f.MaxIdleTime = 0
198+
f.MaxRequests = 0
195199

196200
optionsMU.Lock()
197201
options = nil
@@ -255,6 +259,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
255259
}
256260

257261
f.MaxIdleTime = v
262+
case "max_requests":
263+
if !d.NextArg() {
264+
return d.ArgErr()
265+
}
266+
267+
v, err := strconv.ParseUint(d.Val(), 10, 32)
268+
if err != nil {
269+
return d.WrapErr(err)
270+
}
271+
272+
f.MaxRequests = int(v)
258273
case "php_ini":
259274
parseIniLine := func(d *caddyfile.Dispenser) error {
260275
key := d.Val()
@@ -311,7 +326,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
311326

312327
f.Workers = append(f.Workers, wc)
313328
default:
314-
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
329+
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time, max_requests", d.Val())
315330
}
316331
}
317332
}

docs/config.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
9797
max_threads <num_threads> # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'.
9898
max_wait_time <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
9999
max_idle_time <duration> # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s.
100+
max_requests <num> # (experimental) Sets the maximum number of requests a PHP thread will handle before being restarted, useful for mitigating memory leaks. Applies to both regular and worker threads. Default: 0 (unlimited).
100101
php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
101102
worker {
102103
file <path> # Sets the path to the worker script.
@@ -265,6 +266,25 @@ and otherwise forward the request to the worker matching the path pattern.
265266
}
266267
```
267268

269+
## Restarting Threads After a Number of Requests (Experimental)
270+
271+
FrankenPHP can automatically restart PHP threads after they have handled a given number of requests.
272+
When a thread reaches the limit, it is fully restarted,
273+
cleaning up all memory and state. Other threads continue to serve requests during the restart.
274+
275+
If you notice memory growing over time, the ideal fix is to report the leak
276+
to the responsible extension or library maintainer.
277+
But when the fix depends on a third party you don't control,
278+
`max_requests` provides a pragmatic and hopefully temporary workaround for production:
279+
280+
```caddyfile
281+
{
282+
frankenphp {
283+
max_requests 500
284+
}
285+
}
286+
```
287+
268288
## Environment Variables
269289

270290
The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it:

frankenphp.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ var (
6666

6767
metrics Metrics = nullMetrics{}
6868

69-
maxWaitTime time.Duration
69+
maxWaitTime time.Duration
70+
maxRequestsPerThread int
7071
)
7172

7273
type ErrRejected struct {
@@ -275,6 +276,7 @@ func Init(options ...Option) error {
275276
}
276277

277278
maxWaitTime = opt.maxWaitTime
279+
maxRequestsPerThread = opt.maxRequests
278280

279281
if opt.maxIdleTime > 0 {
280282
maxIdleTime = opt.maxIdleTime
@@ -335,7 +337,7 @@ func Init(options ...Option) error {
335337
initAutoScaling(mainThread)
336338

337339
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
338-
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads))
340+
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread))
339341

340342
if EmbeddedAppPath != "" {
341343
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "embedded PHP app 📦", slog.String("path", EmbeddedAppPath))
@@ -786,5 +788,6 @@ func resetGlobals() {
786788
workersByPath = nil
787789
watcherIsEnabled = false
788790
maxIdleTime = defaultMaxIdleTime
791+
maxRequestsPerThread = 0
789792
globalMu.Unlock()
790793
}

frankenphp_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import (
2323
"os"
2424
"os/exec"
2525
"os/user"
26-
"runtime"
2726
"path/filepath"
27+
"runtime"
2828
"strconv"
2929
"strings"
3030
"sync"

internal/state/state.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const (
3030
TransitionRequested
3131
TransitionInProgress
3232
TransitionComplete
33+
34+
// thread is exiting the C loop for a full ZTS restart (max_requests)
35+
Rebooting
36+
// C thread has exited and ZTS state is cleaned up, ready to spawn a new C thread
37+
RebootReady
3338
)
3439

3540
func (s State) String() string {
@@ -58,6 +63,10 @@ func (s State) String() string {
5863
return "transition in progress"
5964
case TransitionComplete:
6065
return "transition complete"
66+
case Rebooting:
67+
return "rebooting"
68+
case RebootReady:
69+
return "reboot ready"
6170
default:
6271
return "unknown"
6372
}

maxrequests_regular_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package frankenphp_test
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"sync"
9+
"testing"
10+
11+
"github.com/dunglas/frankenphp"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// TestModuleMaxRequests verifies that regular (non-worker) PHP threads restart
16+
// after reaching max_requests by checking debug logs for restart messages.
17+
func TestModuleMaxRequests(t *testing.T) {
18+
const maxRequests = 5
19+
const totalRequests = 30
20+
21+
var buf syncBuffer
22+
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
23+
24+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
25+
for i := 0; i < totalRequests; i++ {
26+
body, resp := testGet("http://example.com/index.php", handler, t)
27+
assert.Equal(t, 200, resp.StatusCode)
28+
assert.Contains(t, body, "I am by birth a Genevese")
29+
}
30+
31+
restartCount := strings.Count(buf.String(), "max requests reached, restarting thread")
32+
t.Logf("Thread restarts observed: %d", restartCount)
33+
assert.GreaterOrEqual(t, restartCount, 2,
34+
"with maxRequests=%d and %d requests on 2 threads, at least 2 restarts should occur", maxRequests, totalRequests)
35+
}, &testOptions{
36+
logger: logger,
37+
initOpts: []frankenphp.Option{
38+
frankenphp.WithNumThreads(2),
39+
frankenphp.WithMaxRequests(maxRequests),
40+
},
41+
})
42+
}
43+
44+
// TestModuleMaxRequestsConcurrent verifies max_requests works under concurrent load
45+
// in module mode. All requests must succeed despite threads restarting.
46+
func TestModuleMaxRequestsConcurrent(t *testing.T) {
47+
const maxRequests = 10
48+
const totalRequests = 200
49+
50+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
51+
var wg sync.WaitGroup
52+
53+
for i := 0; i < totalRequests; i++ {
54+
wg.Add(1)
55+
go func() {
56+
defer wg.Done()
57+
body, resp := testGet("http://example.com/index.php", handler, t)
58+
assert.Equal(t, 200, resp.StatusCode)
59+
assert.Contains(t, body, "I am by birth a Genevese")
60+
}()
61+
}
62+
wg.Wait()
63+
}, &testOptions{
64+
initOpts: []frankenphp.Option{
65+
frankenphp.WithNumThreads(8),
66+
frankenphp.WithMaxRequests(maxRequests),
67+
},
68+
})
69+
}

options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type opt struct {
3131
phpIni map[string]string
3232
maxWaitTime time.Duration
3333
maxIdleTime time.Duration
34+
maxRequests int
3435
}
3536

3637
type workerOpt struct {
@@ -166,6 +167,15 @@ func WithMaxIdleTime(maxIdleTime time.Duration) Option {
166167
}
167168
}
168169

170+
// EXPERIMENTAL: WithMaxRequests sets the default max requests before restarting a PHP thread (0 = unlimited). Applies to regular and worker threads.
171+
func WithMaxRequests(maxRequests int) Option {
172+
return func(o *opt) error {
173+
o.maxRequests = maxRequests
174+
175+
return nil
176+
}
177+
}
178+
169179
// WithWorkerEnv sets environment variables for the worker
170180
func WithWorkerEnv(env map[string]string) WorkerOption {
171181
return func(w *workerOpt) error {

phpthread.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ func (thread *phpThread) boot() {
6565
thread.state.WaitFor(state.Inactive)
6666
}
6767

68+
// reboot exits the C thread loop for full ZTS cleanup, then spawns a fresh C thread.
69+
// Returns false if the thread is no longer in Ready state (e.g. shutting down).
70+
func (thread *phpThread) reboot() bool {
71+
if !thread.state.CompareAndSwap(state.Ready, state.Rebooting) {
72+
return false
73+
}
74+
75+
go func() {
76+
thread.state.WaitFor(state.RebootReady)
77+
78+
if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) {
79+
panic("unable to create thread")
80+
}
81+
}()
82+
83+
return true
84+
}
85+
6886
// shutdown the underlying PHP thread
6987
func (thread *phpThread) shutdown() {
7088
if !thread.state.RequestSafeStateChange(state.ShuttingDown) {
@@ -189,5 +207,9 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.
189207
func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) {
190208
thread := phpThreads[threadIndex]
191209
thread.Unpin()
192-
thread.state.Set(state.Done)
210+
if thread.state.Is(state.Rebooting) {
211+
thread.state.Set(state.RebootReady)
212+
} else {
213+
thread.state.Set(state.Done)
214+
}
193215
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
// Worker that tracks total requests handled across restarts.
3+
// Uses a unique instance ID per worker script execution.
4+
$instanceId = bin2hex(random_bytes(8));
5+
$counter = 0;
6+
7+
while (frankenphp_handle_request(function () use (&$counter, $instanceId) {
8+
$counter++;
9+
echo "instance:$instanceId,count:$counter";
10+
})) {}

threadregular.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package frankenphp
22

33
import (
44
"context"
5+
"log/slog"
56
"runtime"
67
"sync"
78
"sync/atomic"
@@ -15,8 +16,9 @@ import (
1516
type regularThread struct {
1617
contextHolder
1718

18-
state *state.ThreadState
19-
thread *phpThread
19+
state *state.ThreadState
20+
thread *phpThread
21+
requestCount int
2022
}
2123

2224
var (
@@ -50,6 +52,11 @@ func (handler *regularThread) beforeScriptExecution() string {
5052
case state.Ready:
5153
return handler.waitForRequest()
5254

55+
case state.RebootReady:
56+
handler.requestCount = 0
57+
handler.state.Set(state.Ready)
58+
return handler.waitForRequest()
59+
5360
case state.ShuttingDown:
5461
detachRegularThread(handler.thread)
5562
// signal to stop
@@ -77,6 +84,20 @@ func (handler *regularThread) name() string {
7784
}
7885

7986
func (handler *regularThread) waitForRequest() string {
87+
// max_requests reached: restart the thread to clean up all ZTS state
88+
if maxRequestsPerThread > 0 && handler.requestCount >= maxRequestsPerThread {
89+
if globalLogger.Enabled(globalCtx, slog.LevelDebug) {
90+
globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "max requests reached, restarting thread",
91+
slog.Int("thread", handler.thread.threadIndex),
92+
slog.Int("max_requests", maxRequestsPerThread),
93+
)
94+
}
95+
96+
if handler.thread.reboot() {
97+
return ""
98+
}
99+
}
100+
80101
handler.state.MarkAsWaiting(true)
81102

82103
var ch contextHolder
@@ -89,6 +110,7 @@ func (handler *regularThread) waitForRequest() string {
89110
case ch = <-handler.thread.requestChan:
90111
}
91112

113+
handler.requestCount++
92114
handler.thread.contextMu.Lock()
93115
handler.ctx = ch.ctx
94116
handler.contextHolder.frankenPHPContext = ch.frankenPHPContext

0 commit comments

Comments
 (0)