Skip to content

Commit bebcd7a

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

13 files changed

+472
-5
lines changed

caddy/app.go

Lines changed: 17 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,13 +155,15 @@ 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 {
159162
w.options = append(w.options,
160163
frankenphp.WithWorkerEnv(w.Env),
161164
frankenphp.WithWorkerWatchMode(w.Watch),
162165
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
166+
frankenphp.WithWorkerMaxRequests(w.MaxRequests),
163167
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
164168
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
165169
)
@@ -192,6 +196,7 @@ func (f *FrankenPHPApp) Stop() error {
192196
f.NumThreads = 0
193197
f.MaxWaitTime = 0
194198
f.MaxIdleTime = 0
199+
f.MaxRequests = 0
195200

196201
optionsMU.Lock()
197202
options = nil
@@ -255,6 +260,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
255260
}
256261

257262
f.MaxIdleTime = v
263+
case "max_requests":
264+
if !d.NextArg() {
265+
return d.ArgErr()
266+
}
267+
268+
v, err := strconv.ParseUint(d.Val(), 10, 32)
269+
if err != nil {
270+
return d.WrapErr(err)
271+
}
272+
273+
f.MaxRequests = int(v)
258274
case "php_ini":
259275
parseIniLine := func(d *caddyfile.Dispenser) error {
260276
key := d.Val()
@@ -311,7 +327,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
311327

312328
f.Workers = append(f.Workers, wc)
313329
default:
314-
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
330+
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time, max_requests", d.Val())
315331
}
316332
}
317333
}

caddy/workerconfig.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type workerConfig struct {
4141
MatchPath []string `json:"match_path,omitempty"`
4242
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
4343
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
44+
// MaxRequests sets the maximum number of requests a worker thread will handle before restarting (0 = unlimited, similar to php-fpm's pm.max_requests)
45+
MaxRequests int `json:"max_requests,omitempty"`
4446

4547
options []frankenphp.WorkerOption
4648
requestOptions []frankenphp.RequestOption
@@ -145,8 +147,19 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
145147
}
146148

147149
wc.MaxConsecutiveFailures = v
150+
case "max_requests":
151+
if !d.NextArg() {
152+
return wc, d.ArgErr()
153+
}
154+
155+
v, err := strconv.ParseUint(d.Val(), 10, 32)
156+
if err != nil {
157+
return wc, d.WrapErr(err)
158+
}
159+
160+
wc.MaxRequests = int(v)
148161
default:
149-
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v)
162+
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, max_requests", v)
150163
}
151164
}
152165

docs/config.md

Lines changed: 46 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. 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.
@@ -105,6 +106,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
105106
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
106107
name <name> # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file
107108
max_consecutive_failures <num> # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6.
109+
max_requests <num> # Sets the maximum number of requests a worker thread will handle before restarting, useful for mitigating memory leaks. Default: 0 (unlimited). See below.
108110
}
109111
}
110112
}
@@ -190,6 +192,7 @@ php_server [<matcher>] {
190192
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
191193
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.
192194
match <path> # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive.
195+
max_requests <num> # Sets the maximum number of requests a worker thread will handle before restarting, useful for mitigating memory leaks. Default: 0 (unlimited).
193196
}
194197
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
195198
}
@@ -265,6 +268,49 @@ and otherwise forward the request to the worker matching the path pattern.
265268
}
266269
```
267270

271+
## Restarting Threads After a Number of Requests
272+
273+
Similar to PHP-FPM's [`pm.max_requests`](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-requests),
274+
FrankenPHP can automatically restart PHP threads after they have handled a given number of requests.
275+
This is useful for mitigating memory leaks in PHP extensions or application code,
276+
since a restart fully cleans up the thread's memory (including ZTS thread-local storage).
277+
278+
### Module Mode (Without Workers)
279+
280+
When not using workers, `max_requests` is set in the global `frankenphp` block and applies to all regular PHP threads:
281+
282+
```caddyfile
283+
{
284+
frankenphp {
285+
max_requests 500
286+
}
287+
}
288+
```
289+
290+
When a thread reaches the limit, it is transparently restarted with a fresh PHP context.
291+
Other threads continue to serve requests during the restart, so there is no downtime.
292+
293+
### Worker Mode
294+
295+
For workers, `max_requests` is set per worker in the `worker` block:
296+
297+
```caddyfile
298+
{
299+
frankenphp {
300+
worker {
301+
file /path/to/your/worker.php
302+
num 4
303+
max_requests 500
304+
}
305+
}
306+
}
307+
```
308+
309+
When a worker thread reaches the limit, the PHP worker script exits its loop
310+
and restarts automatically, similar to a graceful restart.
311+
312+
Set to `0` (default) to disable the limit and let threads run indefinitely.
313+
268314
## Environment Variables
269315

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

frankenphp.go

Lines changed: 4 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
@@ -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: 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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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?i=" + strings.Clone("0"))
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+
nbParallelRequests: 1,
42+
realServer: true,
43+
initOpts: []frankenphp.Option{
44+
frankenphp.WithNumThreads(2),
45+
frankenphp.WithMaxRequests(maxRequests),
46+
},
47+
})
48+
}
49+
50+
// TestModuleMaxRequestsConcurrent verifies max_requests works under concurrent load
51+
// in module mode. All requests must succeed despite threads restarting.
52+
func TestModuleMaxRequestsConcurrent(t *testing.T) {
53+
const maxRequests = 10
54+
const totalRequests = 200
55+
const concurrency = 20
56+
57+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, _ int) {
58+
require.NotNil(t, ts)
59+
client := &http.Client{Timeout: 10 * time.Second}
60+
61+
var successCount int
62+
var mu sync.Mutex
63+
sem := make(chan struct{}, concurrency)
64+
var wg sync.WaitGroup
65+
66+
for i := 0; i < totalRequests; i++ {
67+
wg.Add(1)
68+
sem <- struct{}{}
69+
go func(i int) {
70+
defer func() { <-sem; wg.Done() }()
71+
72+
resp, err := client.Get(ts.URL + "/index.php?i=" + strings.Clone("0"))
73+
if err != nil {
74+
return
75+
}
76+
body, _ := io.ReadAll(resp.Body)
77+
_ = resp.Body.Close()
78+
79+
if resp.StatusCode == 200 && strings.Contains(string(body), "I am by birth a Genevese") {
80+
mu.Lock()
81+
successCount++
82+
mu.Unlock()
83+
}
84+
}(i)
85+
}
86+
wg.Wait()
87+
88+
t.Logf("Success: %d/%d", successCount, totalRequests)
89+
assert.Equal(t, totalRequests, successCount,
90+
"all requests should succeed despite regular thread restarts")
91+
}, &testOptions{
92+
nbParallelRequests: 1,
93+
realServer: true,
94+
initOpts: []frankenphp.Option{
95+
frankenphp.WithNumThreads(4),
96+
frankenphp.WithMaxRequests(maxRequests),
97+
},
98+
})
99+
}
100+
101+
// TestModuleMaxRequestsZeroIsUnlimited verifies that max_requests=0 (default)
102+
// means threads never restart.
103+
func TestModuleMaxRequestsZeroIsUnlimited(t *testing.T) {
104+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, _ int) {
105+
require.NotNil(t, ts)
106+
client := &http.Client{Timeout: 5 * time.Second}
107+
108+
for i := 0; i < 50; i++ {
109+
resp, err := client.Get(ts.URL + "/index.php?i=" + strings.Clone("0"))
110+
require.NoError(t, err)
111+
body, _ := io.ReadAll(resp.Body)
112+
_ = resp.Body.Close()
113+
114+
assert.Equal(t, 200, resp.StatusCode)
115+
assert.Contains(t, string(body), "I am by birth a Genevese")
116+
}
117+
}, &testOptions{
118+
nbParallelRequests: 1,
119+
realServer: true,
120+
initOpts: []frankenphp.Option{frankenphp.WithNumThreads(2)},
121+
})
122+
}

0 commit comments

Comments
 (0)