mirror of
https://github.com/dunglas/frankenphp.git
synced 2026-04-22 16:27:12 +08:00
5a9bc7fb14
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
199 lines
4.7 KiB
Go
199 lines
4.7 KiB
Go
package frankenphp
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/dunglas/frankenphp/internal/state"
|
|
)
|
|
|
|
// representation of a non-worker PHP thread
|
|
// executes PHP scripts in a web context
|
|
// implements the threadHandler interface
|
|
type regularThread struct {
|
|
contextHolder
|
|
|
|
state *state.ThreadState
|
|
thread *phpThread
|
|
requestCount int
|
|
}
|
|
|
|
var (
|
|
regularThreads []*phpThread
|
|
regularThreadMu = &sync.RWMutex{}
|
|
regularRequestChan chan contextHolder
|
|
queuedRegularThreads = atomic.Int32{}
|
|
)
|
|
|
|
func convertToRegularThread(thread *phpThread) {
|
|
thread.setHandler(®ularThread{
|
|
thread: thread,
|
|
state: thread.state,
|
|
})
|
|
attachRegularThread(thread)
|
|
}
|
|
|
|
// beforeScriptExecution returns the name of the script or an empty string on shutdown
|
|
func (handler *regularThread) beforeScriptExecution() string {
|
|
switch handler.state.Get() {
|
|
case state.TransitionRequested:
|
|
detachRegularThread(handler.thread)
|
|
return handler.thread.transitionToNewHandler()
|
|
|
|
case state.TransitionComplete:
|
|
handler.thread.updateContext(false)
|
|
handler.state.Set(state.Ready)
|
|
|
|
return handler.waitForRequest()
|
|
|
|
case state.Ready:
|
|
return handler.waitForRequest()
|
|
|
|
case state.RebootReady:
|
|
handler.requestCount = 0
|
|
handler.state.Set(state.Ready)
|
|
return handler.waitForRequest()
|
|
|
|
case state.ShuttingDown:
|
|
detachRegularThread(handler.thread)
|
|
// signal to stop
|
|
return ""
|
|
}
|
|
|
|
panic("unexpected state: " + handler.state.Name())
|
|
}
|
|
|
|
func (handler *regularThread) afterScriptExecution(_ int) {
|
|
handler.thread.requestCount.Add(1)
|
|
handler.afterRequest()
|
|
}
|
|
|
|
func (handler *regularThread) frankenPHPContext() *frankenPHPContext {
|
|
return handler.contextHolder.frankenPHPContext
|
|
}
|
|
|
|
func (handler *regularThread) context() context.Context {
|
|
return handler.ctx
|
|
}
|
|
|
|
func (handler *regularThread) name() string {
|
|
return "Regular PHP Thread"
|
|
}
|
|
|
|
func (handler *regularThread) waitForRequest() string {
|
|
// max_requests reached: restart the thread to clean up all ZTS state
|
|
if maxRequestsPerThread > 0 && handler.requestCount >= maxRequestsPerThread {
|
|
if globalLogger.Enabled(globalCtx, slog.LevelDebug) {
|
|
globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "max requests reached, restarting thread",
|
|
slog.Int("thread", handler.thread.threadIndex),
|
|
slog.Int("max_requests", maxRequestsPerThread),
|
|
)
|
|
}
|
|
|
|
if handler.thread.reboot() {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
handler.state.MarkAsWaiting(true)
|
|
|
|
var ch contextHolder
|
|
|
|
select {
|
|
case <-handler.thread.drainChan:
|
|
// go back to beforeScriptExecution
|
|
return handler.beforeScriptExecution()
|
|
case ch = <-regularRequestChan:
|
|
case ch = <-handler.thread.requestChan:
|
|
}
|
|
|
|
handler.requestCount++
|
|
handler.thread.contextMu.Lock()
|
|
handler.ctx = ch.ctx
|
|
handler.contextHolder.frankenPHPContext = ch.frankenPHPContext
|
|
handler.thread.contextMu.Unlock()
|
|
handler.state.MarkAsWaiting(false)
|
|
|
|
// set the scriptFilename that should be executed
|
|
return handler.contextHolder.frankenPHPContext.scriptFilename
|
|
}
|
|
|
|
func (handler *regularThread) afterRequest() {
|
|
handler.contextHolder.frankenPHPContext.closeContext()
|
|
handler.thread.contextMu.Lock()
|
|
handler.contextHolder.frankenPHPContext = nil
|
|
handler.ctx = nil
|
|
handler.thread.contextMu.Unlock()
|
|
}
|
|
|
|
func handleRequestWithRegularPHPThreads(ch contextHolder) error {
|
|
metrics.StartRequest()
|
|
|
|
runtime.Gosched()
|
|
|
|
if queuedRegularThreads.Load() == 0 {
|
|
regularThreadMu.RLock()
|
|
for _, thread := range regularThreads {
|
|
select {
|
|
case thread.requestChan <- ch:
|
|
regularThreadMu.RUnlock()
|
|
<-ch.frankenPHPContext.done
|
|
metrics.StopRequest()
|
|
|
|
return nil
|
|
default:
|
|
// thread was not available
|
|
}
|
|
}
|
|
regularThreadMu.RUnlock()
|
|
}
|
|
|
|
// if no thread was available, mark the request as queued and fan it out to all threads
|
|
queuedRegularThreads.Add(1)
|
|
metrics.QueuedRequest()
|
|
|
|
for {
|
|
select {
|
|
case regularRequestChan <- ch:
|
|
queuedRegularThreads.Add(-1)
|
|
metrics.DequeuedRequest()
|
|
|
|
<-ch.frankenPHPContext.done
|
|
metrics.StopRequest()
|
|
|
|
return nil
|
|
case scaleChan <- ch.frankenPHPContext:
|
|
// the request has triggered scaling, continue to wait for a thread
|
|
case <-timeoutChan(maxWaitTime):
|
|
// the request has timed out stalling
|
|
queuedRegularThreads.Add(-1)
|
|
metrics.DequeuedRequest()
|
|
metrics.StopRequest()
|
|
|
|
ch.frankenPHPContext.reject(ErrMaxWaitTimeExceeded)
|
|
|
|
return ErrMaxWaitTimeExceeded
|
|
}
|
|
}
|
|
}
|
|
|
|
func attachRegularThread(thread *phpThread) {
|
|
regularThreadMu.Lock()
|
|
regularThreads = append(regularThreads, thread)
|
|
regularThreadMu.Unlock()
|
|
}
|
|
|
|
func detachRegularThread(thread *phpThread) {
|
|
regularThreadMu.Lock()
|
|
for i, t := range regularThreads {
|
|
if t == thread {
|
|
regularThreads = append(regularThreads[:i], regularThreads[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
regularThreadMu.Unlock()
|
|
}
|