Skip to content

JIT: Do not read an undefined property slot as NULL in FETCH_OBJ_IS - #23241

Open
EdmondDantes wants to merge 1 commit into
php:PHP-8.4from
EdmondDantes:fix-jit-fetch-obj-is-unset-prop
Open

JIT: Do not read an undefined property slot as NULL in FETCH_OBJ_IS#23241
EdmondDantes wants to merge 1 commit into
php:PHP-8.4from
EdmondDantes:fix-jit-fetch-obj-is-unset-prop

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Aug 13, 2026

Copy link
Copy Markdown

Under opcache.jit=tracing, isset($obj->prop[$key]) reads the property slot of a known property directly and treats IS_UNDEF as NULL. Two things answer for such a slot and are skipped: a lazy object, whose initializer never runs, and a declared property removed by unset(), which __isset()/__get() answer for. opcache.jit=function and opcache.jit=off are correct. Reproduces on the stock php:8.4-cli and php:8.5-cli images; 8.3 predates the shortcut.

class Store { public array $marks = []; }

class Holder {
    public static Store $store;
    public array $marks = [];

    public function __construct() { unset($this->marks); }
    public function &__get(string $name) { return self::$store->$name; }
    public function __isset(string $name): bool { return isset(self::$store->$name); }
    public function mark(string $key): void { $this->marks[$key] = true; }
    public function has(string $key): bool { return isset($this->marks[$key]); }
}

Holder::$store = new Store();
$holder = new Holder();

for ($n = 0; $n < 10; $n++) {
    $key = "k{$n}";
    $holder->mark($key);
    var_dump($holder->has($key));   // true, true, then false eight times
}

php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_hot_func=2 t.php

Cause

unset() on a declared property leaves the slot IS_UNDEF with IS_PROP_UNINIT clear, and zend_std_read_property() answers through __isset() and then __get() in that state (zend_object_handlers.c:929). Every slot of an uninitialized lazy ghost or of a proxy is IS_UNDEF too, and reading one runs the initializer. Only a typed property that was never assigned reads as NULL without side effects.

zend_jit_fetch_obj() skips the IS_UNDEF check when prop_info is known and the result carries MAY_BE_GUARD (zend_jit_ir.c:14394), leaving it to zend_jit_guard_fetch_result_type(). That guard sorts IS_UNDEF out on two paths, and both are wrong for a property:

  • When the trace recorded NULL, the guard is ir_ULE(Z_TYPE, IS_NULL), and IS_UNDEF passes it inline. Nothing deoptimizes; the wrong answer comes straight out of the trace, and a lazy object is left uninitialized.
  • When the trace recorded anything else, the side exit fires and zend_jit_trace_exit() stores NULL in the result and resumes at FETCH_OBJ_IS + 1, so the VM never re-runs the fetch either.

Change

FETCH_OBJ_IS now checks the slot before the guard: an IS_UNDEF slot leaves the trace when the object is lazy, or when the accessors answer for the property and IS_PROP_UNINIT is clear. A never-assigned typed property still reads as NULL inline, so the shortcut keeps working where it is sound.

The deoptimization path stops materializing NULL for FETCH_OBJ_IS and repeats the opcode in the VM, as FETCH_OBJ_R already does. FETCH_DIM_IS keeps both shortcuts: an undefined array index has nothing behind it.

Testing

Three tests, one per path; all three fail on PHP-8.4 without the change:

  • ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt — the side exit,
  • ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt — the inline NULL guard,
  • ext/opcache/tests/jit/fetch_obj_is_lazy_object.phpt — the lazy ghost.

Built on this branch with --enable-debug --enable-opcache: ext/opcache/tests has no new failures (gh18050.phpt fails before and after), Zend/tests under opcache.jit=tracing is 4970 tests with none failed, and Zend/tests/lazy_objects under the same is 225 of 225.

Timings on that debug build, median of five runs, 3M iterations of isset($obj->prop['k']), before → after:

property before after
never-assigned typed, class without accessors 3.2 ms 4.3 ms
slot holds NULL 3.2 ms 3.5 ms
never-assigned typed, class with __isset()/__get() 3.3 ms 4.4 ms
removed by unset(), accessors answer true (300k iterations) 135.1 ms 116.1 ms

The shortcut comes from ace18f4 (#14298), so 8.4 and later are affected.

isset($obj->prop[$key]) answered false for a key the array holds, when prop
was a declared property removed by unset() and served by __isset()/__get(),
and it answered from the IS_UNDEF slots of a lazy object instead of running
its initializer.

A trace reads the slot of a known property directly and left IS_UNDEF to the
result type guard, which admits it whenever the recorded type is NULL. That
holds only while nothing else answers for the slot, so FETCH_OBJ_IS now
checks the slot before the guard: a lazy object and a property the accessors
answer for leave the trace, while a typed property that was never assigned
still reads as NULL inline.

The side exit taken on any other recorded type stored NULL in the result and
resumed after the fetch. FETCH_OBJ_IS now re-runs in the VM, as FETCH_OBJ_R
already does.
@EdmondDantes
EdmondDantes force-pushed the fix-jit-fetch-obj-is-unset-prop branch from 7217e3b to 5764c14 Compare August 13, 2026 09:29
@EdmondDantes EdmondDantes changed the title JIT: Repeat FETCH_OBJ_IS in the VM when the property slot is IS_UNDEF JIT: Do not read an undefined property slot as NULL in FETCH_OBJ_IS Aug 13, 2026
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