Skip to content

feat: Add configurable max_requests for PHP threads#2292

Open
nicolas-grekas wants to merge 1 commit intophp:mainfrom
nicolas-grekas:max_requests
Open

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

Conversation

@nicolas-grekas
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

PR description updated, patch ready and green 🚀

@henderkes
Copy link
Copy Markdown
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
Copy Markdown
Contributor Author

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

@henderkes
Copy link
Copy Markdown
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
Copy Markdown
Contributor Author

BTW, what about defaulting to eg 100?

@henderkes
Copy link
Copy Markdown
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
Copy Markdown
Contributor Author

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

@henderkes
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

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

@henderkes
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

Thanks for the thorough review! Applied all simplifications. Two items I kept:

  • go_frankenphp_on_thread_shutdown check (phpthread.go): The Is(Rebooting) -> RebootReady routing is needed: reboot() goroutine calls WaitFor(RebootReady). Without the check, we'd always set Done and the goroutine would hang.

  • RebootReady in threadworker.go: I added handler.state.Set(state.Ready) before handler.beforeScriptExecution(), otherwise it's infinite recursion (re-enters with same state). This ensures it falls into the Ready case which runs updateContext, onThreadReady, and setupWorkerScript.

Or did I miss something?

@nicolas-grekas nicolas-grekas force-pushed the max_requests branch 2 times, most recently from d66b8ff to cf16368 Compare March 25, 2026 06:44
Copy link
Copy Markdown
Contributor

@AlliBalliBaba AlliBalliBaba left a comment

Choose a reason for hiding this comment

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

Not sure this needs its own section in the docs, otherwise looking good from my side 👍

@nicolas-grekas
Copy link
Copy Markdown
Contributor Author

Not sure this needs its own section in the docs

right, removed
thanks for the review!

@nicolas-grekas nicolas-grekas force-pushed the max_requests branch 3 times, most recently from da41d6c to d12a6b3 Compare March 26, 2026 22:27
@nicolas-grekas
Copy link
Copy Markdown
Contributor Author

I've added an entry back in the doc to expand a bit on the tension this option creates.
Excerpt:

If you notice memory growing over time, the ideal fix is to report the leak to the responsible extension or library maintainer.
But when the fix depends on a third party you don't control, max_requests provides a pragmatic and hopefully temporary workaround for production:

/cc @dunglas

Copy link
Copy Markdown
Member

@dunglas dunglas left a comment

Choose a reason for hiding this comment

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

Code-wise, this looks good to me.

However, I'm still not entirely convinced by the use case. The question is: do we want to introduce and maintain this complexity because some (proprietary) extensions have issues or not.

Normal code shouldn't leak, and open source code can and should be patched instead of using such workarounds.

WDYT @php/frankenphp-collaborators?

@henderkes
Copy link
Copy Markdown
Contributor

Code-wise, this looks good to me.

However, I'm still not entirely convinced by the use case. The question is: do we want to introduce and maintain this complexity because some (proprietary) extensions have issues or not.

Normal code shouldn't leak, and open source code can and should be patched instead of using such workarounds.

WDYT @php/frankenphp-collaborators?

I think in an optimal world, we wouldn't have to maintain this. However, in the world we're living in, Blackfire and some other profilers do still have issues and they're slow to fix them. PHP-FPM has also had this setting forever, so we wouldn't really be going out of line there.

I think an advantage of having this PR is at least having a mechanism in place to restart a regular thread. The maintenance added by this is acceptable to me.

@alexandre-daubois
Copy link
Copy Markdown
Member

I agree with Marc. While extension maintainers should of course fix their leaks, the addition is lightweight and it's opt-in, making it not-that-easy to discover (which is good news and should always stay as-is).

I'm really torn between the idea that "other languages don't need to restart like that, developers are careful about memory leaks and deal with it because it's part of the job" and the reality of the situation.

Even though it’s not ideal, I’m leaning toward a positive opinion on this addition.

@henderkes
Copy link
Copy Markdown
Contributor

Perhaps we could also mark the setting // EXPERIMENTAL. That way we could remove it at a later date without breaking our "BC promises".

@dunglas
Copy link
Copy Markdown
Member

dunglas commented Mar 27, 2026

+1 for merging this as "experimental"

@AlliBalliBaba
Copy link
Copy Markdown
Contributor

I like this addition since rebooting threads has other potential use cases. But as @dunglas mentioned the downside is that it might encourage extensions not to fix their leaks. +1 for experimental

@nicolas-grekas
Copy link
Copy Markdown
Contributor Author

PR updated, experimental note added.

FTR, I talked with blackfire engineers: they aren't able to prioritize looking at this because frankenphp is a very very small share of their customer.s And since frankenphp isn't stable with blackfire, their customers are not going to adopt frankenphp either. Chicken and egg.

@henderkes
Copy link
Copy Markdown
Contributor

And since frankenphp isn't stable with blackfire, their customers are not going to adopt frankenphp either.

And even worse, they're not going to get any customers who care about performance, either, by not properly supporting FrankenPHP or Swoole. You can take a guess why we chose not to adopt Blackfire 😅

@dunglas
Copy link
Copy Markdown
Member

dunglas commented Mar 27, 2026

What are the other use case do you have in mind @AlliBalliBaba ?

@AlliBalliBaba
Copy link
Copy Markdown
Contributor

I think we could also use similar logic to restart all threads when watching or on opcache_reset. Basically, keep everything stalled on the go side and just restart the C threads, might be cleaner than forcing opcache to reset and probably only minimal additional overhead.

Another side effect is that this allows configuring max_requests for workers via Caddyfile, which might be necessary for graceful restarts if we ever support async workers on the PHP side (probably far away, but theoretically already possible with amp/react).

I'm indifferent to the leak prevention though, since this has never been a problem that I have personally observed.

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.

5 participants