Skip to content

Fix OPcache memory protection race under ZTS - #23081

Open
realFlowControl wants to merge 2 commits into
php:PHP-8.4from
realFlowControl:florian/fix-opcache-protect-memory-zts-race
Open

Fix OPcache memory protection race under ZTS#23081
realFlowControl wants to merge 2 commits into
php:PHP-8.4from
realFlowControl:florian/fix-opcache-protect-memory-zts-race

Conversation

@realFlowControl

@realFlowControl realFlowControl commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

I found this after adding tracing-JIT jobs to the parallel CI, which so far only run function JIT. The new job reproducibly crashed while compiling a trace:

AddressSanitizer: SEGV caused by a WRITE
#0 zend_jit_trace_add_code ext/opcache/jit/zend_jit_trace.c:214
#1 zend_jit_finish ext/opcache/jit/zend_jit_ir.c:16295
#2 zend_jit_trace ext/opcache/jit/zend_jit_trace.c:7244
#3 zend_jit_compile_root_trace ext/opcache/jit/zend_jit_trace.c:7454
#4 zend_jit_trace_hot_root ext/opcache/jit/zend_jit_trace.c:8127

The crash occurs because opcache.protect_memory changes shared-memory permissions process-wide, while ZTS threads manage the protection scopes independently. run-tests.php sets opcache.protect_memory=1, which is why this became visible in CI, default is opcache.protect_memory=0 so most likely no one will actually see this crash in prod.

The race is:

  1. Worker A starts compiling a hot trace.
  2. It acquires zend_shared_alloc_lock().
  3. It calls SHM_UNPROTECT(), making OPcache shared memory writable.
  4. Worker B concurrently runs OPcache request activation, which is not guarded by zend_shared_alloc_lock().
  5. Worker B calls SHM_UNPROTECT() followed by SHM_PROTECT().
  6. Because memory protection is process-wide, Worker B makes the mapping read-only for all threads.
  7. Worker A is still compiling and writes JIT trace metadata into the mapping.
  8. PHP crashes with SIGSEGV or EXC_BAD_ACCESS.

This PR makes unprotected sections nested and process-aware under ZTS:

  • Each thread tracks its own unprotect depth.
  • A process-wide counter tracks threads with an active unprotected section.
  • A dedicated mutex serializes the counter and memory-protection transitions.
  • Shared memory returns to read-only only after the last thread leaves its outermost unprotected section.

The dedicated mutex is separate from zend_shared_alloc_lock() because some callers already hold that lock when calling SHM_UNPROTECT().

We do not see this in the php-src ci in ZTS runs, because those are not running multiple threads, but everything is still single threaded.

@realFlowControl
realFlowControl marked this pull request as ready for review August 6, 2026 12:35
@realFlowControl

Copy link
Copy Markdown
Contributor Author

Also see:
#17246 (comment)
#16727 (comment)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant