Skip to content

Commit 2b5961b

Browse files
feat: Add configurable max_requests for PHP threads
1 parent 097563d commit 2b5961b

File tree

11 files changed

+457
-4
lines changed

11 files changed

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

0 commit comments

Comments
 (0)