feat: Add configurable max_requests for PHP threads#2292
feat: Add configurable max_requests for PHP threads#2292nicolas-grekas wants to merge 2 commits intophp:mainfrom
Conversation
45cffba to
bebcd7a
Compare
|
I would propose we either make this a global setting only or a php_server block setting only. I currently don't see why this should be a worker setting. |
|
See #280 As I wrote: this can be dealt with on the PHP side, but this is an ops concern, so it would make sense to have this in the Caddyfile, don't you think? |
b30002c to
9f13975
Compare
|
Actually I had a closer look and realized we should do the same full reset on worker threads too - something userland PHP cannot do. Both worker and module mode now do full ZTS cleanup via That complements user-side refreshes that clean only application state. |
13baffd to
ae8d6ed
Compare
|
PR description updated, patch ready and green 🚀 |
I just meant that I don't think the setting should exist in more than one place in the Caddyfile config. I'm leaning towards it only being a php_server setting (which would affect all workers in that block too). |
|
What about having only |
|
Would be fine, although I think that we generally want almost all options to be inside php_server blocks. I think workers should have never been in global scope either. |
|
BTW, what about defaulting to eg 100? |
|
I don't think we should. Default should stay 0 for performance and because most apps don't actually have memory issues in non-worker mode. |
ae8d6ed to
c635f17
Compare
|
Done, there's not a single max_requests under frankenphp, default to 0 = unlimited. |
|
Can we get a quick vote on 👍 for frankenphp, 👎 for php_server |
|
My reasoning for going with only |
2b5961b to
f2a3936
Compare
|
Thanks for the review @henderkes, should be all addressed now. (just waiting for the CI now) |
f2a3936 to
e29b6a9
Compare
Hmm, that's a good point. |
henderkes
left a comment
There was a problem hiding this comment.
All good from my side now, but I'll look into properly testing for the restart log message tomorrow, if you don't get to it first.
Thanks for the addition and your work on Symfony!
e29b6a9 to
5c53ffb
Compare
4ed787e to
e086410
Compare
|
Thanks for the review, all addressed:
|
e086410 to
8492c94
Compare
| regularThreadMu.Lock() | ||
| regularThreads = make([]*phpThread, 0, opt.numThreads-workerThreadCount) | ||
| regularThreadMu.Unlock() |
There was a problem hiding this comment.
lock here is not necessary
| if thread.state.Is(state.Rebooting) || thread.state.Is(state.RebootReady) { | ||
| thread.state.WaitFor(state.Ready, state.Inactive, state.Done) | ||
| } |
There was a problem hiding this comment.
check is unnecessary
| if !handler.state.CompareAndSwap(state.Ready, state.Rebooting) { | ||
| return "" | ||
| } | ||
| detachRegularThread(handler.thread) |
There was a problem hiding this comment.
detaching is unnecessary
|
|
||
| return handler.worker.fileName | ||
| case state.Rebooting: | ||
| handler.worker.detachThread(handler.thread) |
There was a problem hiding this comment.
detaching is unnecessary
| // restartWorkerThread waits for the C thread to exit, then spawns a fresh one. | ||
| func restartWorkerThread(thread *phpThread, worker *worker) { | ||
| thread.state.WaitFor(state.RebootReady) | ||
| thread.drainChan = make(chan struct{}) |
There was a problem hiding this comment.
making a new drainchan is unnecessary
| handler.thread.updateContext(true) | ||
| handler.worker.attachThread(handler.thread) | ||
| if handler.worker.onThreadReady != nil { | ||
| handler.worker.onThreadReady(handler.thread.threadIndex) | ||
| } | ||
| setupWorkerScript(handler, handler.worker) | ||
| return handler.worker.fileName |
There was a problem hiding this comment.
| handler.thread.updateContext(true) | |
| handler.worker.attachThread(handler.thread) | |
| if handler.worker.onThreadReady != nil { | |
| handler.worker.onThreadReady(handler.thread.threadIndex) | |
| } | |
| setupWorkerScript(handler, handler.worker) | |
| return handler.worker.fileName | |
| return handler.beforeScriptExecution() |
| case state.RebootReady: | ||
| handler.requestCount = 0 | ||
| handler.state.Set(state.Ready) | ||
| attachRegularThread(handler.thread) |
There was a problem hiding this comment.
attaching is unnecessary
| // restartRegularThread waits for the C thread to exit, then spawns a fresh one. | ||
| func restartRegularThread(thread *phpThread) { | ||
| thread.state.WaitFor(state.RebootReady) | ||
| thread.drainChan = make(chan struct{}) |
There was a problem hiding this comment.
making new drain chan is unnecessary
| handler.state.CompareAndSwap(state.Ready, state.Rebooting) | ||
| return false, nil |
There was a problem hiding this comment.
| handler.state.CompareAndSwap(state.Ready, state.Rebooting) | |
| return false, nil | |
| if (handler.state.CompareAndSwap(state.Ready, state.Rebooting){ | |
| return false, nil | |
| } |
PHP-FPM recycles worker processes after a configurable number of requests (
pm.max_requests), preventing memory leaks from accumulating over time. FrankenPHP keeps PHP threads alive indefinitely, so any leak in PHP extensions (e.g. ZTS builds of profiling tools like Blackfire) or application code compounds over hours/days. In production behind reverse proxies like Cloudflare, this can lead to gradual resource exhaustion and eventually 502 errors with no visible warnings in logs.This PR adds a
max_requestsoption in the globalfrankenphpblock that automatically restarts PHP threads after a given number of requests, fully cleaning up the thread's memory and state. It applies to both regular (module mode) and worker threads.When a thread reaches the limit it exits the C thread loop, triggering a full cleanup including any memory leaked by extensions. A fresh thread is then booted transparently. Other threads continue serving requests during the restart.
This cannot be done from userland PHP: restarting a worker script from PHP only resets PHP-level state, not the underlying C thread-local storage where extension-level leaks accumulate. And in module mode (without workers), there is no userland loop to count requests at all.
Default is
0(unlimited), preserving existing behavior.Usage:
{ frankenphp { max_requests 500 } }Changes:
max_requestsCaddyfile directive in the globalfrankenphpblockWithMaxRequestsfunctional optionRebootingandRebootReadystates in the thread state machine for restart coordinationthreadregular.gothreadworker.goshutdown()waits for in-flight reboots to complete before draining threads