Skip to content

Commit d12a6b3

Browse files
feat: Add configurable max_requests for PHP threads
1 parent 006f37f commit d12a6b3

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+
// MaxRequests sets the maximum number of requests a regular (non-worker) 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> # 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
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+
// 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)