Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
eebec0b
fix tests timing out
henderkes Mar 25, 2026
30407d7
cap sleep at 100ms
henderkes Mar 25, 2026
fc77310
Merge remote-tracking branch 'origin/main' into fix/opcache-safe-reset
henderkes Mar 26, 2026
d5ed5da
Merge branch 'main' into fix/opcache-safe-reset
henderkes Mar 26, 2026
3e8d548
Merge branch 'main' into fix/opcache-safe-reset
AlliBalliBaba Apr 8, 2026
dc9a271
Merge branch 'main' into fix/opcache-safe-reset
AlliBalliBaba Apr 21, 2026
97376a7
restarts completely
AlliBalliBaba Apr 22, 2026
672997b
removes unnecessary states.
AlliBalliBaba Apr 22, 2026
28b52c2
removes unused code.
AlliBalliBaba Apr 22, 2026
7e627bd
fixes internal function.
AlliBalliBaba Apr 22, 2026
7762da5
updates comments.
AlliBalliBaba Apr 22, 2026
3da984d
requires a safe state on change.
AlliBalliBaba Apr 24, 2026
56418cd
handles incative threads correctly.
AlliBalliBaba Apr 28, 2026
154f02c
Merge branch 'main' into fix/reboot-threads-on-opcache-reset-and-watcher
AlliBalliBaba May 31, 2026
5f05e75
better race condition guards
AlliBalliBaba May 31, 2026
69b589a
Cleans up state transitions.
AlliBalliBaba May 31, 2026
a687780
cleanup
AlliBalliBaba May 31, 2026
612e0fd
removes randomness
AlliBalliBaba May 31, 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
69 changes: 69 additions & 0 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,75 @@ 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
admin localhost:2999
http_port `+testPort+`
metrics

frankenphp {
num_threads 40
php_ini {
opcache.enable 1
opcache.log_verbosity_level 4
max_execution_time 30s
}
}
}

localhost:`+testPort+` {
php {
root ../testdata
worker {
file sleep.php
match /sleep*
num 20
}
}
}
`, "caddyfile")

wg := sync.WaitGroup{}
numRequests := 500
wg.Add(numRequests)
for i := 0; i < numRequests; i++ {

// introduce a delay every 10 requests
if i%10 == 0 {
time.Sleep(time.Millisecond * 10)
}

go func() {
defer wg.Done()
// spam opcache_reset on intervals
if i%10 > 7 {
tester.AssertGetResponse(
"http://localhost:"+testPort+"/opcache_reset.php",
http.StatusOK,
"opcache reset done",
)
return
}

// otherwise call sleep.php with different sleep and work values
sleep := i % 100
work := i % 100
tester.AssertGetResponse(
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", sleep, work),
)
}()
}

wg.Wait()
}

func TestLog(t *testing.T) {
tester := caddytest.NewTester(t)
initServer(t, tester, `
Expand Down
51 changes: 40 additions & 11 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -844,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) ->
Expand Down Expand Up @@ -915,6 +935,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 @@ -933,7 +957,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 @@ -1288,6 +1321,12 @@ static void *php_thread(void *arg) {
zend_bailout();
}

#if PHP_VERSION_ID < 80500
/* Override opcache here again if loaded as a shared extension
* (php 8.4 and under) */
frankenphp_override_opcache_reset();
#endif

zend_file_handle file_handle;
zend_stream_init_filename(&file_handle, scriptName);

Expand Down Expand Up @@ -1670,16 +1709,6 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv,
return (intptr_t)exit_status;
}

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

return 0;
}

int frankenphp_get_current_memory_limit() { return PG(memory_limit); }

void frankenphp_init_thread_metrics(int max_threads) {
Expand Down
6 changes: 5 additions & 1 deletion frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,6 @@ func Shutdown() {
}

drainWatchers()
drainAutoScaling()
drainPHPThreads()

metrics.Shutdown()
Expand Down Expand Up @@ -756,6 +755,11 @@ 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) {
go mainThread.rebootAllThreads()
}

func convertArgs(args []string) (C.int, []*C.char) {
argc := C.int(len(args))
argv := make([]*C.char, argc)
Expand Down
1 change: 0 additions & 1 deletion frankenphp.h
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,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 {
Expand Down
51 changes: 35 additions & 16 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +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

// States necessary for restarting workers
Restarting
Yielding
Inactive // stable
Ready // stable

// States necessary for transitioning between different handlers
TransitionRequested
Expand All @@ -35,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 {
Expand All @@ -53,10 +51,6 @@ func (s State) String() string {
return "inactive"
case Ready:
return "ready"
case Restarting:
return "restarting"
case Yielding:
return "yielding"
case TransitionRequested:
return "transition requested"
case TransitionInProgress:
Expand All @@ -67,6 +61,8 @@ func (s State) String() string {
return "rebooting"
case RebootReady:
return "reboot ready"
case YieldingForReboot:
return "yielding for reboot"
default:
return "unknown"
}
Expand Down Expand Up @@ -168,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)
Expand All @@ -187,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)
}
Expand Down
Loading
Loading