From e94e3bcb97c3803a00ae00fd1408e556ffa4177c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 14 Dec 2025 23:14:19 +0100 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 97376a7f93f0013bc5574ca0bf20c62cfd11ea42 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 22 Apr 2026 18:12:36 +0200 Subject: [PATCH 22/34] restarts completely --- frankenphp.c | 10 ----- frankenphp.go | 107 ++--------------------------------------------- frankenphp.h | 1 - phpmainthread.go | 59 +++++++++++++++++++++++--- phpthread.go | 8 +++- threadregular.go | 10 +---- threadworker.go | 10 ----- worker.go | 7 +--- 8 files changed, 66 insertions(+), 146 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index bc16d481e5..9b491f535b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1503,16 +1503,6 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, return (intptr_t)exit_status; } -int frankenphp_reset_opcache(void) { - 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; -} - int frankenphp_get_current_memory_limit() { return PG(memory_limit); } void frankenphp_init_thread_metrics(int max_threads) { diff --git a/frankenphp.go b/frankenphp.go index b83df017c7..6c43bf4727 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -32,14 +32,11 @@ 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{} @@ -59,9 +56,8 @@ var ( contextKey = contextKeyStruct{} serverHeader = []string{"FrankenPHP"} - isRunning bool - threadsAreRestarting atomic.Bool - onServerShutdown []func() + isRunning bool + onServerShutdown []func() // Set default values to make Shutdown() idempotent globalMu sync.Mutex @@ -762,104 +758,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 restartThreadsAndOpcacheReset(true) - } -} - -// 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 - scalingMu.Lock() - defer scalingMu.Unlock() - - 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) - }) - } - opcacheResetWg.Wait() - - for _, thread := range threadsToRestart { - thread.drainChan = make(chan struct{}) - thread.state.Set(state.Ready) - } - - threadsAreRestarting.Store(false) -} - -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() - } - - ready.Wait() - - return drainedThreads -} - -func scheduleOpcacheReset(thread *phpThread) { - opcacheResetOnce.Do(func() { - C.frankenphp_reset_opcache() - }) + go mainThread.rebootAllThreads() } func convertArgs(args []string) (C.int, []*C.char) { diff --git a/frankenphp.h b/frankenphp.h index 0ea8c80f41..db07218691 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -182,7 +182,6 @@ void frankenphp_register_server_vars(zval *track_vars_array, frankenphp_server_vars vars); zend_string *frankenphp_init_persistent_string(const char *string, size_t len); -int frankenphp_reset_opcache(void); int frankenphp_get_current_memory_limit(); typedef struct { diff --git a/phpmainthread.go b/phpmainthread.go index 7f9b8fb947..fa55dab466 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -9,6 +9,7 @@ import ( "log/slog" "strings" "sync" + "sync/atomic" "github.com/dunglas/frankenphp/internal/memory" "github.com/dunglas/frankenphp/internal/phpheaders" @@ -18,11 +19,12 @@ import ( // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *state.ThreadState - done chan struct{} - numThreads int - maxThreads int - phpIni map[string]string + state *state.ThreadState + done chan struct{} + numThreads int + maxThreads int + phpIni map[string]string + isRebooting atomic.Bool } var ( @@ -121,6 +123,48 @@ func (mainThread *phpMainThread) start() error { return nil } +// 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 (mainThread *phpMainThread) rebootAllThreads() bool { + if !mainThread.isRebooting.CompareAndSwap(false, true) { + return false + } + + scalingMu.Lock() + defer scalingMu.Unlock() + globalLogger.Info("rebooting all threads") + + wg := sync.WaitGroup{} + rebootingThreads := []*phpThread{} + for _, thread := range phpThreads { + if thread.reboot() { + rebootingThreads = append(rebootingThreads, thread) + wg.Go(func() { + close(thread.drainChan) + thread.state.WaitFor(state.YieldingForReboot) + }) + } + } + wg.Wait() + + mainThread.state.Set(state.Rebooting) + mainThread.state.WaitFor(state.YieldingForReboot) + if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { + panic("unable to recreate main thread after reboot") + } + mainThread.state.WaitFor(state.Ready) + + for _, thread := range rebootingThreads { + thread.drainChan = make(chan struct{}) + thread.state.Set(state.RebootReady) + thread.state.WaitFor(state.Ready, state.ShuttingDown) + } + + mainThread.isRebooting.Store(false) + + return true +} + func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { if thread.state.Is(state.Inactive) { @@ -146,7 +190,7 @@ func go_frankenphp_main_thread_is_ready() { } mainThread.state.Set(state.Ready) - mainThread.state.WaitFor(state.Done) + mainThread.state.WaitFor(state.Done, state.Rebooting) } // max_threads = auto @@ -172,6 +216,9 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { + if mainThread.state.CompareAndSwap(state.Rebooting, state.YieldingForReboot) { + return + } mainThread.state.Set(state.Reserved) } diff --git a/phpthread.go b/phpthread.go index a941de9348..a3c572386d 100644 --- a/phpthread.go +++ b/phpthread.go @@ -208,7 +208,13 @@ func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] thread.Unpin() if thread.state.Is(state.Rebooting) { - thread.state.Set(state.RebootReady) + if mainThread.isRebooting.Load() { + // if all threads are rebooting, yield + thread.state.Set(state.YieldingForReboot) + } else { + // if only this thread is rebooting, set to ready + thread.state.Set(state.RebootReady) + } } else { thread.state.Set(state.Done) } diff --git a/threadregular.go b/threadregular.go index 258a208c6e..d2ccd40408 100644 --- a/threadregular.go +++ b/threadregular.go @@ -50,16 +50,10 @@ func (handler *regularThread) beforeScriptExecution() string { handler.state.Set(state.Ready) 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() case state.Ready: return handler.waitForRequest() - + case state.Rebooting: + return "" case state.RebootReady: handler.requestCount = 0 handler.state.Set(state.Ready) diff --git a/threadworker.go b/threadworker.go index 821ee37f0e..e8fb7a802b 100644 --- a/threadworker.go +++ b/threadworker.go @@ -47,16 +47,6 @@ func (handler *workerThread) beforeScriptExecution() string { } handler.worker.detachThread(handler.thread) return handler.thread.transitionToNewHandler() - case state.Restarting: - if handler.worker.onThreadShutdown != nil { - 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: handler.thread.updateContext(true) if handler.worker.onThreadReady != nil { diff --git a/worker.go b/worker.go index ef624c3724..c507acccc9 100644 --- a/worker.go +++ b/worker.go @@ -165,15 +165,10 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -// EXPERIMENTAL: DrainWorkers finishes all worker scripts before a graceful shutdown -func DrainWorkers() { - _ = 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() { - restartThreadsAndOpcacheReset(true) + mainThread.rebootAllThreads() } func (worker *worker) attachThread(thread *phpThread) { From 672997bf718f7534e8caccb5ed65c8cbb4caeb9c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 22 Apr 2026 18:14:54 +0200 Subject: [PATCH 23/34] removes unnecessary states. --- internal/state/state.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 28dd6a30f7..4a77dfc457 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -22,12 +22,6 @@ const ( Inactive Ready - // States necessary for restarting workers - Restarting - Yielding - OpcacheResetting - OpcacheResettingDone - // States necessary for transitioning between different handlers TransitionRequested TransitionInProgress @@ -37,6 +31,8 @@ const ( Rebooting // C thread has exited and ZTS state is cleaned up, ready to spawn a new C thread RebootReady + // all threads are yielding for main thread reboot + YieldingForReboot ) func (s State) String() string { @@ -55,14 +51,6 @@ func (s State) String() string { return "inactive" case Ready: return "ready" - case Restarting: - return "restarting" - case Yielding: - return "yielding" - case OpcacheResetting: - return "opcache resetting" - case OpcacheResettingDone: - return "opcache reset done" case TransitionRequested: return "transition requested" case TransitionInProgress: @@ -73,6 +61,8 @@ func (s State) String() string { return "rebooting" case RebootReady: return "reboot ready" + case YieldingForReboot: + return "yielding for reboot" default: return "unknown" } From 28b52c2db8d596baec281427fd08158257bb80f6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 22 Apr 2026 18:19:59 +0200 Subject: [PATCH 24/34] removes unused code. --- frankenphp.c | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 9b491f535b..cc49b81be2 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -94,21 +94,15 @@ static void frankenphp_fork_child(void) { is_forked_child = true; } /* 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. */ + * instead of resetting opcache, reboot all threads */ 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); } } From 7e627bd223b47fefb4e900f70bdd27351de3899e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 22 Apr 2026 18:34:43 +0200 Subject: [PATCH 25/34] fixes internal function. --- frankenphp.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index cc49b81be2..8edebe4894 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -103,6 +103,8 @@ static void frankenphp_override_opcache_reset(void) { if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION && ((zend_internal_function *)func)->handler != ZEND_FN(frankenphp_opcache_reset)) { + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); } } From 7762da524ea6bae8fc510f238e5a36f44aad3f46 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 22 Apr 2026 18:36:58 +0200 Subject: [PATCH 26/34] updates comments. --- phpmainthread.go | 3 +-- worker.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index fa55dab466..bb4bb01cb0 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -123,8 +123,7 @@ func (mainThread *phpMainThread) start() error { return nil } -// 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). +// rebootAllThreads reboots all underlying C threads, but keeps the go side alive func (mainThread *phpMainThread) rebootAllThreads() bool { if !mainThread.isRebooting.CompareAndSwap(false, true) { return false diff --git a/worker.go b/worker.go index c507acccc9..debc285718 100644 --- a/worker.go +++ b/worker.go @@ -166,7 +166,7 @@ func newWorker(o workerOpt) (*worker, error) { } // RestartWorkers attempts to restart all workers gracefully -// All workers must be restarted at the same time to prevent issues with opcache resetting. +// All threads must be restarted at the same time to prevent issues with opcache resetting func RestartWorkers() { mainThread.rebootAllThreads() } From 3da984dd2e28f4e45b50319720518eaeb4a81fe5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 24 Apr 2026 14:42:27 +0200 Subject: [PATCH 27/34] requires a safe state on change. --- phpthread.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/phpthread.go b/phpthread.go index a3c572386d..dfeeb71062 100644 --- a/phpthread.go +++ b/phpthread.go @@ -68,7 +68,8 @@ func (thread *phpThread) boot() { // 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) { + if !thread.state.RequestSafeStateChange(state.Rebooting) { + // thread already shutting down or done return false } From 56418cd6b9525f98a8fc31e737f06242f03e480a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 28 Apr 2026 12:07:09 +0200 Subject: [PATCH 28/34] handles incative threads correctly. --- threadinactive.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/threadinactive.go b/threadinactive.go index b5d11fcdfc..b3fceb6b11 100644 --- a/threadinactive.go +++ b/threadinactive.go @@ -25,15 +25,20 @@ func (handler *inactiveThread) beforeScriptExecution() string { case state.TransitionRequested: return thread.transitionToNewHandler() - case state.Booting, state.TransitionComplete: + case state.Booting, state.TransitionComplete, state.Inactive: thread.state.Set(state.Inactive) // wait for external signal to start or shut down thread.state.MarkAsWaiting(true) - thread.state.WaitFor(state.TransitionRequested, state.ShuttingDown) + thread.state.WaitFor(state.TransitionRequested, state.ShuttingDown, state.Rebooting) thread.state.MarkAsWaiting(false) return handler.beforeScriptExecution() + case state.Rebooting: + return "" + case state.RebootReady: + thread.state.Set(state.Inactive) + return handler.beforeScriptExecution() case state.ShuttingDown: // signal to stop From 5f05e7523bc26857887f1338f2b54138bd714e7c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 31 May 2026 20:12:23 +0200 Subject: [PATCH 29/34] better race condition guards --- caddy/caddy_test.go | 2 +- frankenphp.go | 1 - phpmainthread.go | 40 ++++++++++++++++++++++++++++-- scaling.go | 10 -------- worker.go | 60 +-------------------------------------------- 5 files changed, 40 insertions(+), 73 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index cbf42d35fc..3ee09a1726 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1766,7 +1766,7 @@ func TestOpcacheReset(t *testing.T) { `, "caddyfile") wg := sync.WaitGroup{} - numRequests := 1000 + numRequests := 500 wg.Add(numRequests) for i := 0; i < numRequests; i++ { diff --git a/frankenphp.go b/frankenphp.go index 6c43bf4727..15a19021c4 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -370,7 +370,6 @@ func Shutdown() { } drainWatchers() - drainAutoScaling() drainPHPThreads() metrics.Shutdown() diff --git a/phpmainthread.go b/phpmainthread.go index 4f860cbf78..935b6c2b56 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/dunglas/frankenphp/internal/memory" "github.com/dunglas/frankenphp/internal/phpheaders" @@ -31,6 +32,8 @@ var ( phpThreads []*phpThread mainThread *phpMainThread commonHeaders map[string]*C.zend_string + // drainGracePeriod: time to wait for threads to yield before arming force-kill + drainGracePeriod = 30 * time.Second ) // initPHPThreads starts the main PHP thread, @@ -80,6 +83,10 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) } func drainPHPThreads() { + // disallow any scaling or restarting threads while draining + scalingMu.Lock() + defer scalingMu.Unlock() + if mainThread == nil { return // mainThread was never initialized } @@ -136,9 +143,16 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { return false } + // allow no scaling or shutdown while rebooting scalingMu.Lock() defer scalingMu.Unlock() - globalLogger.Info("rebooting all threads") + + if !mainThread.state.Is(state.Ready) { + // mainThread has shut down in the meantime, no need to reboot + return false + } + + globalLogger.Info("rebooting all PHP threads") wg := sync.WaitGroup{} rebootingThreads := []*phpThread{} @@ -151,7 +165,29 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { }) } } - wg.Wait() + + done := make(chan struct{}) + go func() { + wg.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 rebootingThreads { + if !thread.state.Is(state.YieldingForReboot) { + thread.forceKillMu.RLock() + C.frankenphp_force_kill_thread(thread.forceKill) + thread.forceKillMu.RUnlock() + } + } + <-done + } mainThread.state.Set(state.Rebooting) mainThread.state.WaitFor(state.YieldingForReboot) diff --git a/scaling.go b/scaling.go index c606925fdf..1489d6b570 100644 --- a/scaling.go +++ b/scaling.go @@ -53,16 +53,6 @@ func initAutoScaling(mainThread *phpMainThread) { go startDownScalingThreads(done) } -func drainAutoScaling() { - scalingMu.Lock() - - if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down autoscaling", slog.Int("autoScaledThreads", len(autoScaledThreads))) - } - - scalingMu.Unlock() -} - func addRegularThread() (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { diff --git a/worker.go b/worker.go index e80fba75e7..a4a37d3080 100644 --- a/worker.go +++ b/worker.go @@ -165,69 +165,11 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -// drainGracePeriod: time to wait for threads to yield before arming force-kill. -var drainGracePeriod = 30 * time.Second - // EXPERIMENTAL: DrainWorkers initiates a graceful drain of all worker scripts. // 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 + drainPHPThreads() } // RestartWorkers attempts to restart all workers gracefully. From 69b589a5fa038487a3204f27c1cd27ef858d7812 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 31 May 2026 23:04:03 +0200 Subject: [PATCH 30/34] Cleans up state transitions. --- internal/state/state.go | 39 ++++++++++++++---- phpmainthread.go | 88 +++++++++++++++++++++-------------------- phpthread.go | 38 +++++++++--------- scaling.go | 6 ++- worker_internal_test.go | 6 +-- 5 files changed, 103 insertions(+), 74 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 4a77dfc457..9a68aaa434 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -12,15 +12,15 @@ type State int const ( // lifecycle States of a thread - Reserved State = iota + Reserved State = iota // stable Booting BootRequested ShuttingDown Done // these States are 'stable' and safe to transition from at any time - Inactive - Ready + Inactive // stable + Ready // stable // States necessary for transitioning between different handlers TransitionRequested @@ -164,16 +164,39 @@ func (ts *ThreadState) WaitFor(states ...State) { <-sub.ch } +func (ts *ThreadState) WaitForStateWithTimeout(timeout time.Duration, states ...State) bool { + ts.mu.Lock() + if slices.Contains(states, ts.currentState) { + ts.mu.Unlock() + + return true + } + + sub := stateSubscriber{ + states: states, + ch: make(chan struct{}), + } + ts.subscribers = append(ts.subscribers, sub) + ts.mu.Unlock() + select { + case <-sub.ch: + return true + case <-time.After(timeout): + return false + } +} + // RequestSafeStateChange safely requests a state change from a different goroutine +// returns false if thread is already in reserved state (shut down) func (ts *ThreadState) RequestSafeStateChange(nextState State) bool { ts.mu.Lock() switch ts.currentState { - // disallow state changes if shutting down or done - case ShuttingDown, Done, Reserved: + // disallow state changes when already shutting down or done + case Reserved, ShuttingDown, Done: ts.mu.Unlock() return false - // ready and inactive are safe states to transition from + // ready and inactive are stable states to transition from case Ready, Inactive: ts.currentState = nextState ts.notifySubscribers(nextState) @@ -183,8 +206,8 @@ func (ts *ThreadState) RequestSafeStateChange(nextState State) bool { } ts.mu.Unlock() - // wait for the state to change to a safe state - ts.WaitFor(Ready, Inactive, ShuttingDown) + // wait for the state to change to a stable state + ts.WaitFor(Ready, Inactive, Reserved) return ts.RequestSafeStateChange(nextState) } diff --git a/phpmainthread.go b/phpmainthread.go index 935b6c2b56..f7182bb474 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -32,8 +32,10 @@ var ( phpThreads []*phpThread mainThread *phpMainThread commonHeaders map[string]*C.zend_string - // drainGracePeriod: time to wait for threads to yield before arming force-kill - drainGracePeriod = 30 * time.Second + + // timeouts to wait for threads to yield before arming force-kill + shutDownGracePeriod = 30 * time.Second + rebootGracePeriod = 6 * time.Second ) // initPHPThreads starts the main PHP thread, @@ -87,25 +89,15 @@ func drainPHPThreads() { scalingMu.Lock() defer scalingMu.Unlock() - if mainThread == nil { + if mainThread == nil || !mainThread.state.Is(state.Ready) { return // mainThread was never initialized } - // Idempotent: post-drain state is Reserved; a re-entry (e.g. a - // failed-Init cleanup) must not double-close mainThread.done. - if mainThread.state.Is(state.Reserved) { - return - } + doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) mainThread.state.Set(state.ShuttingDown) close(mainThread.done) for _, thread := range phpThreads { - // shut down all reserved threads - if thread.state.CompareAndSwap(state.Reserved, state.Done) { - doneWG.Done() - continue - } - // shut down all active threads go func(thread *phpThread) { thread.shutdown() doneWG.Done() @@ -152,43 +144,41 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { return false } - globalLogger.Info("rebooting all PHP threads") - - wg := sync.WaitGroup{} + rebootStart := time.Now() + rebootWg := sync.WaitGroup{} rebootingThreads := []*phpThread{} + + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "rebooting all PHP threads") + for _, thread := range phpThreads { if thread.reboot() { rebootingThreads = append(rebootingThreads, thread) - wg.Go(func() { - close(thread.drainChan) - thread.state.WaitFor(state.YieldingForReboot) - }) } } - done := make(chan struct{}) - go func() { - wg.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 rebootingThreads { - if !thread.state.Is(state.YieldingForReboot) { - thread.forceKillMu.RLock() - C.frankenphp_force_kill_thread(thread.forceKill) - thread.forceKillMu.RUnlock() + for _, thread := range rebootingThreads { + rebootWg.Go(func() { + close(thread.drainChan) + if thread.state.WaitForStateWithTimeout(rebootGracePeriod, state.YieldingForReboot) { + return } - } - <-done + + // thread has not shut down in time, force kill the thread on the PHP side + globalLogger.LogAttrs( + globalCtx, + slog.LevelWarn, + "force-killing thread on reboot timeout", + slog.String("name", thread.name()), + slog.String("state", thread.state.Name()), + slog.String("timeout", rebootGracePeriod.String()), + ) + thread.sendKillSignal() + thread.state.WaitFor(state.YieldingForReboot) + }) } + rebootWg.Wait() + mainThread.state.Set(state.Rebooting) mainThread.state.WaitFor(state.YieldingForReboot) if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { @@ -198,12 +188,24 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { for _, thread := range rebootingThreads { thread.drainChan = make(chan struct{}) - thread.state.Set(state.RebootReady) - thread.state.WaitFor(state.Ready, state.ShuttingDown) + if thread.state.CompareAndSwap(state.YieldingForReboot, state.RebootReady) { + // wait for any of the stable states + thread.state.WaitFor(state.Ready, state.Inactive, state.Reserved) + } else { + panic("unexpected state on reboot: " + thread.state.Name() + " for thread " + thread.name()) + } } mainThread.isRebooting.Store(false) + globalLogger.LogAttrs( + globalCtx, + slog.LevelInfo, + "all PHP threads rebooted", + slog.String("duration", time.Since(rebootStart).String()), + slog.Int("num_threads", len(rebootingThreads)), + ) + return true } diff --git a/phpthread.go b/phpthread.go index e5e9c3be47..b9dcb91f28 100644 --- a/phpthread.go +++ b/phpthread.go @@ -5,10 +5,10 @@ package frankenphp import "C" import ( "context" + "log/slog" "runtime" "sync" "sync/atomic" - "time" "unsafe" "github.com/dunglas/frankenphp/internal/state" @@ -100,9 +100,6 @@ func (thread *phpThread) reboot() bool { // shutdown the underlying PHP thread func (thread *phpThread) shutdown() { if !thread.state.RequestSafeStateChange(state.ShuttingDown) { - // already shutting down or done, wait for the C thread to finish - thread.state.WaitFor(state.Done, state.Reserved) - return } @@ -114,26 +111,23 @@ func (thread *phpThread) shutdown() { // syscall (macOS, Windows non-alertable Sleep) the thread will exit // when the syscall completes naturally; the operator's orchestrator // is responsible for any harder timeout. - done := make(chan struct{}) - go func() { + if !thread.state.WaitForStateWithTimeout(shutDownGracePeriod, state.Done) { + globalLogger.LogAttrs( + globalCtx, + slog.LevelWarn, + "force-killing thread on shutdown timeout", + slog.String("name", thread.name()), + slog.String("state", thread.state.Name()), + slog.String("timeout", shutDownGracePeriod.String()), + ) + thread.sendKillSignal() thread.state.WaitFor(state.Done) - close(done) - }() - select { - case <-done: - case <-time.After(drainGracePeriod): - thread.forceKillMu.RLock() - C.frankenphp_force_kill_thread(thread.forceKill) - thread.forceKillMu.RUnlock() - <-done } thread.drainChan = make(chan struct{}) // threads go back to the reserved state from which they can be booted again - if mainThread.state.Is(state.Ready) { - thread.state.Set(state.Reserved) - } + thread.state.Set(state.Reserved) } // setHandler changes the thread handler safely @@ -189,6 +183,14 @@ func (thread *phpThread) name() string { return thread.handler.name() } +// send a kill signal to PHP (ZTS compatible) +// make sure to only call this if PHP is actively handling a request +func (thread *phpThread) sendKillSignal() { + thread.forceKillMu.RLock() + C.frankenphp_force_kill_thread(thread.forceKill) + thread.forceKillMu.RUnlock() +} + // Pin a string that is not null-terminated // PHP's zend_string may contain null-bytes func (thread *phpThread) pinString(s string) *C.char { diff --git a/scaling.go b/scaling.go index 1489d6b570..5b2f8c77af 100644 --- a/scaling.go +++ b/scaling.go @@ -59,7 +59,8 @@ func addRegularThread() (*phpThread, error) { return nil, ErrMaxThreadsReached } convertToRegularThread(thread) - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Reserved) + thread.state.WaitFor(state.Ready, state.Inactive, state.Reserved) // stable states + return thread, nil } @@ -69,7 +70,8 @@ func addWorkerThread(worker *worker) (*phpThread, error) { return nil, ErrMaxThreadsReached } convertToWorkerThread(thread, worker) - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Reserved) + thread.state.WaitFor(state.Ready, state.Inactive, state.Reserved) // stable states + return thread, nil } diff --git a/worker_internal_test.go b/worker_internal_test.go index b47a86ad8e..c0e405b701 100644 --- a/worker_internal_test.go +++ b/worker_internal_test.go @@ -20,9 +20,9 @@ func TestRestartWorkersForceKillsStuckThread(t *testing.T) { t.Skipf("force-kill cannot interrupt blocking syscalls on %s", runtime.GOOS) } - prev := drainGracePeriod - drainGracePeriod = 500 * time.Millisecond - t.Cleanup(func() { drainGracePeriod = prev }) + prev := rebootGracePeriod + rebootGracePeriod = 500 * time.Millisecond + t.Cleanup(func() { rebootGracePeriod = prev }) cwd, _ := os.Getwd() testDataDir := cwd + "/testdata/" From a687780b802567299496a61726af0065eb0b141e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 31 May 2026 23:23:09 +0200 Subject: [PATCH 31/34] cleanup --- frankenphp.c | 43 ++++++++++++++++++++----------------------- phpmainthread.go | 7 ++++--- phpthread.go | 2 ++ 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 07f8c64b67..fe44e626fa 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -222,22 +222,6 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { #endif } -/* Forward declaration */ -PHP_FUNCTION(frankenphp_opcache_reset); - -/* Try to override opcache_reset if opcache is loaded. - * instead of resetting opcache, reboot all threads */ -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)) { - ((zend_internal_function *)func)->handler = - ZEND_FN(frankenphp_opcache_reset); - } -} - void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; @@ -615,13 +599,6 @@ PHP_FUNCTION(frankenphp_getenv) { } } /* }}} */ -/* {{{ thread-safe opcache reset */ -PHP_FUNCTION(frankenphp_opcache_reset) { - go_schedule_opcache_reset(thread_index); - - RETVAL_TRUE; -} /* }}} */ - /* {{{ Fetch all HTTP request headers */ PHP_FUNCTION(frankenphp_request_headers) { ZEND_PARSE_PARAMETERS_NONE(); @@ -867,6 +844,26 @@ PHP_FUNCTION(frankenphp_log) { } } +/* {{{ thread-safe opcache reset */ +PHP_FUNCTION(frankenphp_opcache_reset) { + go_schedule_opcache_reset(thread_index); + + RETVAL_TRUE; +} /* }}} */ + +/* Try to override opcache_reset if opcache is loaded. + * instead of resetting opcache, reboot all threads */ +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)) { + ((zend_internal_function *)func)->handler = + ZEND_FN(frankenphp_opcache_reset); + } +} + #ifdef FRANKENPHP_TEST /* Test-only entry point that exercises zval.h end-to-end: * validate -> persist (request -> persistent memory) -> diff --git a/phpmainthread.go b/phpmainthread.go index f7182bb474..9cb5540b42 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -132,9 +132,12 @@ func (mainThread *phpMainThread) start() error { // rebootAllThreads reboots all underlying C threads, but keeps the go side alive func (mainThread *phpMainThread) rebootAllThreads() bool { if !mainThread.isRebooting.CompareAndSwap(false, true) { + // if already rebooting, ignore the call return false } + defer mainThread.isRebooting.Store(false) + // allow no scaling or shutdown while rebooting scalingMu.Lock() defer scalingMu.Unlock() @@ -196,12 +199,10 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { } } - mainThread.isRebooting.Store(false) - globalLogger.LogAttrs( globalCtx, slog.LevelInfo, - "all PHP threads rebooted", + "thread reboot finished", slog.String("duration", time.Since(rebootStart).String()), slog.Int("num_threads", len(rebootingThreads)), ) diff --git a/phpthread.go b/phpthread.go index b9dcb91f28..f39e66d49e 100644 --- a/phpthread.go +++ b/phpthread.go @@ -100,6 +100,8 @@ func (thread *phpThread) reboot() bool { // shutdown the underlying PHP thread func (thread *phpThread) shutdown() { if !thread.state.RequestSafeStateChange(state.ShuttingDown) { + // thread is already shutting down, prefer the stable reserved state over done + _ = thread.state.CompareAndSwap(state.Done, state.Reserved) return } From 612e0fdfc7683bba59696c7214d1dab9859e0cf8 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 31 May 2026 23:30:04 +0200 Subject: [PATCH 32/34] removes randomness --- caddy/caddy_test.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 3ee09a1726..3137053e2a 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "math/rand/v2" "net/http" "os" "path/filepath" @@ -1770,15 +1769,15 @@ func TestOpcacheReset(t *testing.T) { wg.Add(numRequests) for i := 0; i < numRequests; i++ { - // introduce some random delay - if rand.IntN(10) > 8 { + // introduce a delay every 10 requests + if i%10 == 0 { time.Sleep(time.Millisecond * 10) } go func() { defer wg.Done() - // randomly call opcache_reset - if rand.IntN(10) > 7 { + // spam opcache_reset on intervals + if i%10 > 7 { tester.AssertGetResponse( "http://localhost:"+testPort+"/opcache_reset.php", http.StatusOK, @@ -1787,7 +1786,7 @@ func TestOpcacheReset(t *testing.T) { return } - // otherwise call sleep.php with random sleep and work values + // otherwise call sleep.php with different sleep and work values sleep := i % 100 work := i % 100 tester.AssertGetResponse( From 9a27467313d04d71eff82ae8d0bdfe3aebb241c2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 2 Jun 2026 22:43:41 +0200 Subject: [PATCH 33/34] also trigger on any opcache overflow. --- frankenphp.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index fe44e626fa..e1520db5c0 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -844,9 +844,14 @@ PHP_FUNCTION(frankenphp_log) { } } +static void frankenphp_opcache_restart_hook(int reason) { + (void)reason; + go_schedule_opcache_reset(frankenphp_thread_index()); +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { - go_schedule_opcache_reset(thread_index); + go_schedule_opcache_reset(frankenphp_thread_index()); RETVAL_TRUE; } /* }}} */ @@ -1506,6 +1511,13 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); +#if defined(ZTS) && PHP_VERSION_ID >= 80400 + /* Also restart everything on opcache memory overflow or similar events, the + * hook is triggered right before an opcache reset is scheduled + */ + zend_accel_schedule_restart_hook = frankenphp_opcache_restart_hook; +#endif + /* check if a default filter is set in php.ini and only filter if * it is, this is deprecated and will be removed in PHP 9 */ char *default_filter; From b9a46327744f0ecfff020a447f6a5b10ddf65b86 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 2 Jun 2026 22:47:30 +0200 Subject: [PATCH 34/34] fixes reason. --- frankenphp.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index e1520db5c0..353ece7b49 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -851,7 +851,7 @@ static void frankenphp_opcache_restart_hook(int reason) { /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { - go_schedule_opcache_reset(frankenphp_thread_index()); + frankenphp_opcache_restart_hook(0); RETVAL_TRUE; } /* }}} */