Skip to content

Commit 39b9692

Browse files
fix: reboot threads instead of opcache_reset (#2364)
Instead of resetting the opcache (manually or on overflow), reboot all threads including the main thread while keeping requests queued. This avoids multiple races that exist in opcache and opcache ZTS. Does also affect resets when watching. --------- Co-authored-by: henderkes <m@pyc.ac>
1 parent 3bc35af commit 39b9692

17 files changed

Lines changed: 372 additions & 175 deletions

caddy/admin_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func TestRestartWorkerViaAdminApi(t *testing.T) {
4040
}
4141
`, "caddyfile")
4242

43+
// make sure workers are not still running from any previous tests
44+
assertAdminResponse(t, tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n")
45+
4346
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
4447
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2")
4548

caddy/caddy_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,6 +1794,75 @@ func TestDd(t *testing.T) {
17941794
)
17951795
}
17961796

1797+
// test to force the opcache segfault race condition under concurrency (~1.7s)
1798+
func TestOpcacheReset(t *testing.T) {
1799+
tester := caddytest.NewTester(t)
1800+
tester.Client.Timeout = 60 * time.Second
1801+
tester.InitServer(`
1802+
{
1803+
skip_install_trust
1804+
admin localhost:2999
1805+
http_port `+testPort+`
1806+
metrics
1807+
1808+
frankenphp {
1809+
num_threads 40
1810+
php_ini {
1811+
opcache.enable 1
1812+
opcache.log_verbosity_level 4
1813+
max_execution_time 30s
1814+
}
1815+
}
1816+
}
1817+
1818+
localhost:`+testPort+` {
1819+
php {
1820+
root ../testdata
1821+
worker {
1822+
file sleep.php
1823+
match /sleep*
1824+
num 20
1825+
}
1826+
}
1827+
}
1828+
`, "caddyfile")
1829+
1830+
wg := sync.WaitGroup{}
1831+
numRequests := 500
1832+
wg.Add(numRequests)
1833+
for i := 0; i < numRequests; i++ {
1834+
1835+
// introduce a delay every 10 requests
1836+
if i%10 == 0 {
1837+
time.Sleep(time.Millisecond * 10)
1838+
}
1839+
1840+
go func(i int) {
1841+
defer wg.Done()
1842+
// spam opcache_reset on intervals
1843+
if i%10 > 7 {
1844+
tester.AssertGetResponse(
1845+
"http://localhost:"+testPort+"/opcache_reset.php",
1846+
http.StatusOK,
1847+
"opcache reset done",
1848+
)
1849+
return
1850+
}
1851+
1852+
// otherwise call sleep.php with different sleep and work values
1853+
sleep := i % 100
1854+
work := i % 100
1855+
tester.AssertGetResponse(
1856+
fmt.Sprintf("http://localhost:%s/sleep.php?sleep=%d&work=%d", testPort, sleep, work),
1857+
http.StatusOK,
1858+
fmt.Sprintf("slept for %d ms and worked for %d iterations", sleep, work),
1859+
)
1860+
}(i)
1861+
}
1862+
1863+
wg.Wait()
1864+
}
1865+
17971866
func TestLog(t *testing.T) {
17981867
tester := caddytest.NewTester(t)
17991868
initServer(t, tester, `

caddy/hotreload_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,20 @@ func TestHotReload(t *testing.T) {
6262
req = req.WithContext(cx)
6363
resp := tester.AssertResponseCode(req, http.StatusOK)
6464

65-
connected.Done()
66-
6765
var receivedBody strings.Builder
6866

6967
buf := make([]byte, 1024)
68+
isConnected := false
7069
for {
7170
n, err := resp.Body.Read(buf)
7271
if n > 0 {
7372
receivedBody.Write(buf[:n])
7473
}
74+
if !isConnected {
75+
// wait for the first bytes before marking the client as connected
76+
isConnected = true
77+
connected.Done()
78+
}
7579
if strings.Contains(receivedBody.String(), "index.php") {
7680
cancel()
7781

frankenphp.c

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,31 @@ PHP_FUNCTION(frankenphp_log) {
934934
}
935935
}
936936

937+
static void frankenphp_opcache_restart_hook(int reason) {
938+
(void)reason;
939+
go_schedule_opcache_reset(frankenphp_thread_index());
940+
}
941+
942+
/* {{{ thread-safe opcache reset */
943+
PHP_FUNCTION(frankenphp_opcache_reset) {
944+
frankenphp_opcache_restart_hook(0);
945+
946+
RETVAL_TRUE;
947+
} /* }}} */
948+
949+
/* Try to override opcache_reset if opcache is loaded.
950+
* instead of resetting opcache, reboot all threads */
951+
static void frankenphp_override_opcache_reset(void) {
952+
zend_function *func = zend_hash_str_find_ptr(
953+
CG(function_table), "opcache_reset", sizeof("opcache_reset") - 1);
954+
if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION &&
955+
((zend_internal_function *)func)->handler !=
956+
ZEND_FN(frankenphp_opcache_reset)) {
957+
((zend_internal_function *)func)->handler =
958+
ZEND_FN(frankenphp_opcache_reset);
959+
}
960+
}
961+
937962
#ifdef FRANKENPHP_TEST
938963
/* Test-only entry point that exercises zval.h end-to-end:
939964
* validate -> persist (request -> persistent memory) ->
@@ -1005,6 +1030,10 @@ PHP_MINIT_FUNCTION(frankenphp) {
10051030
php_error(E_WARNING, "Failed to find built-in getenv function");
10061031
}
10071032

1033+
// Override opcache_reset (may not be available yet if opcache loads as a
1034+
// shared extension in PHP 8.4 and below)
1035+
frankenphp_override_opcache_reset();
1036+
10081037
return SUCCESS;
10091038
}
10101039

@@ -1023,7 +1052,16 @@ static zend_module_entry frankenphp_module = {
10231052
static int frankenphp_startup(sapi_module_struct *sapi_module) {
10241053
php_import_environment_variables = get_full_env;
10251054

1026-
return php_module_startup(sapi_module, &frankenphp_module);
1055+
int result = php_module_startup(sapi_module, &frankenphp_module);
1056+
#if PHP_VERSION_ID < 80500
1057+
if (result == SUCCESS) {
1058+
/* Override opcache here again if loaded as a shared extension
1059+
* (php 8.4 and under) */
1060+
frankenphp_override_opcache_reset();
1061+
}
1062+
#endif
1063+
1064+
return result;
10271065
}
10281066

10291067
static int frankenphp_deactivate(void) { return SUCCESS; }
@@ -1392,6 +1430,12 @@ static void *php_thread(void *arg) {
13921430
zend_bailout();
13931431
}
13941432

1433+
#if PHP_VERSION_ID < 80500
1434+
/* Override opcache here again if loaded as a shared extension
1435+
* (php 8.4 and under) */
1436+
frankenphp_override_opcache_reset();
1437+
#endif
1438+
13951439
zend_file_handle file_handle;
13961440
zend_stream_init_filename(&file_handle, scriptName);
13971441

@@ -1571,6 +1615,13 @@ static void *php_main(void *arg) {
15711615

15721616
frankenphp_sapi_module.startup(&frankenphp_sapi_module);
15731617

1618+
#if defined(ZTS) && PHP_VERSION_ID >= 80400
1619+
/* Also restart everything on opcache memory overflow or similar events, the
1620+
* hook is triggered right before an opcache reset is scheduled
1621+
*/
1622+
zend_accel_schedule_restart_hook = frankenphp_opcache_restart_hook;
1623+
#endif
1624+
15741625
/* check if a default filter is set in php.ini and only filter if
15751626
* it is, this is deprecated and will be removed in PHP 9 */
15761627
char *default_filter;
@@ -1784,16 +1835,6 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv,
17841835
return (intptr_t)exit_status;
17851836
}
17861837

1787-
int frankenphp_reset_opcache(void) {
1788-
zend_function *opcache_reset =
1789-
zend_hash_str_find_ptr(CG(function_table), ZEND_STRL("opcache_reset"));
1790-
if (opcache_reset) {
1791-
zend_call_known_function(opcache_reset, NULL, NULL, NULL, 0, NULL, NULL);
1792-
}
1793-
1794-
return 0;
1795-
}
1796-
17971838
int frankenphp_get_current_memory_limit() { return PG(memory_limit); }
17981839

17991840
void frankenphp_init_thread_metrics(int max_threads) {

frankenphp.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ func Shutdown() {
375375
}
376376

377377
drainWatchers()
378-
drainAutoScaling()
379378
drainPHPThreads()
380379

381380
metrics.Shutdown()
@@ -761,6 +760,13 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool {
761760
return C.bool(phpThreads[threadIndex].frankenPHPContext().isDone)
762761
}
763762

763+
//export go_schedule_opcache_reset
764+
func go_schedule_opcache_reset(threadIndex C.uintptr_t) {
765+
if mainThread != nil {
766+
go mainThread.rebootAllThreads()
767+
}
768+
}
769+
764770
func convertArgs(args []string) (C.int, []*C.char) {
765771
argc := C.int(len(args))
766772
argv := make([]*C.char, argc)

frankenphp.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,6 @@ void frankenphp_register_server_vars(zval *track_vars_array,
204204
frankenphp_server_vars vars);
205205

206206
zend_string *frankenphp_init_persistent_string(const char *string, size_t len);
207-
int frankenphp_reset_opcache(void);
208207
int frankenphp_get_current_memory_limit();
209208

210209
typedef struct {

internal/state/state.go

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,15 @@ type State int
1212

1313
const (
1414
// lifecycle States of a thread
15-
Reserved State = iota
15+
Reserved State = iota // stable
1616
Booting
1717
BootRequested
1818
ShuttingDown
1919
Done
2020

2121
// these States are 'stable' and safe to transition from at any time
22-
Inactive
23-
Ready
24-
25-
// States necessary for restarting workers
26-
Restarting
27-
Yielding
22+
Inactive // stable
23+
Ready // stable
2824

2925
// States necessary for transitioning between different handlers
3026
TransitionRequested
@@ -33,8 +29,11 @@ const (
3329

3430
// thread is exiting the C loop for a full ZTS restart (max_requests)
3531
Rebooting
32+
ForceRebooting
3633
// C thread has exited and ZTS state is cleaned up, ready to spawn a new C thread
3734
RebootReady
35+
// all threads are yielding for main thread reboot
36+
YieldingForReboot
3837
)
3938

4039
func (s State) String() string {
@@ -53,10 +52,6 @@ func (s State) String() string {
5352
return "inactive"
5453
case Ready:
5554
return "ready"
56-
case Restarting:
57-
return "restarting"
58-
case Yielding:
59-
return "yielding"
6055
case TransitionRequested:
6156
return "transition requested"
6257
case TransitionInProgress:
@@ -65,8 +60,12 @@ func (s State) String() string {
6560
return "transition complete"
6661
case Rebooting:
6762
return "rebooting"
63+
case ForceRebooting:
64+
return "rebooting (force)"
6865
case RebootReady:
6966
return "reboot ready"
67+
case YieldingForReboot:
68+
return "yielding for reboot"
7069
default:
7170
return "unknown"
7271
}
@@ -168,16 +167,46 @@ func (ts *ThreadState) WaitFor(states ...State) {
168167
<-sub.ch
169168
}
170169

170+
func (ts *ThreadState) WaitForStateWithTimeout(timeout time.Duration, states ...State) bool {
171+
ts.mu.Lock()
172+
if slices.Contains(states, ts.currentState) {
173+
ts.mu.Unlock()
174+
175+
return true
176+
}
177+
178+
sub := stateSubscriber{
179+
states: states,
180+
ch: make(chan struct{}),
181+
}
182+
ts.subscribers = append(ts.subscribers, sub)
183+
ts.mu.Unlock()
184+
185+
select {
186+
case <-sub.ch:
187+
return true
188+
case <-time.After(timeout):
189+
ts.mu.Lock()
190+
// remove subscriber so there is no leak potential
191+
ts.subscribers = slices.DeleteFunc(ts.subscribers, func(s stateSubscriber) bool {
192+
return sub.ch == s.ch
193+
})
194+
ts.mu.Unlock()
195+
return false
196+
}
197+
}
198+
171199
// RequestSafeStateChange safely requests a state change from a different goroutine
200+
// returns false if the thread is already reserved, shutting down, or done
172201
func (ts *ThreadState) RequestSafeStateChange(nextState State) bool {
173202
ts.mu.Lock()
174203
switch ts.currentState {
175-
// disallow state changes if shutting down or done
176-
case ShuttingDown, Done, Reserved:
204+
// disallow state changes when already shutting down or done
205+
case Reserved, ShuttingDown, Done:
177206
ts.mu.Unlock()
178207

179208
return false
180-
// ready and inactive are safe states to transition from
209+
// ready and inactive are stable states to transition from
181210
case Ready, Inactive:
182211
ts.currentState = nextState
183212
ts.notifySubscribers(nextState)
@@ -187,8 +216,8 @@ func (ts *ThreadState) RequestSafeStateChange(nextState State) bool {
187216
}
188217
ts.mu.Unlock()
189218

190-
// wait for the state to change to a safe state
191-
ts.WaitFor(Ready, Inactive, ShuttingDown)
219+
// wait for the state to change to a stable state
220+
ts.WaitFor(Ready, Inactive, Reserved)
192221

193222
return ts.RequestSafeStateChange(nextState)
194223
}

0 commit comments

Comments
 (0)