Files
Nicolas Grekas 5a9bc7fb14 feat: Add configurable max_requests for PHP threads (#2292)
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:

```caddyfile
{
    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
2026-04-16 14:15:56 +02:00

268 lines
6.4 KiB
Go

package frankenphp
import (
"context"
"fmt"
"log/slog"
"time"
)
// defaultMaxConsecutiveFailures is the default maximum number of consecutive failures before panicking
const defaultMaxConsecutiveFailures = 6
// Option instances allow to configure FrankenPHP.
type Option func(h *opt) error
// WorkerOption instances allow configuring FrankenPHP worker.
type WorkerOption func(*workerOpt) error
// opt contains the available options.
//
// If you change this, also update the Caddy module and the documentation.
type opt struct {
hotReloadOpt
ctx context.Context
numThreads int
maxThreads int
workers []workerOpt
logger *slog.Logger
metrics Metrics
phpIni map[string]string
maxWaitTime time.Duration
maxIdleTime time.Duration
maxRequests int
}
type workerOpt struct {
mercureContext
name string
fileName string
num int
maxThreads int
env PreparedEnv
requestOptions []RequestOption
watch []string
maxConsecutiveFailures int
extensionWorkers *extensionWorkers
onThreadReady func(int)
onThreadShutdown func(int)
onServerStartup func()
onServerShutdown func()
}
// WithContext sets the main context to use.
func WithContext(ctx context.Context) Option {
return func(h *opt) error {
h.ctx = ctx
return nil
}
}
// WithNumThreads configures the number of PHP threads to start.
func WithNumThreads(numThreads int) Option {
return func(o *opt) error {
o.numThreads = numThreads
return nil
}
}
func WithMaxThreads(maxThreads int) Option {
return func(o *opt) error {
o.maxThreads = maxThreads
return nil
}
}
func WithMetrics(m Metrics) Option {
return func(o *opt) error {
o.metrics = m
return nil
}
}
// WithWorkers configures the PHP workers to start
func WithWorkers(name, fileName string, num int, options ...WorkerOption) Option {
return func(o *opt) error {
worker := workerOpt{
name: name,
fileName: fileName,
num: num,
env: PrepareEnv(nil),
watch: []string{},
maxConsecutiveFailures: defaultMaxConsecutiveFailures,
}
for _, option := range options {
if err := option(&worker); err != nil {
return err
}
}
o.workers = append(o.workers, worker)
return nil
}
}
// EXPERIMENTAL: WithExtensionWorkers allow extensions to create workers.
//
// A worker script with the provided name, fileName and thread count will be registered, along with additional
// configuration through WorkerOptions.
//
// Workers are designed to run indefinitely and will be gracefully shut down when FrankenPHP shuts down.
//
// Extension workers receive the lowest priority when determining thread allocations. If the requested number of threads
// cannot be allocated, then FrankenPHP will panic and provide this information to the user (who will need to allocate
// more total threads). Don't be greedy.
func WithExtensionWorkers(name, fileName string, numThreads int, options ...WorkerOption) (Workers, Option) {
w := &extensionWorkers{
name: name,
fileName: fileName,
num: numThreads,
}
w.options = append(options, withExtensionWorkers(w))
return w, WithWorkers(w.name, w.fileName, w.num, w.options...)
}
// WithLogger configures the global logger to use.
func WithLogger(l *slog.Logger) Option {
return func(o *opt) error {
o.logger = l
return nil
}
}
// WithPhpIni configures user defined PHP ini settings.
func WithPhpIni(overrides map[string]string) Option {
return func(o *opt) error {
o.phpIni = overrides
return nil
}
}
// WithMaxWaitTime configures the max time a request may be stalled waiting for a thread.
func WithMaxWaitTime(maxWaitTime time.Duration) Option {
return func(o *opt) error {
o.maxWaitTime = maxWaitTime
return nil
}
}
// WithMaxIdleTime configures the max time an autoscaled thread may be idle before being deactivated.
func WithMaxIdleTime(maxIdleTime time.Duration) Option {
return func(o *opt) error {
o.maxIdleTime = maxIdleTime
return nil
}
}
// EXPERIMENTAL: WithMaxRequests sets the default max requests before restarting a PHP thread (0 = unlimited). Applies to regular and worker threads.
func WithMaxRequests(maxRequests int) Option {
return func(o *opt) error {
o.maxRequests = maxRequests
return nil
}
}
// WithWorkerEnv sets environment variables for the worker
func WithWorkerEnv(env map[string]string) WorkerOption {
return func(w *workerOpt) error {
w.env = PrepareEnv(env)
return nil
}
}
// WithWorkerRequestOptions sets options for the main dummy request created for the worker
func WithWorkerRequestOptions(options ...RequestOption) WorkerOption {
return func(w *workerOpt) error {
w.requestOptions = append(w.requestOptions, options...)
return nil
}
}
// WithWorkerMaxThreads sets the max number of threads for this specific worker
func WithWorkerMaxThreads(num int) WorkerOption {
return func(w *workerOpt) error {
w.maxThreads = num
return nil
}
}
// WithWorkerWatchMode sets directories to watch for file changes
func WithWorkerWatchMode(watch []string) WorkerOption {
return func(w *workerOpt) error {
w.watch = watch
return nil
}
}
// WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking
func WithWorkerMaxFailures(maxFailures int) WorkerOption {
return func(w *workerOpt) error {
if maxFailures < -1 {
return fmt.Errorf("max consecutive failures must be >= -1, got %d", maxFailures)
}
w.maxConsecutiveFailures = maxFailures
return nil
}
}
func WithWorkerOnReady(f func(int)) WorkerOption {
return func(w *workerOpt) error {
w.onThreadReady = f
return nil
}
}
func WithWorkerOnShutdown(f func(int)) WorkerOption {
return func(w *workerOpt) error {
w.onThreadShutdown = f
return nil
}
}
// WithWorkerOnServerStartup adds a function to be called right after server startup. Useful for extensions.
func WithWorkerOnServerStartup(f func()) WorkerOption {
return func(w *workerOpt) error {
w.onServerStartup = f
return nil
}
}
// WithWorkerOnServerShutdown adds a function to be called right before server shutdown. Useful for extensions.
func WithWorkerOnServerShutdown(f func()) WorkerOption {
return func(w *workerOpt) error {
w.onServerShutdown = f
return nil
}
}
func withExtensionWorkers(w *extensionWorkers) WorkerOption {
return func(wo *workerOpt) error {
wo.extensionWorkers = w
return nil
}
}