-
Notifications
You must be signed in to change notification settings - Fork 442
feat: Add configurable max_requests for PHP threads #2292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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), | ||
| }, | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. check is unnecessary |
||
|
|
||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
| 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"; | ||
| })) {} |
| 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" | ||
|
|
@@ -15,8 +18,9 @@ import ( | |
| type regularThread struct { | ||
| contextHolder | ||
|
|
||
| state *state.ThreadState | ||
| thread *phpThread | ||
| state *state.ThreadState | ||
| thread *phpThread | ||
| requestCount int | ||
| } | ||
|
|
||
| var ( | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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) | ||
|
|
@@ -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{}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
|
|
||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| func handleRequestWithRegularPHPThreads(ch contextHolder) error { | ||
| metrics.StartRequest() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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