From 1bd96ad3f1a94fff16458ea727bbe8dc64823bb9 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Wed, 11 Mar 2026 08:42:59 +0100 Subject: [PATCH 1/3] feat(debug): add more metrics in ThreadDebugState --- caddy/admin_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ debugstate.go | 35 +++++++++++++++++++++++++++++++++-- frankenphp.c | 7 +++++++ frankenphp.h | 1 + phpthread.go | 19 +++++++++++++------ threadworker.go | 3 +++ 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 09576d3f84..f5ec8c4c7a 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -82,6 +82,46 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { assert.Len(t, debugState.ThreadDebugStates, 3) } +func TestThreadDebugStateMetricsAfterRequests(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + num_threads 2 + worker ../testdata/worker-with-counter.php 1 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite worker-with-counter.php + php + } + } + `, "caddyfile") + + // make a few requests so counters are populated + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:3") + + debugState := getDebugState(t, tester) + + hasRequestCount := false + for _, ts := range debugState.ThreadDebugStates { + if ts.RequestCount > 0 { + hasRequestCount = true + assert.Greater(t, ts.MemoryUsage, int64(0), "thread %d (%s) should report memory usage", ts.Index, ts.Name) + } + } + assert.True(t, hasRequestCount, "at least one thread should have RequestCount > 0 after serving requests") +} + func TestAutoScaleWorkerThreads(t *testing.T) { wg := sync.WaitGroup{} maxTries := 10 diff --git a/debugstate.go b/debugstate.go index c18813ec1b..2e9fc08541 100644 --- a/debugstate.go +++ b/debugstate.go @@ -12,6 +12,11 @@ type ThreadDebugState struct { IsWaiting bool IsBusy bool WaitingSinceMilliseconds int64 + CurrentURI string + CurrentMethod string + RequestStartedAt int64 + RequestCount int64 + MemoryUsage int64 } // EXPERIMENTAL: FrankenPHPDebugState prints the state of all PHP threads - debugging purposes only @@ -39,12 +44,38 @@ func DebugState() FrankenPHPDebugState { // threadDebugState creates a small jsonable status message for debugging purposes func threadDebugState(thread *phpThread) ThreadDebugState { - return ThreadDebugState{ + isBusy := !thread.state.IsInWaitingState() + + s := ThreadDebugState{ Index: thread.threadIndex, Name: thread.name(), State: thread.state.Name(), IsWaiting: thread.state.IsInWaitingState(), - IsBusy: !thread.state.IsInWaitingState(), + IsBusy: isBusy, WaitingSinceMilliseconds: thread.state.WaitTime(), } + + s.RequestCount = thread.requestCount.Load() + s.MemoryUsage = thread.lastMemoryUsage.Load() + + if isBusy { + thread.handlerMu.RLock() + fc := thread.handler.frankenPHPContext() + thread.handlerMu.RUnlock() + + if fc != nil && fc.request != nil && fc.responseWriter != nil { + if fc.originalRequest != nil { + s.CurrentURI = fc.originalRequest.URL.RequestURI() + s.CurrentMethod = fc.originalRequest.Method + } else { + s.CurrentURI = fc.requestURI + s.CurrentMethod = fc.request.Method + } + if !fc.startedAt.IsZero() { + s.RequestStartedAt = fc.startedAt.UnixMilli() + } + } + } + + return s } diff --git a/frankenphp.c b/frankenphp.c index c25a3505f3..1dc90eadeb 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -244,8 +244,12 @@ static void frankenphp_reset_session_state(void) { } #endif +static __thread size_t thread_last_memory_usage = 0; + /* Adapted from php_request_shutdown */ static void frankenphp_worker_request_shutdown() { + thread_last_memory_usage = zend_memory_usage(0); + /* Flush all output buffers */ zend_try { php_output_end_all(); } zend_end_try(); @@ -1233,6 +1237,7 @@ int frankenphp_execute_script(char *file_name) { sandboxed_env = NULL; } + thread_last_memory_usage = zend_memory_usage(0); php_request_shutdown((void *)0); frankenphp_free_request_context(); @@ -1405,6 +1410,8 @@ int frankenphp_reset_opcache(void) { int frankenphp_get_current_memory_limit() { return PG(memory_limit); } +size_t frankenphp_get_current_memory_usage() { return thread_last_memory_usage; } + static zend_module_entry **modules = NULL; static int modules_len = 0; static int (*original_php_register_internal_extensions_func)(void) = NULL; diff --git a/frankenphp.h b/frankenphp.h index f25cb85128..959d9056d1 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -185,6 +185,7 @@ void frankenphp_register_server_vars(zval *track_vars_array, zend_string *frankenphp_init_persistent_string(const char *string, size_t len); int frankenphp_reset_opcache(void); int frankenphp_get_current_memory_limit(); +size_t frankenphp_get_current_memory_usage(); void register_extensions(zend_module_entry **m, int len); diff --git a/phpthread.go b/phpthread.go index fdf263717c..2fbd4ea7e2 100644 --- a/phpthread.go +++ b/phpthread.go @@ -7,6 +7,7 @@ import ( "context" "runtime" "sync" + "sync/atomic" "unsafe" "github.com/dunglas/frankenphp/internal/state" @@ -16,12 +17,14 @@ import ( // identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner - threadIndex int - requestChan chan contextHolder - drainChan chan struct{} - handlerMu sync.RWMutex - handler threadHandler - state *state.ThreadState + threadIndex int + requestChan chan contextHolder + drainChan chan struct{} + handlerMu sync.RWMutex + handler threadHandler + state *state.ThreadState + requestCount atomic.Int64 + lastMemoryUsage atomic.Int64 } // threadHandler defines how the callbacks from the C thread should be handled @@ -173,6 +176,10 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. if exitStatus < 0 { panic(ErrScriptExecution) } + + thread.requestCount.Add(1) + thread.lastMemoryUsage.Store(int64(C.frankenphp_get_current_memory_usage())) + thread.handler.afterScriptExecution(int(exitStatus)) // unpin all memory used during script execution diff --git a/threadworker.go b/threadworker.go index a0984afab7..3401df9253 100644 --- a/threadworker.go +++ b/threadworker.go @@ -292,6 +292,9 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval fc.handlerReturn = r } + thread.requestCount.Add(1) + thread.lastMemoryUsage.Store(int64(C.frankenphp_get_current_memory_usage())) + fc.closeContext() thread.handler.(*workerThread).workerFrankenPHPContext = nil thread.handler.(*workerThread).workerContext = nil From f4211e1f6377b36961e37b93b066493b97b094a4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 24 Mar 2026 10:02:00 +0100 Subject: [PATCH 2/3] feat(debug): add more metrics in ThreadDebugState --- debugstate.go | 41 ++++++++++++++++++++++++----------------- frankenphp.c | 22 ++++++++++++++++++---- frankenphp.h | 9 ++++++++- phpmainthread.go | 3 +++ phpthread.go | 19 +++++++------------ threadregular.go | 1 + threadworker.go | 1 - 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/debugstate.go b/debugstate.go index 2e9fc08541..e862719df9 100644 --- a/debugstate.go +++ b/debugstate.go @@ -1,5 +1,7 @@ package frankenphp +// #include "frankenphp.h" +import "C" import ( "github.com/dunglas/frankenphp/internal/state" ) @@ -56,25 +58,30 @@ func threadDebugState(thread *phpThread) ThreadDebugState { } s.RequestCount = thread.requestCount.Load() - s.MemoryUsage = thread.lastMemoryUsage.Load() + s.MemoryUsage = int64(C.frankenphp_get_thread_memory_usage(C.uintptr_t(thread.threadIndex))) - if isBusy { - thread.handlerMu.RLock() - fc := thread.handler.frankenPHPContext() - thread.handlerMu.RUnlock() + if !isBusy { + return s + } - if fc != nil && fc.request != nil && fc.responseWriter != nil { - if fc.originalRequest != nil { - s.CurrentURI = fc.originalRequest.URL.RequestURI() - s.CurrentMethod = fc.originalRequest.Method - } else { - s.CurrentURI = fc.requestURI - s.CurrentMethod = fc.request.Method - } - if !fc.startedAt.IsZero() { - s.RequestStartedAt = fc.startedAt.UnixMilli() - } - } + thread.handlerMu.RLock() + defer thread.handlerMu.RUnlock() + + fc := thread.handler.frankenPHPContext() + if fc == nil || fc.request == nil || fc.responseWriter == nil { + return s + } + + if fc.originalRequest == nil { + s.CurrentURI = fc.requestURI + s.CurrentMethod = fc.request.Method + } else { + s.CurrentURI = fc.originalRequest.URL.RequestURI() + s.CurrentMethod = fc.originalRequest.Method + } + + if !fc.startedAt.IsZero() { + s.RequestStartedAt = fc.startedAt.UnixMilli() } return s diff --git a/frankenphp.c b/frankenphp.c index 1dc90eadeb..787c566d47 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -244,11 +244,12 @@ static void frankenphp_reset_session_state(void) { } #endif -static __thread size_t thread_last_memory_usage = 0; +static frankenphp_thread_metrics *thread_metrics = NULL; /* Adapted from php_request_shutdown */ static void frankenphp_worker_request_shutdown() { - thread_last_memory_usage = zend_memory_usage(0); + __atomic_store_n(&thread_metrics[thread_index].last_memory_usage, + zend_memory_usage(0), __ATOMIC_RELAXED); /* Flush all output buffers */ zend_try { php_output_end_all(); } @@ -1237,7 +1238,8 @@ int frankenphp_execute_script(char *file_name) { sandboxed_env = NULL; } - thread_last_memory_usage = zend_memory_usage(0); + __atomic_store_n(&thread_metrics[thread_index].last_memory_usage, + zend_memory_usage(0), __ATOMIC_RELAXED); php_request_shutdown((void *)0); frankenphp_free_request_context(); @@ -1410,7 +1412,19 @@ int frankenphp_reset_opcache(void) { int frankenphp_get_current_memory_limit() { return PG(memory_limit); } -size_t frankenphp_get_current_memory_usage() { return thread_last_memory_usage; } +void frankenphp_init_thread_metrics(int max_threads) { + thread_metrics = calloc(max_threads, sizeof(frankenphp_thread_metrics)); +} + +void frankenphp_destroy_thread_metrics(void) { + free(thread_metrics); + thread_metrics = NULL; +} + +size_t frankenphp_get_thread_memory_usage(uintptr_t idx) { + return __atomic_load_n(&thread_metrics[idx].last_memory_usage, + __ATOMIC_RELAXED); +} static zend_module_entry **modules = NULL; static int modules_len = 0; diff --git a/frankenphp.h b/frankenphp.h index 959d9056d1..bdba41d84a 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -185,7 +185,14 @@ void frankenphp_register_server_vars(zval *track_vars_array, zend_string *frankenphp_init_persistent_string(const char *string, size_t len); int frankenphp_reset_opcache(void); int frankenphp_get_current_memory_limit(); -size_t frankenphp_get_current_memory_usage(); + +typedef struct { + size_t last_memory_usage; +} frankenphp_thread_metrics; + +void frankenphp_init_thread_metrics(int max_threads); +void frankenphp_destroy_thread_metrics(void); +size_t frankenphp_get_thread_memory_usage(uintptr_t thread_index); void register_extensions(zend_module_entry **m, int len); diff --git a/phpmainthread.go b/phpmainthread.go index ba3917e846..7f9b8fb947 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -54,6 +54,8 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) return nil, err } + C.frankenphp_init_thread_metrics(C.int(mainThread.maxThreads)) + // initialize all other threads phpThreads = make([]*phpThread, mainThread.maxThreads) phpThreads[0] = initialThread @@ -97,6 +99,7 @@ func drainPHPThreads() { doneWG.Wait() mainThread.state.Set(state.Done) mainThread.state.WaitFor(state.Reserved) + C.frankenphp_destroy_thread_metrics() phpThreads = nil } diff --git a/phpthread.go b/phpthread.go index 2fbd4ea7e2..3787dbe6d9 100644 --- a/phpthread.go +++ b/phpthread.go @@ -17,14 +17,13 @@ import ( // identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner - threadIndex int - requestChan chan contextHolder - drainChan chan struct{} - handlerMu sync.RWMutex - handler threadHandler - state *state.ThreadState - requestCount atomic.Int64 - lastMemoryUsage atomic.Int64 + threadIndex int + requestChan chan contextHolder + drainChan chan struct{} + handlerMu sync.RWMutex + handler threadHandler + state *state.ThreadState + requestCount atomic.Int64 } // threadHandler defines how the callbacks from the C thread should be handled @@ -176,10 +175,6 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. if exitStatus < 0 { panic(ErrScriptExecution) } - - thread.requestCount.Add(1) - thread.lastMemoryUsage.Store(int64(C.frankenphp_get_current_memory_usage())) - thread.handler.afterScriptExecution(int(exitStatus)) // unpin all memory used during script execution diff --git a/threadregular.go b/threadregular.go index 938d98350c..67b0c6b375 100644 --- a/threadregular.go +++ b/threadregular.go @@ -60,6 +60,7 @@ func (handler *regularThread) beforeScriptExecution() string { } func (handler *regularThread) afterScriptExecution(_ int) { + handler.thread.requestCount.Add(1) handler.afterRequest() } diff --git a/threadworker.go b/threadworker.go index 3401df9253..73e39429a7 100644 --- a/threadworker.go +++ b/threadworker.go @@ -293,7 +293,6 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval } thread.requestCount.Add(1) - thread.lastMemoryUsage.Store(int64(C.frankenphp_get_current_memory_usage())) fc.closeContext() thread.handler.(*workerThread).workerFrankenPHPContext = nil From 7d05cb905e208905a80f7317f51ea2db92ca7f86 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 24 Mar 2026 15:27:32 +0100 Subject: [PATCH 3/3] handle nil thread handler --- debugstate.go | 12 ++++++++++-- phpthread.go | 10 +++++++--- threadregular.go | 4 ++++ threadworker.go | 6 ++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/debugstate.go b/debugstate.go index e862719df9..62f3f9f7a0 100644 --- a/debugstate.go +++ b/debugstate.go @@ -65,9 +65,17 @@ func threadDebugState(thread *phpThread) ThreadDebugState { } thread.handlerMu.RLock() - defer thread.handlerMu.RUnlock() + handler := thread.handler + thread.handlerMu.RUnlock() - fc := thread.handler.frankenPHPContext() + if handler == nil { + return s + } + + thread.contextMu.RLock() + defer thread.contextMu.RUnlock() + + fc := handler.frankenPHPContext() if fc == nil || fc.request == nil || fc.responseWriter == nil { return s } diff --git a/phpthread.go b/phpthread.go index 3787dbe6d9..c2660c550e 100644 --- a/phpthread.go +++ b/phpthread.go @@ -22,6 +22,7 @@ type phpThread struct { drainChan chan struct{} handlerMu sync.RWMutex handler threadHandler + contextMu sync.RWMutex state *state.ThreadState requestCount atomic.Int64 } @@ -127,10 +128,13 @@ func (thread *phpThread) context() context.Context { func (thread *phpThread) name() string { thread.handlerMu.RLock() - name := thread.handler.name() - thread.handlerMu.RUnlock() + defer thread.handlerMu.RUnlock() - return name + if thread.handler == nil { + return "unknown" + } + + return thread.handler.name() } // Pin a string that is not null-terminated diff --git a/threadregular.go b/threadregular.go index 67b0c6b375..30a3434407 100644 --- a/threadregular.go +++ b/threadregular.go @@ -89,8 +89,10 @@ func (handler *regularThread) waitForRequest() string { case ch = <-handler.thread.requestChan: } + 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 @@ -99,8 +101,10 @@ func (handler *regularThread) waitForRequest() string { 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 { diff --git a/threadworker.go b/threadworker.go index 73e39429a7..bc1500240c 100644 --- a/threadworker.go +++ b/threadworker.go @@ -131,8 +131,10 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // make sure to close the worker request context if handler.workerFrankenPHPContext != nil { handler.workerFrankenPHPContext.closeContext() + handler.thread.contextMu.Lock() handler.workerFrankenPHPContext = nil handler.workerContext = nil + handler.thread.contextMu.Unlock() } // on exit status 0 we just run the worker script again @@ -235,8 +237,10 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { case requestCH = <-handler.worker.requestChan: } + handler.thread.contextMu.Lock() handler.workerContext = requestCH.ctx handler.workerFrankenPHPContext = requestCH.frankenPHPContext + handler.thread.contextMu.Unlock() handler.state.MarkAsWaiting(false) if globalLogger.Enabled(requestCH.ctx, slog.LevelDebug) { @@ -295,8 +299,10 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval thread.requestCount.Add(1) fc.closeContext() + thread.contextMu.Lock() thread.handler.(*workerThread).workerFrankenPHPContext = nil thread.handler.(*workerThread).workerContext = nil + thread.contextMu.Unlock() if globalLogger.Enabled(ctx, slog.LevelDebug) { if fc.request == nil {