[php-src] Issue #21772: SHM_PROTECT/SHM_UNPROTECT race in opcache under ZTS with multiple threads and protect_memory=1

[email protected] (EdmondDantes)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/21772
Author: EdmondDantes

### Description

When multiple PHP threads run concurrently in the same process (ZTS build), opcache's `SHM_PROTECT()`/`SHM_UNPROTECT()` calls race with each other because `mprotect()` is process-global, but there is no coordination (refcounting) between threads.

**I'm not 100% sure this is a bug vs. a known limitation.** But the behavior is surprising and I wanted to report it for discussion.

#### What happens

`SHM_PROTECT()` calls `mprotect(shm, PROT_READ)` and `SHM_UNPROTECT()` calls `mprotect(shm, PROT_READ|PROT_WRITE)`. These are process-wide — they affect all threads.

In `ZEND_RINIT_FUNCTION(zend_accelerator)` (ZendAccelerator.c, lines 2714–2778), every `php_request_startup()` executes an `SHM_UNPROTECT()` / `SHM_PROTECT()` pair. In a multi-threaded ZTS process, each thread calls `php_request_startup()` independently.

When tracing JIT is enabled and compiling a hot trace, it also holds SHM unprotected (zend_jit_trace.c, line 7512). If a second thread's RINIT calls `SHM_PROTECT()` while the first thread is still writing to SHM inside JIT compilation, the first thread gets SIGSEGV (write to read-only page).

Timeline:

```
Main thread                              Worker thread
───────────                              ─────────────
zend_jit_compile_root_trace:
  zend_shared_alloc_lock()
  SHM_UNPROTECT()  ← PROT_READ|WRITE
  writing to zend_jit_traces[N]...       php_request_startup():
  ...                                      RINIT(accel):
  ...                                        SHM_UNPROTECT() ← no-op
  ...                                        ...checks restart_pending...
  ...                                        SHM_PROTECT() ← mprotect(PROT_READ)
  ...                                        done, returns
  t->code_start = start  → SIGSEGV
  (page is now read-only)
```

The `zend_shared_alloc_lock()` serializes JIT compilation between threads, but it does NOT prevent RINIT's `SHM_PROTECT()` from running concurrently — RINIT doesn't acquire that lock for its `SHM_UNPROTECT/PROTECT` pair.

#### How to reproduce

Any ZTS setup with multiple threads running PHP code concurrently + `opcache.protect_memory=1` + `opcache.jit=tracing`.

Minimal reproduction with [TrueAsync](https://github.com/true-async/php-async) threads (but should be reproducible with any ZTS threading extension — parallel, pmmpthread, etc.):

```php
<?php
use Async\ThreadPool;
use function Async\spawn;
use function Async\await;

spawn(function() {
    $pool = new ThreadPool(2);
    $future = $pool->submit(fn() => 42);
    echo await($future) . "\n";
    $pool->close();
    echo "Done\n";
});
```

Run with:
```
php -d opcache.enable_cli=1 \
    -d opcache.jit=tracing \
    -d opcache.jit_buffer_size=64M \
    -d opcache.protect_memory=1 \
    -d opcache.jit_hot_loop=1 \
    -d opcache.jit_hot_func=1 \
    test.php
```

Result: correct output `42\nDone` followed by SIGSEGV in `zend_jit_trace_add_code` (zend_jit_trace.c:227).

The crash does NOT happen with:
- `opcache.protect_memory=0` (default) — `mprotect` is never called, no race
- `opcache.jit=function` — functions are compiled before threads start, no runtime compilation during concurrent execution

#### Notes

- The `krakjoe/parallel` extension has the same architecture (calls `php_request_startup()` per thread) and avoids this by only testing with `opcache.jit=function` or `opcache.jit=disable` in CI. Their ASAN+JIT job explicitly uses `-d opcache.jit=function`.
- `run-tests.php` hardcodes `opcache.protect_memory=1` (line 300), so any multi-threaded test suite using `run-tests.php` with tracing JIT will hit this.
- The underlying issue is that `mprotect()` is process-global but `SHM_UNPROTECT/PROTECT` pairs are not coordinated across threads — there is no refcount to prevent one thread's `PROTECT` from overriding another thread's active `UNPROTECT` window.

### PHP Version

```
PHP 8.6.0-dev (cli) (ZTS DEBUG)
Zend Engine v4.6.0-dev
    with Zend OPcache v8.6.0-dev
```

### Operating System

Ubuntu 24.04 (Linux 6.17)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.