Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type FrankenPHPApp struct {
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
// The maximum amount of time an autoscaled thread may be idle before being deactivated
MaxIdleTime time.Duration `json:"max_idle_time,omitempty"`
// MaxRequests sets the maximum number of requests a regular (non-worker) PHP thread handles before restarting (0 = unlimited)
MaxRequests int `json:"max_requests,omitempty"`

opts []frankenphp.Option
metrics frankenphp.Metrics
Expand Down Expand Up @@ -153,6 +155,7 @@ func (f *FrankenPHPApp) Start() error {
frankenphp.WithPhpIni(f.PhpIni),
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
frankenphp.WithMaxRequests(f.MaxRequests),
)

for _, w := range f.Workers {
Expand Down Expand Up @@ -192,6 +195,7 @@ func (f *FrankenPHPApp) Stop() error {
f.NumThreads = 0
f.MaxWaitTime = 0
f.MaxIdleTime = 0
f.MaxRequests = 0

optionsMU.Lock()
options = nil
Expand Down Expand Up @@ -255,6 +259,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}

f.MaxIdleTime = v
case "max_requests":
if !d.NextArg() {
return d.ArgErr()
}

v, err := strconv.ParseUint(d.Val(), 10, 32)
if err != nil {
return d.WrapErr(err)
}

f.MaxRequests = int(v)
case "php_ini":
parseIniLine := func(d *caddyfile.Dispenser) error {
key := d.Val()
Expand Down Expand Up @@ -311,7 +326,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {

f.Workers = append(f.Workers, wc)
default:
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time, max_requests", d.Val())
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
max_threads <num_threads> # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'.
max_wait_time <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
max_idle_time <duration> # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s.
max_requests <num> # Sets the maximum number of requests a PHP thread will handle before being restarted, useful for mitigating memory leaks. Applies to both regular and worker threads. Default: 0 (unlimited). See below.
php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
worker {
file <path> # Sets the path to the worker script.
Expand Down Expand Up @@ -265,6 +266,29 @@ and otherwise forward the request to the worker matching the path pattern.
}
```

## Restarting Threads After a Number of Requests

Similar to PHP-FPM's [`pm.max_requests`](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-requests),
FrankenPHP can automatically restart PHP threads after they have handled a given number of requests.
This is useful for mitigating memory leaks in PHP extensions or application code,
since a restart fully cleans up the thread's memory and state.

The `max_requests` setting in the global `frankenphp` block applies to all PHP threads (both regular and worker threads):

```caddyfile
{
frankenphp {
max_requests 500
}
}
```

When a thread reaches the limit, the underlying C thread is fully restarted,
cleaning up all memory and state, including any memory leaked by PHP extensions.
Other threads continue to serve requests during the restart, so there is no downtime.

Set to `0` (default) to disable the limit and let threads run indefinitely.

## Environment Variables

The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it:
Expand Down
7 changes: 6 additions & 1 deletion frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ var (

metrics Metrics = nullMetrics{}

maxWaitTime time.Duration
maxWaitTime time.Duration
maxRequestsPerThread int
)

type ErrRejected struct {
Expand Down Expand Up @@ -275,6 +276,7 @@ func Init(options ...Option) error {
}

maxWaitTime = opt.maxWaitTime
maxRequestsPerThread = opt.maxRequests

if opt.maxIdleTime > 0 {
maxIdleTime = opt.maxIdleTime
Expand Down Expand Up @@ -316,7 +318,9 @@ func Init(options ...Option) error {
}

regularRequestChan = make(chan contextHolder)
regularThreadMu.Lock()
regularThreads = make([]*phpThread, 0, opt.numThreads-workerThreadCount)
regularThreadMu.Unlock()
Comment on lines +321 to +323
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

for i := 0; i < opt.numThreads-workerThreadCount; i++ {
convertToRegularThread(getInactivePHPThread())
}
Expand Down Expand Up @@ -786,5 +790,6 @@ func resetGlobals() {
workersByPath = nil
watcherIsEnabled = false
maxIdleTime = defaultMaxIdleTime
maxRequestsPerThread = 0
globalMu.Unlock()
}
9 changes: 9 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const (
TransitionRequested
TransitionInProgress
TransitionComplete

// thread is exiting the C loop for a full ZTS restart (max_requests)
Rebooting
// C thread has exited and ZTS state is cleaned up, ready to spawn a new C thread
RebootReady
)

func (s State) String() string {
Expand Down Expand Up @@ -58,6 +63,10 @@ func (s State) String() string {
return "transition in progress"
case TransitionComplete:
return "transition complete"
case Rebooting:
return "rebooting"
case RebootReady:
return "reboot ready"
default:
return "unknown"
}
Expand Down
69 changes: 69 additions & 0 deletions maxrequests_regular_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package frankenphp_test

import (
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/assert"
)

// TestModuleMaxRequests verifies that regular (non-worker) PHP threads restart
// after reaching max_requests by checking debug logs for restart messages.
func TestModuleMaxRequests(t *testing.T) {
const maxRequests = 5
const totalRequests = 30

var buf syncBuffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))

runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
for i := 0; i < totalRequests; i++ {
body, resp := testGet("http://example.com/index.php", handler, t)
assert.Equal(t, 200, resp.StatusCode)
assert.Contains(t, body, "I am by birth a Genevese")
}

restartCount := strings.Count(buf.String(), "max requests reached, restarting thread")
t.Logf("Thread restarts observed: %d", restartCount)
assert.GreaterOrEqual(t, restartCount, 2,
"with maxRequests=%d and %d requests on 2 threads, at least 2 restarts should occur", maxRequests, totalRequests)
}, &testOptions{
logger: logger,
initOpts: []frankenphp.Option{
frankenphp.WithNumThreads(2),
frankenphp.WithMaxRequests(maxRequests),
},
})
}

// TestModuleMaxRequestsConcurrent verifies max_requests works under concurrent load
// in module mode. All requests must succeed despite threads restarting.
func TestModuleMaxRequestsConcurrent(t *testing.T) {
const maxRequests = 10
const totalRequests = 200

runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
var wg sync.WaitGroup

for i := 0; i < totalRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
body, resp := testGet("http://example.com/index.php", handler, t)
assert.Equal(t, 200, resp.StatusCode)
assert.Contains(t, body, "I am by birth a Genevese")
}()
}
wg.Wait()
}, &testOptions{
initOpts: []frankenphp.Option{
frankenphp.WithNumThreads(8),
frankenphp.WithMaxRequests(maxRequests),
},
})
}
10 changes: 10 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type opt struct {
phpIni map[string]string
maxWaitTime time.Duration
maxIdleTime time.Duration
maxRequests int
}

type workerOpt struct {
Expand Down Expand Up @@ -166,6 +167,15 @@ func WithMaxIdleTime(maxIdleTime time.Duration) Option {
}
}

// 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 {
Expand Down
11 changes: 10 additions & 1 deletion phpthread.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func (thread *phpThread) boot() {

// shutdown the underlying PHP thread
func (thread *phpThread) shutdown() {
// if rebooting, wait for the reboot goroutine to finish
if thread.state.Is(state.Rebooting) || thread.state.Is(state.RebootReady) {
thread.state.WaitFor(state.Ready, state.Inactive, state.Done)
}
Comment on lines +68 to +70
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 !thread.state.RequestSafeStateChange(state.ShuttingDown) {
// already shutting down or done, wait for the C thread to finish
thread.state.WaitFor(state.Done, state.Reserved)
Expand Down Expand Up @@ -183,5 +188,9 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.
func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) {
thread := phpThreads[threadIndex]
thread.Unpin()
thread.state.Set(state.Done)
if thread.state.Is(state.Rebooting) {
thread.state.Set(state.RebootReady)
} else {
thread.state.Set(state.Done)
}
}
10 changes: 10 additions & 0 deletions testdata/worker-counter-persistent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
// Worker that tracks total requests handled across restarts.
// Uses a unique instance ID per worker script execution.
$instanceId = bin2hex(random_bytes(8));
$counter = 0;

while (frankenphp_handle_request(function () use (&$counter, $instanceId) {
$counter++;
echo "instance:$instanceId,count:$counter";
})) {}
45 changes: 43 additions & 2 deletions threadregular.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package frankenphp

// #include "frankenphp.h"
import "C"
import (
"context"
"log/slog"
"runtime"
"sync"
"sync/atomic"
Expand All @@ -15,8 +18,9 @@ import (
type regularThread struct {
contextHolder

state *state.ThreadState
thread *phpThread
state *state.ThreadState
thread *phpThread
requestCount int
}

var (
Expand Down Expand Up @@ -50,6 +54,12 @@ func (handler *regularThread) beforeScriptExecution() string {
case state.Ready:
return handler.waitForRequest()

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

return handler.waitForRequest()

case state.ShuttingDown:
detachRegularThread(handler.thread)
// signal to stop
Expand All @@ -76,6 +86,24 @@ func (handler *regularThread) name() string {
}

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

go restartRegularThread(handler.thread)

return ""
}

handler.state.MarkAsWaiting(true)

var ch contextHolder
Expand All @@ -88,6 +116,7 @@ func (handler *regularThread) waitForRequest() string {
case ch = <-handler.thread.requestChan:
}

handler.requestCount++
handler.ctx = ch.ctx
handler.contextHolder.frankenPHPContext = ch.frankenPHPContext
handler.state.MarkAsWaiting(false)
Expand All @@ -102,6 +131,18 @@ func (handler *regularThread) afterRequest() {
handler.ctx = nil
}

// 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


if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) {
panic("unable to create thread")
}

// the new C thread will call beforeScriptExecution with state RebootReady
}

func handleRequestWithRegularPHPThreads(ch contextHolder) error {
metrics.StartRequest()

Expand Down
Loading
Loading