Skip to content

feat: Add configurable max_requests for PHP threads#2292

Open
nicolas-grekas wants to merge 2 commits intophp:mainfrom
nicolas-grekas:max_requests
Open

feat: Add configurable max_requests for PHP threads#2292
nicolas-grekas wants to merge 2 commits intophp:mainfrom
nicolas-grekas:max_requests

Conversation

@nicolas-grekas
Copy link
Contributor

@nicolas-grekas nicolas-grekas commented Mar 21, 2026

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_requests option in the global frankenphp block 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:

  • New max_requests Caddyfile directive in the global frankenphp block
  • New WithMaxRequests functional option
  • New Rebooting and RebootReady states in the thread state machine for restart coordination
  • Regular thread restart in threadregular.go
  • Worker thread restart in threadworker.go
  • Safe shutdown: shutdown() waits for in-flight reboots to complete before draining threads
  • Tests for both module and worker mode (sequential and concurrent), with debug log verification
  • Updated docs

@henderkes
Copy link
Contributor

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.

@nicolas-grekas
Copy link
Contributor Author

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?

@nicolas-grekas nicolas-grekas force-pushed the max_requests branch 3 times, most recently from b30002c to 9f13975 Compare March 21, 2026 17:03
@nicolas-grekas
Copy link
Contributor Author

nicolas-grekas commented Mar 21, 2026

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 ts_free_thread() when max_requests is reached.

That complements user-side refreshes that clean only application state.

@nicolas-grekas nicolas-grekas force-pushed the max_requests branch 2 times, most recently from 13baffd to ae8d6ed Compare March 21, 2026 21:28
@nicolas-grekas
Copy link
Contributor Author

PR description updated, patch ready and green 🚀

@henderkes
Copy link
Contributor

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?

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).

@nicolas-grekas
Copy link
Contributor Author

What about having only frankenphp { max_requests } that applies to everything? That would work also for global workers.

@henderkes
Copy link
Contributor

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.

@nicolas-grekas
Copy link
Contributor Author

BTW, what about defaulting to eg 100?

@henderkes
Copy link
Contributor

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.

@nicolas-grekas
Copy link
Contributor Author

Done, there's not a single max_requests under frankenphp, default to 0 = unlimited.

@henderkes
Copy link
Contributor

henderkes commented Mar 22, 2026

Can we get a quick vote on frankenphp wide vs per-php_server?

👍 for frankenphp, 👎 for php_server
@php/frankenphp-collaborators

@nicolas-grekas
Copy link
Contributor Author

My reasoning for going with only frankenphp after your suggestion: the ini settings and list of enabled extensions is global. If there's a leak to accommodate for, it's thus global also.

@nicolas-grekas nicolas-grekas force-pushed the max_requests branch 2 times, most recently from 2b5961b to f2a3936 Compare March 22, 2026 16:59
@nicolas-grekas
Copy link
Contributor Author

Thanks for the review @henderkes, should be all addressed now. (just waiting for the CI now)

@henderkes
Copy link
Contributor

Thanks for the review @henderkes, should be all addressed now. (just waiting for the CI now)

Hmm, that's a good point.

Copy link
Contributor

@henderkes henderkes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@nicolas-grekas
Copy link
Contributor Author

Thanks for the review, all addressed:

  • Added Rebooting state to internal/state/state.go
  • Removed needsRestart field, restartingThreads and shutdownInProgress atomics
  • waitForRequest/waitForWorkerRequest sets state.Rebooting before triggering restart
  • beforeScriptExecution handles Rebooting state to exit C loop
  • shutdown() waits for Rebooting threads to complete before proceeding
  • Restart goroutines check mainThread.state.Is(Ready) to abort during shutdown
  • Use testGet() instead of manual HTTP clients
  • Removed explicit timeouts, rely on test-level timeout
  • 100% success required (not 95%)
  • Removed TestModuleMaxRequestsZeroIsUnlimited (didn't test much indeed)
  • Moved worker tests to worker_test.go
  • Deleted maxrequests_test.go

Comment on lines +321 to +323
regularThreadMu.Lock()
regularThreads = make([]*phpThread, 0, opt.numThreads-workerThreadCount)
regularThreadMu.Unlock()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lock here is not necessary

Comment on lines +68 to +70
if thread.state.Is(state.Rebooting) || thread.state.Is(state.RebootReady) {
thread.state.WaitFor(state.Ready, state.Inactive, state.Done)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check is unnecessary

if !handler.state.CompareAndSwap(state.Ready, state.Rebooting) {
return ""
}
detachRegularThread(handler.thread)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detaching is unnecessary


return handler.worker.fileName
case state.Rebooting:
handler.worker.detachThread(handler.thread)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making a new drainchan is unnecessary

Comment on lines +73 to 79
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making new drain chan is unnecessary

Comment on lines +252 to +253
handler.state.CompareAndSwap(state.Ready, state.Rebooting)
return false, nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
handler.state.CompareAndSwap(state.Ready, state.Rebooting)
return false, nil
if (handler.state.CompareAndSwap(state.Ready, state.Rebooting){
return false, nil
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants