一次真实生产事故的完整复盘: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_GETfast 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 corrupted → SIGABRT |
二、事故代码:一个把 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_R,ext/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_ARGopline 在上一轮走过 slow path,在zend_std_read_property()里被打上了 bit。 - Sibling-primed:另一条
FETCH_OBJ_Ropline(读同一 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 A,SIMPLE_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_ARG 从 EX_VAR(opline->result.var) 读参数 ││ —— getter 还没跑,读的是相邻 property slot 的原始字节 │└────────────────────────────────────────────────────────────────────┘
拿到的”字节序列”被 file_get_contents() 当路径处理,就出现了:
- 含
\0→ValueError: 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_R和FETCH_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_Rcase,因为 by-ref 时它必须尾调到FETCH_OBJ_W,by-ref的传参模式只有到DO_FCALL_BY_NAME才能确定。所以拆一个专用 helperzend_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_Rcase 用的是同一个函数,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_Whandler。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.c:FETCH_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 failureZend/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 和 $actId 是 protected(set),构造后不可变,$filename 也就一直是常量,”构造时缓存一次”和”每次算”完全语义等价,甚至更省 CPU。
运行时兜底:php.ini 里显式写死 opcache.jit = disable 或 opcache.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=0opcache.enable=1 + jit=disableopcache.enable=1 + jit=tracingopcache.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) 复现,一直不触发。因为 strlen 被 zend_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,希望本文能帮你少走几条弯路——尤其是:下刀之前,先把污染源盘清楚,再决定该在污染源修还是在消费侧修。
附录:延伸阅读
- 官方 issue:GH-22857 — Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a virtual property hook when calling an unqualified namespaced-fallback function
- 对称的 tracing JIT 修复:GH-21006 → GH-21369
- PHP 8.4 Property Hooks RFC:wiki.php.net/rfc/property-hooks
- PHP 8.4 Asymmetric Visibility RFC:wiki.php.net/rfc/asymmetric-visibi...
- Zend VM 内部机制:
Zend/zend_vm_def.h、Zend/zend_object_handlers.c - OPcache JIT 实现:
ext/opcache/jit/zend_jit.c、ext/opcache/jit/zend_jit_ir.c - 类型推断(附带的 FETCH_OBJ_R hooked-property RC1 泄漏方向):
Zend/Optimizer/zend_inference.c - OPcache JIT 参数解读:
opcache.jit=CRTO四位数分别代表 opt_level / trigger / opt_flags / cpu_flags
完
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: