Skip to content

Commit 5c53ffb

Browse files
feat: Add configurable max_requests for PHP threads
1 parent 097563d commit 5c53ffb

File tree

11 files changed

+466
-4
lines changed

11 files changed

+466
-4
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: 25 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). See below.
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.
@@ -190,6 +191,7 @@ php_server [<matcher>] {
190191
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
191192
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here.
192193
match <path> # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive.
194+
max_requests <num> # Sets the maximum number of requests a worker thread will handle before restarting, useful for mitigating memory leaks. Default: 0 (unlimited).
193195
}
194196
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
195197
}
@@ -265,6 +267,29 @@ and otherwise forward the request to the worker matching the path pattern.
265267
}
266268
```
267269

270+
## Restarting Threads After a Number of Requests
271+
272+
Similar to PHP-FPM's [`pm.max_requests`](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-requests),
273+
FrankenPHP can automatically restart PHP threads after they have handled a given number of requests.
274+
This is useful for mitigating memory leaks in PHP extensions or application code,
275+
since a restart fully cleans up the thread's memory and state.
276+
277+
The `max_requests` setting in the global `frankenphp` block applies to all PHP threads (both regular and worker threads):
278+
279+
```caddyfile
280+
{
281+
frankenphp {
282+
max_requests 500
283+
}
284+
}
285+
```
286+
287+
When a thread reaches the limit, the underlying C thread is fully restarted,
288+
cleaning up all ZTS thread-local storage, including any memory leaked by PHP extensions.
289+
Other threads continue to serve requests during the restart, so there is no downtime.
290+
291+
Set to `0` (default) to disable the limit and let threads run indefinitely.
292+
268293
## Environment Variables
269294

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

frankenphp.go

Lines changed: 12 additions & 1 deletion
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
@@ -369,6 +371,13 @@ func Shutdown() {
369371

370372
drainWatchers()
371373
drainAutoScaling()
374+
375+
// signal restart goroutines to stop spawning and wait for in-flight ones
376+
shutdownInProgress.Store(true)
377+
for restartingThreads.Load() > 0 {
378+
runtime.Gosched()
379+
}
380+
372381
drainPHPThreads()
373382

374383
metrics.Shutdown()
@@ -786,5 +795,7 @@ func resetGlobals() {
786795
workersByPath = nil
787796
watcherIsEnabled = false
788797
maxIdleTime = defaultMaxIdleTime
798+
maxRequestsPerThread = 0
799+
shutdownInProgress.Store(false)
789800
globalMu.Unlock()
790801
}

frankenphp_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type testOptions struct {
4545
realServer bool
4646
logger *slog.Logger
4747
initOpts []frankenphp.Option
48+
workerOpts []frankenphp.WorkerOption
4849
requestOpts []frankenphp.RequestOption
4950
phpIni map[string]string
5051
}
@@ -66,6 +67,7 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
6667
frankenphp.WithWorkerEnv(opts.env),
6768
frankenphp.WithWorkerWatchMode(opts.watch),
6869
}
70+
workerOpts = append(workerOpts, opts.workerOpts...)
6971
initOpts = append(initOpts, frankenphp.WithWorkers("workerName", testDataDir+opts.workerScript, opts.nbWorkers, workerOpts...))
7072
}
7173
initOpts = append(initOpts, opts.initOpts...)

maxrequests_regular_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package frankenphp_test
2+
3+
import (
4+
"io"
5+
"log/slog"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/dunglas/frankenphp"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// TestModuleMaxRequests verifies that regular (non-worker) PHP threads restart
19+
// after reaching max_requests by checking debug logs for restart messages.
20+
func TestModuleMaxRequests(t *testing.T) {
21+
const maxRequests = 5
22+
const totalRequests = 30
23+
24+
var buf syncBuffer
25+
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
26+
27+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, _ int) {
28+
require.NotNil(t, ts)
29+
client := &http.Client{Timeout: 5 * time.Second}
30+
31+
for i := 0; i < totalRequests; i++ {
32+
resp, err := client.Get(ts.URL + "/index.php")
33+
require.NoError(t, err, "request %d should succeed", i)
34+
35+
body, err := io.ReadAll(resp.Body)
36+
require.NoError(t, err)
37+
_ = resp.Body.Close()
38+
39+
assert.Equal(t, 200, resp.StatusCode, "request %d should return 200, got body: %s", i, string(body))
40+
assert.Contains(t, string(body), "I am by birth a Genevese",
41+
"request %d should return correct body", i)
42+
}
43+
44+
restartCount := strings.Count(buf.String(), "max requests reached, restarting thread")
45+
t.Logf("Thread restarts observed: %d", restartCount)
46+
assert.GreaterOrEqual(t, restartCount, 2,
47+
"with maxRequests=%d and %d requests on 2 threads, at least 2 restarts should occur", maxRequests, totalRequests)
48+
}, &testOptions{
49+
realServer: true,
50+
logger: logger,
51+
initOpts: []frankenphp.Option{
52+
frankenphp.WithNumThreads(2),
53+
frankenphp.WithMaxRequests(maxRequests),
54+
},
55+
})
56+
}
57+
58+
// TestModuleMaxRequestsConcurrent verifies max_requests works under concurrent load
59+
// in module mode. All requests must succeed despite threads restarting.
60+
func TestModuleMaxRequestsConcurrent(t *testing.T) {
61+
const maxRequests = 10
62+
const totalRequests = 200
63+
const concurrency = 20
64+
65+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, _ int) {
66+
require.NotNil(t, ts)
67+
client := &http.Client{Timeout: 10 * time.Second}
68+
69+
var successCount int
70+
var mu sync.Mutex
71+
sem := make(chan struct{}, concurrency)
72+
var wg sync.WaitGroup
73+
74+
for i := 0; i < totalRequests; i++ {
75+
wg.Add(1)
76+
sem <- struct{}{}
77+
go func(i int) {
78+
defer func() { <-sem; wg.Done() }()
79+
80+
resp, err := client.Get(ts.URL + "/index.php")
81+
if err != nil {
82+
return
83+
}
84+
body, _ := io.ReadAll(resp.Body)
85+
_ = resp.Body.Close()
86+
87+
if resp.StatusCode == 200 && strings.Contains(string(body), "I am by birth a Genevese") {
88+
mu.Lock()
89+
successCount++
90+
mu.Unlock()
91+
}
92+
}(i)
93+
}
94+
wg.Wait()
95+
96+
t.Logf("Success: %d/%d", successCount, totalRequests)
97+
assert.GreaterOrEqual(t, successCount, int(totalRequests*0.95),
98+
"at least 95%% of requests should succeed despite regular thread restarts")
99+
}, &testOptions{
100+
realServer: true,
101+
initOpts: []frankenphp.Option{
102+
frankenphp.WithNumThreads(8),
103+
frankenphp.WithMaxRequests(maxRequests),
104+
},
105+
})
106+
}
107+
108+
// TestModuleMaxRequestsZeroIsUnlimited verifies that max_requests=0 (default)
109+
// means threads never restart.
110+
func TestModuleMaxRequestsZeroIsUnlimited(t *testing.T) {
111+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, _ int) {
112+
require.NotNil(t, ts)
113+
client := &http.Client{Timeout: 5 * time.Second}
114+
115+
for i := 0; i < 50; i++ {
116+
resp, err := client.Get(ts.URL + "/index.php")
117+
require.NoError(t, err)
118+
body, _ := io.ReadAll(resp.Body)
119+
_ = resp.Body.Close()
120+
121+
assert.Equal(t, 200, resp.StatusCode)
122+
assert.Contains(t, string(body), "I am by birth a Genevese")
123+
}
124+
}, &testOptions{
125+
realServer: true,
126+
initOpts: []frankenphp.Option{frankenphp.WithNumThreads(2)},
127+
})
128+
}

0 commit comments

Comments
 (0)