Skip to content

Revert "fix: removes opcache_hook again (#2564)" - #2621

Open
nicolas-grekas wants to merge 2 commits into
php:mainfrom
nicolas-grekas:opcache-hook
Open

Revert "fix: removes opcache_hook again (#2564)"#2621
nicolas-grekas wants to merge 2 commits into
php:mainfrom
nicolas-grekas:opcache-hook

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Reverts #2564, with a regression test and a fix for the concern that motivated the removal.

Without the hook, an opcache restart takes the whole process down with SIGSEGV.

Why the hook matters

opcache defers a restart until accel_is_inactive(), which probes for a conflicting lock with fcntl F_GETLK on its own lock file. POSIX fcntl locks belong to the process and never conflict with locks that process already holds, so the probe reports "inactive" no matter how many threads are inside a request:

held: F_RDLCK on byte 1 (what accel_activate_add takes per request)
  same thread   probe -> F_UNLCK (reports INACTIVE)
  other thread  probe -> F_UNLCK (reports INACTIVE)
  child process probe -> F_RDLCK (reports BUSY)

The last line is the control: the mechanism does work across processes, which is what it was designed for, and kill_all_lockers() makes that assumption explicit by signalling the locking pid. It does nothing for a single-process threaded server.

So the restart gets carried out at the next ZEND_RINIT_FUNCTION(zend_accelerator) on any thread, while workers are still running. accel_interned_strings_restore_state() memsets every string interned past the startup watermark and rewinds top, and zend_shared_alloc_restore_state() rewinds each segment position. The op_array a worker is executing is handed back out to the next compile.

Workers are the worst case. They never reach accel_post_deactivate, because the worker request cycle only runs MODULES_TO_RELOAD and not zend_activate_modules(), so they hold shared memory references for their whole life instead of for one request.

Reproducer

On dunglas/frankenphp:latest (PHP 8.5.9 ZTS, FrankenPHP 1.12.7): a worker holding an interned literal and a const array, plus a second route on regular threads that compiles and invalidates scripts to overflow the opcache hash.

Run Config Restart Result
A memory_consumption=8, max_accelerated_files=200 performed SIGSEGV, exit 139
B as A plus max_wasted_percentage=50 performed SIGSEGV, exit 139
C memory_consumption=128, max_accelerated_files=20000 none possible 960 files compiled, worker healthy, server alive
D memory_consumption=128, max_accelerated_files=200 performed SIGSEGV, exit 139

Run C is the control: same workload and same file churn, the only difference being that nothing can overflow. opcache's own log carries Restart Scheduled! Reason: hash overflow followed by Restarting! in exactly the runs that die.

Run D is worth a look on its own. With a 128 MB pool and max_wasted_percentage=50 the gate in zend_accel_schedule_restart_if_necessary() should need 64 MB of waste, but the restart fired at 222 KB. The startup log shows opcache.memory_consumption cannot be changed when OPcache is already set up once per thread, which suggests ZCG(accel_directives).memory_consumption stays 0 on those threads and the gate divides by zero. That would mean restarts are far easier to hit under ZTS than the documented 5 % suggests. Happy to split that out if it is worth chasing separately.

Commit 1: revert plus test

TestOpcacheRestartKeepsWorkerThreadsAlive sets a small opcache, starts a worker holding those references, compiles until a restart has actually been performed (the counters only move once it is carried out, not when it is scheduled), then asserts the worker still answers.

Checked in both directions on dunglas/frankenphp:builder-php8.5, same tree, only the hook differing:

  • with the hook: --- PASS (0.24s), and the log shows rebooting all PHP threads then thread reboot finished num_threads=48
  • without the hook: signal: segmentation fault (core dumped), FAIL

The test skips when opcache is unavailable or when a restart cannot be forced. Note it does not fail cleanly without the hook, it takes the test binary down, which is the nature of the bug.

Commit 2: repeated restarts

The concern in #2564 is real: a runtime that invalidates heavily could restart opcache over and over, and rebooting the threads each time would turn that into a reboot loop.

The reboots are not throttled. opcache rewinds its shared memory whether or not the threads are ready for it, so declining a reboot brings the segfault straight back. Skipping is not a safe trade.

A sustained loop means the compiled code does not fit in opcache.memory_consumption, so opcache can never settle: it fills, overflows, restarts and fills again. That configuration already thrashes today, just silently. So the loop is now reported instead of hidden, with an error naming the two settings that fix it, logged once per window rather than on every restart.

go_schedule_opcache_reset() now knows whether the restart came from opcache or from a userland opcache_reset(). Only the automatic path is counted, so calling opcache_reset() in a loop does not produce the message. Reboot coalescing already exists, rebootAllThreads() returns early while a reboot is in flight.

Test results

Full library suite passes with both commits applied: go test ., 23.9 s, on builder-php8.5.

Scope

The hook is #if defined(ZTS) && PHP_VERSION_ID >= 80400, so 8.2 and 8.3 have no protection either way. The root cause is upstream opcache behaviour under ZTS rather than a FrankenPHP bug, and it is not specific to workers, but workers are where the exposure is permanent. I can take it upstream too if you prefer that venue.

This reverts 3f43199 and adds a
regression test.

Without the hook, an opcache restart is performed while worker threads
are still executing code that lives in opcache shared memory, and the
process dies with SIGSEGV.

opcache defers a restart until accel_is_inactive(), which probes for a
conflicting lock using fcntl F_GETLK on its own lock file. POSIX fcntl
locks belong to the process and never conflict with locks that process
already holds, so the probe reports "inactive" no matter how many threads
of this process are inside a request. That check works for prefork SAPIs,
where the accessors are other processes, and kill_all_lockers() makes the
assumption explicit by signalling the locking pid. It does nothing for a
single-process threaded server.

Once the restart is carried out, accel_interned_strings_restore_state()
memsets every string interned past the startup watermark and
zend_shared_alloc_restore_state() rewinds each segment position, so the
op_array a worker is executing is handed back out to the next compile.

Rebooting threads when the restart is scheduled is what gets them out of
shared memory before the rewind happens. The hook fires at most once per
restart cycle, because zend_accel_schedule_restart() returns early while
restart_pending is already set.

Measured on dunglas/frankenphp:builder-php8.5 (PHP 8.5.9 ZTS): with the
hook the new test passes and logs a thread reboot, without it the test
binary dies with "signal: segmentation fault (core dumped)".
Addresses the concern that motivated php#2564: a runtime that invalidates
heavily could restart opcache over and over, and rebooting the threads
every time would turn that into a reboot loop.

Reboots are not throttled. opcache rewinds its shared memory whether or
not the threads are ready, so declining a reboot brings back the segfault
the hook exists to prevent. Skipping is not a safe trade.

What the loop actually means is that the compiled code does not fit in
opcache.memory_consumption, so opcache can never settle: it fills, it
overflows, it restarts, it fills again. Today that same configuration
already thrashes, just silently. This makes it visible with an error
naming the two ini settings that fix it, logged once per window rather
than on every restart.

go_schedule_opcache_reset() now knows whether the restart came from
opcache or from a userland opcache_reset(). Only the automatic path is
counted, so an operator calling opcache_reset() in a loop does not
trigger the message.

Reboot coalescing already exists: rebootAllThreads() returns early when a
reboot is in flight.

frankenphp_opcache_restart_hook() moves behind the same guard as the
assignment in php_main(), which is now its only caller. Without that,
builds for PHP 8.2 and 8.3 fail with -Werror=unused-function.
@henderkes

Copy link
Copy Markdown
Contributor

the issue here is that we weren't able to reproduce the issues where the server grinded to a halt with this logic in place. if we want to bring it back (and I think the better place to handle this is in php-src itself, given how complicated it turns out to be here), we better find a way to reproduce and verify this fixes it

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Doesn't the attached test provide the reproducer we need?

@henderkes

Copy link
Copy Markdown
Contributor

A sustained loop means the compiled code does not fit in opcache.memory_consumption, so opcache can never settle: it fills, overflows, restarts and fills again.

I don't think this would be the issue because after a restart it would've cleared back to zero, not permanently stayed slow after.

@nicolas-grekas

nicolas-grekas commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Ah you mean that reproducer about the slowness. I don't know about it, but maybe the added log line will help figure out if restart is involved.

What I know is that without the restart hook, threads segfault at will because their opcache pointers become stale.

@AlliBalliBaba

Copy link
Copy Markdown
Contributor

I think the issue is that a single request might do so much opcache invalidation that you can end up in a restart loop. Apps that do this are likely not very performant or well written to begin with, but a crash is probably less frustrating than endless hanging.

I agree with @hernderkes that the fix probably lies somewhere in php-src, there are some PRs like php/php-src#22281 trying to do this, but it's probably multiple race conditions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants