Skip to content

isset($obj->prop[$key]) misses under the tracing JIT when prop is unset() and served by &__get() #223

Description

@EdmondDantes

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions