From e94e3bcb97c3803a00ae00fd1408e556ffa4177c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 14 Dec 2025 23:14:19 +0100 Subject: [PATCH 01/32] Tests. --- caddy/caddy_test.go | 69 ++++++++++++++++++++++++++++++++++++++ frankenphp.c | 19 +++++++++++ frankenphp.go | 64 +++++++++++++++++++++++++++++++++-- phpmainthread_test.go | 2 +- testdata/opcache_reset.php | 9 +++++ testdata/require.php | 6 ++++ threadregular.go | 6 ++++ watcher.go | 2 +- worker.go | 2 ++ 9 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 testdata/opcache_reset.php create mode 100644 testdata/require.php diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 5f95c0ab31..bd10ff7791 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -3,6 +3,7 @@ package caddy_test import ( "bytes" "fmt" + "math/rand/v2" "net/http" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddytest" @@ -1472,3 +1474,70 @@ func TestDd(t *testing.T) { "dump123", ) } + +// test to force the opcache segfault race condition under concurrency (~1.7s) +func TestOpcacheReset(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + metrics + + frankenphp { + num_threads 40 + php_ini { + opcache.enable 1 + zend_extension opcache.so + opcache.log_verbosity_level 4 + } + } + } + + localhost:`+testPort+` { + php { + root ../testdata + worker { + file sleep.php + match /sleep* + num 20 + } + } + } + `, "caddyfile") + + wg := sync.WaitGroup{} + numRequests := 100 + wg.Add(numRequests) + for i := 0; i < numRequests; i++ { + + // introduce some random delay + if rand.IntN(10) > 8 { + time.Sleep(time.Millisecond * 10) + } + + go func() { + // randomly call opcache_reset + if rand.IntN(10) > 5 { + tester.AssertGetResponse( + "http://localhost:"+testPort+"/opcache_reset.php", + http.StatusOK, + "opcache reset done", + ) + wg.Done() + return + } + + // otherwise call sleep.php with random sleep and work values + tester.AssertGetResponse( + fmt.Sprintf("http://localhost:%s/sleep.php?sleep=%d&work=%d", testPort, i, i), + http.StatusOK, + fmt.Sprintf("slept for %d ms and worked for %d iterations", i, i), + ) + wg.Done() + }() + } + + wg.Wait() +} diff --git a/frankenphp.c b/frankenphp.c index 04782a9b65..7b59d24faa 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -73,6 +73,7 @@ bool should_filter_var = 0; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread zval *os_environment = NULL; +zif_handler orig_opcache_reset; void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; @@ -340,6 +341,15 @@ PHP_FUNCTION(frankenphp_getenv) { } } /* }}} */ +/* {{{ thread-safe opcache reset */ +PHP_FUNCTION(frankenphp_opcache_reset) { + if (go_schedule_opcache_reset(thread_index)) { + orig_opcache_reset(INTERNAL_FUNCTION_PARAM_PASSTHRU); + } + + RETVAL_FALSE; +} /* }}} */ + /* {{{ Fetch all HTTP request headers */ PHP_FUNCTION(frankenphp_request_headers) { ZEND_PARSE_PARAMETERS_NONE(); @@ -570,6 +580,15 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } + // Override opcache_reset + func = zend_hash_str_find_ptr(CG(function_table), "opcache_reset", + sizeof("opcache_reset") - 1); + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION) { + orig_opcache_reset = ((zend_internal_function *)func)->handler; + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } + return SUCCESS; } diff --git a/frankenphp.go b/frankenphp.go index da0bdead3c..09b87cf537 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -32,11 +32,14 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "syscall" "time" "unsafe" // debug on Linux //_ "github.com/ianlancetaylor/cgosymbolizer" + + "github.com/dunglas/frankenphp/internal/state" ) type contextKeyStruct struct{} @@ -56,8 +59,10 @@ var ( contextKey = contextKeyStruct{} serverHeader = []string{"FrankenPHP"} - isRunning bool - onServerShutdown []func() + isRunning bool + isOpcacheResetting atomic.Bool + threadsAreRestarting atomic.Bool + onServerShutdown []func() // Set default values to make Shutdown() idempotent globalMu sync.Mutex @@ -698,6 +703,61 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { return C.bool(phpThreads[threadIndex].frankenPHPContext().isDone) } +//export go_schedule_opcache_reset +func go_schedule_opcache_reset(threadIndex C.uintptr_t) C.bool { + if isOpcacheResetting.CompareAndSwap(false, true) { + restartThreadsForOpcacheReset(nil) + return C.bool(true) + } + + return C.bool(phpThreads[threadIndex].state.Is(state.Restarting)) +} + +// restart all threads for an opcache_reset +func restartThreadsForOpcacheReset(exceptThisThread *phpThread) { + if threadsAreRestarting.Load() { + // ignore reloads while a restart is already ongoing + return + } + + // disallow scaling threads while restarting workers + scalingMu.Lock() + defer scalingMu.Unlock() + + // drain workers + globalLogger.Info("Restarting all PHP threads for opcache_reset") + threadsToRestart := drainWorkerThreads() + + // drain regular threads + globalLogger.Info("Draining regular PHP threads for opcache_reset") + wg := sync.WaitGroup{} + for _, thread := range regularThreads { + if thread.state.Is(state.Ready) { + threadsToRestart = append(threadsToRestart, thread) + thread.state.Set(state.Restarting) + close(thread.drainChan) + + wg.Go(func() { + thread.state.WaitFor(state.Yielding) + }) + } + } + + // other threads may not parse new scripts while this thread is scheduling an opcache_reset + // sleeping a bit here makes this much less likely to happen + // waiting for all other threads to drain first can potentially deadlock + time.Sleep(100 * time.Millisecond) + + go func() { + wg.Wait() + for _, thread := range threadsToRestart { + thread.drainChan = make(chan struct{}) + thread.state.Set(state.Ready) + isOpcacheResetting.Store(false) + } + }() +} + // ExecuteScriptCLI executes the PHP script passed as parameter. // It returns the exit status code of the script. func ExecuteScriptCLI(script string, args []string) int { diff --git a/phpmainthread_test.go b/phpmainthread_test.go index b777c33942..a570215115 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -97,7 +97,7 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { var ( isDone atomic.Bool - wg sync.WaitGroup + wg sync.WaitGroup ) numThreads := 10 diff --git a/testdata/opcache_reset.php b/testdata/opcache_reset.php new file mode 100644 index 0000000000..b19a80bf43 --- /dev/null +++ b/testdata/opcache_reset.php @@ -0,0 +1,9 @@ + Date: Sun, 28 Dec 2025 22:42:54 +0100 Subject: [PATCH 02/32] Wait 1s for deadlocks. --- frankenphp.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 09b87cf537..dea7d6da98 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -706,7 +706,7 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { //export go_schedule_opcache_reset func go_schedule_opcache_reset(threadIndex C.uintptr_t) C.bool { if isOpcacheResetting.CompareAndSwap(false, true) { - restartThreadsForOpcacheReset(nil) + restartThreadsForOpcacheReset(phpThreads[threadIndex]) return C.bool(true) } @@ -737,19 +737,31 @@ func restartThreadsForOpcacheReset(exceptThisThread *phpThread) { thread.state.Set(state.Restarting) close(thread.drainChan) + if thread == exceptThisThread { + continue + } + wg.Go(func() { thread.state.WaitFor(state.Yielding) }) } } - // other threads may not parse new scripts while this thread is scheduling an opcache_reset - // sleeping a bit here makes this much less likely to happen - // waiting for all other threads to drain first can potentially deadlock - time.Sleep(100 * time.Millisecond) - + done := make(chan struct{}) go func() { wg.Wait() + close(done) + }() + + select { + case <-done: + // all other threads are drained + case <-time.After(time.Second): + // probably a deadlock, continue anyway and hope for the best + } + + go func() { + exceptThisThread.state.WaitFor(state.Yielding) for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) thread.state.Set(state.Ready) From d1e28d5df55a0d2ad527d5018ca8929c2210e95d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 28 Dec 2025 22:54:57 +0100 Subject: [PATCH 03/32] Adjusts waitgroup logic. --- frankenphp.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index f91528058e..090d0fd6c1 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -765,11 +765,12 @@ func go_schedule_opcache_reset(threadIndex C.uintptr_t) C.bool { return C.bool(true) } + // always call the original opcache_reset if already restarting return C.bool(phpThreads[threadIndex].state.Is(state.Restarting)) } // restart all threads for an opcache_reset -func restartThreadsForOpcacheReset(exceptThisThread *phpThread) { +func restartThreadsForOpcacheReset(callingThread *phpThread) { if threadsAreRestarting.Load() { // ignore reloads while a restart is already ongoing return @@ -792,16 +793,13 @@ func restartThreadsForOpcacheReset(exceptThisThread *phpThread) { thread.state.Set(state.Restarting) close(thread.drainChan) - if thread == exceptThisThread { - continue - } - wg.Go(func() { thread.state.WaitFor(state.Yielding) }) } } + wg.Done() // ignore the calling thread done := make(chan struct{}) go func() { wg.Wait() @@ -816,7 +814,7 @@ func restartThreadsForOpcacheReset(exceptThisThread *phpThread) { } go func() { - exceptThisThread.state.WaitFor(state.Yielding) + callingThread.state.WaitFor(state.Yielding) for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) thread.state.Set(state.Ready) From 8e87d00829a9de9fc3b85a86590c3168dc3ff2e6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 29 Dec 2025 23:16:03 +0100 Subject: [PATCH 04/32] Starts separate opcache_reset request flow once all threads are stopped. --- frankenphp.c | 10 +++--- frankenphp.go | 86 +++++++++++++++++++----------------------------- threadregular.go | 1 + threadworker.go | 7 +--- worker.go | 38 +++++++++++++++++---- 5 files changed, 73 insertions(+), 69 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 669f7d6c28..23b221753f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -343,11 +343,9 @@ PHP_FUNCTION(frankenphp_getenv) { /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { - if (go_schedule_opcache_reset(thread_index)) { - orig_opcache_reset(INTERNAL_FUNCTION_PARAM_PASSTHRU); - } + go_schedule_opcache_reset(thread_index); - RETVAL_FALSE; + RETVAL_TRUE; } /* }}} */ /* {{{ Fetch all HTTP request headers */ @@ -1270,11 +1268,15 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { + php_request_startup(); zend_function *opcache_reset = zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset")); if (opcache_reset) { + ((zend_internal_function *)opcache_reset)->handler = orig_opcache_reset; zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); + ((zend_internal_function *)opcache_reset)->handler = ZEND_FN(frankenphp_opcache_reset); } + php_request_shutdown((void *)0); return 0; } diff --git a/frankenphp.go b/frankenphp.go index 090d0fd6c1..bd304789f1 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -59,10 +59,9 @@ var ( contextKey = contextKeyStruct{} serverHeader = []string{"FrankenPHP"} - isRunning bool - isOpcacheResetting atomic.Bool - threadsAreRestarting atomic.Bool - onServerShutdown []func() + isRunning bool + restartCounter atomic.Int32 + onServerShutdown []func() // Set default values to make Shutdown() idempotent globalMu sync.Mutex @@ -759,68 +758,49 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { } //export go_schedule_opcache_reset -func go_schedule_opcache_reset(threadIndex C.uintptr_t) C.bool { - if isOpcacheResetting.CompareAndSwap(false, true) { - restartThreadsForOpcacheReset(phpThreads[threadIndex]) - return C.bool(true) +func go_schedule_opcache_reset(threadIndex C.uintptr_t) { + if restartCounter.CompareAndSwap(0, 1) { + go restartThreadsForOpcacheReset() } - - // always call the original opcache_reset if already restarting - return C.bool(phpThreads[threadIndex].state.Is(state.Restarting)) } // restart all threads for an opcache_reset -func restartThreadsForOpcacheReset(callingThread *phpThread) { - if threadsAreRestarting.Load() { - // ignore reloads while a restart is already ongoing - return - } - +func restartThreadsForOpcacheReset() { // disallow scaling threads while restarting workers scalingMu.Lock() defer scalingMu.Unlock() - // drain workers - globalLogger.Info("Restarting all PHP threads for opcache_reset") - threadsToRestart := drainWorkerThreads() - - // drain regular threads - globalLogger.Info("Draining regular PHP threads for opcache_reset") - wg := sync.WaitGroup{} - for _, thread := range regularThreads { - if thread.state.Is(state.Ready) { - threadsToRestart = append(threadsToRestart, thread) - thread.state.Set(state.Restarting) - close(thread.drainChan) - - wg.Go(func() { - thread.state.WaitFor(state.Yielding) - }) - } + threadsToRestart := drainWorkerThreads(true) + + for _, thread := range threadsToRestart { + thread.drainChan = make(chan struct{}) + thread.state.Set(state.Ready) } +} - wg.Done() // ignore the calling thread - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() +func scheduleOpcacheReset(thread *phpThread) { + restartCounter.Add(-1) + if restartCounter.Load() != 1 { + return // only the last restarting thread will trigger an actual opcache_reset + } + workerThread, ok := thread.handler.(*workerThread) + fc, _ := newDummyContext("/opcache_reset") + if ok && workerThread.worker != nil { + workerThread.dummyFrankenPHPContext = fc + defer func() { workerThread.dummyFrankenPHPContext = nil }() + } - select { - case <-done: - // all other threads are drained - case <-time.After(time.Second): - // probably a deadlock, continue anyway and hope for the best + regularThread, ok := thread.handler.(*regularThread) + if ok { + regularThread.contextHolder.frankenPHPContext = fc + defer func() { regularThread.contextHolder.frankenPHPContext = nil }() } - go func() { - callingThread.state.WaitFor(state.Yielding) - for _, thread := range threadsToRestart { - thread.drainChan = make(chan struct{}) - thread.state.Set(state.Ready) - isOpcacheResetting.Store(false) - } - }() + globalLogger.Info("resetting opcache in all threads") + C.frankenphp_reset_opcache() + + // all threads should have restarted now + restartCounter.Store(0) } // ExecuteScriptCLI executes the PHP script passed as parameter. diff --git a/threadregular.go b/threadregular.go index 801116822d..86dade86c8 100644 --- a/threadregular.go +++ b/threadregular.go @@ -50,6 +50,7 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.waitForRequest() case state.Restarting: handler.state.Set(state.Yielding) + scheduleOpcacheReset(handler.thread) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() diff --git a/threadworker.go b/threadworker.go index ae7e4545f2..ba544e24c2 100644 --- a/threadworker.go +++ b/threadworker.go @@ -51,6 +51,7 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.state.Set(state.Yielding) + scheduleOpcacheReset(handler.thread) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() case state.Ready, state.TransitionComplete: @@ -226,12 +227,6 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } - // flush the opcache when restarting due to watcher or admin api - // note: this is done right before frankenphp_handle_request() returns 'false' - if handler.state.Is(state.Restarting) { - C.frankenphp_reset_opcache() - } - return false, nil case requestCH = <-handler.thread.requestChan: case requestCH = <-handler.worker.requestChan: diff --git a/worker.go b/worker.go index 58680ea178..73ed5855c4 100644 --- a/worker.go +++ b/worker.go @@ -171,10 +171,10 @@ func newWorker(o workerOpt) (*worker, error) { // EXPERIMENTAL: DrainWorkers finishes all worker scripts before a graceful shutdown func DrainWorkers() { - _ = drainWorkerThreads() + _ = drainWorkerThreads(false) } -func drainWorkerThreads() []*phpThread { +func drainWorkerThreads(withRegularThreads bool) []*phpThread { var ( ready sync.WaitGroup drainedThreads []*phpThread @@ -192,7 +192,7 @@ func drainWorkerThreads() []*phpThread { // we'll proceed to restart all other threads anyway continue } - + restartCounter.Add(1) close(thread.drainChan) drainedThreads = append(drainedThreads, thread) @@ -205,6 +205,31 @@ func drainWorkerThreads() []*phpThread { worker.threadMutex.RUnlock() } + if withRegularThreads { + regularThreadMu.RLock() + ready.Add(len(regularThreads)) + + for _, thread := range regularThreads { + if !thread.state.RequestSafeStateChange(state.Restarting) { + ready.Done() + + // no state change allowed == thread is shutting down + // we'll proceed to restart all other threads anyway + continue + } + restartCounter.Add(1) + close(thread.drainChan) + drainedThreads = append(drainedThreads, thread) + + go func(thread *phpThread) { + thread.state.WaitFor(state.Yielding) + ready.Done() + }(thread) + } + + regularThreadMu.RUnlock() + } + ready.Wait() return drainedThreads @@ -213,13 +238,14 @@ func drainWorkerThreads() []*phpThread { // RestartWorkers attempts to restart all workers gracefully // All workers must be restarted at the same time to prevent issues with opcache resetting. func RestartWorkers() { - threadsAreRestarting.Store(true) - defer threadsAreRestarting.Store(false) + if !restartCounter.CompareAndSwap(0, 1) { + return // another restart is already in progress + } // disallow scaling threads while restarting workers scalingMu.Lock() defer scalingMu.Unlock() - threadsToRestart := drainWorkerThreads() + threadsToRestart := drainWorkerThreads(false) for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) From acf2a1c84dcbe8a4fc9a374b500775b9103e635d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 29 Dec 2025 23:28:11 +0100 Subject: [PATCH 05/32] Test with grace period (again) --- caddy/caddy_test.go | 1 - frankenphp.c | 4 ++-- frankenphp.go | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 12ac84e675..aaa600d642 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1489,7 +1489,6 @@ func TestOpcacheReset(t *testing.T) { num_threads 40 php_ini { opcache.enable 1 - zend_extension opcache.so opcache.log_verbosity_level 4 } } diff --git a/frankenphp.c b/frankenphp.c index 23b221753f..d586cb5362 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1268,15 +1268,15 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { - php_request_startup(); zend_function *opcache_reset = zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset")); if (opcache_reset) { + php_request_startup(); ((zend_internal_function *)opcache_reset)->handler = orig_opcache_reset; zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); ((zend_internal_function *)opcache_reset)->handler = ZEND_FN(frankenphp_opcache_reset); + php_request_shutdown((void *)0); } - php_request_shutdown((void *)0); return 0; } diff --git a/frankenphp.go b/frankenphp.go index bd304789f1..227991aabb 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -798,6 +798,7 @@ func scheduleOpcacheReset(thread *phpThread) { globalLogger.Info("resetting opcache in all threads") C.frankenphp_reset_opcache() + time.Sleep(200 * time.Millisecond) // opcache_reset grace period // all threads should have restarted now restartCounter.Store(0) From d8c185ccff6c68d40cb090a74dc72769948bceb3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 2 Jan 2026 20:09:27 +0100 Subject: [PATCH 06/32] Force all threads to call opcache_reset(). --- frankenphp.go | 100 ++++++++++++++++++++++++++++++++-------- internal/state/state.go | 2 + threadregular.go | 2 + threadworker.go | 2 + worker.go | 77 +------------------------------ 5 files changed, 89 insertions(+), 94 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 227991aabb..d6c1a72154 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -59,9 +59,9 @@ var ( contextKey = contextKeyStruct{} serverHeader = []string{"FrankenPHP"} - isRunning bool - restartCounter atomic.Int32 - onServerShutdown []func() + isRunning bool + threadsAreRestarting atomic.Bool + onServerShutdown []func() // Set default values to make Shutdown() idempotent globalMu sync.Mutex @@ -759,49 +759,111 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { //export go_schedule_opcache_reset func go_schedule_opcache_reset(threadIndex C.uintptr_t) { - if restartCounter.CompareAndSwap(0, 1) { - go restartThreadsForOpcacheReset() + if threadsAreRestarting.CompareAndSwap(false, true) { + go restartThreadsAndOpcacheReset(true) } } // restart all threads for an opcache_reset -func restartThreadsForOpcacheReset() { +func restartThreadsAndOpcacheReset(withRegularThreads bool) { // disallow scaling threads while restarting workers scalingMu.Lock() defer scalingMu.Unlock() - threadsToRestart := drainWorkerThreads(true) + threadsToRestart := drainThreads(withRegularThreads) + + opcacheResetWg := sync.WaitGroup{} + for _, thread := range threadsToRestart { + thread.state.Set(state.OpcacheResetting) + opcacheResetWg.Go(func() { + thread.state.WaitFor(state.OpcacheResettingDone) + }) + } + + opcacheResetWg.Wait() for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) thread.state.Set(state.Ready) } + + threadsAreRestarting.Store(false) } -func scheduleOpcacheReset(thread *phpThread) { - restartCounter.Add(-1) - if restartCounter.Load() != 1 { - return // only the last restarting thread will trigger an actual opcache_reset +func drainThreads(withRegularThreads bool) []*phpThread { + var ( + ready sync.WaitGroup + drainedThreads []*phpThread + ) + + for _, worker := range workers { + worker.threadMutex.RLock() + ready.Add(len(worker.threads)) + + for _, thread := range worker.threads { + if !thread.state.RequestSafeStateChange(state.Restarting) { + ready.Done() + + // no state change allowed == thread is shutting down + // we'll proceed to restart all other threads anyway + continue + } + close(thread.drainChan) + drainedThreads = append(drainedThreads, thread) + + go func(thread *phpThread) { + thread.state.WaitFor(state.Yielding) + ready.Done() + }(thread) + } + + worker.threadMutex.RUnlock() + } + + if withRegularThreads { + regularThreadMu.RLock() + ready.Add(len(regularThreads)) + + for _, thread := range regularThreads { + if !thread.state.RequestSafeStateChange(state.Restarting) { + ready.Done() + + // no state change allowed == thread is shutting down + // we'll proceed to restart all other threads anyway + continue + } + close(thread.drainChan) + drainedThreads = append(drainedThreads, thread) + + go func(thread *phpThread) { + thread.state.WaitFor(state.Yielding) + ready.Done() + }(thread) + } + + regularThreadMu.RUnlock() } - workerThread, ok := thread.handler.(*workerThread) + + ready.Wait() + + return drainedThreads +} + +func scheduleOpcacheReset(thread *phpThread) { fc, _ := newDummyContext("/opcache_reset") - if ok && workerThread.worker != nil { + + if workerThread, ok := thread.handler.(*workerThread); ok { workerThread.dummyFrankenPHPContext = fc defer func() { workerThread.dummyFrankenPHPContext = nil }() } - regularThread, ok := thread.handler.(*regularThread) - if ok { + if regularThread, ok := thread.handler.(*regularThread); ok { regularThread.contextHolder.frankenPHPContext = fc defer func() { regularThread.contextHolder.frankenPHPContext = nil }() } globalLogger.Info("resetting opcache in all threads") C.frankenphp_reset_opcache() - time.Sleep(200 * time.Millisecond) // opcache_reset grace period - - // all threads should have restarted now - restartCounter.Store(0) } // ExecuteScriptCLI executes the PHP script passed as parameter. diff --git a/internal/state/state.go b/internal/state/state.go index b15c008bce..3eba4dc47e 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -24,6 +24,8 @@ const ( // States necessary for restarting workers Restarting State = "restarting" Yielding State = "yielding" + OpcacheResetting State = "opcache resetting" + OpcacheResettingDone State = "opcache reset done" // States necessary for transitioning between different handlers TransitionRequested State = "transition requested" diff --git a/threadregular.go b/threadregular.go index 86dade86c8..ebca0b72ad 100644 --- a/threadregular.go +++ b/threadregular.go @@ -50,7 +50,9 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.waitForRequest() case state.Restarting: handler.state.Set(state.Yielding) + handler.state.WaitFor(state.OpcacheResetting) scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() diff --git a/threadworker.go b/threadworker.go index ba544e24c2..f0a2237e4c 100644 --- a/threadworker.go +++ b/threadworker.go @@ -51,7 +51,9 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.state.Set(state.Yielding) + handler.state.WaitFor(state.OpcacheResetting) scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() case state.Ready, state.TransitionComplete: diff --git a/worker.go b/worker.go index 73ed5855c4..c965661ea8 100644 --- a/worker.go +++ b/worker.go @@ -171,86 +171,13 @@ func newWorker(o workerOpt) (*worker, error) { // EXPERIMENTAL: DrainWorkers finishes all worker scripts before a graceful shutdown func DrainWorkers() { - _ = drainWorkerThreads(false) -} - -func drainWorkerThreads(withRegularThreads bool) []*phpThread { - var ( - ready sync.WaitGroup - drainedThreads []*phpThread - ) - - for _, worker := range workers { - worker.threadMutex.RLock() - ready.Add(len(worker.threads)) - - for _, thread := range worker.threads { - if !thread.state.RequestSafeStateChange(state.Restarting) { - ready.Done() - - // no state change allowed == thread is shutting down - // we'll proceed to restart all other threads anyway - continue - } - restartCounter.Add(1) - close(thread.drainChan) - drainedThreads = append(drainedThreads, thread) - - go func(thread *phpThread) { - thread.state.WaitFor(state.Yielding) - ready.Done() - }(thread) - } - - worker.threadMutex.RUnlock() - } - - if withRegularThreads { - regularThreadMu.RLock() - ready.Add(len(regularThreads)) - - for _, thread := range regularThreads { - if !thread.state.RequestSafeStateChange(state.Restarting) { - ready.Done() - - // no state change allowed == thread is shutting down - // we'll proceed to restart all other threads anyway - continue - } - restartCounter.Add(1) - close(thread.drainChan) - drainedThreads = append(drainedThreads, thread) - - go func(thread *phpThread) { - thread.state.WaitFor(state.Yielding) - ready.Done() - }(thread) - } - - regularThreadMu.RUnlock() - } - - ready.Wait() - - return drainedThreads + _ = drainThreads(false) } // RestartWorkers attempts to restart all workers gracefully // All workers must be restarted at the same time to prevent issues with opcache resetting. func RestartWorkers() { - if !restartCounter.CompareAndSwap(0, 1) { - return // another restart is already in progress - } - // disallow scaling threads while restarting workers - scalingMu.Lock() - defer scalingMu.Unlock() - - threadsToRestart := drainWorkerThreads(false) - - for _, thread := range threadsToRestart { - thread.drainChan = make(chan struct{}) - thread.state.Set(state.Ready) - } + restartThreadsAndOpcacheReset(false) } func (worker *worker) attachThread(thread *phpThread) { From 3e7cdd023b39d00cff97117577031b9070f55a72 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 3 Jan 2026 21:56:54 +0100 Subject: [PATCH 07/32] test --- frankenphp.go | 3 ++- worker.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index d6c1a72154..2d6a6511a1 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -862,8 +862,9 @@ func scheduleOpcacheReset(thread *phpThread) { defer func() { regularThread.contextHolder.frankenPHPContext = nil }() } - globalLogger.Info("resetting opcache in all threads") + globalLogger.Info("resetting opcache", "thread", thread.name()) C.frankenphp_reset_opcache() + globalLogger.Info("opcache reset completed", "thread", thread.name()) } // ExecuteScriptCLI executes the PHP script passed as parameter. diff --git a/worker.go b/worker.go index c965661ea8..4a3ae7cedf 100644 --- a/worker.go +++ b/worker.go @@ -177,7 +177,7 @@ func DrainWorkers() { // RestartWorkers attempts to restart all workers gracefully // All workers must be restarted at the same time to prevent issues with opcache resetting. func RestartWorkers() { - restartThreadsAndOpcacheReset(false) + restartThreadsAndOpcacheReset(true) } func (worker *worker) attachThread(thread *phpThread) { From df82e814dfbd2286c2f0555dad8185b2066db3e0 Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 13 Mar 2026 21:00:01 +0700 Subject: [PATCH 08/32] fix clang format --- frankenphp.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index c36c4fbf11..5e43cf74ab 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1418,7 +1418,8 @@ int frankenphp_reset_opcache(void) { php_request_startup(); ((zend_internal_function *)opcache_reset)->handler = orig_opcache_reset; zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); - ((zend_internal_function *)opcache_reset)->handler = ZEND_FN(frankenphp_opcache_reset); + ((zend_internal_function *)opcache_reset)->handler = + ZEND_FN(frankenphp_opcache_reset); php_request_shutdown((void *)0); } From 0d87765fb8b84608df29dfc3d067f34984aacb06 Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 13 Mar 2026 22:37:31 +0700 Subject: [PATCH 09/32] call into cgo for reset directly, no fake dummy --- frankenphp.c | 55 ++++++++++++++++++++++++++++++++------------------- frankenphp.go | 23 ++++++++------------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 5e43cf74ab..bd24b9bdc7 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -89,6 +89,25 @@ __thread HashTable *sandboxed_env = NULL; __thread zval *os_environment = NULL; zif_handler orig_opcache_reset; +/* Forward declaration */ +PHP_FUNCTION(frankenphp_opcache_reset); + +/* Try to override opcache_reset if opcache is loaded. + * Safe to call multiple times - skips if already overridden in this function + * table. Uses handler comparison instead of orig_opcache_reset check so that + * a fresh function table after PHP module restart is always re-overridden. */ +static void frankenphp_override_opcache_reset(void) { + zend_function *func = zend_hash_str_find_ptr(CG(function_table), "opcache_reset", + sizeof("opcache_reset") - 1); + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && + ((zend_internal_function *)func)->handler != + ZEND_FN(frankenphp_opcache_reset)) { + orig_opcache_reset = ((zend_internal_function *)func)->handler; + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } +} + void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; @@ -724,14 +743,8 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } - // Override opcache_reset - func = zend_hash_str_find_ptr(CG(function_table), "opcache_reset", - sizeof("opcache_reset") - 1); - if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION) { - orig_opcache_reset = ((zend_internal_function *)func)->handler; - ((zend_internal_function *)func)->handler = - ZEND_FN(frankenphp_opcache_reset); - } + // Override opcache_reset (may not be available yet if opcache loads after us) + frankenphp_override_opcache_reset(); return SUCCESS; } @@ -751,7 +764,14 @@ static zend_module_entry frankenphp_module = { static int frankenphp_startup(sapi_module_struct *sapi_module) { php_import_environment_variables = get_full_env; - return php_module_startup(sapi_module, &frankenphp_module); + int result = php_module_startup(sapi_module, &frankenphp_module); + if (result == SUCCESS) { + /* All extensions are now loaded. Override opcache_reset if opcache + * was not yet available during our MINIT (shared extension load order). */ + frankenphp_override_opcache_reset(); + } + + return result; } static int frankenphp_deactivate(void) { return SUCCESS; } @@ -1412,17 +1432,12 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { - zend_function *opcache_reset = - zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset")); - if (opcache_reset) { - php_request_startup(); - ((zend_internal_function *)opcache_reset)->handler = orig_opcache_reset; - zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); - ((zend_internal_function *)opcache_reset)->handler = - ZEND_FN(frankenphp_opcache_reset); - php_request_shutdown((void *)0); - } - + zend_execute_data execute_data; + zval retval; + memset(&execute_data, 0, sizeof(execute_data)); + ZVAL_UNDEF(&retval); + orig_opcache_reset(&execute_data, &retval); + zval_ptr_dtor(&retval); return 0; } diff --git a/frankenphp.go b/frankenphp.go index 9149226315..81aec1372e 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -765,6 +765,10 @@ func go_schedule_opcache_reset(threadIndex C.uintptr_t) { } } +// opcacheResetOnce ensures only one thread calls the actual opcache_reset. +// Multiple threads calling it concurrently can race on shared memory. +var opcacheResetOnce sync.Once + // restart all threads for an opcache_reset func restartThreadsAndOpcacheReset(withRegularThreads bool) { // disallow scaling threads while restarting workers @@ -773,6 +777,7 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { threadsToRestart := drainThreads(withRegularThreads) + opcacheResetOnce = sync.Once{} opcacheResetWg := sync.WaitGroup{} for _, thread := range threadsToRestart { thread.state.Set(state.OpcacheResetting) @@ -851,21 +856,9 @@ func drainThreads(withRegularThreads bool) []*phpThread { } func scheduleOpcacheReset(thread *phpThread) { - fc, _ := newDummyContext("/opcache_reset") - - if workerThread, ok := thread.handler.(*workerThread); ok { - workerThread.dummyFrankenPHPContext = fc - defer func() { workerThread.dummyFrankenPHPContext = nil }() - } - - if regularThread, ok := thread.handler.(*regularThread); ok { - regularThread.contextHolder.frankenPHPContext = fc - defer func() { regularThread.contextHolder.frankenPHPContext = nil }() - } - - globalLogger.Info("resetting opcache", "thread", thread.name()) - C.frankenphp_reset_opcache() - globalLogger.Info("opcache reset completed", "thread", thread.name()) + opcacheResetOnce.Do(func() { + C.frankenphp_reset_opcache() + }) } func convertArgs(args []string) (C.int, []*C.char) { From 0564eaf1505c36521b869e87c9150a3f826b5277 Mon Sep 17 00:00:00 2001 From: henderkes Date: Fri, 13 Mar 2026 22:52:08 +0700 Subject: [PATCH 10/32] clang fmt --- frankenphp.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index bd24b9bdc7..3f0e540adb 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -97,8 +97,8 @@ PHP_FUNCTION(frankenphp_opcache_reset); * table. Uses handler comparison instead of orig_opcache_reset check so that * a fresh function table after PHP module restart is always re-overridden. */ static void frankenphp_override_opcache_reset(void) { - zend_function *func = zend_hash_str_find_ptr(CG(function_table), "opcache_reset", - sizeof("opcache_reset") - 1); + zend_function *func = zend_hash_str_find_ptr( + CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && ((zend_internal_function *)func)->handler != ZEND_FN(frankenphp_opcache_reset)) { From 49fc8784f5276d8b703447b25a111414bb356bd7 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 10:37:00 +0700 Subject: [PATCH 11/32] override opcache reset handler for every php thread in php 8.2 --- frankenphp.c | 1 + 1 file changed, 1 insertion(+) diff --git a/frankenphp.c b/frankenphp.c index 3f0e540adb..3c81d072d5 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1232,6 +1232,7 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { static int frankenphp_request_startup() { frankenphp_update_request_context(); + frankenphp_override_opcache_reset(); if (php_request_startup() == SUCCESS) { return SUCCESS; } From 22c6ba60f7c31761a069d33af824b2ac3bad720c Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 11:03:57 +0700 Subject: [PATCH 12/32] maybe after request startup? --- frankenphp.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index 3c81d072d5..a07cba86a1 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1232,8 +1232,8 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { static int frankenphp_request_startup() { frankenphp_update_request_context(); - frankenphp_override_opcache_reset(); if (php_request_startup() == SUCCESS) { + frankenphp_override_opcache_reset(); return SUCCESS; } From 66702fe0d828e3c0b311cda0bbb11671ead67019 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 18:39:46 +0700 Subject: [PATCH 13/32] try not resetting opcache? --- frankenphp.c | 1 - frankenphp.go | 21 ++++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index a07cba86a1..cc420aeb5d 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -86,7 +86,6 @@ HashTable *main_thread_env = NULL; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread HashTable *sandboxed_env = NULL; -__thread zval *os_environment = NULL; zif_handler orig_opcache_reset; /* Forward declaration */ diff --git a/frankenphp.go b/frankenphp.go index 81aec1372e..25effdc704 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -777,16 +777,19 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { threadsToRestart := drainThreads(withRegularThreads) - opcacheResetOnce = sync.Once{} - opcacheResetWg := sync.WaitGroup{} - for _, thread := range threadsToRestart { - thread.state.Set(state.OpcacheResetting) - opcacheResetWg.Go(func() { - thread.state.WaitFor(state.OpcacheResettingDone) - }) - } + // on 8.2 debian it segfaults, skip opcache reset + if Version().VersionID >= 80300 { + opcacheResetOnce = sync.Once{} + opcacheResetWg := sync.WaitGroup{} + for _, thread := range threadsToRestart { + thread.state.Set(state.OpcacheResetting) + opcacheResetWg.Go(func() { + thread.state.WaitFor(state.OpcacheResettingDone) + }) + } - opcacheResetWg.Wait() + opcacheResetWg.Wait() + } for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) From 7cb94e6d31e7ae25143ea8ba42b1d21d7049c116 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 19:06:49 +0700 Subject: [PATCH 14/32] don't overrride opcache_reset at all in php 8.2 --- frankenphp.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index cc420aeb5d..57c037fea6 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -742,8 +742,10 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } +#if PHP_VERSION_ID >= 80300 // Override opcache_reset (may not be available yet if opcache loads after us) frankenphp_override_opcache_reset(); +#endif return SUCCESS; } @@ -765,9 +767,11 @@ static int frankenphp_startup(sapi_module_struct *sapi_module) { int result = php_module_startup(sapi_module, &frankenphp_module); if (result == SUCCESS) { +#if PHP_VERSION_ID >= 80300 /* All extensions are now loaded. Override opcache_reset if opcache * was not yet available during our MINIT (shared extension load order). */ frankenphp_override_opcache_reset(); +#endif } return result; @@ -1232,7 +1236,9 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { static int frankenphp_request_startup() { frankenphp_update_request_context(); if (php_request_startup() == SUCCESS) { +#if PHP_VERSION_ID >= 80300 frankenphp_override_opcache_reset(); +#endif return SUCCESS; } From e9533b8f216e102573860987c697534566ad1630 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 19:34:18 +0700 Subject: [PATCH 15/32] dont even run the test --- caddy/caddy_test.go | 4 ++++ frankenphp.c | 2 ++ 2 files changed, 6 insertions(+) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index ebed000925..adba4c7d21 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -16,6 +16,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddytest" + "github.com/dunglas/frankenphp" "github.com/dunglas/frankenphp/internal/fastabs" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -1544,6 +1545,9 @@ func TestDd(t *testing.T) { // test to force the opcache segfault race condition under concurrency (~1.7s) func TestOpcacheReset(t *testing.T) { + if frankenphp.Version().VersionID < 80300 { + t.Skip("opcache reset test requires PHP 8.3+") + } tester := caddytest.NewTester(t) tester.InitServer(` { diff --git a/frankenphp.c b/frankenphp.c index 57c037fea6..0c4da675a1 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -91,6 +91,7 @@ zif_handler orig_opcache_reset; /* Forward declaration */ PHP_FUNCTION(frankenphp_opcache_reset); +#if PHP_VERSION_ID >= 80300 /* Try to override opcache_reset if opcache is loaded. * Safe to call multiple times - skips if already overridden in this function * table. Uses handler comparison instead of orig_opcache_reset check so that @@ -106,6 +107,7 @@ static void frankenphp_override_opcache_reset(void) { ZEND_FN(frankenphp_opcache_reset); } } +#endif void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; From 7c28f3d4526e26542b75fe1c35925d137d1faed4 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 20:18:24 +0700 Subject: [PATCH 16/32] don't wait for resetting in php 8.2 --- frankenphp.go | 3 +-- threadregular.go | 8 +++++--- threadworker.go | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 25effdc704..2f9b4277b0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -777,7 +777,7 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { threadsToRestart := drainThreads(withRegularThreads) - // on 8.2 debian it segfaults, skip opcache reset + // on 8.2 opcache_reset() segfaults, skip it entirely if Version().VersionID >= 80300 { opcacheResetOnce = sync.Once{} opcacheResetWg := sync.WaitGroup{} @@ -787,7 +787,6 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { thread.state.WaitFor(state.OpcacheResettingDone) }) } - opcacheResetWg.Wait() } diff --git a/threadregular.go b/threadregular.go index 8b7bbd2347..5041f6f067 100644 --- a/threadregular.go +++ b/threadregular.go @@ -50,9 +50,11 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.waitForRequest() case state.Restarting: handler.state.Set(state.Yielding) - handler.state.WaitFor(state.OpcacheResetting) - scheduleOpcacheReset(handler.thread) - handler.state.Set(state.OpcacheResettingDone) + if Version().VersionID >= 80300 { + handler.state.WaitFor(state.OpcacheResetting) + scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) + } handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() diff --git a/threadworker.go b/threadworker.go index 68e0605a4b..61f0c5a4ef 100644 --- a/threadworker.go +++ b/threadworker.go @@ -51,9 +51,11 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.state.Set(state.Yielding) - handler.state.WaitFor(state.OpcacheResetting) - scheduleOpcacheReset(handler.thread) - handler.state.Set(state.OpcacheResettingDone) + if Version().VersionID >= 80300 { + handler.state.WaitFor(state.OpcacheResetting) + scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) + } handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() case state.Ready, state.TransitionComplete: From d88821a8d03d95a1af5fae4e14cb152c78480659 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 14 Mar 2026 21:56:48 +0700 Subject: [PATCH 17/32] original opcache reset in 8.2 --- frankenphp.c | 22 +++++++++++++++++----- threadregular.go | 1 - threadworker.go | 8 ++++++++ worker.go | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 0c4da675a1..b249c6a43b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -86,12 +86,12 @@ HashTable *main_thread_env = NULL; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread HashTable *sandboxed_env = NULL; -zif_handler orig_opcache_reset; +#if PHP_VERSION_ID >= 80300 /* Forward declaration */ PHP_FUNCTION(frankenphp_opcache_reset); +zif_handler orig_opcache_reset; -#if PHP_VERSION_ID >= 80300 /* Try to override opcache_reset if opcache is loaded. * Safe to call multiple times - skips if already overridden in this function * table. Uses handler comparison instead of orig_opcache_reset check so that @@ -479,12 +479,14 @@ PHP_FUNCTION(frankenphp_getenv) { } } /* }}} */ +#if PHP_VERSION_ID >= 80300 /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(thread_index); RETVAL_TRUE; } /* }}} */ +#endif /* {{{ Fetch all HTTP request headers */ PHP_FUNCTION(frankenphp_request_headers) { @@ -768,13 +770,13 @@ static int frankenphp_startup(sapi_module_struct *sapi_module) { php_import_environment_variables = get_full_env; int result = php_module_startup(sapi_module, &frankenphp_module); +#if PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 if (result == SUCCESS) { -#if PHP_VERSION_ID >= 80300 /* All extensions are now loaded. Override opcache_reset if opcache * was not yet available during our MINIT (shared extension load order). */ frankenphp_override_opcache_reset(); -#endif } +#endif return result; } @@ -1238,7 +1240,9 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { static int frankenphp_request_startup() { frankenphp_update_request_context(); if (php_request_startup() == SUCCESS) { -#if PHP_VERSION_ID >= 80300 +#if PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 + /* for php 8.5+ opcache is always compiled statically, so it's already + * hooked in main request startup */ frankenphp_override_opcache_reset(); #endif return SUCCESS; @@ -1440,12 +1444,20 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { +#if PHP_VERSION_ID >= 80300 zend_execute_data execute_data; zval retval; memset(&execute_data, 0, sizeof(execute_data)); ZVAL_UNDEF(&retval); orig_opcache_reset(&execute_data, &retval); zval_ptr_dtor(&retval); +#else + zend_function *opcache_reset = + zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset")); + if (opcache_reset) { + zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); + } +#endif return 0; } diff --git a/threadregular.go b/threadregular.go index 5041f6f067..08f70463d8 100644 --- a/threadregular.go +++ b/threadregular.go @@ -57,7 +57,6 @@ func (handler *regularThread) beforeScriptExecution() string { } handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() - case state.Ready: return handler.waitForRequest() diff --git a/threadworker.go b/threadworker.go index 61f0c5a4ef..dba7b78254 100644 --- a/threadworker.go +++ b/threadworker.go @@ -229,6 +229,14 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } + if Version().VersionID < 80300 { + // flush the opcache when restarting due to watcher or admin api + // note: this is done right before frankenphp_handle_request() returns 'false' + if handler.state.Is(state.Restarting) { + C.frankenphp_reset_opcache() + } + } + return false, nil case requestCH = <-handler.thread.requestChan: case requestCH = <-handler.worker.requestChan: diff --git a/worker.go b/worker.go index ef624c3724..5f9c228abd 100644 --- a/worker.go +++ b/worker.go @@ -173,7 +173,7 @@ func DrainWorkers() { // RestartWorkers attempts to restart all workers gracefully // All workers must be restarted at the same time to prevent issues with opcache resetting. func RestartWorkers() { - restartThreadsAndOpcacheReset(true) + restartThreadsAndOpcacheReset(false) } func (worker *worker) attachThread(thread *phpThread) { From 45d49c1a6504a9e3de0f86a7e8c4267ce0c0d82d Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 15 Mar 2026 09:35:15 +0700 Subject: [PATCH 18/32] make it run on 8.2 again --- caddy/caddy_test.go | 4 ---- frankenphp.c | 29 ++++++++--------------------- frankenphp.go | 19 ++++++++----------- threadregular.go | 8 +++----- threadworker.go | 20 +++++--------------- 5 files changed, 24 insertions(+), 56 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index adba4c7d21..ebed000925 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -16,7 +16,6 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddytest" - "github.com/dunglas/frankenphp" "github.com/dunglas/frankenphp/internal/fastabs" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -1545,9 +1544,6 @@ func TestDd(t *testing.T) { // test to force the opcache segfault race condition under concurrency (~1.7s) func TestOpcacheReset(t *testing.T) { - if frankenphp.Version().VersionID < 80300 { - t.Skip("opcache reset test requires PHP 8.3+") - } tester := caddytest.NewTester(t) tester.InitServer(` { diff --git a/frankenphp.c b/frankenphp.c index b249c6a43b..821dd8f3c1 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -87,7 +87,6 @@ __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread HashTable *sandboxed_env = NULL; -#if PHP_VERSION_ID >= 80300 /* Forward declaration */ PHP_FUNCTION(frankenphp_opcache_reset); zif_handler orig_opcache_reset; @@ -107,7 +106,6 @@ static void frankenphp_override_opcache_reset(void) { ZEND_FN(frankenphp_opcache_reset); } } -#endif void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; @@ -479,14 +477,12 @@ PHP_FUNCTION(frankenphp_getenv) { } } /* }}} */ -#if PHP_VERSION_ID >= 80300 /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(thread_index); RETVAL_TRUE; } /* }}} */ -#endif /* {{{ Fetch all HTTP request headers */ PHP_FUNCTION(frankenphp_request_headers) { @@ -746,10 +742,9 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } -#if PHP_VERSION_ID >= 80300 - // Override opcache_reset (may not be available yet if opcache loads after us) + // Override opcache_reset (may not be available yet if opcache loads as a + // shared extension in PHP 8.4 and below) frankenphp_override_opcache_reset(); -#endif return SUCCESS; } @@ -770,10 +765,10 @@ static int frankenphp_startup(sapi_module_struct *sapi_module) { php_import_environment_variables = get_full_env; int result = php_module_startup(sapi_module, &frankenphp_module); -#if PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 +#if PHP_VERSION_ID < 80500 if (result == SUCCESS) { - /* All extensions are now loaded. Override opcache_reset if opcache - * was not yet available during our MINIT (shared extension load order). */ + /* Override opcache here again if loaded as a shared extension + * (php 8.4 and under) */ frankenphp_override_opcache_reset(); } #endif @@ -1240,9 +1235,9 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { static int frankenphp_request_startup() { frankenphp_update_request_context(); if (php_request_startup() == SUCCESS) { -#if PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 - /* for php 8.5+ opcache is always compiled statically, so it's already - * hooked in main request startup */ +#if PHP_VERSION_ID < 80500 + /* Override opcache here again if loaded as a shared extension + * (php 8.4 and under) */ frankenphp_override_opcache_reset(); #endif return SUCCESS; @@ -1444,20 +1439,12 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { -#if PHP_VERSION_ID >= 80300 zend_execute_data execute_data; zval retval; memset(&execute_data, 0, sizeof(execute_data)); ZVAL_UNDEF(&retval); orig_opcache_reset(&execute_data, &retval); zval_ptr_dtor(&retval); -#else - zend_function *opcache_reset = - zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset")); - if (opcache_reset) { - zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL); - } -#endif return 0; } diff --git a/frankenphp.go b/frankenphp.go index 2f9b4277b0..e82570400a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -777,18 +777,15 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { threadsToRestart := drainThreads(withRegularThreads) - // on 8.2 opcache_reset() segfaults, skip it entirely - if Version().VersionID >= 80300 { - opcacheResetOnce = sync.Once{} - opcacheResetWg := sync.WaitGroup{} - for _, thread := range threadsToRestart { - thread.state.Set(state.OpcacheResetting) - opcacheResetWg.Go(func() { - thread.state.WaitFor(state.OpcacheResettingDone) - }) - } - opcacheResetWg.Wait() + opcacheResetOnce = sync.Once{} + opcacheResetWg := sync.WaitGroup{} + for _, thread := range threadsToRestart { + thread.state.Set(state.OpcacheResetting) + opcacheResetWg.Go(func() { + thread.state.WaitFor(state.OpcacheResettingDone) + }) } + opcacheResetWg.Wait() for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) diff --git a/threadregular.go b/threadregular.go index 08f70463d8..74bce3eff4 100644 --- a/threadregular.go +++ b/threadregular.go @@ -50,11 +50,9 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.waitForRequest() case state.Restarting: handler.state.Set(state.Yielding) - if Version().VersionID >= 80300 { - handler.state.WaitFor(state.OpcacheResetting) - scheduleOpcacheReset(handler.thread) - handler.state.Set(state.OpcacheResettingDone) - } + handler.state.WaitFor(state.OpcacheResetting) + scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() case state.Ready: diff --git a/threadworker.go b/threadworker.go index dba7b78254..d715c8d18f 100644 --- a/threadworker.go +++ b/threadworker.go @@ -51,11 +51,9 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.state.Set(state.Yielding) - if Version().VersionID >= 80300 { - handler.state.WaitFor(state.OpcacheResetting) - scheduleOpcacheReset(handler.thread) - handler.state.Set(state.OpcacheResettingDone) - } + handler.state.WaitFor(state.OpcacheResetting) + scheduleOpcacheReset(handler.thread) + handler.state.Set(state.OpcacheResettingDone) handler.state.WaitFor(state.Ready, state.ShuttingDown) return handler.beforeScriptExecution() case state.Ready, state.TransitionComplete: @@ -75,9 +73,9 @@ func (handler *workerThread) beforeScriptExecution() string { // signal to stop return "" + default: + panic("unexpected state: " + handler.state.Name()) } - - panic("unexpected state: " + handler.state.Name()) } func (handler *workerThread) afterScriptExecution(exitStatus int) { @@ -229,14 +227,6 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } - if Version().VersionID < 80300 { - // flush the opcache when restarting due to watcher or admin api - // note: this is done right before frankenphp_handle_request() returns 'false' - if handler.state.Is(state.Restarting) { - C.frankenphp_reset_opcache() - } - } - return false, nil case requestCH = <-handler.thread.requestChan: case requestCH = <-handler.worker.requestChan: From d189770cc7c273a68a92968d4780be9ae2a07494 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 16 Mar 2026 09:32:47 +0700 Subject: [PATCH 19/32] Update worker.go Signed-off-by: Marc --- worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker.go b/worker.go index 5f9c228abd..ef624c3724 100644 --- a/worker.go +++ b/worker.go @@ -173,7 +173,7 @@ func DrainWorkers() { // RestartWorkers attempts to restart all workers gracefully // All workers must be restarted at the same time to prevent issues with opcache resetting. func RestartWorkers() { - restartThreadsAndOpcacheReset(false) + restartThreadsAndOpcacheReset(true) } func (worker *worker) attachThread(thread *phpThread) { From eebec0b87f8943eddd7128d14ca802d35ba3b6a6 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 25 Mar 2026 21:26:12 +0700 Subject: [PATCH 20/32] fix tests timing out --- caddy/caddy_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index ebed000925..1a5774dd42 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1545,6 +1545,7 @@ func TestDd(t *testing.T) { // test to force the opcache segfault race condition under concurrency (~1.7s) func TestOpcacheReset(t *testing.T) { tester := caddytest.NewTester(t) + tester.Client.Timeout = 60 * time.Second tester.InitServer(` { skip_install_trust @@ -1557,6 +1558,7 @@ func TestOpcacheReset(t *testing.T) { php_ini { opcache.enable 1 opcache.log_verbosity_level 4 + max_execution_time 30s } } } @@ -1574,7 +1576,7 @@ func TestOpcacheReset(t *testing.T) { `, "caddyfile") wg := sync.WaitGroup{} - numRequests := 100 + numRequests := 1000 wg.Add(numRequests) for i := 0; i < numRequests; i++ { @@ -1584,6 +1586,7 @@ func TestOpcacheReset(t *testing.T) { } go func() { + defer wg.Done() // randomly call opcache_reset if rand.IntN(10) > 5 { tester.AssertGetResponse( @@ -1591,7 +1594,6 @@ func TestOpcacheReset(t *testing.T) { http.StatusOK, "opcache reset done", ) - wg.Done() return } @@ -1601,7 +1603,6 @@ func TestOpcacheReset(t *testing.T) { http.StatusOK, fmt.Sprintf("slept for %d ms and worked for %d iterations", i, i), ) - wg.Done() }() } From 30407d77137f91eb14c0acf81377170f4f3392c0 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 25 Mar 2026 21:46:42 +0700 Subject: [PATCH 21/32] cap sleep at 100ms --- caddy/caddy_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 1a5774dd42..bc61612858 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1588,7 +1588,7 @@ func TestOpcacheReset(t *testing.T) { go func() { defer wg.Done() // randomly call opcache_reset - if rand.IntN(10) > 5 { + if rand.IntN(10) > 7 { tester.AssertGetResponse( "http://localhost:"+testPort+"/opcache_reset.php", http.StatusOK, @@ -1598,10 +1598,12 @@ func TestOpcacheReset(t *testing.T) { } // otherwise call sleep.php with random sleep and work values + sleep := i % 100 + work := i % 100 tester.AssertGetResponse( - fmt.Sprintf("http://localhost:%s/sleep.php?sleep=%d&work=%d", testPort, i, i), + fmt.Sprintf("http://localhost:%s/sleep.php?sleep=%d&work=%d", testPort, sleep, work), http.StatusOK, - fmt.Sprintf("slept for %d ms and worked for %d iterations", i, i), + fmt.Sprintf("slept for %d ms and worked for %d iterations", sleep, work), ) }() } From 6a0428f015be6f7e6dbd0a1a6560b1469ca63ad0 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 22:17:32 +0200 Subject: [PATCH 22/32] Cleans up implementation and adresses race conditions. --- caddy/caddy_test.go | 4 ++-- frankenphp.c | 47 +++++++++++---------------------------------- frankenphp.go | 7 ++++--- 3 files changed, 17 insertions(+), 41 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index bc61612858..597ed9ab57 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1576,7 +1576,7 @@ func TestOpcacheReset(t *testing.T) { `, "caddyfile") wg := sync.WaitGroup{} - numRequests := 1000 + numRequests := 500 // increase if test is flaky to get consistent failures wg.Add(numRequests) for i := 0; i < numRequests; i++ { @@ -1588,7 +1588,7 @@ func TestOpcacheReset(t *testing.T) { go func() { defer wg.Done() // randomly call opcache_reset - if rand.IntN(10) > 7 { + if i % 10 > 7 { tester.AssertGetResponse( "http://localhost:"+testPort+"/opcache_reset.php", http.StatusOK, diff --git a/frankenphp.c b/frankenphp.c index 864dd3fb5b..ed2c0af112 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -86,27 +86,8 @@ HashTable *main_thread_env = NULL; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread HashTable *sandboxed_env = NULL; - -/* Forward declaration */ -PHP_FUNCTION(frankenphp_opcache_reset); zif_handler orig_opcache_reset; -/* Try to override opcache_reset if opcache is loaded. - * Safe to call multiple times - skips if already overridden in this function - * table. Uses handler comparison instead of orig_opcache_reset check so that - * a fresh function table after PHP module restart is always re-overridden. */ -static void frankenphp_override_opcache_reset(void) { - zend_function *func = zend_hash_str_find_ptr( - CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); - if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && - ((zend_internal_function *)func)->handler != - ZEND_FN(frankenphp_opcache_reset)) { - orig_opcache_reset = ((zend_internal_function *)func)->handler; - ((zend_internal_function *)func)->handler = - ZEND_FN(frankenphp_opcache_reset); - } -} - void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; @@ -747,10 +728,6 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } - // Override opcache_reset (may not be available yet if opcache loads as a - // shared extension in PHP 8.4 and below) - frankenphp_override_opcache_reset(); - return SUCCESS; } @@ -770,13 +747,6 @@ static int frankenphp_startup(sapi_module_struct *sapi_module) { php_import_environment_variables = get_full_env; int result = php_module_startup(sapi_module, &frankenphp_module); -#if PHP_VERSION_ID < 80500 - if (result == SUCCESS) { - /* Override opcache here again if loaded as a shared extension - * (php 8.4 and under) */ - frankenphp_override_opcache_reset(); - } -#endif return result; } @@ -1118,12 +1088,6 @@ static void *php_thread(void *arg) { zend_bailout(); } -#if PHP_VERSION_ID < 80500 - /* Override opcache here again if loaded as a shared extension - * (php 8.4 and under) */ - frankenphp_override_opcache_reset(); -#endif - zend_file_handle file_handle; zend_stream_init_filename(&file_handle, scriptName); @@ -1284,6 +1248,17 @@ static void *php_main(void *arg) { should_filter_var = default_filter != NULL; original_user_abort_setting = PG(ignore_user_abort); + /* Override opcache_reset, needs to be here again if loaded as a shared extension (php 8.4 and under) */ + zend_function *func = zend_hash_str_find_ptr( + CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && + ((zend_internal_function *)func)->handler != + ZEND_FN(frankenphp_opcache_reset)) { + orig_opcache_reset = ((zend_internal_function *)func)->handler; + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } + go_frankenphp_main_thread_is_ready(); /* channel closed, shutdown gracefully */ diff --git a/frankenphp.go b/frankenphp.go index e82570400a..46059bdd6d 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -761,7 +761,10 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { //export go_schedule_opcache_reset func go_schedule_opcache_reset(threadIndex C.uintptr_t) { if threadsAreRestarting.CompareAndSwap(false, true) { - go restartThreadsAndOpcacheReset(true) + go func(){ + restartThreadsAndOpcacheReset(true) + threadsAreRestarting.Store(false) + }() } } @@ -791,8 +794,6 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { thread.drainChan = make(chan struct{}) thread.state.Set(state.Ready) } - - threadsAreRestarting.Store(false) } func drainThreads(withRegularThreads bool) []*phpThread { From 4036febc9dba84eb7d494521cb7ddd19fc509d71 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 22:24:48 +0200 Subject: [PATCH 23/32] merge --- .github/workflows/docker.yaml | 10 +- .github/workflows/docs.yaml | 1 + .github/workflows/sanitizers.yaml | 9 +- .github/workflows/static.yaml | 15 +- .github/workflows/tests.yaml | 21 ++- .github/workflows/translate.yaml | 1 + .github/workflows/windows.yaml | 6 +- .github/workflows/wrap-issue-details.yaml | 2 +- .gitignore | 2 + build-static.sh | 1 - caddy/app.go | 17 +- caddy/caddy_test.go | 189 +++++++++++++++++++++ caddy/go.mod | 90 +++++----- caddy/go.sum | 193 +++++++++++----------- caddy/module.go | 12 +- caddy/php-server.go | 4 +- cgi.go | 29 ++-- context.go | 9 +- docs/config.md | 20 +++ docs/extensions.md | 2 +- frankenphp.c | 20 +++ frankenphp.go | 7 +- frankenphp_test.go | 2 +- go.mod | 4 +- go.sum | 8 +- internal/state/state.go | 9 + maxrequests_regular_test.go | 69 ++++++++ options.go | 10 ++ phpthread.go | 24 ++- testdata/server-globals.php | 28 ++++ testdata/worker-counter-persistent.php | 10 ++ testdata/worker-with-counter.php | 6 +- threadregular.go | 26 ++- threadworker.go | 24 +++ worker_test.go | 93 +++++++++++ 35 files changed, 762 insertions(+), 211 deletions(-) create mode 100644 maxrequests_regular_test.go create mode 100644 testdata/server-globals.php create mode 100644 testdata/worker-counter-persistent.php diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 36b66cc28d..c1c29bbfc3 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -80,6 +80,7 @@ jobs: VERSION: ${{ (github.ref_type == 'tag' && github.ref_name) || steps.check.outputs.ref || 'dev' }} PHP_VERSION: ${{ steps.check.outputs.php_version }} build: + environment: dockerhub runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: - prepare @@ -129,8 +130,8 @@ jobs: uses: docker/login-action@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build id: build uses: docker/bake-action@v7 @@ -204,6 +205,7 @@ jobs: # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ push: + environment: dockerhub runs-on: ubuntu-24.04 needs: - prepare @@ -227,8 +229,8 @@ jobs: uses: docker/login-action@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create manifest list and push working-directory: /tmp/metadata run: | diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 5b9316b9cb..3423f553a3 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -16,6 +16,7 @@ concurrency: cancel-in-progress: true jobs: deploy: + environment: website runs-on: ubuntu-slim steps: - name: Trigger website deployment diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 5ba8813bae..15fe013d5b 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -18,6 +18,7 @@ permissions: contents: read env: GOTOOLCHAIN: local + GOTESTSUM_FORMAT: pkgname-and-test-fails jobs: # Adapted from https://github.com/beberlei/hdrhistogram-php sanitizers: @@ -102,13 +103,13 @@ jobs: run: echo "$(pwd)/php/target/bin" >> "$GITHUB_PATH" - name: Install e-dant/watcher uses: ./.github/actions/watcher - - name: Set Set CGO flags + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Set CGO flags run: | { echo "CGO_CFLAGS=$CFLAGS -I${PWD}/watcher/target/include $(php-config --includes)" echo "CGO_LDFLAGS=$LDFLAGS $(php-config --ldflags) $(php-config --libs)" } >> "$GITHUB_ENV" - - name: Compile tests - run: go test ${{ matrix.sanitizer == 'msan' && '-tags=nowatcher' || '' }} -${{ matrix.sanitizer }} -v -x -c - name: Run tests - run: ./frankenphp.test -test.v + run: gotestsum -- ${{ matrix.sanitizer == 'msan' && '-tags=nowatcher' || '' }} -${{ matrix.sanitizer }} ./... diff --git a/.github/workflows/static.yaml b/.github/workflows/static.yaml index d1557905a0..165c48bcb2 100644 --- a/.github/workflows/static.yaml +++ b/.github/workflows/static.yaml @@ -84,6 +84,7 @@ jobs: VERSION: ${{ steps.check.outputs.ref || 'dev' }} build-linux-musl: + environment: dockerhub permissions: contents: write id-token: write @@ -121,8 +122,8 @@ jobs: uses: docker/login-action@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set VERSION run: | if [ "${GITHUB_REF_TYPE}" == "tag" ]; then @@ -219,6 +220,7 @@ jobs: BINARY: ./frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} build-linux-gnu: + environment: dockerhub permissions: contents: write id-token: write @@ -289,8 +291,8 @@ jobs: uses: docker/login-action@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build id: build uses: docker/bake-action@v7 @@ -377,6 +379,7 @@ jobs: # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ push: + environment: dockerhub runs-on: ubuntu-24.04 needs: - prepare @@ -402,8 +405,8 @@ jobs: uses: docker/login-action@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create manifest list and push working-directory: /tmp/metadata run: | diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 407d9fb0c9..396d9ccf77 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,6 +19,7 @@ permissions: env: GOTOOLCHAIN: local GOEXPERIMENT: cgocheck2 + GOTESTSUM_FORMAT: pkgname-and-test-fails jobs: tests-linux: name: Tests (Linux, PHP ${{ matrix.php-versions }}) @@ -64,13 +65,13 @@ jobs: - name: Build testcli binary working-directory: internal/testcli/ run: go build - - name: Compile library tests - run: go test -race -v -x -c + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest - name: Run library tests - run: ./frankenphp.test -test.v + run: gotestsum -- -race ./... - name: Run Caddy module tests working-directory: caddy/ - run: go test -race -v ./... + run: gotestsum -- -race ./... - name: Run Fuzzing Tests working-directory: caddy/ run: go test -fuzz FuzzRequest -fuzztime 20s @@ -136,9 +137,11 @@ jobs: run: | echo "CGO_CFLAGS=$(php-config --includes)" >> "${GITHUB_ENV}" echo "CGO_LDFLAGS=$(php-config --ldflags) $(php-config --libs)" >> "${GITHUB_ENV}" + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest - name: Run integration tests working-directory: internal/extgen/ - run: go test -tags integration -v -timeout 30m + run: gotestsum -- -tags integration -timeout 30m tests-mac: name: Tests (macOS, PHP 8.5) runs-on: macos-latest @@ -164,7 +167,9 @@ jobs: env: phpts: ts debug: true - - name: Set Set CGO flags + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Set CGO flags run: | { echo "CGO_CFLAGS=-I/opt/homebrew/include/ $(php-config --includes)" @@ -173,7 +178,7 @@ jobs: - name: Build run: go build -tags nowatcher - name: Run library tests - run: go test -tags nowatcher -race -v ./... + run: gotestsum -- -tags nowatcher -race ./... - name: Run Caddy module tests working-directory: caddy/ - run: go test -race -v ./... + run: gotestsum -- -race ./... diff --git a/.github/workflows/translate.yaml b/.github/workflows/translate.yaml index cfc72d516e..c8bf52b099 100644 --- a/.github/workflows/translate.yaml +++ b/.github/workflows/translate.yaml @@ -13,6 +13,7 @@ permissions: pull-requests: write jobs: build: + environment: translate name: Translate Docs runs-on: ubuntu-latest steps: diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index c8510b557c..8da5547899 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -33,6 +33,7 @@ permissions: env: GOTOOLCHAIN: local + GOTESTSUM_FORMAT: pkgname-and-test-fails GOFLAGS: "-ldflags=-extldflags=-fuse-ld=lld -tags=nobadger,nomysql,nopgx" PHP_DOWNLOAD_BASE: "https://downloads.php.net/~windows/releases/" CC: clang @@ -228,9 +229,10 @@ jobs: "opcache.enable=0`r`nopcache.enable_cli=0" | Out-File php.ini $env:PHPRC = Get-Location - go test -race ./... + go install gotest.tools/gotestsum@latest + gotestsum -- -race ./... if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cd caddy - go test -race ./... + gotestsum -- -race ./... if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } working-directory: ${{ github.workspace }}\frankenphp diff --git a/.github/workflows/wrap-issue-details.yaml b/.github/workflows/wrap-issue-details.yaml index fe240552af..ddf0d6ab94 100644 --- a/.github/workflows/wrap-issue-details.yaml +++ b/.github/workflows/wrap-issue-details.yaml @@ -12,7 +12,7 @@ jobs: permissions: issues: write steps: - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 with: script: | const body = context.payload.issue.body; diff --git a/.gitignore b/.gitignore index 34f7a0fbcf..ecf194a98a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /caddy/frankenphp/Build +/caddy/frankenphp/Caddyfile.test /caddy/frankenphp/frankenphp /caddy/frankenphp/frankenphp.exe +/caddy/frankenphp/public /dist /github_conf /internal/testserver/testserver diff --git a/build-static.sh b/build-static.sh index 429c8376ef..fb4cb2f16d 100755 --- a/build-static.sh +++ b/build-static.sh @@ -158,7 +158,6 @@ fi if [ -z "${PHP_EXTENSIONS}" ]; then # enable EMBED mode, first check if project has dumped extensions if [ -n "${EMBED}" ] && [ -f "${EMBED}/composer.json" ] && [ -f "${EMBED}/composer.lock" ] && [ -f "${EMBED}/vendor/composer/installed.json" ]; then - cd "${EMBED}" # read the extensions using spc dump-extensions PHP_EXTENSIONS=$(${spcCommand} dump-extensions "${EMBED}" --format=text --no-dev --no-ext-output="${defaultExtensions}") else diff --git a/caddy/app.go b/caddy/app.go index 9242d870c6..fbe72eb620 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -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"` + // EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited) + MaxRequests int `json:"max_requests,omitempty"` opts []frankenphp.Option metrics frankenphp.Metrics @@ -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 { @@ -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 @@ -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() @@ -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()) } } } diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 597ed9ab57..652980a21d 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -487,6 +487,195 @@ func TestPHPServerDirectiveDisableFileServer(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/not-found.txt", http.StatusOK, "I am by birth a Genevese (i not set)") } +func TestPHPServerGlobals(t *testing.T) { + documentRoot, _ := filepath.Abs("../testdata") + scriptFilename := filepath.Join(documentRoot, "server-globals.php") + + tester := caddytest.NewTester(t) + initServer(t, tester, ` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + https_port 9443 + } + + localhost:`+testPort+` { + root ../testdata + php_server { + index server-globals.php + } + } + `, "caddyfile") + + // Request to /en: no matching file, falls through to server-globals.php worker + // SCRIPT_NAME should be /server-globals.php, PHP_SELF should be /server-globals.php (no /en), PATH_INFO empty + tester.AssertGetResponse( + "http://localhost:"+testPort+"/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php +PATH_INFO: +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /en +`, scriptFilename, documentRoot), + ) + + // Request to /server-globals.php/en: explicit PHP file with path info + // SCRIPT_NAME should be /server-globals.php, PHP_SELF should be /server-globals.php/en, PATH_INFO should be /en + tester.AssertGetResponse( + "http://localhost:"+testPort+"/server-globals.php/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php/en +PATH_INFO: /en +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /server-globals.php/en +`, scriptFilename, documentRoot), + ) +} + +func TestWorkerPHPServerGlobals(t *testing.T) { + documentRoot, _ := filepath.Abs("../testdata") + documentRoot2, _ := filepath.Abs("../caddy") + scriptFilename := documentRoot + string(filepath.Separator) + "server-globals.php" + testPortNum, _ := strconv.Atoi(testPort) + testPortTwo := strconv.Itoa(testPortNum + 1) + testPortThree := strconv.Itoa(testPortNum + 2) + + tester := caddytest.NewTester(t) + initServer(t, tester, ` + { + skip_install_trust + admin localhost:2999 + + frankenphp { + worker { + file ../testdata/server-globals.php + num 1 + } + } + } + + http://localhost:`+testPort+` { + php_server { + root ../testdata + index server-globals.php + } + } + + http://localhost:`+testPortTwo+` { + php_server { + root ../testdata + index server-globals.php + worker { + file server-globals.php + num 1 + } + } + } + + http://localhost:`+testPortThree+` { + php_server { + root ./ + index server-globals.php + worker { + file ../testdata/server-globals.php + num 1 + match * + } + } + } + `, "caddyfile") + + // === Site 1: global worker with php_server === + // because we don't specify a php file, PATH_INFO should be empty + tester.AssertGetResponse( + "http://localhost:"+testPort+"/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php +PATH_INFO: +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /en +`, scriptFilename, documentRoot), + ) + + tester.AssertGetResponse( + "http://localhost:"+testPort+"/server-globals.php/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php/en +PATH_INFO: /en +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /server-globals.php/en +`, scriptFilename, documentRoot), + ) + + // === Site 2: php_server with its own worker === + // because the request does not specify a php file, PATH_INFO should be empty + tester.AssertGetResponse( + "http://localhost:"+testPortTwo+"/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php +PATH_INFO: +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /en +`, scriptFilename, documentRoot), + ) + + tester.AssertGetResponse( + "http://localhost:"+testPortTwo+"/server-globals.php/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: /server-globals.php +SCRIPT_FILENAME: %s +PHP_SELF: /server-globals.php/en +PATH_INFO: /en +DOCUMENT_ROOT: %s +DOCUMENT_URI: /server-globals.php +REQUEST_URI: /server-globals.php/en +`, scriptFilename, documentRoot), + ) + + // === Site 3: php_server with its own match worker === + tester.AssertGetResponse( + "http://localhost:"+testPortThree+"/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: +SCRIPT_FILENAME: %s +PHP_SELF: +PATH_INFO: +DOCUMENT_ROOT: %s +DOCUMENT_URI: +REQUEST_URI: /en +`, scriptFilename, documentRoot2), + ) + + tester.AssertGetResponse( + "http://localhost:"+testPortThree+"/server-globals.php/en", + http.StatusOK, + fmt.Sprintf(`SCRIPT_NAME: +SCRIPT_FILENAME: %s +PHP_SELF: +PATH_INFO: +DOCUMENT_ROOT: %s +DOCUMENT_URI: +REQUEST_URI: /server-globals.php/en +`, scriptFilename, documentRoot2), + ) +} + func TestMetrics(t *testing.T) { var wg sync.WaitGroup tester := caddytest.NewTester(t) diff --git a/caddy/go.mod b/caddy/go.mod index f6d6fc6704..fca6d8b164 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -10,9 +10,9 @@ require ( github.com/caddyserver/caddy/v2 v2.11.2 github.com/caddyserver/certmagic v0.25.2 github.com/dunglas/caddy-cbrotli v1.0.1 - github.com/dunglas/frankenphp v1.12.1 - github.com/dunglas/mercure v0.21.11 - github.com/dunglas/mercure/caddy v0.21.11 + github.com/dunglas/frankenphp v1.12.2 + github.com/dunglas/mercure v0.22.1 + github.com/dunglas/mercure/caddy v0.22.1 github.com/dunglas/vulcain/caddy v1.4.0 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 @@ -23,7 +23,7 @@ require github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca require ( cel.dev/expr v0.25.1 // indirect - cloud.google.com/go/auth v0.19.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect @@ -31,7 +31,7 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/DeRuina/timberjack v1.4.0 // indirect + github.com/DeRuina/timberjack v1.4.1 // indirect github.com/KimMachineGun/automemlimit v0.7.5 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -52,7 +52,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/badger v1.6.2 // indirect @@ -67,11 +67,11 @@ require ( github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/getkin/kin-openapi v0.134.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect - github.com/go-jose/go-jose/v3 v3.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect @@ -84,14 +84,14 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/brotli/go/cbrotli v1.1.0 // indirect - github.com/google/cel-go v0.27.0 // indirect + github.com/google/cel-go v0.28.0 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.20.0 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect @@ -109,7 +109,7 @@ require ( github.com/mailru/easyjson v0.9.2 // indirect github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect github.com/maypok86/otter/v2 v2.3.0 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect @@ -147,7 +147,7 @@ require ( github.com/smallstep/linkedca v0.25.0 // indirect github.com/smallstep/nosql v0.8.0 // indirect github.com/smallstep/pkcs7 v0.2.1 // indirect - github.com/smallstep/scep v0.0.0-20260311011040-6d82bb27e647 // indirect + github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1 // indirect github.com/smallstep/truststore v0.13.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -170,34 +170,34 @@ require ( github.com/zeebo/blake3 v0.2.4 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect - go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/contrib/propagators/autoprop v0.67.0 // indirect - go.opentelemetry.io/contrib/propagators/aws v1.42.0 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.42.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.42.0 // indirect - go.opentelemetry.io/contrib/propagators/ot v1.42.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect - go.opentelemetry.io/otel/log v0.18.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.42.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.43.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.43.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.43.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.43.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.step.sm/crypto v0.77.1 // indirect + go.step.sm/crypto v0.77.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -211,15 +211,15 @@ require ( golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/api v0.273.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/api v0.275.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/caddy/go.sum b/caddy/go.sum index 2e376a49af..2324684a36 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ= -cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -28,8 +28,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/DeRuina/timberjack v1.4.0 h1:Ipw9KjS/6K6A9D1xdhWebYJFqdQez5gXwfzmeKOroqE= -github.com/DeRuina/timberjack v1.4.0/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= +github.com/DeRuina/timberjack v1.4.1 h1:JftM5HN/ITKehAXjtdbGqN5XZIS1biHm7VSjU0Qbtqg= +github.com/DeRuina/timberjack v1.4.1/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -122,8 +122,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= -github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -152,10 +152,10 @@ github.com/dunglas/caddy-cbrotli v1.0.1 h1:mkg7EB1GmoyfBt3kY3mq4o/0bfnBeq7ZLQjmV github.com/dunglas/caddy-cbrotli v1.0.1/go.mod h1:uXABy3tjy1FABF+3JWKVh1ajFvIO/kfpwHaeZGSBaAY= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/dunglas/mercure v0.21.11 h1:4Sd/Q77j8uh9SI5D9ZMg5sePlWs336+9CKxDQC1FV34= -github.com/dunglas/mercure v0.21.11/go.mod h1:WPMgfqonUiO1qB+W8Tya63Ngag9ZwplGMXSOy8P/uMg= -github.com/dunglas/mercure/caddy v0.21.11 h1:WnasC7EiqBPAB0CpBEPrm7vLiuL7o3BOVmfGDghnyVM= -github.com/dunglas/mercure/caddy v0.21.11/go.mod h1:MlGm4jbpBV+9nizn03PDejTEM916z3WDP9zO/Yw8OYQ= +github.com/dunglas/mercure v0.22.1 h1:Ug64C8OP7qnorwE4k7S4o/TZHtYun53nNp6HEkHZTH8= +github.com/dunglas/mercure v0.22.1/go.mod h1:ZVXN2Tm/YM26g+mhX5aFhNEN9FjwfHVYpq5DNZvKSfg= +github.com/dunglas/mercure/caddy v0.22.1 h1:BC3UAqhHC4jasO688Cg6hZraWYJGV8Fgc1L5wJT8464= +github.com/dunglas/mercure/caddy v0.22.1/go.mod h1:yfOcqpuXHBXljrda3XAxqVoZg/gwaSKmcjVEjDEWdlk= github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4= github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= github.com/dunglas/vulcain v1.4.0 h1:uGMTLKmw53yJNKBwCtD3GOmnmGw4SfsIqYfb3NEKvbA= @@ -176,16 +176,16 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= -github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -219,8 +219,8 @@ github.com/google/brotli/go/cbrotli v1.1.0 h1:YwHD/rwSgUSL4b2S3ZM2jnNymm+tmwKQqj github.com/google/brotli/go/cbrotli v1.1.0/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= @@ -230,8 +230,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws= -github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ= +github.com/google/go-tpm-tools v0.4.8 h1:V4oIYyAD3BykOycwYQzO29WefDouQMTsYZqmG3HxOfM= +github.com/google/go-tpm-tools v0.4.8/go.mod h1:4DfiOtiS1KppJjwf1+tqtW4K3PrCJjAAqFKj/TYTJKg= github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -240,8 +240,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs= -github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -295,8 +295,8 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= @@ -391,8 +391,8 @@ github.com/smallstep/nosql v0.8.0 h1:FBTCUfKPmWYbrozW+RBKu+fnvbn+zr5rVli/XB4Jp4A github.com/smallstep/nosql v0.8.0/go.mod h1:5dUpNotHLHhOUapP0PLBVVfp3tG1DFC31VRccg+Cqwo= github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= -github.com/smallstep/scep v0.0.0-20260311011040-6d82bb27e647 h1:yGbgItMEIe7pA1zkC6JJb2LZpnC0dZJPDu7LMdAsOkw= -github.com/smallstep/scep v0.0.0-20260311011040-6d82bb27e647/go.mod h1:QQhwLqCS13nhv8L5ov7NgusowENUtXdEzdytjmJHdZQ= +github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1 h1:lpXBkQKj1rT1oGX/2idvt8xbrOrnoQxH/+CjoeMxs9E= +github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1/go.mod h1:QQhwLqCS13nhv8L5ov7NgusowENUtXdEzdytjmJHdZQ= github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -476,66 +476,66 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= -go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= -go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= -go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/contrib/propagators/autoprop v0.67.0 h1:XhcQRf4MeqwQw96FcnatDAj6gwE19SUrWZ1VwNg77iE= -go.opentelemetry.io/contrib/propagators/autoprop v0.67.0/go.mod h1:7OK06SuNIBIlc5Uq3JGQEsKHuXw29t9OJemvDYyP1dk= -go.opentelemetry.io/contrib/propagators/aws v1.42.0 h1:Kbr3xDxs6kcxp5ThXTKWK2OtwLhNoXBVtqguNYcsZL0= -go.opentelemetry.io/contrib/propagators/aws v1.42.0/go.mod h1:Jzw9hZHtxdpCN7x8S17UH59X/EiFivp6VXLs9bdM1OQ= -go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU= -go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc= -go.opentelemetry.io/contrib/propagators/jaeger v1.42.0 h1:jP8unWI6q5kcb3gpGLjKDGaUa+JW+nHKWvpS/q+YuWA= -go.opentelemetry.io/contrib/propagators/jaeger v1.42.0/go.mod h1:xd89e/pUyPatUP1C4z1UknD9jHptESO99tWyvd4mWD4= -go.opentelemetry.io/contrib/propagators/ot v1.42.0 h1:uQjD1NNqX1+DfcAoWParPt1egNg9vC9gH4xarJ9Khxo= -go.opentelemetry.io/contrib/propagators/ot v1.42.0/go.mod h1:yw/c2TCmQLIv109HBOCn6NlJ8Dp7MNfjMcqQZRnAMmg= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= -go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= -go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw= -go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk= -go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA= -go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w= +go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o= +go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk= +go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/contrib/propagators/autoprop v0.68.0 h1:wLGFvNBPqQhzBn0QRBZjrriH8lZ9gqtTz8ufHEjLg7k= +go.opentelemetry.io/contrib/propagators/autoprop v0.68.0/go.mod h1:evWK9nCqCzH8nhclTlpkdUzmxrmJQ2mrWCdKIvyOYec= +go.opentelemetry.io/contrib/propagators/aws v1.43.0 h1:EwnsB3cXRLAh7/Nr/9rMuGw73nfb3z6uAvVDjRrbeUg= +go.opentelemetry.io/contrib/propagators/aws v1.43.0/go.mod h1:CJjTym6F87tEdm61Qvnz5xrV8vKlH4C92djiqcn62k8= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw= +go.opentelemetry.io/contrib/propagators/jaeger v1.43.0 h1:peiLMz1+aqJE+3L4mOVtR9wlmv+yh/JVYXCBjqmzJJE= +go.opentelemetry.io/contrib/propagators/jaeger v1.43.0/go.mod h1:Agvif+4A8p/3UtZzJ0MCcDEuQwgtrzM71DueU41DCs8= +go.opentelemetry.io/contrib/propagators/ot v1.43.0 h1:Hh1HahlGc81AOE7siqi1tVOlbanY/UxMMWedpb0d5oQ= +go.opentelemetry.io/contrib/propagators/ot v1.43.0/go.mod h1:58MlyS7lghzYvAm5LN9gGmZpCMQEMB5vpZp9SRgOyE4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.step.sm/crypto v0.77.1 h1:4EEqfKdv0egQ1lqz2RhnU8Jv6QgXZfrgoxWMqJF9aDs= -go.step.sm/crypto v0.77.1/go.mod h1:U/SsmEm80mNnfD5WIkbhuW/B1eFp3fgFvdXyDLpU1AQ= +go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= +go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -603,14 +603,13 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -644,18 +643,18 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= +google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/caddy/module.go b/caddy/module.go index 2241e216e2..fe14818105 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -117,11 +117,11 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { f.SplitPath = []string{".php"} } - if opt, err := frankenphp.WithRequestSplitPath(f.SplitPath); err == nil { - f.requestOptions = append(f.requestOptions, opt) - } else { - f.requestOptions = append(f.requestOptions, opt) + opt, err := frankenphp.WithRequestSplitPath(f.SplitPath) + if err != nil { + return fmt.Errorf("invalid split_path: %w", err) } + f.requestOptions = append(f.requestOptions, opt) if f.ResolveRootSymlink == nil { f.ResolveRootSymlink = new(true) @@ -559,7 +559,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) }), } rewriteHandler := rewrite.Rewrite{ - URI: "{http.matchers.file.relative}", + URI: "{http.matchers.file.relative}{http.matchers.file.remainder}", } rewriteRoute := caddyhttp.Route{ MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet}, @@ -573,7 +573,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) // match only requests that are for PHP files var pathList []string for _, ext := range extensions { - pathList = append(pathList, "*"+ext) + pathList = append(pathList, "*"+ext, "*"+ext+"/*") } phpMatcherSet := caddy.ModuleMap{ "path": h.JSON(pathList), diff --git a/caddy/php-server.go b/caddy/php-server.go index bb7cdd3521..c102f8f2ce 100644 --- a/caddy/php-server.go +++ b/caddy/php-server.go @@ -171,7 +171,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) { }, nil), } rewriteHandler := rewrite.Rewrite{ - URI: "{http.matchers.file.relative}", + URI: "{http.matchers.file.relative}{http.matchers.file.remainder}", } rewriteRoute := caddyhttp.Route{ MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet}, @@ -182,7 +182,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) { // match only requests that are for PHP files var pathList []string for _, ext := range extensions { - pathList = append(pathList, "*"+ext) + pathList = append(pathList, "*"+ext, "*"+ext+"/*") } phpMatcherSet := caddy.ModuleMap{ "path": caddyconfig.JSON(pathList, nil), diff --git a/cgi.go b/cgi.go index e0b1857317..d4d6a32d37 100644 --- a/cgi.go +++ b/cgi.go @@ -111,7 +111,7 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { requestURI = fc.requestURI } - requestPath := ensureLeadingSlash(request.URL.Path) + phpSelf := fc.scriptName + fc.pathInfo C.frankenphp_register_server_vars(trackVarsArray, C.frankenphp_server_vars{ // approximate total length to avoid array re-hashing: @@ -129,8 +129,8 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { document_root_len: C.size_t(len(fc.documentRoot)), path_info: toUnsafeChar(fc.pathInfo), path_info_len: C.size_t(len(fc.pathInfo)), - php_self: toUnsafeChar(requestPath), - php_self_len: C.size_t(len(requestPath)), + php_self: toUnsafeChar(phpSelf), + php_self_len: C.size_t(len(phpSelf)), document_uri: toUnsafeChar(fc.docURI), document_uri_len: C.size_t(len(fc.docURI)), script_filename: toUnsafeChar(fc.scriptFilename), @@ -208,17 +208,26 @@ func splitCgiPath(fc *frankenPHPContext) { if splitPos := splitPos(path, splitPath); splitPos > -1 { fc.docURI = path[:splitPos] fc.pathInfo = path[splitPos:] + } - // Strip PATH_INFO from SCRIPT_NAME - fc.scriptName = strings.TrimSuffix(path, fc.pathInfo) - - // Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875 - // Info: https://tools.ietf.org/html/rfc3875#section-4.1.13 - if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") { - fc.scriptName = "/" + fc.scriptName + // If a worker is already assigned explicitly, derive SCRIPT_NAME from its filename + if fc.worker != nil { + fc.scriptFilename = fc.worker.fileName + docRootWithSep := fc.documentRoot + string(filepath.Separator) + if strings.HasPrefix(fc.worker.fileName, docRootWithSep) { + fc.scriptName = filepath.ToSlash(strings.TrimPrefix(fc.worker.fileName, fc.documentRoot)) + } else { + fc.docURI = "" + fc.pathInfo = "" } + return } + // Strip PATH_INFO from SCRIPT_NAME + // Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875 + // Info: https://tools.ietf.org/html/rfc3875#section-4.1.13 + fc.scriptName = ensureLeadingSlash(strings.TrimSuffix(path, fc.pathInfo)) + // TODO: is it possible to delay this and avoid saving everything in the context? // SCRIPT_FILENAME is the absolute path of SCRIPT_NAME fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) diff --git a/context.go b/context.go index 92f3b7471c..add8eff7bd 100644 --- a/context.go +++ b/context.go @@ -86,14 +86,7 @@ func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Reques } } - // If a worker is already assigned explicitly, use its filename and skip parsing path variables - if fc.worker != nil { - fc.scriptFilename = fc.worker.fileName - } else { - // If no worker was assigned, split the path into the "traditional" CGI path variables. - // This needs to already happen here in case a worker script still matches the path. - splitCgiPath(fc) - } + splitCgiPath(fc) fc.requestURI = r.URL.RequestURI() diff --git a/docs/config.md b/docs/config.md index bfcdeb1230..e16d1fa185 100644 --- a/docs/config.md +++ b/docs/config.md @@ -97,6 +97,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c max_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 # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. max_idle_time # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s. + max_requests # (experimental) 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). php_ini # Set a php.ini directive. Can be used several times to set multiple directives. worker { file # Sets the path to the worker script. @@ -265,6 +266,25 @@ and otherwise forward the request to the worker matching the path pattern. } ``` +## Restarting Threads After a Number of Requests (Experimental) + +FrankenPHP can automatically restart PHP threads after they have handled a given number of requests. +When a thread reaches the limit, it is fully restarted, +cleaning up all memory and state. Other threads continue to serve requests during the restart. + +If you notice memory growing over time, the ideal fix is to report the leak +to the responsible extension or library maintainer. +But when the fix depends on a third party you don't control, +`max_requests` provides a pragmatic and hopefully temporary workaround for production: + +```caddyfile +{ + frankenphp { + max_requests 500 + } +} +``` + ## Environment Variables The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it: diff --git a/docs/extensions.md b/docs/extensions.md index 2fe78035cb..ba07c397fd 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1,6 +1,6 @@ # Writing PHP Extensions in Go -With FrankenPHP, you can **write PHP extensions in Go**, which allows you to create **high-performance native functions** that can be called directly from PHP. Your applications can leverage any existing or new Go library, as well as the infamous concurrency model of **goroutines right from your PHP code**. +With FrankenPHP, you can **write PHP extensions in Go**, which allows you to create **high-performance native functions** that can be called directly from PHP. Your applications can leverage any existing or new Go library, as well as the famous concurrency model of **goroutines right from your PHP code**. Writing PHP extensions is typically done in C, but it's also possible to write them in other languages with a bit of extra work. PHP extensions allow you to leverage the power of low-level languages to extend PHP's functionalities, for example, by adding native functions or optimizing specific operations. diff --git a/frankenphp.c b/frankenphp.c index ed2c0af112..a398a08cb9 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -609,6 +609,12 @@ PHP_FUNCTION(frankenphp_handle_request) { } } +#ifndef PHP_WIN32 + if (UNEXPECTED(is_forked_child)) { + _exit(EG(exit_status)); + } +#endif + frankenphp_worker_request_shutdown(); go_frankenphp_finish_worker_request(thread_index, callback_ret); if (result.r1 != NULL) { @@ -707,6 +713,9 @@ PHP_FUNCTION(frankenphp_log) { PHP_MINIT_FUNCTION(frankenphp) { register_frankenphp_symbols(module_number); +#ifndef PHP_WIN32 + pthread_atfork(NULL, NULL, frankenphp_fork_child); +#endif zend_function *func; @@ -1096,6 +1105,11 @@ static void *php_thread(void *arg) { /* Execute the PHP script, potential bailout to zend_catch */ php_execute_script(&file_handle); +#ifndef PHP_WIN32 + if (UNEXPECTED(is_forked_child)) { + _exit(EG(exit_status)); + } +#endif zend_destroy_file_handle(&file_handle); reset_sandboxed_environment(); @@ -1112,6 +1126,12 @@ static void *php_thread(void *arg) { } } zend_catch { +#ifndef PHP_WIN32 + if (UNEXPECTED(is_forked_child)) { + _exit(EG(exit_status)); + } +#endif + /* Critical failure from php_execute_script or php_request_shutdown, mark * the thread as unhealthy */ thread_is_healthy = false; diff --git a/frankenphp.go b/frankenphp.go index 46059bdd6d..1267014ac6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -70,7 +70,8 @@ var ( metrics Metrics = nullMetrics{} - maxWaitTime time.Duration + maxWaitTime time.Duration + maxRequestsPerThread int ) type ErrRejected struct { @@ -279,6 +280,7 @@ func Init(options ...Option) error { } maxWaitTime = opt.maxWaitTime + maxRequestsPerThread = opt.maxRequests if opt.maxIdleTime > 0 { maxIdleTime = opt.maxIdleTime @@ -339,7 +341,7 @@ func Init(options ...Option) error { initAutoScaling(mainThread) if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads)) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread)) if EmbeddedAppPath != "" { globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "embedded PHP app 📦", slog.String("path", EmbeddedAppPath)) @@ -893,5 +895,6 @@ func resetGlobals() { workersByPath = nil watcherIsEnabled = false maxIdleTime = defaultMaxIdleTime + maxRequestsPerThread = 0 globalMu.Unlock() } diff --git a/frankenphp_test.go b/frankenphp_test.go index 47e65c490b..f5355784cf 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -23,8 +23,8 @@ import ( "os" "os/exec" "os/user" - "runtime" "path/filepath" + "runtime" "strconv" "strings" "sync" diff --git a/go.mod b/go.mod index 5b283ba779..aa86676471 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ retract v1.0.0-rc.1 // Human error require ( github.com/Masterminds/sprig/v3 v3.3.0 - github.com/dunglas/mercure v0.21.11 + github.com/dunglas/mercure v0.22.1 github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be github.com/maypok86/otter/v2 v2.3.0 github.com/prometheus/client_golang v1.23.2 @@ -61,7 +61,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index eb41331951..71c245824f 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dunglas/mercure v0.21.11 h1:4Sd/Q77j8uh9SI5D9ZMg5sePlWs336+9CKxDQC1FV34= -github.com/dunglas/mercure v0.21.11/go.mod h1:WPMgfqonUiO1qB+W8Tya63Ngag9ZwplGMXSOy8P/uMg= +github.com/dunglas/mercure v0.22.1 h1:Ug64C8OP7qnorwE4k7S4o/TZHtYun53nNp6HEkHZTH8= +github.com/dunglas/mercure v0.22.1/go.mod h1:ZVXN2Tm/YM26g+mhX5aFhNEN9FjwfHVYpq5DNZvKSfg= github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4= github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= @@ -114,8 +114,8 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/state/state.go b/internal/state/state.go index c29b53b024..28dd6a30f7 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -32,6 +32,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 { @@ -64,6 +69,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" } diff --git a/maxrequests_regular_test.go b/maxrequests_regular_test.go new file mode 100644 index 0000000000..47a39a02d8 --- /dev/null +++ b/maxrequests_regular_test.go @@ -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), + }, + }) +} diff --git a/options.go b/options.go index 9ba1f916f6..a9cd2a2630 100644 --- a/options.go +++ b/options.go @@ -31,6 +31,7 @@ type opt struct { phpIni map[string]string maxWaitTime time.Duration maxIdleTime time.Duration + maxRequests int } type workerOpt struct { @@ -166,6 +167,15 @@ func WithMaxIdleTime(maxIdleTime time.Duration) Option { } } +// 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 { diff --git a/phpthread.go b/phpthread.go index c2660c550e..a941de9348 100644 --- a/phpthread.go +++ b/phpthread.go @@ -65,6 +65,24 @@ func (thread *phpThread) boot() { thread.state.WaitFor(state.Inactive) } +// reboot exits the C thread loop for full ZTS cleanup, then spawns a fresh C thread. +// Returns false if the thread is no longer in Ready state (e.g. shutting down). +func (thread *phpThread) reboot() bool { + if !thread.state.CompareAndSwap(state.Ready, state.Rebooting) { + return false + } + + go func() { + thread.state.WaitFor(state.RebootReady) + + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + panic("unable to create thread") + } + }() + + return true +} + // shutdown the underlying PHP thread func (thread *phpThread) shutdown() { if !thread.state.RequestSafeStateChange(state.ShuttingDown) { @@ -189,5 +207,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) + } } diff --git a/testdata/server-globals.php b/testdata/server-globals.php new file mode 100644 index 0000000000..26cc408461 --- /dev/null +++ b/testdata/server-globals.php @@ -0,0 +1,28 @@ + 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 @@ -97,6 +118,7 @@ func (handler *regularThread) waitForRequest() string { case ch = <-handler.thread.requestChan: } + handler.requestCount++ handler.thread.contextMu.Lock() handler.ctx = ch.ctx handler.contextHolder.frankenPHPContext = ch.frankenPHPContext diff --git a/threadworker.go b/threadworker.go index ea26d4897e..821ee37f0e 100644 --- a/threadworker.go +++ b/threadworker.go @@ -26,6 +26,7 @@ type workerThread struct { workerContext context.Context isBootingScript bool // true if the worker has not reached frankenphp_handle_request yet failureCount int // number of consecutive startup failures + requestCount int // number of requests handled since last restart } func convertToWorkerThread(thread *phpThread, worker *worker) { @@ -65,6 +66,12 @@ func (handler *workerThread) beforeScriptExecution() string { setupWorkerScript(handler, handler.worker) return handler.worker.fileName + case state.Rebooting: + return "" + case state.RebootReady: + handler.requestCount = 0 + handler.state.Set(state.Ready) + return handler.beforeScriptExecution() case state.ShuttingDown: if handler.worker.onThreadShutdown != nil { handler.worker.onThreadShutdown(handler.thread.threadIndex) @@ -119,6 +126,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { handler.dummyFrankenPHPContext = fc handler.dummyContext = ctx handler.isBootingScript = true + handler.requestCount = 0 if globalLogger.Enabled(ctx, slog.LevelDebug) { globalLogger.LogAttrs(ctx, slog.LevelDebug, "starting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) @@ -216,6 +224,21 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { metrics.ReadyWorker(handler.worker.name) } + // max_requests reached: signal reboot for full ZTS cleanup + if maxRequestsPerThread > 0 && handler.requestCount >= maxRequestsPerThread { + if globalLogger.Enabled(globalCtx, slog.LevelDebug) { + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "max requests reached, restarting", + slog.String("worker", handler.worker.name), + slog.Int("thread", handler.thread.threadIndex), + slog.Int("max_requests", maxRequestsPerThread), + ) + } + + if handler.thread.reboot() { + return false, nil + } + } + if handler.state.Is(state.TransitionComplete) { handler.state.Set(state.Ready) } @@ -234,6 +257,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { case requestCH = <-handler.worker.requestChan: } + handler.requestCount++ handler.thread.contextMu.Lock() handler.workerContext = requestCH.ctx handler.workerFrankenPHPContext = requestCH.frankenPHPContext diff --git a/worker_test.go b/worker_test.go index 97cc80d2e4..3fd2d63f94 100644 --- a/worker_test.go +++ b/worker_test.go @@ -5,11 +5,13 @@ import ( "fmt" "io" "log" + "log/slog" "net/http" "net/http/httptest" "net/url" "strconv" "strings" + "sync" "testing" "github.com/dunglas/frankenphp" @@ -169,3 +171,94 @@ func TestKeepRunningOnConnectionAbort(t *testing.T) { assert.Equal(t, "requests:2", body2, "should not have stopped execution after the first request was aborted") }, &testOptions{workerScript: "worker-with-counter.php", nbWorkers: 1, nbParallelRequests: 1}) } + +// TestWorkerMaxRequests verifies that a worker restarts after reaching max_requests. +func TestWorkerMaxRequests(t *testing.T) { + const maxRequests = 5 + const totalRequests = 20 + + 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) { + instanceIDs := make(map[string]int) + + for i := 0; i < totalRequests; i++ { + body, resp := testGet("http://example.com/worker-counter-persistent.php", handler, t) + assert.Equal(t, 200, resp.StatusCode) + + parts := strings.Split(body, ",") + if len(parts) == 2 { + instanceID := strings.TrimPrefix(parts[0], "instance:") + instanceIDs[instanceID]++ + } + } + + t.Logf("Unique worker instances seen: %d (expected >= %d)", len(instanceIDs), totalRequests/maxRequests) + for id, count := range instanceIDs { + t.Logf(" instance %s: handled %d requests", id, count) + } + + assert.GreaterOrEqual(t, len(instanceIDs), totalRequests/maxRequests) + + for id, count := range instanceIDs { + assert.LessOrEqual(t, count, maxRequests, + fmt.Sprintf("instance %s handled %d requests, exceeding max_requests=%d", id, count, maxRequests)) + } + + restartCount := strings.Count(buf.String(), "max requests reached, restarting") + t.Logf("Worker restarts observed: %d", restartCount) + assert.GreaterOrEqual(t, restartCount, 2) + }, &testOptions{ + workerScript: "worker-counter-persistent.php", + nbWorkers: 1, + nbParallelRequests: 1, + logger: logger, + initOpts: []frankenphp.Option{frankenphp.WithNumThreads(2), frankenphp.WithMaxRequests(maxRequests)}, + }) +} + +// TestWorkerMaxRequestsHighConcurrency verifies max_requests works under concurrent load. +func TestWorkerMaxRequestsHighConcurrency(t *testing.T) { + const maxRequests = 10 + const totalRequests = 200 + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + var ( + mu sync.Mutex + instanceIDs = make(map[string]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/worker-counter-persistent.php", handler, t) + assert.Equal(t, 200, resp.StatusCode) + + mu.Lock() + parts := strings.Split(body, ",") + if len(parts) == 2 { + instanceID := strings.TrimPrefix(parts[0], "instance:") + instanceIDs[instanceID]++ + } + mu.Unlock() + }() + } + wg.Wait() + + t.Logf("instances: %d", len(instanceIDs)) + assert.Greater(t, len(instanceIDs), 4, "workers should have restarted multiple times") + + for id, count := range instanceIDs { + assert.LessOrEqual(t, count, maxRequests, + fmt.Sprintf("instance %s handled %d requests, exceeding max_requests=%d", id, count, maxRequests)) + } + }, &testOptions{ + workerScript: "worker-counter-persistent.php", + nbWorkers: 4, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithNumThreads(5), frankenphp.WithMaxRequests(maxRequests)}, + }) +} From 1c38ab8828d45ccf91d2f6e19e5d0d0b95fe24ee Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 22:28:00 +0200 Subject: [PATCH 24/32] formatiing --- caddy/caddy_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 652980a21d..fa294ea882 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1768,7 +1768,6 @@ func TestOpcacheReset(t *testing.T) { numRequests := 500 // increase if test is flaky to get consistent failures wg.Add(numRequests) for i := 0; i < numRequests; i++ { - // introduce some random delay if rand.IntN(10) > 8 { time.Sleep(time.Millisecond * 10) From 81688bf054d4c94d492dd57e1e1071fec3aa385d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 22:34:28 +0200 Subject: [PATCH 25/32] merge draining with timeouts. --- caddy/caddy_test.go | 2 +- frankenphp.c | 5 ++-- frankenphp.go | 26 +++++++++++++++++++-- worker.go | 56 --------------------------------------------- 4 files changed, 28 insertions(+), 61 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index fa294ea882..3e26229ec6 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1776,7 +1776,7 @@ func TestOpcacheReset(t *testing.T) { go func() { defer wg.Done() // randomly call opcache_reset - if i % 10 > 7 { + if i%10 > 7 { tester.AssertGetResponse( "http://localhost:"+testPort+"/opcache_reset.php", http.StatusOK, diff --git a/frankenphp.c b/frankenphp.c index daa5db6e08..f2210fc70d 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1453,13 +1453,14 @@ static void *php_main(void *arg) { should_filter_var = default_filter != NULL; original_user_abort_setting = PG(ignore_user_abort); - /* Override opcache_reset, needs to be here again if loaded as a shared extension (php 8.4 and under) */ + /* Override opcache_reset, needs to be here again if loaded as a shared + * extension (php 8.4 and under) */ zend_function *func = zend_hash_str_find_ptr( CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && ((zend_internal_function *)func)->handler != ZEND_FN(frankenphp_opcache_reset)) { - orig_opcache_reset = ((zend_internal_function *)func)->handler; + orig_opcache_reset = ((zend_internal_function *)func)->handler; ((zend_internal_function *)func)->handler = ZEND_FN(frankenphp_opcache_reset); } diff --git a/frankenphp.go b/frankenphp.go index 1267014ac6..5fefc0106f 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -763,7 +763,7 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { //export go_schedule_opcache_reset func go_schedule_opcache_reset(threadIndex C.uintptr_t) { if threadsAreRestarting.CompareAndSwap(false, true) { - go func(){ + go func() { restartThreadsAndOpcacheReset(true) threadsAreRestarting.Store(false) }() @@ -852,7 +852,29 @@ func drainThreads(withRegularThreads bool) []*phpThread { regularThreadMu.RUnlock() } - ready.Wait() + // wait for all threads, force kill any thread still stuck in a blocking syscall + done := make(chan struct{}) + go func() { + ready.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(drainGracePeriod): + // Force-kill any thread still stuck in a blocking syscall, then + // keep waiting unconditionally. On platforms where force-kill + // cannot interrupt the syscall (macOS, Windows non-alertable + // Sleep) the thread exits when the syscall completes naturally. + for _, thread := range drainedThreads { + if !thread.state.Is(state.Yielding) { + thread.forceKillMu.RLock() + C.frankenphp_force_kill_thread(thread.forceKill) + thread.forceKillMu.RUnlock() + } + } + <-done + } return drainedThreads } diff --git a/worker.go b/worker.go index 627d33884f..125f767055 100644 --- a/worker.go +++ b/worker.go @@ -172,62 +172,6 @@ var drainGracePeriod = 30 * time.Second // Blocks until every drained thread yields. Force-kill is armed after a // grace period to wake threads parked in blocking syscalls (sleep, I/O). func DrainWorkers() { - _ = drainWorkerThreads() -} - -func drainWorkerThreads() (drainedThreads []*phpThread) { - var ready sync.WaitGroup - - for _, worker := range workers { - worker.threadMutex.RLock() - ready.Add(len(worker.threads)) - - for _, thread := range worker.threads { - if !thread.state.RequestSafeStateChange(state.Restarting) { - ready.Done() - - // no state change allowed == thread is shutting down - // we'll proceed to restart all other threads anyway - continue - } - - thread.handler.drain() - close(thread.drainChan) - drainedThreads = append(drainedThreads, thread) - - go func(thread *phpThread) { - thread.state.WaitFor(state.Yielding, state.ShuttingDown, state.Done) - ready.Done() - }(thread) - } - - worker.threadMutex.RUnlock() - } - - done := make(chan struct{}) - go func() { - ready.Wait() - close(done) - }() - - select { - case <-done: - case <-time.After(drainGracePeriod): - // Force-kill any thread still stuck in a blocking syscall, then - // keep waiting unconditionally. On platforms where force-kill - // cannot interrupt the syscall (macOS, Windows non-alertable - // Sleep) the thread exits when the syscall completes naturally. - for _, thread := range drainedThreads { - if !thread.state.Is(state.Yielding) { - thread.forceKillMu.RLock() - C.frankenphp_force_kill_thread(thread.forceKill) - thread.forceKillMu.RUnlock() - } - } - <-done - } - - return drainedThreads _ = drainThreads(false) } From 1d5d954acad86235b7ca423ec4c0af35f8324647 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 22:55:18 +0200 Subject: [PATCH 26/32] Fix for 8.2 --- frankenphp.c | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index f2210fc70d..a6ea9c5090 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -92,8 +92,13 @@ HashTable *main_thread_env = NULL; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread HashTable *sandboxed_env = NULL; + zif_handler orig_opcache_reset; +#if PHP_VERSION_ID < 80300 +pthread_mutex_t opcache_reset_mutex_php_82; +#endif + #ifndef PHP_WIN32 static bool is_forked_child = false; static void frankenphp_fork_child(void) { is_forked_child = true; } @@ -227,6 +232,22 @@ static void frankenphp_update_request_context() { /* let PHP handle basic auth */ php_handle_auth_data(authorization_header); + +/* On PHP 8.2 and under opcache_reset needs to be reset on every request. TODO: + * remove this once we drop support for PHP 8.2 */ +#if PHP_VERSION_ID < 80300 + pthread_mutex_lock(&opcache_reset_mutex_php_82); + zend_function *func = zend_hash_str_find_ptr( + CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && + ((zend_internal_function *)func)->handler != + ZEND_FN(frankenphp_opcache_reset)) { + orig_opcache_reset = ((zend_internal_function *)func)->handler; + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } + pthread_mutex_unlock(&opcache_reset_mutex_php_82); +#endif } static void frankenphp_free_request_context() { @@ -1453,8 +1474,7 @@ static void *php_main(void *arg) { should_filter_var = default_filter != NULL; original_user_abort_setting = PG(ignore_user_abort); - /* Override opcache_reset, needs to be here again if loaded as a shared - * extension (php 8.4 and under) */ + /* Override opcache_reset for a thread-safe reset */ zend_function *func = zend_hash_str_find_ptr( CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && @@ -1465,6 +1485,10 @@ static void *php_main(void *arg) { ZEND_FN(frankenphp_opcache_reset); } +#if PHP_VERSION_ID < 80300 + pthread_mutex_init(&opcache_reset_mutex_php_82, NULL); +#endif + go_frankenphp_main_thread_is_ready(); /* channel closed, shutdown gracefully. drainPHPThreads has already From 8f28271ce0265406d809f2f24844b49d94883679 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 23:01:02 +0200 Subject: [PATCH 27/32] test --- frankenphp.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index a6ea9c5090..14c23301c8 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -97,6 +97,7 @@ zif_handler orig_opcache_reset; #if PHP_VERSION_ID < 80300 pthread_mutex_t opcache_reset_mutex_php_82; +PHP_FUNCTION(frankenphp_opcache_reset); #endif #ifndef PHP_WIN32 @@ -236,17 +237,17 @@ static void frankenphp_update_request_context() { /* On PHP 8.2 and under opcache_reset needs to be reset on every request. TODO: * remove this once we drop support for PHP 8.2 */ #if PHP_VERSION_ID < 80300 - pthread_mutex_lock(&opcache_reset_mutex_php_82); zend_function *func = zend_hash_str_find_ptr( CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && ((zend_internal_function *)func)->handler != ZEND_FN(frankenphp_opcache_reset)) { + pthread_mutex_lock(&opcache_reset_mutex_php_82); orig_opcache_reset = ((zend_internal_function *)func)->handler; ((zend_internal_function *)func)->handler = ZEND_FN(frankenphp_opcache_reset); + pthread_mutex_unlock(&opcache_reset_mutex_php_82); } - pthread_mutex_unlock(&opcache_reset_mutex_php_82); #endif } From 8dd20fe17cb06b690f5bc3727fc232a07bcf4f15 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 May 2026 23:01:07 +0200 Subject: [PATCH 28/32] test --- frankenphp.c | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 14c23301c8..754adb0df2 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -239,9 +239,7 @@ static void frankenphp_update_request_context() { #if PHP_VERSION_ID < 80300 zend_function *func = zend_hash_str_find_ptr( CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); - if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && - ((zend_internal_function *)func)->handler != - ZEND_FN(frankenphp_opcache_reset)) { + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION) { pthread_mutex_lock(&opcache_reset_mutex_php_82); orig_opcache_reset = ((zend_internal_function *)func)->handler; ((zend_internal_function *)func)->handler = From 2f9e5ceecc1bc112061eb9240dd5d42bdc128252 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 19 May 2026 18:39:27 +0700 Subject: [PATCH 29/32] address random testing reproducibility comment from copilot --- caddy/caddy_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 3e26229ec6..f9baff4c18 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -3,7 +3,6 @@ package caddy_test import ( "bytes" "fmt" - "math/rand/v2" "net/http" "os" "path/filepath" @@ -1767,9 +1766,9 @@ func TestOpcacheReset(t *testing.T) { wg := sync.WaitGroup{} numRequests := 500 // increase if test is flaky to get consistent failures wg.Add(numRequests) + // pseudo random sleep simulator for i := 0; i < numRequests; i++ { - // introduce some random delay - if rand.IntN(10) > 8 { + if i%10 == 0 { time.Sleep(time.Millisecond * 10) } @@ -1785,7 +1784,7 @@ func TestOpcacheReset(t *testing.T) { return } - // otherwise call sleep.php with random sleep and work values + // otherwise call sleep.php with sleep and work values sleep := i % 100 work := i % 100 tester.AssertGetResponse( From fac95212a081c09e340ae0898ad50bcb126531ab Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 19 May 2026 18:42:01 +0700 Subject: [PATCH 30/32] address other copilot comment --- frankenphp.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 5fefc0106f..446f35e8f4 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -764,15 +764,19 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool { func go_schedule_opcache_reset(threadIndex C.uintptr_t) { if threadsAreRestarting.CompareAndSwap(false, true) { go func() { + defer threadsAreRestarting.Store(false) restartThreadsAndOpcacheReset(true) - threadsAreRestarting.Store(false) }() } } -// opcacheResetOnce ensures only one thread calls the actual opcache_reset. -// Multiple threads calling it concurrently can race on shared memory. -var opcacheResetOnce sync.Once +// opcacheResetOnce ensures only one thread per restart generation calls +// the actual opcache_reset; concurrent calls into opcache can corrupt SHM +var opcacheResetOnce atomic.Pointer[sync.Once] + +func init() { + opcacheResetOnce.Store(&sync.Once{}) +} // restart all threads for an opcache_reset func restartThreadsAndOpcacheReset(withRegularThreads bool) { @@ -782,7 +786,7 @@ func restartThreadsAndOpcacheReset(withRegularThreads bool) { threadsToRestart := drainThreads(withRegularThreads) - opcacheResetOnce = sync.Once{} + opcacheResetOnce.Store(&sync.Once{}) opcacheResetWg := sync.WaitGroup{} for _, thread := range threadsToRestart { thread.state.Set(state.OpcacheResetting) @@ -880,7 +884,7 @@ func drainThreads(withRegularThreads bool) []*phpThread { } func scheduleOpcacheReset(thread *phpThread) { - opcacheResetOnce.Do(func() { + opcacheResetOnce.Load().Do(func() { C.frankenphp_reset_opcache() }) } From 19d8010b1924406579a9369a01c583f50a9c6183 Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 19 May 2026 18:49:44 +0700 Subject: [PATCH 31/32] don't overwrite orig_opcache_reset pointer with our own replacement --- frankenphp.c | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 754adb0df2..8b9aa86faf 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -239,11 +239,16 @@ static void frankenphp_update_request_context() { #if PHP_VERSION_ID < 80300 zend_function *func = zend_hash_str_find_ptr( CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1); - if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION) { + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && + ((zend_internal_function *)func)->handler != + ZEND_FN(frankenphp_opcache_reset)) { pthread_mutex_lock(&opcache_reset_mutex_php_82); - orig_opcache_reset = ((zend_internal_function *)func)->handler; - ((zend_internal_function *)func)->handler = - ZEND_FN(frankenphp_opcache_reset); + if (((zend_internal_function *)func)->handler != + ZEND_FN(frankenphp_opcache_reset)) { + orig_opcache_reset = ((zend_internal_function *)func)->handler; + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } pthread_mutex_unlock(&opcache_reset_mutex_php_82); } #endif @@ -1685,6 +1690,9 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, } int frankenphp_reset_opcache(void) { + if (orig_opcache_reset == NULL) { + return 0; // perhaps raise a warning here and fall through to calling the original? + } zend_execute_data execute_data; zval retval; memset(&execute_data, 0, sizeof(execute_data)); From 08c9d6072484e6f399e41c6547edc3393224017c Mon Sep 17 00:00:00 2001 From: henderkes Date: Tue, 19 May 2026 18:56:27 +0700 Subject: [PATCH 32/32] damn clang format --- frankenphp.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index 8b9aa86faf..cd611e9003 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1691,7 +1691,8 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, int frankenphp_reset_opcache(void) { if (orig_opcache_reset == NULL) { - return 0; // perhaps raise a warning here and fall through to calling the original? + return 0; // perhaps raise a warning here and + // fall through to calling the original? } zend_execute_data execute_data; zval retval;