Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e94e3bc
Tests.
AlliBalliBaba Dec 14, 2025
48bf41f
Merge branch 'main' into fix/opcache-safe-reset
AlliBalliBaba Dec 14, 2025
973d722
Wait 1s for deadlocks.
AlliBalliBaba Dec 28, 2025
2a9a9a8
Merge branch 'main' into fix/opcache-safe-reset
AlliBalliBaba Dec 28, 2025
d1e28d5
Adjusts waitgroup logic.
AlliBalliBaba Dec 28, 2025
8e87d00
Starts separate opcache_reset request flow once all threads are stopped.
AlliBalliBaba Dec 29, 2025
acf2a1c
Test with grace period (again)
AlliBalliBaba Dec 29, 2025
d8c185c
Force all threads to call opcache_reset().
AlliBalliBaba Jan 2, 2026
3e7cdd0
test
AlliBalliBaba Jan 3, 2026
1d75824
Merge remote-tracking branch 'origin/main' into fix/opcache-safe-reset
henderkes Mar 13, 2026
df82e81
fix clang format
henderkes Mar 13, 2026
0d87765
call into cgo for reset directly, no fake dummy
henderkes Mar 13, 2026
0564eaf
clang fmt
henderkes Mar 13, 2026
49fc878
override opcache reset handler for every php thread in php 8.2
henderkes Mar 14, 2026
22c6ba6
maybe after request startup?
henderkes Mar 14, 2026
66702fe
try not resetting opcache?
henderkes Mar 14, 2026
7cb94e6
don't overrride opcache_reset at all in php 8.2
henderkes Mar 14, 2026
e9533b8
dont even run the test
henderkes Mar 14, 2026
7c28f3d
don't wait for resetting in php 8.2
henderkes Mar 14, 2026
d88821a
original opcache reset in 8.2
henderkes Mar 14, 2026
45d49c1
make it run on 8.2 again
henderkes Mar 15, 2026
d189770
Update worker.go
henderkes Mar 16, 2026
0b2521d
Merge remote-tracking branch 'origin/main' into fix/opcache-safe-reset
henderkes Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package caddy_test
import (
"bytes"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -1541,6 +1542,72 @@ 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.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
metrics

frankenphp {
num_threads 40
php_ini {
opcache.enable 1
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()
}

func TestLog(t *testing.T) {
tester := caddytest.NewTester(t)
initServer(t, tester, `
Expand Down
59 changes: 52 additions & 7 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ __thread uintptr_t thread_index;
__thread bool is_worker_thread = false;
__thread HashTable *sandboxed_env = NULL;

/* Forward declaration */
PHP_FUNCTION(frankenphp_opcache_reset);
zif_handler orig_opcache_reset;

/* Try to override opcache_reset if opcache is loaded.
* Safe to call multiple times - skips if already overridden in this function
* table. Uses handler comparison instead of orig_opcache_reset check so that
* a fresh function table after PHP module restart is always re-overridden. */
static void frankenphp_override_opcache_reset(void) {
zend_function *func = zend_hash_str_find_ptr(
CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1);
if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION &&
((zend_internal_function *)func)->handler !=
ZEND_FN(frankenphp_opcache_reset)) {
orig_opcache_reset = ((zend_internal_function *)func)->handler;
((zend_internal_function *)func)->handler =
ZEND_FN(frankenphp_opcache_reset);
}
}

void frankenphp_update_local_thread_context(bool is_worker) {
is_worker_thread = is_worker;

Expand Down Expand Up @@ -457,6 +477,13 @@ 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();
Expand Down Expand Up @@ -715,6 +742,10 @@ PHP_MINIT_FUNCTION(frankenphp) {
php_error(E_WARNING, "Failed to find built-in getenv function");
}

// Override opcache_reset (may not be available yet if opcache loads as a
// shared extension in PHP 8.4 and below)
frankenphp_override_opcache_reset();

return SUCCESS;
}

Expand All @@ -733,7 +764,16 @@ 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 PHP_VERSION_ID < 80500
if (result == SUCCESS) {
/* Override opcache here again if loaded as a shared extension
* (php 8.4 and under) */
frankenphp_override_opcache_reset();
}
#endif

return result;
}

static int frankenphp_deactivate(void) { return SUCCESS; }
Expand Down Expand Up @@ -1195,6 +1235,11 @@ 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 < 80500
/* Override opcache here again if loaded as a shared extension
* (php 8.4 and under) */
frankenphp_override_opcache_reset();
#endif
return SUCCESS;
}

Expand Down Expand Up @@ -1394,12 +1439,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) {
zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL);
}

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;
}

Expand Down
110 changes: 108 additions & 2 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -56,8 +59,9 @@ var (
contextKey = contextKeyStruct{}
serverHeader = []string{"FrankenPHP"}

isRunning bool
onServerShutdown []func()
isRunning bool
threadsAreRestarting atomic.Bool
onServerShutdown []func()

// Set default values to make Shutdown() idempotent
globalMu sync.Mutex
Expand Down Expand Up @@ -754,6 +758,108 @@ 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) {
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()
})
}

func convertArgs(args []string) (C.int, []*C.char) {
argc := C.int(len(args))
argv := make([]*C.char, argc)
Expand Down
6 changes: 6 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const (
// States necessary for restarting workers
Restarting
Yielding
OpcacheResetting
OpcacheResettingDone

// States necessary for transitioning between different handlers
TransitionRequested
Expand Down Expand Up @@ -52,6 +54,10 @@ func (s State) String() string {
return "restarting"
case Yielding:
return "yielding"
case OpcacheResetting:
return "opcache resetting"
case OpcacheResettingDone:
return "opcache reset done"
case TransitionRequested:
return "transition requested"
case TransitionInProgress:
Expand Down
9 changes: 9 additions & 0 deletions testdata/opcache_reset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

require_once __DIR__.'/_executor.php';

return function () {
require __DIR__ .'/require.php';
opcache_reset();
echo "opcache reset done";
};
6 changes: 6 additions & 0 deletions testdata/require.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

// dummy require file for opcache_reset test
return function (){
echo "";
};
10 changes: 9 additions & 1 deletion threadregular.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package frankenphp

// #include "frankenphp.h"
import "C"
import (
"context"
"runtime"
Expand Down Expand Up @@ -46,7 +48,13 @@ 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()

Expand Down
Loading
Loading