Skip to content

Commit e7f2c85

Browse files
feat: Add configurable max_requests for PHP threads
1 parent 0a226ad commit e7f2c85

File tree

12 files changed

+287
-7
lines changed

12 files changed

+287
-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: 3 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,8 @@ and otherwise forward the request to the worker matching the path pattern.
265266
}
266267
```
267268

269+
## Restarting Threads After a Number of Requests
270+
268271
## Environment Variables
269272

270273
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
@@ -62,6 +62,24 @@ func (thread *phpThread) boot() {
6262
thread.state.WaitFor(state.Inactive)
6363
}
6464

65+
// reboot exits the C thread loop for full ZTS cleanup, then spawns a fresh C thread.
66+
// Returns false if the thread is no longer in Ready state (e.g. shutting down).
67+
func (thread *phpThread) reboot() bool {
68+
if !thread.state.CompareAndSwap(state.Ready, state.Rebooting) {
69+
return false
70+
}
71+
72+
go func() {
73+
thread.state.WaitFor(state.RebootReady)
74+
75+
if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) {
76+
panic("unable to create thread")
77+
}
78+
}()
79+
80+
return true
81+
}
82+
6583
// shutdown the underlying PHP thread
6684
func (thread *phpThread) shutdown() {
6785
if !thread.state.RequestSafeStateChange(state.ShuttingDown) {
@@ -183,5 +201,9 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.
183201
func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) {
184202
thread := phpThreads[threadIndex]
185203
thread.Unpin()
186-
thread.state.Set(state.Done)
204+
if thread.state.Is(state.Rebooting) {
205+
thread.state.Set(state.RebootReady)
206+
} else {
207+
thread.state.Set(state.Done)
208+
}
187209
}
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
@@ -76,6 +83,20 @@ func (handler *regularThread) name() string {
7683
}
7784

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

81102
var ch contextHolder
@@ -88,6 +109,7 @@ func (handler *regularThread) waitForRequest() string {
88109
case ch = <-handler.thread.requestChan:
89110
}
90111

112+
handler.requestCount++
91113
handler.ctx = ch.ctx
92114
handler.contextHolder.frankenPHPContext = ch.frankenPHPContext
93115
handler.state.MarkAsWaiting(false)

0 commit comments

Comments
 (0)