Summary
With the tracing JIT on, isset($obj->prop[$key]) answers false for a key the array holds, when prop is a declared property that was unset() and is served by &__get()/__isset(), and coroutines switch between the write and the read. opcache.jit=off (opcache still on) always answers correctly, and so does opcache off.
Reproduce
<?php
use Async\Scope;
use function Async\await;
use function Async\spawn;
use function Async\suspend;
class Store
{
public array $marks = [];
}
class Holder
{
/** @var array<int, Store> one backing store per coroutine */
public static array $store = [];
/** Which store the magic accessors answer from, as a request context would decide. */
public static int $current = 0;
public array $marks = [];
public function __construct()
{
unset($this->marks);
}
public function &__get(string $name)
{
$store = self::$store[self::$current];
return $store->$name;
}
public function __isset(string $name): bool
{
return isset(self::$store[self::$current]->$name);
}
public function mark(string $key): void
{
$this->marks[$key] = true;
}
public function has(string $key): bool
{
return isset($this->marks[$key]);
}
}
$holder = new Holder();
$bad = 0;
await(spawn(static function () use ($holder, &$bad) {
$scope = new Scope();
for ($i = 0; $i < 40; $i++) {
$scope->spawn(static function () use ($i, $holder, &$bad) {
Holder::$store[$i] = new Store();
for ($round = 0; $round < 400; $round++) {
$key = "k{$i}-{$round}";
Holder::$current = $i;
suspend();
Holder::$current = $i;
$holder->mark($key);
suspend();
Holder::$current = $i;
if ($holder->has($key) !== isset(Holder::$store[$i]->marks[$key])) {
echo "MISMATCH {$key}: through the object 0, in the array 1\n";
$bad++;
}
}
});
}
$scope->awaitCompletion(\Async\timeout(30_000));
}));
echo $bad === 0 ? "clean\n" : "{$bad} mismatches\n";
$ docker run --rm -v "$PWD":/probe -w /probe trueasync/php-true-async:latest php single.php
MISMATCH k14-3: through the object 0, in the array 1
…
8 mismatches
$ docker run --rm -v "$PWD":/probe -w /probe trueasync/php-true-async:latest php -d opcache.jit=off single.php
clean
Eight mismatches on every run of the image, the first around round 3 of the fifteenth coroutine. -d opcache.jit=off and -d opcache.enable_cli=0 are clean, three runs each.
$holder->mark($key) writes through the same &__get() and the write always lands — the array holds the key, which is what the right-hand side of the comparison reads. Only the isset() through the object answers wrongly.
Where it was found
Laravel's Blade under laravel-spawn. The view factory keeps its render state per request by unset()-ing the sixteen declared properties and serving them from the request's context through &__get(), so Factory::hasRenderedOnce() — isset($this->renderedOnce[$id]) — is exactly this shape. Sixty concurrent requests rendering one page: four of them emit an @once block twice, because the check misses the mark the same render wrote a moment earlier. A page can lose @push content and section content the same way; those go through isset($this->pushes[$section]) and isset($this->sections[$section]).
Fetching the array into a local first and asking isset($local[$key]) always answers correctly, which is the workaround the package now carries.
Environment
trueasync/php-true-async:latest, PHP 8.6.0-dev ZTS, TrueAsync ABI v0.24.0, built 2026-08-11
opcache.jit=tracing, opcache.jit_buffer_size=128M, opcache and Xdebug 3.6.0-dev loaded (xdebug.mode=off changes nothing)
- Linux 6.6 (WSL2), 16 cores
Not reproduced on a --disable-debug-less local build, which has opcache.jit=disable; whether current true-async is affected is untested for want of a release build with the JIT here.
Summary
With the tracing JIT on,
isset($obj->prop[$key])answers false for a key the array holds, whenpropis a declared property that wasunset()and is served by&__get()/__isset(), and coroutines switch between the write and the read.opcache.jit=off(opcache still on) always answers correctly, and so does opcache off.Reproduce
Eight mismatches on every run of the image, the first around round 3 of the fifteenth coroutine.
-d opcache.jit=offand-d opcache.enable_cli=0are clean, three runs each.$holder->mark($key)writes through the same&__get()and the write always lands — the array holds the key, which is what the right-hand side of the comparison reads. Only theisset()through the object answers wrongly.Where it was found
Laravel's Blade under
laravel-spawn. The view factory keeps its render state per request byunset()-ing the sixteen declared properties and serving them from the request's context through&__get(), soFactory::hasRenderedOnce()—isset($this->renderedOnce[$id])— is exactly this shape. Sixty concurrent requests rendering one page: four of them emit an@onceblock twice, because the check misses the mark the same render wrote a moment earlier. A page can lose@pushcontent and section content the same way; those go throughisset($this->pushes[$section])andisset($this->sections[$section]).Fetching the array into a local first and asking
isset($local[$key])always answers correctly, which is the workaround the package now carries.Environment
trueasync/php-true-async:latest, PHP 8.6.0-dev ZTS, TrueAsync ABI v0.24.0, built 2026-08-11opcache.jit=tracing,opcache.jit_buffer_size=128M, opcache and Xdebug 3.6.0-dev loaded (xdebug.mode=offchanges nothing)Not reproduced on a
--disable-debug-less local build, which hasopcache.jit=disable; whether currenttrue-asyncis affected is untested for want of a release build with the JIT here.