一次真实生产事故的完整复盘:PHP-8.4-JIT-虚拟属性-Hook-读取踩堆

一个 PHP 8.4 JIT bug 的完整闭环:从线上 SIGABRT 到上游合入的 JIT-only 补丁

关键词:PHP 8.4 · Property Hook · Asymmetric Visibility · OPcache · Function JIT ·
opcache.jit=1205 · FETCH_OBJ_FUNC_ARG · SIMPLE_GET fast path · hook-enter guard ·
GH-22857 · GH-21369

环境:PHP 8.4.23 (NTS, Alpine, Docker) + Swoole/Hyperf

提示:本文是继「排查复盘」之后的最终修复版。第一版补丁走的是引擎侧
zend_std_read_property() 加 opline 检查的思路,被上游 reviewer 指出对
sibling-slot 变体不闭环,最终改为纯 JIT 层的 hook-enter guard,
对齐 tracing JIT 里 GH-21369 修复 GH-21006 的形状。本文按最终版本讲。


一、事故快照

线上活动服务运行几小时后,日志开始随机冒出三种”看起来毫无关联”的错误,接着 worker 被 SIGABRT 拉起:

[WARNING] file_get_contents(): Path must not be empty
[WARNING] dumpToYaml(): Argument #2 must be of type ?string, StdoutLogger given
zend_mm_heap corrupted
signal=6 (SIGABRT)

三条报错分别指向不同的调用点、不同的参数、不同的类型。事后证明它们是同一颗子弹打出的不同弹孔——SEND_FUNC_ARG尚未被 getter 填过的结果槽里读出了相邻属性的字节,字节内容不同,症状就不同:

相邻槽里恰好是 症状
\0 的字符串 ValueError: must not contain any null bytes
对象指针(如 LoggerInterface TypeError: expected string, got <adjacent class>
破坏了堆头的字节 zend_mm_heap corruptedSIGABRT

二、事故代码:一个把 PHP 8.4 新特性全叠一起的 ActConfig

<?php
namespace App\ConfigCenter;

class ActConfig
{
 // ① asymmetric visibility 属性
 public protected(set) LoggerInterface $logger;
 // ② 虚拟 property hook:get-only,无 backing storage public string $filename { get => self::getConfigFileName($this->serviceType, $this->actId); }
 protected mixed $prevCfg = null;
 // ③ constructor promoted + asymmetric visibility public function __construct( public protected(set) string $serviceType, public protected(set) string $actId, ) { $this->logger = StdoutLogger::getInstance(); }
 public static function getConfigFileName(string $serviceType, string $actId): string { return dirname(__DIR__, 2) . "/tmp/act_{$serviceType}_$actId.yaml"; }
 public function saveConfig(array $cfg): void { // ④ 关键触发点:hook 直接作为 unqualified 命名空间函数的实参 + @ 静默
 if (!$this->prevCfg && ($str = @\file_get_contents($this->filename))) { $this->prevCfg = parseYamlString($str); } dumpToYaml($cfg, $this->filename); \clearstatcache(false, $this->filename); }}

PHP 8.4 三张新牌(property hooks / asymmetric visibility / promoted asym)叠在一起,正好命中了 function JIT 里一个还没被发现的 bug。


三、锁定 JIT 层:多组正交对照

排查前后走了 10 条弯路(版本、Swoole、CurlClient、preload、file_cache……逐个证伪)。最后靠一组正交对照实验把嫌疑锁死在 JIT 层:

组别 opcache.enable opcache.jit 结果
A 0 ✅ 干净
B 1 disable ✅ 干净
C 1 tracing ✅ 干净
D 1 1205 ❌ 稳定复现

铁证到手:这是纯 function JIT bug,与 OPcache 优化器无关,与 tracing JIT 也无关。


四、Delta Debugging:8 条必要条件

写一套 reduce_*.py 脚本,从真实项目开始每次删/改一小段代码、跑 20 次验证是否还崩,逐步逼近。最终归结出 8 条必要条件,缺一不可(每一条移除后 20/20 OK,全部具备则 20/20 复现):

# 条件
1 opcache.jit=1205(function JIT)—— disable / tracing 均不触发
2 类位于 namespace
3 调用未加 \ 前缀,也无 use function —— 编译期落到 INIT_NS_FCALL_BY_NAME 后备解析
4 参数是虚拟 property hook(get-only、无 backing store)
5 Hook 直接作为实参(opcode FETCH_OBJ_FUNC_ARG)—— 先赋值到局部就变 FETCH_OBJ_R,bug 消失
6 调用被 @ 包围(BEGIN_SILENCE/END_SILENCE
7 类字段布局:hook 前后各有一个 asymmetric-visibility 属性 + 两个 promoted asym 参数
8 Hook get => 里调用静态方法读 promoted asym 属性

一个 30 行的独立 CLI 复现,第一次调用即触发(不需要预热,5000/5000 iteration 都命中):

<?php
namespace App\ConfigCenter;

interface HandlerInterface { public function noop(): void; }

final class DefaultHandler implements HandlerInterface {
 private static ?self $i = null; public static function getInstance(): self { return self::$i ??= new self(); } public function noop(): void {}}

class Container {
 public protected(set) HandlerInterface $handler; public string $path { get => self::build($this->kind, $this->id); } protected mixed $prev = null;
 public function __construct( public protected(set) string $kind, public protected(set) string $id, ) { $this->handler = DefaultHandler::getInstance(); }
 public static function build(string $k, string $i): string { return "/tmp/nonexistent_{$k}_{$i}.dat"; }
 public function step(): void { @file_get_contents($this->path);   // ← 触发
 }}

(new Container('alpha', 'beta'))->step();

对应的 opcode dump(opcache.jit_debug=0x1FF):

0027 INIT_NS_FCALL_BY_NAME 1 string("App\\ConfigCenter\\file_get_contents")
0028 CHECK_FUNC_ARG 1
0029 #28.V3 = FETCH_OBJ_FUNC_ARG (ref) THIS string("path")
0030 SEND_FUNC_ARG #28.V3 1
0031 #29.V3 = DO_FCALL_BY_NAME

因为 namespace fallback 到调用点才能解析,callee arginfo 在编译期未知,所以生成的是 FETCH_OBJ_FUNC_ARG(不能被 optimizer 改写成 FETCH_OBJ_R)。这是必须走 FUNC_ARG 而非 R 的关键。


五、根因:SIMPLE_GET fast path × FUNC_ARG 缺 hook-enter guard

要看懂 bug,需要把 PHP 引擎里三块拼图拼起来。

5.1 拼图一:SIMPLE_GET 缓存位

PHP 8.4 为 property hook 引入了一组”内联 fast path”缓存位,写在 property cache slot 里:

// Zend/zend_object_handlers.h
#define ZEND_PROPERTY_HOOK_SIMPLE_READ_BIT  2u
#define ZEND_PROPERTY_HOOK_SIMPLE_WRITE_BIT 4u
#define ZEND_PROPERTY_HOOK_SIMPLE_GET_BIT   8u   // 内联 hook getter,跳过 slow path

SIMPLE_GET 的语义:一旦某条 property 的 getter 被证实是”只读 hook 且无副作用”,就在 cache slot 打勾,之后同一 property 的 FETCH_OBJ_R 不再走 zend_std_read_property() 慢路径,而是在 VM handler 里直接把 hook getter push 成一个新 call frame

// Zend/zend_vm_def.h::ZEND_FETCH_OBJ_R (SIMPLE_GET 快路径伪代码)
if (IS_PROPERTY_HOOK_SIMPLE_GET(prop_offset)) {
 zend_execute_data *call = zend_vm_stack_push_call_frame(...); call->return_value = EX_VAR(opline->result.var);       // 预留结果槽
 // Set EG(current_execute_data) = call, then: ZEND_VM_ENTER_EX();                                    // 返回 opline|ENTER_BIT}

ZEND_VM_ENTER_EX() 返回的是 opline | ZEND_VM_ENTER_BIT——一个带”我要进入新 frame”标志位的 IP。dispatch loop 拿到它就切进 getter frame,getter 跑完再回来。

5.2 拼图二:FETCH_OBJ_FUNC_ARG 复用 FETCH_OBJ_R handler

FETCH_OBJ_FUNC_ARG运行时才能区分参数是 by-value 还是 by-ref:

// Zend/zend_vm_def.h::ZEND_FETCH_OBJ_FUNC_ARG
if (UNEXPECTED(ZEND_CALL_INFO(EX(call)) & ZEND_CALL_SEND_ARG_BY_REF)) {
 ZEND_VM_DISPATCH_TO_HANDLER(ZEND_FETCH_OBJ_W);   // by-ref → W handler} else {
 ZEND_VM_DISPATCH_TO_HANDLER(ZEND_FETCH_OBJ_R);   // by-value → R handler}

关键:by-value 情况下,FUNC_ARG 直接 tail-call 到 FETCH_OBJ_R 的 handler。这条 tail-call 是纯粹的 code-reuse——EX(opline) 还是那条 FUNC_ARG 的 opline,只是执行的机器指令流跳到了 R handler 里。

于是 R handler 内部就可能命中 SIMPLE_GET fast path、push 一个 getter frame、返回带 ENTER_BIT 的 IP——但 opline 是 FUNC_ARG 的

5.3 拼图三:function JIT 里 FETCH_OBJ_R 有 hook-enter guard,FETCH_OBJ_FUNC_ARG 没有

function JIT 为不同 opcode 生成机器码。对 FETCH_OBJ_Rext/opcache/jit/zend_jit.c 里有专门处理(近似伪码):

case ZEND_FETCH_OBJ_R:
 /* 生成 zend_jit_fetch_obj() 内联;如果目标 property 可能是 hooked,
 * 生成一个 hook-enter guard:
 *   handler 返回后判断 IP 是否等于 opline+1 *     IP == opline+1  → 正常快路径,命中 IR_IF_TRUE 分支,继续下一条 opcode *     IP != opline+1  → 发生了 VM_ENTER,尾调进新 IP(切进 getter frame)
 */ ... break;
/* 其余 opcode(含 FETCH_OBJ_FUNC_ARG)→ default: 分支 */default:
 zend_jit_handler(&ctx, opline, ...); /* ★ 没有 hook-enter guard! */

FETCH_OBJ_FUNC_ARG 走的是 default:,生成的机器码里没有“handler 返回时如果发生了 ENTER,切走”这一分支。

5.4 三张拼图拼起来 —— 完整触发路径

SIMPLE_GET bit 有两种被 prime 的方式(这一点是最终修复相较于第一版补丁的关键差别):

  • Self-primed:同一条 FETCH_OBJ_FUNC_ARG opline 在上一轮走过 slow path,在 zend_std_read_property() 里被打上了 bit。
  • Sibling-primed另一条 FETCH_OBJ_R opline(读同一 property)先走 slow path 把 bit 打上——因为 compact_literals 会把两条 opline 的 cache slot 合并成同一个 slot,所以 FUNC_ARG 会读到别人打的 bit。这就是 @iliaal 在 review 中提出的 sibling-slot 变体。

不管哪种 prime 方式,一旦 bit 被点亮,接下来 FUNC_ARG 走的流程就是:

┌────────────────────────────────────────────────────────────────────┐
│ FETCH_OBJ_FUNC_ARG(opline ASIMPLE_GET bit 已亮) │├────────────────────────────────────────────────────────────────────┤
│ JIT 生成的机器码:zend_jit_handler(...)VM handler             ││   └─ VM handler tail-call 到 FETCH_OBJ_R handler                  ││       └─ 命中 SIMPLE_GET fast path: ││           ├─ push 一个 getter call frame                           ││           ├─ call->return_value = EX_VAR(opline->result.var)      │
│           ├─ 但 getter 还没跑!结果槽还是原有字节 ││           └─ 返回 opline | ZEND_VM_ENTER_BIT                       ││ 控制权回到 JIT 生成的机器码 ││   ├─ ★ FUNC_ARG 无 hook-enter check                                ││   ├─ ★ JIT 编译期就把下一条 SEND_FUNC_ARG 的机器码顺序排在后面 ││   └─ ★ SEND_FUNC_ARGEX_VAR(opline->result.var) 读参数 ││      —— getter 还没跑,读的是相邻 property slot 的原始字节 │└────────────────────────────────────────────────────────────────────┘

拿到的”字节序列”被 file_get_contents() 当路径处理,就出现了:

  • \0ValueError: must not contain any null bytes
  • 是对象指针 → TypeError: expected string, got <adjacent class>
  • 覆写了堆头 → zend_mm_heap corrupted + SIGABRT

三种”看起来毫无关联”的错误,其实是同一颗子弹打出的不同弹孔。


六、两版修复的对比:为什么最终选 JIT-only

排查完成后,第一版补丁在引擎侧下刀,第二版换到JIT 侧,走了完全不同的路。这一段值得单独讲。

6.1 第一版(已被上游拒绝):zend_std_read_property() 加 opline check

思路:既然 bug 是 FUNC_ARG opline 下 SIMPLE_GET bit 被错误 prime,那就在 slow path 里加一道 opline 判断,只有当前 opline 是 ZEND_FETCH_OBJ_R 时才允许 prime。

// Zend/zend_object_handlers.c  zend_std_read_property()
const zend_execute_data *execute_data = EG(current_execute_data);
if (EXPECTED(cache_slot
 && EX(opline) && EX(opline)->opcode == ZEND_FETCH_OBJ_R   // ← 新加
 && zend_execute_ex == execute_ex && ce->default_object_handlers->read_property == zend_std_read_property && !ce->create_object && !zend_is_in_hook(prop_info) && !(prop_info->hooks[ZEND_PROPERTY_HOOK_GET]->common.fn_flags & ZEND_ACC_RETURN_REFERENCE))) { ZEND_SET_PROPERTY_HOOK_SIMPLE_GET(cache_slot);}

风格上与同函数对 SIMPLE_READ 的处理一致(SIMPLE_READ 早有此 guard),改动只有一行判断。pure self-primed 场景确实修好了:FUNC_ARG opline 永远不会把 SIMPLE_GET 打上去,FUNC_ARG 每次都走 slow path,getter 每次实打实执行。

reviewer @arnaud-lb@iliaal 的反馈把这版打回:

  • @iliaal 的关键指出:这个 fix 不覆盖 sibling-slot 变体。因为 compact_literals 会把同一 property 的 FETCH_OBJ_RFETCH_OBJ_FUNC_ARG 两条 opline 的 cache slot 合并成同一个。只要项目里有一处普通 FETCH_OBJ_R(例如 $warm = $this->path;),它就会把 SIMPLE_GET bit 打上,之后紧跟的 FETCH_OBJ_FUNC_ARG 一样爆炸。
  • @arnaud-lb 的层级判断:bug 的根源在 JIT 生成代码没做 hook-enter guard,应当在 JIT 层修,不该让引擎侧背这个锅。

一句话:第一版修的是”污染源”,但污染源不止一处

6.2 第二版(最终版):function JIT 里给 FETCH_OBJ_FUNC_ARG 加 hook-enter guard

对齐 tracing JIT 修 GH-21006 时用过的 GH-21369 形状——JIT-only 补丁,让 FETCH_OBJ_FUNC_ARG 的机器码里显式插入 hook-enter guard,与 FETCH_OBJ_R 走同一条路径。

核心补丁只碰两个文件(对应仓库里 fix84.py 生成的最终形态):

6.2.1 ext/opcache/jit/zend_jit.c:把 FUNC_ARG 引到 R 的编译路径

FETCH_OBJ_R/IS/W 那一组 case 之前多加一个 case label,然后把 by-value / by-ref 拆开:

case ZEND_FETCH_OBJ_FUNC_ARG:
case ZEND_FETCH_OBJ_R:
case ZEND_FETCH_OBJ_IS:
case ZEND_FETCH_OBJ_W:
 /* ... */ if (opline->opcode == ZEND_FETCH_OBJ_FUNC_ARG) { /* 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. */ if (!zend_jit_fetch_obj_func_arg(jit, opline, op_array, ssa, ssa_op, op1_info, op1_addr, ce, ce_is_instanceof, on_this)) { goto jit_failure; } } else { 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; } }

关键点:

  • FUNC_ARG 不再走 default: 分支——default: 是纯 zend_jit_handler() 调用,没有 hook-enter guard。
  • 但 FUNC_ARG 也不能直接复用 FETCH_OBJ_R case,因为 by-ref 时它必须尾调到 FETCH_OBJ_Wby-ref 的传参模式只有到 DO_FCALL_BY_NAME 才能确定。所以拆一个专用 helper zend_jit_fetch_obj_func_arg()

6.2.2 ext/opcache/jit/zend_jit_ir.c:新的 helper zend_jit_fetch_obj_func_arg()

这个 helper 就是”FUNC_ARG 版” 的 R fetch,内嵌了运行时 by-ref check + hook-enter 逻辑:

static int zend_jit_fetch_obj_func_arg(zend_jit_ctx *jit, const zend_op *opline,
 zend_op_array *op_array, zend_ssa *ssa, const zend_ssa_op *ssa_op, uint32_t op1_info, zend_jit_addr op1_addr, zend_class_entry *ce, bool ce_is_instanceof, bool on_this){
 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)) { return 0; } }
 /* 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: cold. FUNC_ARG handler 会再自查一次 flag,然后 tail-call 到 FETCH_OBJ_W */ ir_IF_TRUE_cold(if_by_ref); if (!zend_jit_handler(jit, opline, zend_may_throw(opline, ssa_op, op_array, ssa))) { return 0; } end_by_ref = ir_END();
 /* zend_jit_handler() 在 by-ref 路径上把 IP 设成了 opline+1;
 * 这个编译期结论在 by-val 分支以及 merge 之后都不成立,reset 掉。 */ zend_jit_reset_last_valid_opline(jit);
 /* by-val path:走 zend_jit_fetch_obj —— 与 FETCH_OBJ_R 完全同款,
 * hook-enter guard 也就自动就位。 */ ir_IF_FALSE(if_by_ref); if (!zend_jit_fetch_obj(jit, 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))) { return 0; } ir_MERGE_WITH(end_by_ref);
 return 1;}

要点:

  • by-val 分支 直接调 zend_jit_fetch_obj()——这跟 FETCH_OBJ_R case 用的是同一个函数,FETCH_OBJ_R 的 hook-enter guard 是 zend_jit_fetch_obj() 里生成的,所以 FUNC_ARG 的 by-val 也自然拿到 guard。
  • by-ref 分支 走 cold(ir_IF_TRUE_cold),因为绝大多数场景不 by-ref;走 zend_jit_handler() 的正常路径,尾调进 FETCH_OBJ_W handler。FETCH_OBJ_W 没有 SIMPLE_GET fast path,不会 push getter frame,所以 guard 在这一支上是 runtime no-op。
  • delayed call chain flush:进 branch 前必须把 delayed call chain 冲掉,避免 by-val 分支和 merge 之后拿到过时的 EX(call)。这个坑是本地跑 Zend/tests/property_hooks 全套时才踩到的边界情况。
  • zend_jit_reset_last_valid_opline()zend_jit_handler() 会告诉后续 codegen “IP 已经是 opline+1“,这只在 by-ref 分支成立,进 merge 前必须撤销。

一次改动,两个变体(self-primed 与 sibling-primed)同时闭合

6.2.3 补丁自动化脚本

上面两段代码是通过一个幂等 Python 脚本 fix84.py 打进 php-84-gh22857/ 源码树的(比 git apply 更容易多次跑不出错),锚点用的是”case label 前的三行 R/IS/W” 和”RES_REG_ADDR()+zend_may_throw(...))“这一段函数调用的唯一签名,两个锚点互不重叠,可以放心反复运行。脚本本身不到 170 行,逻辑就是”找锚点 → 花括号深度匹配 → 替换”,跟本文关系不大,此处不展开。

6.3 对比表

维度 引擎侧方案 JIT-only 方案(最终)
落点 Zend/zend_object_handlers.c ext/opcache/jit/zend_jit.c + zend_jit_ir.c
修复思路 拒绝在 FUNC_ARG opline 下 prime SIMPLE_GET 让 JIT 生成的 FUNC_ARG 机器码带 hook-enter guard
Self-primed 场景 ✅ 覆盖 ✅ 覆盖
Sibling-primed 场景 ❌ 不覆盖(compact_literals 合并 slot) ✅ 覆盖
对非 JIT 路径的影响 有:所有 hook 首次读都要多做一次 opline check
与 tracing JIT 修 GH-21006 的对称性 不对称 对称(形状对齐 GH-21369)
上游态度 Rejected Accepted — Fixes GH-22857

七、CI 里冒出来的另一只虫:FETCH_OBJ_R + hooked 属性的内存泄漏

PR 提交之后,gh22857.phpt 在 CI debug build 上报了 200 个 64 字节泄漏。第一反应是”补丁有问题”。本地插桩 zend_alloc.c 打印泄漏块内容,发现:

  • 200 个泄漏块全部是 hook 返回值 zend_string
  • refcount 都是 0——JIT 生成的机器码里做过一次 GC_DELREF 但没走 free-on-zero;
  • 把泄漏触发行 $warm = $this->path; 里的 $this->path 换成非 hook 的普通属性,就不泄漏;
  • 把仓库整个回滚到 master 未修改 zend_jit.c 的状态,同样泄漏

铁证:这是 master 本身就有的 pre-existing bug,跟本 PR 无关。

定位到 Zend/Optimizer/zend_inference.cFETCH_OBJ_R 的类型推断有一段”如果类没有 create_object / read_property 未覆盖 / 没有 __get,那么结果不可能是 RC1”,用来消一条 refcount 处理路径:

// zend_inference.c,ZEND_FETCH_OBJ_R 的类型推断片段
if (ce
 && !ce->create_object && ce->default_object_handlers->read_property == zend_std_read_property && !ce->__get && !result_may_be_separated(ssa, ssa_op)) { tmp &= ~MAY_BE_RC1;}

对普通声明属性这条推断是正确的(fetch 只是 copy,得到的一定是 RCN)。但它没有把 hooked/virtual property 排除掉——hooked property 走 zend_std_read_property 时会执行 getter,getter 返回的可能是全新的 RC1 字符串。SSA 类型里丢掉 MAY_BE_RC1 之后,JIT 就按 RCN 处理,GC_DELREF 不带 free-on-zero,泄一个漏一个。

处理方式:

  • PR 里不修这个泄漏(超出 GH-22857 scope)。
  • 修改测试里那条泄漏触发行为 $this->prev = $this->path;——依然能对同一 property 打上 SIMPLE_GET bit(sibling-primed 场景需要的),但把结果落到 CV 换成落到 property slot,绕开 CV destroy 那条泄漏路径。
  • 单独提一个新 issue,把这个 pre-existing 泄漏交给上游处理。修法方向是 zend_inference.c 里那一段 tmp &= ~MAY_BE_RC1; 需要在 prop_info->hooks 非空时不生效。

顺带一提:这个泄漏对内存布局非常敏感——不同脚本路径 / SHM 布局下时隐时现。这就解释了排查早期”改循环次数或换 phpt 路径就不泄漏”的诡异现象,跟本文主 bug 无关。


八、回归测试

ext/opcache/tests/jit/gh22857.phpt(200 轮,覆盖两条 primer 路径):

--TEST--
GH-22857 Function JIT: FETCH_OBJ_FUNC_ARG on a virtual property hook must not send garbage
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=64M
opcache.jit=1205
--EXTENSIONS--
opcache
--FILE--
<?php
namespace Regression;

interface HandlerInterface { public function noop(): void; }

final class DefaultHandler implements HandlerInterface {
 private static ?self $i = null; public static function getInstance(): self { return self::$i ??= new self(); } public function noop(): void {}}

/* Variant 1 — self-primed: same FETCH_OBJ_FUNC_ARG opline primes and consumes. */
class Container {
 public protected(set) HandlerInterface $handler; public string $path { get => self::build($this->kind, $this->id); } protected mixed $prev = null; public function __construct( public protected(set) string $kind, public protected(set) string $id, ) { $this->handler = DefaultHandler::getInstance(); } public static function build(string $k, string $i): string { return "/nonexistent/regression_{$k}_{$i}.dat"; } public function step(): void { $r = @file_get_contents($this->path); if ($r !== false) throw new \RuntimeException('unexpected non-false'); }}

/* Variant 2 — sibling-primed: FETCH_OBJ_R stashed into a property warms the
 shared cache slot, then FETCH_OBJ_FUNC_ARG consumes it. */class Container2 extends Container {
 public function step(): void { $this->prev = $this->path;                        // FETCH_OBJ_R warm-up $r = @file_get_contents($this->path);             // FETCH_OBJ_FUNC_ARG if ($r !== false) throw new \RuntimeException('unexpected non-false'); }}

$c1 = new Container('alpha', 'beta');
$c2 = new Container2('gamma', 'delta');
for ($i = 0; $i < 200; $i++) {
 try { $c1->step(); $c2->step(); } catch (\Throwable $e) { printf("iter=%d threw %s: %s\n", $i, $e::class, $e->getMessage()); exit(1); }}
echo "OK\n";
?>
--EXPECT--
OK

保底验证方式:把补丁 revert 掉再跑必须 FAIL,把补丁应用回来必须 PASS。只有这两者同时成立,才能证明修复真的生效。

本地结果:

  • ext/opcache/tests/jit/gh22857.phpt:PASS,0 泄漏(debug build)
  • ext/opcache/tests/jit 全套:0 failure
  • Zend/tests/property_hooks(默认 + -d opcache.jit=1205 -d opcache.jit_hot_func=1):0 failure
  • 复现矩阵:opcache.jit=disable/1201..1205/tracing 全部干净

九、临时缓解:应用侧 / 运行时的六种改法

如果你还没升级到带这个 patch 的 PHP,或者短期不方便升,可以在应用侧或 ini 侧做以下任一改动来规避 —— 破坏 8 条件里任一条即可:

# 改法 破坏的条件 改动量 推荐
1 给 hook 加 backing store:public string $filename; + 构造赋值 条件 4 2 行 ⭐⭐⭐⭐⭐
2 \ 前缀:@\file_get_contents(...) 条件 3 1 处 ⭐⭐⭐⭐⭐
3 use function file_get_contents; 放文件顶部 条件 3 1 行 ⭐⭐⭐⭐⭐
4 局部变量中转:$p = $this->filename; @file_get_contents($p); 条件 5 1 行 ⭐⭐⭐⭐
5 去掉 @,改 try/catch 或忽略 warning 条件 6 视代码 ⭐⭐⭐
6 hook 改为普通 getter 方法 条件 4 全项目改 ⭐⭐

本次生产环境实际采用的是方案 1:给 $filename 加 backing store,构造时算一次。因为 $serviceType$actIdprotected(set),构造后不可变,$filename 也就一直是常量,”构造时缓存一次”和”每次算”完全语义等价,甚至更省 CPU。

运行时兜底:php.ini 里显式写死 opcache.jit = disableopcache.jit = tracing,避免默认值飘。Swoole/IO-bound 应用 JIT 收益本来就 <5%,disable 代价可忽略;CPU-bound 应用可退到 tracing 模式(tracing JIT 不受此 bug 影响)。


十、复盘与教训

10.1 三种”看似无关”的症状 = 一颗子弹

ValueError(null byte)、TypeError(相邻对象)、heap corrupted(覆写堆头)——从错误信息看完全指向三个方向,其实全是 SEND_FUNC_ARG 读了未被填充的结果槽,读到的字节内容不同而已。

教训:出现多种随机症状但共享一个触发路径时,优先怀疑”堆内存被破坏的下游表现”,而不是三个独立 bug。

10.2 涉及 JIT/优化器 时必做四组正交对照

  • opcache.enable=0
  • opcache.enable=1 + jit=disable
  • opcache.enable=1 + jit=tracing
  • opcache.enable=1 + jit=1205

四组都跑过,才能判断到底哪一层出问题。第一次错误宣称”优化器 bug”就是因为只跑了 opcache.enable=0,没跑 enable=1 + jit=disable

10.3 修复层级:污染源 vs 消费者

第一版打在污染源(zend_std_read_property 的 SIMPLE_GET prime),只能覆盖一处污染源;第二版打在消费者(JIT 生成的 FUNC_ARG 机器码),覆盖所有污染源。

教训:修一个数据流 bug 前,问自己:”污染源只有这一处吗?合并/优化管线会不会把污染引到别处?”如果答案不确定,改到消费侧通常更稳。

10.4 CI 泄漏不一定是你 PR 的锅

CI 上的 200 个泄漏在本地重新验证:把 zend_jit.c 回滚到 master 一样泄漏。这时候要顶住”我的 PR 有问题”的直觉,把根因找到、单独交给 upstream、然后调整测试规避掉——不是我的 scope,就不背这个锅。

10.5 写 phpt 复现要避开”specialized-compiled” 函数

早期用 strlen($this->path) 复现,一直不触发。因为 strlenzend_compile.c:5339 特殊编译成一元 ZEND_STRLEN opcode,不走 INIT_NS_FCALL_BY_NAME,条件 3 直接失守。同类”看起来是普通函数、其实是专用 opcode”的名字还有:is_null / is_int / intval / count / get_class / func_num_args 等一大批。写 JIT 相关 opcode 级复现时一定要选没有 special compile 的函数(比如 file_get_contents)。


十一、结语

从”报警响起”到”PR merged”跨了将近一周,10 条错误假设、两版补丁(第一版被拒重写)、CI 上再冒出一只无关的 pre-existing 泄漏。最终修复的核心逻辑其实就一段 —— 让 JIT 生成 FETCH_OBJ_FUNC_ARG 的机器码时,走跟 FETCH_OBJ_R 一样的路径,把那个”handler 返回后如果 IP 变了就切帧”的 hook-enter guard 也带上,by-ref 时才 fallback 到通用 handler。

一段代码,闭合了两个变体,对齐了 tracing JIT 早就有的形状。

如果你的生产环境是 PHP 8.4.x + property hook 组合,值得跑一下上文 8 条件表自查一遍;如果你在给 php/php-src 提 PR,希望本文能帮你少走几条弯路——尤其是:下刀之前,先把污染源盘清楚,再决定该在污染源修还是在消费侧修。


附录:延伸阅读


本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2天前 自动加精
codezhao
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 6
kinyou

的确是很有见解

3周前 评论
jiangjun

6,能想到那里去,很厉害

3周前 评论
fatrbaby

坦率地说,如果是我的话,我可能解决不了这个问题,非常惭愧。

2周前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!