Fix GH-22857: JIT wrong code for FETCH_OBJ_FUNC_ARG with property hooks - #22897
Conversation
|
Ping @arnaud-lb @iliaal — this is the JIT-only rework you suggested; would appreciate another look when you have time. Thanks! |
There was a problem hiding this comment.
I don't see the reason to loop it 200 times. In your original reproduce script you loop it 5000 times
If I am understanding your bug correctly, two or three calls can reproduce the bug. But maybe I am missing something
Update: I test this locally, with only one single $c->step() call can stably reproduce the bug.
@LamentXU123 Thanks for the review — you're right that the bug triggers after just a couple of calls, so let me clarify why the loop is there, since it's not about the number of calls needed to trigger it. Mechanism: on the first step() the function runs in the VM and primes the SIMPLE_GET bit on the property's cache slot (slow path, getter runs correctly). The second call is JIT-compiled and consumes that primed bit; without the guard the JIT reads whatever sits in the adjacent property slot instead of running the getter. So ~2 iterations is enough to trigger the wrong behavior. The 200-iteration loop is a reliability measure for the regression test, not a trigger requirement. The unguarded read consumes whatever bytes happen to be in the sibling slot, and whether that produces a visible failure (a thrown \Error/\TypeError from file_get_contents, or heap corruption) is data-dependent and non-deterministic. With only 2–3 iterations a buggy build can occasionally slip through (false negative), defeating the purpose of a regression test — I verified in the PR description that without the fix it fails 20/20, and the loop is what makes that consistent. This matters most for the second variant (Container2): compact_literals can merge the cache slots of the preceding FETCH_OBJ_R and the FETCH_OBJ_FUNC_ARG, so whether the shared-slot case is actually hit is sensitive to literal compaction / JIT recompilation timing — a loop guarantees we exercise it. If you'd prefer a smaller loop I'm happy to shrink it, or alternatively I can make the assertion deterministic by checking the property value directly instead of relying on file_get_contents fataling — that would let us shrink the loop while still reliably failing on a buggy build. Let me know which you prefer. |
|
for Container2, the loop itself does not make compact_literals merge the cache slots. that should be determined at compile time. The loop only repeats the bad read once that shape is already present. I would prefer the deterministic assertion approach you mentioned. IMO A regression test that needs 200 iterations to become reliable is usually a sign that we should assert the wrong value behavior more directly. |
@LamentXU123 Thanks — I've reworked the test to use the deterministic assertion approach you preferred. What changed: The assertion no longer relies on file_get_contents() fatally erroring on whatever garbage the unguarded JIT read happened to land on (that was data-dependent, which is exactly why the old version needed 200 iterations to become reliable). Container — self-primes the SIMPLE_GET bit on the first (VM) call and consumes it once JIT-compiled. Verified locally: with the fix the test prints OK; forcing the getter flag off (simulating the buggy JIT by dropping the hook-enter guard) makes it deterministically FAIL with the RuntimeException. Let me know if you'd prefer the loop dropped to a single call or any other tweak. |
|
Unfortunately we can't exit to VM in the function JIT as the VM stack may not be up to date as some vars may be kept only in registers. It's only safe for The following test demonstrates that: --TEST--
GH-22857: Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a property hook (SIMPLE_GET fast path)
--ENV--
A=1
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=64M
opcache.jit=1205
opcache.jit_hot_func=1
--FILE--
<?php
class C {
public $prop {
get { return 1; }
}
}
function f(int $a0, $obj) {
// $a is in reg: all uses in supported opcodes, in non-entry BBs
$a = $a0;
$b = $a + 2;
// $obj->prop uses FETCH_FUNC_ARG
// $a is used after returning to VM
$c = g($obj->prop, $a ? 1 : 2);
return $b + $c;
}
if (getenv('A')) {
// Function is not known at compile time
function g($a, $b) {
var_dump($b);
return $a;
}
}
$c = new C();
var_dump(f(1, $c)); // Set SIMPLE_GET and inline caches
var_dump(f(1, $c));
?>
--EXPECT--
int(1)
int(4)
int(1)
int(4)Output: What I suggested previously should work. |
@arnaud-lb You're right — I reproduced your counter-example locally against my branch and got the exact same diff (Undefined variable $a). The hook-enter guard is only sound when nothing lives solely in registers, which holds for the minimal-JIT-only FETCH_OBJ_R path but not for FETCH_OBJ_FUNC_ARG at INLINE level. I'll rework this along the lines you suggested: handle FETCH_OBJ_FUNC_ARG in the INLINE switch with a runtime SEND_ARG_BY_REF check — by-value dispatching to zend_jit_fetch_obj() (getter runs inside the helper, registers stay live), by-ref taking a cold-path handler — keeping the generic-switch guard only for opt levels below INLINE where there's no reg alloc. I'll also add your test as gh22857_002.phpt. Thanks for the counter-example. |
|
Okay. Looks good now code wise, but I am not an expert on this so let's wait for other's reviews. |
PHP : C:\obj\Release_TS\phpdbg.exe
|
| /* FETCH_OBJ_FUNC_ARG's by-value fetch dispatches into the | ||
| * FETCH_OBJ_R handler, which may take the SIMPLE_GET hook fast | ||
| * path and push a getter frame; by-ref dispatches into | ||
| * FETCH_OBJ_W. The function JIT may keep values solely in | ||
| * registers, so we must NOT exit to the VM (stale stack slots). | ||
| * Inline the by-value path through zend_jit_fetch_obj, which runs | ||
| * the hook getter inside a helper and keeps all registers live. | ||
| * The by-ref path has no SIMPLE_GET fast path, so the generic | ||
| * handler (a full C call, safe under register allocation) is used. | ||
| * This mirrors the tracing JIT fix for GH-21006 (GH-21369); the | ||
| * runtime by-ref check is required because the passing mode is | ||
| * only known once the callee is resolved via namespace fallback. | ||
| * See GH-22857. */ | ||
| { | ||
| ir_ref rx, call_info, if_by_ref, end_by_ref; | ||
|
|
||
| /* Both runtime paths must observe a consistent frame | ||
| * state. The delayed call chain would otherwise only | ||
| * be flushed inside the by-ref branch (by | ||
| * zend_jit_set_ip() in zend_jit_handler()), leaving | ||
| * EX(call) stale on the by-val path and after the | ||
| * merge. Flush it before branching. */ | ||
| if (jit->delayed_call_level) { | ||
| if (!zend_jit_save_call_chain(jit, jit->delayed_call_level)) { | ||
| goto jit_failure; | ||
| } | ||
| } | ||
|
|
||
| // JIT: if (ZEND_CALL_INFO(EX(call)) & ZEND_CALL_SEND_ARG_BY_REF) | ||
| if (jit->reuse_ip) { | ||
| rx = jit_IP(jit); | ||
| } else { | ||
| rx = ir_LOAD_A(jit_EX(call)); | ||
| } | ||
| call_info = ir_LOAD_U32(jit_CALL(rx, This.u1.type_info)); | ||
| if_by_ref = ir_IF(ir_AND_U32(call_info, ir_CONST_U32(ZEND_CALL_SEND_ARG_BY_REF))); | ||
|
|
||
| /* by-ref path: the FUNC_ARG handler re-checks the flag | ||
| * and dispatches into FETCH_OBJ_W */ | ||
| ir_IF_TRUE_cold(if_by_ref); | ||
| if (!zend_jit_handler(&ctx, opline, | ||
| zend_may_throw(opline, ssa_op, op_array, ssa))) { | ||
| goto jit_failure; | ||
| } | ||
| end_by_ref = ir_END(); | ||
|
|
||
| /* zend_jit_handler() stored IP = opline + 1 on the | ||
| * by-ref path only; that compile-time knowledge is | ||
| * invalid for the by-val path and after the merge. */ | ||
| zend_jit_reset_last_valid_opline(jit); | ||
|
|
||
| /* by-val path */ | ||
| ir_IF_FALSE(if_by_ref); | ||
| if (!zend_jit_fetch_obj(&ctx, opline, op_array, ssa, ssa_op, | ||
| op1_info, op1_addr, 0, ce, ce_is_instanceof, on_this, 0, 0, NULL, | ||
| RES_REG_ADDR(), IS_UNKNOWN, | ||
| zend_may_throw(opline, ssa_op, op_array, ssa))) { | ||
| goto jit_failure; | ||
| } | ||
| ir_MERGE_WITH(end_by_ref); | ||
| } |
There was a problem hiding this comment.
This looks good, but please extract this part to a new function in zend_jit_ir.c so that we keep most IR-related code in that file.
|
NB: Please also rebase on PHP-8.4 and then change the base of the PR |
a4e1dc0 to
42537b8
Compare
74b9f41 to
bb605d3
Compare
… on a property hook In the function JIT, ZEND_FETCH_OBJ_FUNC_ARG's by-value fetch dispatches into the FETCH_OBJ_R handler, which may take the SIMPLE_GET hook fast path and push a getter frame. Because the function JIT may keep values solely in registers, exiting to the VM there leaves stale stack slots. Inline the by-value path through zend_jit_fetch_obj (which runs the hook getter inside a helper and keeps all registers live), and keep the by-ref path on the generic handler (a full C call, safe under register allocation). The runtime by-ref check is required because the passing mode is only known once the callee is resolved via namespace fallback. The IR block is extracted into zend_jit_fetch_obj_func_arg() in zend_jit_ir.c so that IR-related code stays in that file, and the ZEND_FETCH_OBJ_FUNC_ARG case is merged with the ZEND_FETCH_OBJ_R/IS/W case to deduplicate the setup. This mirrors the tracing JIT fix for phpGH-21006 (phpGH-21369).
bb605d3 to
51a74ad
Compare
@arnaud-lb Done — all points addressed in the latest force-push (rebased onto PHP-8.4):
|
|
Thank you @zhaohao19941221! |
* PHP-8.4: CS Fix phpGH-22857: Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a property hook (php#22897)
Summary
Fix GH-22857: heap corruption / spurious
ValueError/TypeErrorwhen a property hook is read viaFETCH_OBJ_FUNC_ARGunder function JIT (opcache.jit=1205).This supersedes the earlier engine-side attempt in the previous PR (
fix-22857-JIT-virtual-property-hook-as-arg-to-unqualified-namespaced-fallback-coderzhao-2026-07-22). Based on review feedback from @arnaud-lb and @iliaal, the fix has been rewritten as a JIT-only change that mirrors the shape of GH-21369 (which fixed the analogous GH-21006 for tracing JIT).Fixes GH-22857.
Root cause
ZEND_FETCH_OBJ_FUNC_ARGdispatches into theZEND_FETCH_OBJ_Rhandler for by-value argument fetches (seezend_vm_def.h). When the shared property cache slot has theSIMPLE_GETbit set, theFETCH_OBJ_Rhandler:EG(current_execute_data)to the new frame.opline | ZEND_VM_ENTER_BITto signal the caller to re-enter the VM at the new frame.The
SIMPLE_GETbit can be set in two ways:FETCH_OBJ_FUNC_ARGopline primed it on a previous iteration (via slow path inzend_std_read_property()).FETCH_OBJ_Ron the same property primed it, andcompact_literalshas merged the two oplines' cache slots.Function JIT already handles this case for
ZEND_FETCH_OBJ_Rvia a hook-enter guard inzend_jit.cthat compares the returned IP againstopline+1and tail-calls into the new frame when they differ. However,ZEND_FETCH_OBJ_FUNC_ARGwas compiled through the genericdefault:branch with no such guard, so the JIT-emittedSEND_FUNC_ARGthat follows read the argument slot before the getter had actually run.Depending on the adjacent property slot's contents, the symptom is:
ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes(deterministic 20/20 in the reproducer)TypeError: <fn>(): Argument #N ($arg) must be of type ?string, <adjacent-class> given(observed in the real-world Hyperf/Swoole application that led to this report)zend_mm_heap corrupted→SIGABRT(also observed in the real-world code)All three symptoms have the same root cause:
SEND_FUNC_ARGpicking up garbage from an adjacent property slot before the getter has run.Fix
Compile
ZEND_FETCH_OBJ_FUNC_ARGthrough the same path asZEND_FETCH_OBJ_Rso it emits the hook-enter guard. BecauseZEND_FETCH_OBJ_FUNC_ARGis not handled by the INLINE-only switch above, the sharedcelocal can carry a stale value from a previous opline iteration; reset it toNULLforFETCH_OBJ_FUNC_ARGso the guard is always emitted regardless of anyfinal/no-hooks fast path that theFETCH_OBJ_Rcase may otherwise skip.The by-reference dispatch (to
FETCH_OBJ_W) never takes theSIMPLE_GETfast path (that fast path only exists in theFETCH_OBJ_Rhandler), so the guard is a run-time no-op there: the by-ref path always returns withIP == opline+1and theIR_IF_TRUEbranch is taken.This mirrors the JIT-only shape used for the tracing JIT in GH-21369 (which fixed the analogous GH-21006 for tracing).
Alternatives considered
zend_std_read_property()(the shape of the previous PR): only primeSIMPLE_GETwhen the current opline isZEND_FETCH_OBJ_R. Rejected because it does not close the sibling-primed variant ($warm = $this->path; @file_get_contents($this->path);) that @iliaal called out —compact_literalsshares the cache slot, and the JIT-compiledFUNC_ARGstill consumes a bit primed by the siblingFETCH_OBJ_R.FETCH_OBJ_FUNC_ARGinto by-value / by-ref paths in JIT (@arnaud-lb's suggestion of dispatching tozend_jit_fetch_obj()for by-value and a cold-path handler for by-ref): would work but is a larger change; the current shape reuses the exact same generic-handler + hook-enter-guard idiom theFETCH_OBJ_Rcase already uses, keeping the fix minimal.Necessary conditions (established by systematic delta-debugging)
Removing any one of these makes the bug disappear. Each condition was verified with 20 runs per variant:
opcache.jit=1205(function JIT).disableandtracingare 20/20 OK.namespace.\, nouse function) soINIT_NS_FCALL_BY_NAME+FETCH_OBJ_FUNC_ARGis emitted (optimizer can't resolve namespaced-fallback targets, so theFETCH_OBJ_FUNC_ARGisn't rewritten toFETCH_OBJ_R).FETCH_OBJ_FUNC_ARG). Assigning to a local first usesFETCH_OBJ_Rand hides the bug.@(BEGIN_SILENCE/END_SILENCE).get =>calls a static method reading the promoted asymmetric properties.Reproducer (before the fix)
Without the fix:
20/20crashing / erroring with the symptoms above.With
opcache.jit=disableoropcache.jit=tracing:20/20clean.With the fix applied:
20/20clean for all three JIT modes.Test
ext/opcache/tests/jit/gh22857.phpt— 200 iterations covering both trigger paths:Container::step()— original issue: the sameFETCH_OBJ_FUNC_ARGopline primesSIMPLE_GETon iteration 1 and consumes it on iteration 2.Container2::step()— sibling-slot variant that @iliaal pointed out: a precedingFETCH_OBJ_Ron the same property primes the shared cache slot, and the followingFETCH_OBJ_FUNC_ARGconsumes it.Fails before the fix (20/20 under
jit=1205), passes after.Backport
The bug has been present since PHP 8.4 (property hooks introduced in 8.4). Targeting
master(PHP 8.6-dev) as suggested; happy to also targetPHP-8.4/PHP-8.5if maintainers prefer.Related
FETCH_OBJ_FUNC_ARGon a virtual property hook when calling an unqualified namespaced-fallback function #22857Thanks @arnaud-lb and @iliaal for the review feedback that redirected this to the correct fix.