关键词:PHP 8.4、Property Hook、Asymmetric Visibility、OPcache、JIT、opcache.jit=1205、FETCH_OBJ_FUNC_ARG、SIMPLE_GET、zend_mm_heap corrupted、为什么虚拟 property hook 会读到相邻属性的内存?环境:PHP 8.4.23 (NTS, Alpine, Docker) + Swoole
opcache.jit=1205、FETCH_OBJ_FUNC_ARG、SIMPLE_GET、zend_mm_heap corrupted、为什么虚拟 property hook 会读到相邻属性的内存?环境:PHP 8.4.23 (NTS, Alpine, Docker) + Swoole线上活动服务运行几个小时后,日志开始随机出现三种"看起来毫无关联"的异常,接着 worker 就 SIGABRT 被 supervisor 拉起:
[level=WARNING] file_get_contents(): Path must not be empty
[level=WARNING] dumpToYaml(): Argument #2 must be of type ?string, StdoutLogger given
zend_mm_heap corrupted
signal=6 (SIGABRT)
三个报错分别指向不同的调用点、不同的参数、不同的类型,看起来完全是三个 bug。
但事后证明它们是同一个 bug 的三种投影。
事故最终定位在 项目里一个用于读取活动配置的类:
<?php
namespace App\ConfigCenter;
class ActConfig
{
// ① asymmetric visibility 属性
public protected(set) LoggerInterface logger;
// ② 虚拟 property hook:get-only,无 backing storage
public stringfilename {
get => self::getConfigFileName(this->serviceType,this->actId);
}
protected mixed prevCfg = null;
// ③ promoted properties + asymmetric visibility
public function __construct(
public protected(set) stringserviceType,
public protected(set) string actId,
) {this->logger = StdoutLogger::getInstance();
}
public static function getConfigFileName(string serviceType, stringactId): 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(8.4 新特性)
- Asymmetric visibility(8.4 新特性)
- Constructor promoted properties + asymmetric visibility(8.4 新特性)
问题就出在这三张"新牌"叠在一起,正好命中了 PHP 8.4 OPcache JIT 里一个还没被发现的 bug。
从"看到崩溃"到"锁定 JIT bug",我们走了 10 个错误方向。这里如实记录,供后来人参考:
| 阶段 | 假设 | 验证方法 | 结果 |
|---|---|---|---|
| 1 | PHP 版本太老,不支持 property hook | php -v | ❌ 是 8.4.23,早支持了 |
| 2 | 是已知 issue GH-17376(JIT + property hook) | 检查 changelog | ❌ 8.4.6 已修,不是这条 |
| 3 | Swoole 协程内存踩坏 | 单进程 CLI 复现 | ❌ CLI 也崩,与协程无关 |
| 4 | Property hook 引擎实现 bug | 写 minimal repro | ❌ 简化后不触发 |
| 5 | 私有 CurlClient 踩堆 | 换成 PHP 原生 curl | ❌ 依然崩 |
| 6 | 响应体过大 | 换成 mock 数据 | ❌ 依然崩 |
| 7 | opcache.file_cache 缓存了旧 opcode | 检查 ini | ❌ 未启用 |
| 8 | opcache.preload | 检查 ini | ❌ 未启用 |
| 9 | JIT 层 bug | opcache.jit=disable | ✅ 不崩了! |
| 10 | OPcache 优化器 pass bug(曾一度定论) | opcache.enable=0 | ✅ 不崩 —— 但可能只是巧合 |
第 10 步之后我一度错误地下结论说是"OPcache 优化器 bug"。用户接着提了个致命问题:
"你确定 JIT 是否启用不影响 bug 触发?怎么验证出来的?"
我在容器里做了决定性的对照实验:
# 测试 1:opcache 开 + JIT 关
timeout 25 php \
-d opcache.enable=1 \
-d opcache.enable_cli=1 \
-d opcache.jit=disable \
swoole_server_main.php start 2>&1 | grep -E "Path must|heap corrupted"
# 结果:无任何错误输出,完全干净
# 测试 2:opcache 开 + JIT 开(1205,默认)
timeout 25 php \
-d opcache.enable=1 \
-d opcache.enable_cli=1 \
-d opcache.jit=1205 \
swoole_server_main.php start 2>&1 | grep -E "Path must|heap corrupted"
# 结果:Path must not be empty 稳定复现
铁证到手:这是纯粹的 JIT bug(不是优化器 bug、不是引擎 bug、不是 Swoole bug)。
有了触发方向,接下来就是最小复现。我写了一套 delta-debugging 脚本(reduce_*.py),从真实项目开始,每次删/改一小段代码,跑 20 次验证是否还崩,逐步逼近。最终归结出 8 条必要条件,缺一不可:
| # | 条件 |
|---|---|
| 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 属性 |
一个 100 行以内的独立 CLI 复现(gh_issue_repro.php)稳定复现,20/20 触发:
<?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 stringpath { get => self::build(this->kind,this->id); }
protected mixed prev = null;
public function __construct(
public protected(set) stringkind,
public protected(set) string id,
) {this->handler = DefaultHandler::getInstance();
}
public static function build(string k, stringi): string {
return "/tmp/nonexistent_{k}_{i}.dat";
}
public function step(): void {
@file_get_contents($this->path); // ← 触发
}
}
(new Container('alpha', 'beta'))->step();
运行结果:
$ php -d opcache.enable_cli=1 -d opcache.jit=disable repro.php
# 无输出,正常退出
$ php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M \
-d opcache.jit=1205 repro.php
PHP Warning: file_get_contents(): Argument #1 ($filename) must not contain
any null bytes in repro.php on line ...
搞清楚 bug 需要把 PHP 引擎内部的三块拼图拼起来。
PHP 8.4 引入 property hook 后,Zend 为了让 hook 读取尽可能快,在运行时缓存槽(cache slot)里放了几个 bit 用来做 fast path:
// Zend/zend_object_handlers.h
#define ZEND_PROPERTY_HOOK_SIMPLE_READ_BIT 2u // 直接读 backing store
#define ZEND_PROPERTY_HOOK_SIMPLE_WRITE_BIT 4u
#define ZEND_PROPERTY_HOOK_SIMPLE_GET_BIT 8u // 内联 hook getter,跳过 slow path
当 zend_std_read_property() 首次调用 hook 并成功返回时,它会在 cache slot 上"打勾",下次同一属性访问就能走极速通道 —— 不再走 zend_std_read_property 慢路径,而是直接从 VM handler 里把 hook getter push 到调用栈。
伪代码:
// zend_vm_def.h::ZEND_FETCH_OBJ_R (第 2140 行附近)
if (IS_PROPERTY_HOOK_SIMPLE_GET(prop_offset)) {
// 直接把 hook function push 成一个新 call frame
zend_execute_data *call = zend_vm_stack_push_call_frame(...);
call->return_value = EX_VAR(opline->result.var);
// 返回给 dispatch loop 一个带 ZEND_VM_ENTER_BIT 的 opline
ZEND_VM_ENTER_EX();
}
VM dispatch loop 看到 ENTER_BIT 就知道"哦,进新 frame 了",去执行 hook 的 opcode,hook 返回后回来继续。
问题的第一颗种子在这里:
// 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
} else {
ZEND_VM_DISPATCH_TO_HANDLER(ZEND_FETCH_OBJ_R); // by-value: 落到这里!
}
FETCH_OBJ_FUNC_ARG 在 by-value 情况下直接 tail-call FETCH_OBJ_R 的 handler。opline 还是 FUNC_ARG 的那条,但代码逻辑跑的是 FETCH_OBJ_R 的。
这意味着:FETCH_OBJ_R handler 内部通过 EX(opline) 拿到的 opline,opcode 可能是 ZEND_FETCH_OBJ_FUNC_ARG 而不是 ZEND_FETCH_OBJ_R。
Function-mode JIT(opcache.jit=1205)为不同 opcode 生成机器码。对 FETCH_OBJ_R,它有专门的处理(ext/opcache/jit/zend_jit.c:2857-2886):
case ZEND_FETCH_OBJ_R:
// 生成对 VM handler 的调用
zend_jit_handler(&ctx, opline, ...);
// 关键的 hook-enter guard:
// 如果 handler 返回的 IP 不等于 opline+1,说明 hook 被 push 了
// → 退出 JIT,回到 VM dispatch loop 去跑 hook
if (opline->op2_type == IS_CONST && ...) {
ir_ref if_hook_enter = ir_IF(jit_CMP_IP(jit, IR_EQ, opline + 1));
ir_IF_FALSE(if_hook_enter); // IP != opline+1 → 有 hook!
if (GCC_GLOBAL_REGS) {
ir_TAILCALL(IR_VOID, ir_LOAD_A(jit_IP(jit)));
} else {
zend_jit_vm_enter(jit, jit_IP(jit));
}
ir_IF_TRUE(if_hook_enter); // IP == opline+1 → 正常继续
}
break;
// ...
default:
// 所有其它 opcode(包括 FETCH_OBJ_FUNC_ARG)走这里
zend_jit_handler(&ctx, opline, ...);
// ★ 没有 hook-enter guard!
FETCH_OBJ_FUNC_ARG 走的是 default: —— 生成的机器码里没有"如果 hook 被 push 了就退出"这一分支。
┌─────────────────────────────────────────────────────────────────┐
│ Iteration 1(cache 冷,SIMPLE_GET bit 未设置) │
├─────────────────────────────────────────────────────────────────┤
│ FETCH_OBJ_FUNC_ARG (opline A) │
│ ├─ JIT 生成的机器码:zend_jit_handler → 调 VM handler │
│ ├─ VM handler tail-call 到 FETCH_OBJ_R handler │
│ ├─ cache slot 冷 → fast path 未命中 → 走 slow path │
│ ├─ slow path 调 zend_std_read_property() │
│ ├─ 命中 hooked property 分支,成功调用 getter,返回字符串 │
│ └─ ★ 在 cache slot 打上 SIMPLE_GET bit(bug 就埋在这里! │
│ 此时 EX(opline)->opcode 其实是 FETCH_OBJ_FUNC_ARG 不是 │
│ FETCH_OBJ_R,但补丁前的代码不检查 opcode 就直接打勾) │
│ │
│ 首次调用正常完成。 │
├─────────────────────────────────────────────────────────────────┤
│ Iteration 2(cache 热,SIMPLE_GET bit 已设置) │
├─────────────────────────────────────────────────────────────────┤
│ FETCH_OBJ_FUNC_ARG (opline A) │
│ ├─ JIT 生成的机器码:zend_jit_handler → 调 VM handler │
│ ├─ VM handler tail-call 到 FETCH_OBJ_R handler │
│ ├─ cache slot 热 → prop_offset 位含 SIMPLE_GET bit │
│ ├─ 走 SIMPLE_GET fast path: │
│ │ ├─ 分配 hook 的 call frame │
│ │ ├─ call->return_value = EX_VAR(opline->result.var) │
│ │ ├─ 但 hook 还没跑! │
│ │ └─ ZEND_VM_ENTER_EX() 返回 opline | ZEND_VM_ENTER_BIT │
│ ├─ 控制权回到 JIT 生成的机器码 │
│ ├─ JIT 把返回值当 IP 存起来(含 ENTER_BIT) │
│ ├─ ★ FETCH_OBJ_FUNC_ARG 没有 hook-enter check │
│ ├─ ★ JIT 生成的下一条机器码是 SEND_FUNC_ARG │
│ └─ ★ SEND_FUNC_ARG 从 EX_VAR(opline->result.var) 读参数值 │
│ │
│ 但 hook 还没执行!那个 slot 里是垃圾数据(相邻属性的原始字节) │
│ ↓ │
│ file_get_contents(<相邻属性的字节>) 被调用 │
│ │
│ 症状取决于 slot 里恰好是什么: │
│ ┌───────────────────┬─────────────────────────────────────┐ │
│ │ 相邻是含 \0 的串 │ ValueError: must not contain \0 │ │
│ │ 相邻是 object │ TypeError: expected string, got Obj │ │
│ │ 写回破坏堆头 │ zend_mm_heap corrupted → SIGABRT │ │
│ └───────────────────┴─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Iteration 1(cache 冷,SIMPLE_GET bit 未设置) │
├─────────────────────────────────────────────────────────────────┤
│ FETCH_OBJ_FUNC_ARG (opline A) │
│ ├─ JIT 生成的机器码:zend_jit_handler → 调 VM handler │
│ ├─ VM handler tail-call 到 FETCH_OBJ_R handler │
│ ├─ cache slot 冷 → fast path 未命中 → 走 slow path │
│ ├─ slow path 调 zend_std_read_property() │
│ ├─ 命中 hooked property 分支,成功调用 getter,返回字符串 │
│ └─ ★ 在 cache slot 打上 SIMPLE_GET bit(bug 就埋在这里! │
│ 此时 EX(opline)->opcode 其实是 FETCH_OBJ_FUNC_ARG 不是 │
│ FETCH_OBJ_R,但补丁前的代码不检查 opcode 就直接打勾) │
│ │
│ 首次调用正常完成。 │
├─────────────────────────────────────────────────────────────────┤
│ Iteration 2(cache 热,SIMPLE_GET bit 已设置) │
├─────────────────────────────────────────────────────────────────┤
│ FETCH_OBJ_FUNC_ARG (opline A) │
│ ├─ JIT 生成的机器码:zend_jit_handler → 调 VM handler │
│ ├─ VM handler tail-call 到 FETCH_OBJ_R handler │
│ ├─ cache slot 热 → prop_offset 位含 SIMPLE_GET bit │
│ ├─ 走 SIMPLE_GET fast path: │
│ │ ├─ 分配 hook 的 call frame │
│ │ ├─ call->return_value = EX_VAR(opline->result.var) │
│ │ ├─ 但 hook 还没跑! │
│ │ └─ ZEND_VM_ENTER_EX() 返回 opline | ZEND_VM_ENTER_BIT │
│ ├─ 控制权回到 JIT 生成的机器码 │
│ ├─ JIT 把返回值当 IP 存起来(含 ENTER_BIT) │
│ ├─ ★ FETCH_OBJ_FUNC_ARG 没有 hook-enter check │
│ ├─ ★ JIT 生成的下一条机器码是 SEND_FUNC_ARG │
│ └─ ★ SEND_FUNC_ARG 从 EX_VAR(opline->result.var) 读参数值 │
│ │
│ 但 hook 还没执行!那个 slot 里是垃圾数据(相邻属性的原始字节) │
│ ↓ │
│ file_get_contents(<相邻属性的字节>) 被调用 │
│ │
│ 症状取决于 slot 里恰好是什么: │
│ ┌───────────────────┬─────────────────────────────────────┐ │
│ │ 相邻是含 \0 的串 │ ValueError: must not contain \0 │ │
│ │ 相邻是 object │ TypeError: expected string, got Obj │ │
│ │ 写回破坏堆头 │ zend_mm_heap corrupted → SIGABRT │ │
│ └───────────────────┴─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
三种"看起来毫无关联"的错误,实际上是同一颗子弹打出不同弹孔的问题。
真正的最小侵入修复只需要一处判断 —— 在 Zend/zend_object_handlers.c 的 zend_std_read_property() 里,只有当当前 opline 是纯 ZEND_FETCH_OBJ_R 时才允许写入 SIMPLE_GET bit:
// Zend/zend_object_handlers.c zend_std_read_property()
/* Only prime the SIMPLE_GET fast path for a plain ZEND_FETCH_OBJ_R
* opline. ZEND_FETCH_OBJ_FUNC_ARG dispatches into this same handler
* for by-value argument fetches, but its opline->extended_value can
* carry the ZEND_FETCH_REF bit and its result slot lives inside the
* pending call frame -- neither of which is accounted for by the
* SIMPLE_GET fast path in zend_vm_def.h. Caching the bit under a
* FETCH_OBJ_FUNC_ARG opline would let a later FETCH_OBJ_R (or a
* JIT-compiled FUNC_ARG that has no hook-enter check) hit that fast
* path with a mismatched opline and read garbage from the adjacent
* property slot -- typically surfacing as a "must not contain any
* null bytes" ValueError, an "expected string, got <Class>"
* TypeError, or a heap corruption abort. Mirrors the opline check
* already used for SIMPLE_READ above. */
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);
}
关键就三行:EX(opline)、EX(opline)->opcode == ZEND_FETCH_OBJ_R。这与同函数上方对 SIMPLE_READ 的处理风格完全一致(SIMPLE_READ 早就有这个 guard,只是 SIMPLE_GET 忘加了)。
- 纯 FUNC_ARG 场景(用户的实际问题):cache slot 里的 SIMPLE_GET bit 永远不会被 FUNC_ARG opline 设置 → cache 永远保持"未 prime"状态 → 后续 FUNC_ARG 每次都走 slow path → slow path 里
zend_std_read_property 正确调用 hook 并返回值 → bug 消失。 - 性能影响:只影响"hook 唯一被访问的路径就是 FUNC_ARG"的极端场景,正常场景(
$x = $this->hook、$obj->hook)都是走 FETCH_OBJ_R,SIMPLE_GET fast path 照样启用,性能无损。
zend_std_read_property 正确调用 hook 并返回值 → bug 消失。$x = $this->hook、$obj->hook)都是走 FETCH_OBJ_R,SIMPLE_GET fast path 照样启用,性能无损。如果你还没升级到 patch 后的 PHP,或者不方便升 PHP,可以在应用代码或运行时配置侧做以下任一改动来规避:
| # | 改法 | 破坏的条件 | 改动量 | 推荐度 |
|---|---|---|---|---|
| 1 | 给 hook 加 backing store:public string $filename; + 构造函数一次性赋值 | 条件 4 | 2 行 | ⭐⭐⭐⭐⭐ |
| 2 | 加 \ 前缀:@\file_get_contents(...) | 条件 3 | 1 处 | ⭐⭐⭐⭐⭐ |
| 3 | 加 use function:文件顶部 use function file_get_contents; | 条件 3 | 1 行 | ⭐⭐⭐⭐⭐ |
| 4 | 局部变量中转:$p = $this->filename; @file_get_contents($p); | 条件 5 | 1 行 | ⭐⭐⭐⭐ |
| 5 | 去掉 @:改成 try/catch 或忽略 warning | 条件 6 | 视代码 | ⭐⭐⭐ |
| 6 | hook 改成方法:public function getFilename() { ... } | Hook 本身 | 全项目改 | ⭐⭐ |
其中 方案 1(加 backing store) 是我们本次生产环境实际采用的做法:
class ActConfig
{
public protected(set) LoggerInterface logger;
// 从 pure virtual hook 改成有 backing store 的普通属性
public stringfilename;
protected mixed prevCfg = null;
public function __construct(
public protected(set) stringserviceType,
public protected(set) string actId,
) {this->logger = StdoutLogger::getInstance();
// 构造时算一次,之后每次读直接取属性槽
this->filename = self::getConfigFileName(serviceType, $actId);
}
// ...
}
为什么这个改动"语义等价"?因为 $serviceType 和 $actId 是 protected(set),构造后不能改;类内部也没有任何地方给它们赋新值 → $filename 在对象生命周期内本来就是常量 → 构造时缓存 vs 每次算完全等价,甚至更省 CPU。
在 php.ini 里加一行,作为代码库其它文件不小心又命中 8 条件时的兜底:
opcache.enable = 1
opcache.jit = disable ; 或 opcache.jit = tracing(tracing 模式也不受影响)
- Swoole / IO-bound 应用:JIT 收益本来就 <5%,
disable代价可忽略 - CPU-bound 应用:可以退到
tracing模式(也不受此 bug 影响)
推荐组合:应用侧改 + php.ini 全局兜底,双保险。
一次 minimal patch,只碰 3 个文件:
NEWS | 11 +++
Zend/tests/property_hooks/virtual_hook_as_func_arg.phpt | 94 +++++
Zend/zend_object_handlers.c | 16 +++
3 files changed, 121 insertions(+)
--TEST--
Virtual property hook read via FETCH_OBJ_FUNC_ARG must not prime SIMPLE_GET (function JIT)
--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 {}
}
class Container {
public protected(set) HandlerInterface handler;
public stringpath {
get => self::build(this->kind,this->id);
}
protected mixed prev = null;
public function __construct(
public protected(set) stringkind,
public protected(set) string id,
) {this->handler = DefaultHandler::getInstance();
}
public static function build(string k, stringi): 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 return');
}
}
}c = new Container('alpha', 'beta');
for (i = 0;i < 200; i++) {
try {c->step();
} catch (\Throwable e) {
printf("iter=%d threw %s: %s\n",i, e::class,e->getMessage());
exit(1);
}
}
echo "OK\n";
?>
--EXPECT--
OK
--TEST--
Virtual property hook read via FETCH_OBJ_FUNC_ARG must not prime SIMPLE_GET (function JIT)
--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 {}
}
class Container {
public protected(set) HandlerInterface handler;
public stringpath {
get => self::build(this->kind,this->id);
}
protected mixed prev = null;
public function __construct(
public protected(set) stringkind,
public protected(set) string id,
) {this->handler = DefaultHandler::getInstance();
}
public static function build(string k, stringi): 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 return');
}
}
}c = new Container('alpha', 'beta');
for (i = 0;i < 200; i++) {
try {c->step();
} catch (\Throwable e) {
printf("iter=%d threw %s: %s\n",i, e::class,e->getMessage());
exit(1);
}
}
echo "OK\n";
?>
--EXPECT--
OK
保底验证方式:把 patch revert 掉再跑这个 phpt,必须 FAIL;再把 patch 应用回来,必须 PASS。只有这两个条件同时成立,才能证明修复真的生效。
事后回看,这个 bug 有几个"反直觉"的特性让 debug 特别费劲:
ValueError 讲的是"null byte"、TypeError 讲的是"类型不对"、heap corrupted 讲的是"内存管理"——从错误信息你根本猜不到它们指向同一个 root cause(读了错位的属性槽)。
教训:出现多种"随机症状"时,优先怀疑是"堆内存被破坏"的下游表现,而不是三个独立 bug。
前后写了 5 版 repro_*.php,简化到只剩 30 行的 hook + asymmetric visibility,都不触发。最后靠 delta debugging 才发现"复杂类环境"这个条件里,其实关键是"namespace + 未加 \ 的函数调用 + code>@ 静默"这一组特定 opcode 组合。
教训:JIT bug 的 minimal repro 常常需要精确到 opcode 层面的组合,光看 PHP 代码往往简化不下去。
我在第 10 步曾错误宣称"是 opcache 优化器 bug",因为我只跑了 opcache.enable=0 的对照,没跑 opcache.enable=1 + jit=disable 的对照。用户一句"怎么验证出来的"直接把我打回原形。
教训:涉及 JIT / 优化器时,必须做多组正交对照:
opcache.enable=0opcache.enable=1 + jit=disableopcache.enable=1 + jit=tracingopcache.enable=1 + jit=1205
四组都跑过,才能定位到底哪一层。
写 phpt 复现时,我一开始用了 strlen($this->path),结果发现 strlen 是 zend_compile.c:5339 特殊编译成一元 ZEND_STRLEN opcode 的,不走 INIT_NS_FCALL_BY_NAME。类似的还有 is_null、is_int、intval、count、get_class 等一大批"看起来是普通函数、其实编译成专用 opcode"的名字。
教训:写 JIT 相关的 opcode 级复现时,一定要用没有 special compile 的函数(如 file_get_contents)。
从这个 bug 出发,可以总结出几条更通用的 PHP 8.4 使用建议:
- 虚拟 property hook 慎用:能加 backing store 就加,纯 virtual hook(get-only + 无 backing)在 8.4 早期版本容易踩边缘情况。
命名空间里的函数调用加
\前缀:\file_get_contents(...)比file_get_contents(...)更快(生成INIT_FCALL而不是INIT_NS_FCALL_BY_NAME),还能绕开这次的 bug。php-cs-fixer的native_function_invocation规则可以一次性搞定:'native_function_invocation' => [ 'include' => ['@all'], 'scope' => 'namespaced', 'strict' => true, ],- 升级前跑一轮混沌测试:涉及 property hook + asymmetric visibility 的类,升到 8.4.x 后跑一遍完整业务用例,别指望"编译过 + 单测过"就万事大吉。
Docker 镜像固定 opcache 配置:不管用不用 JIT,
opcache.jit明确写死到php.ini里,别让默认值飘。这次事故就有一部分原因是"我以为没开 JIT"。
这个 bug 从"报警响起"到"提出 PR"跨了将近 3 天。10 条错误假设、5 版失败的 minimal repro、被自己的错误结论坑过一次 —— 最终定论其实就一行代码:
&& EX(opline)->opcode == ZEND_FETCH_OBJ_R
一行代码修一个能让线上服务随机 SIGABRT 的 bug。如果你的生产环境是 PHP 8.4.x + property hook 组合,值得跑一下上面的 delta-debug 条件表自查一遍。如果你正在给 php/php-src 提 PR,希望本文能帮你少走几条弯路。 - 官方 issue:GH-22857Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a virtual property hook when calling an unqualified namespaced-fallback function
- PHP 8.4 Property Hooks RFC:https://wiki.php.net/rfc/property-hooks
- PHP 8.4 Asymmetric Visibility RFC:https://wiki.php.net/rfc/asymmetric-visibility-v2
- 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 - OPcache JIT 参数解读:
opcache.jit=CRTO 四位数分别代表 opt_level / trigger / opt_flags / cpu_flags
完
Zend/zend_vm_def.h、Zend/zend_object_handlers.cext/opcache/jit/zend_jit.c、ext/opcache/jit/zend_jit_ir.copcache.jit=CRTO 四位数分别代表 opt_level / trigger / opt_flags / cpu_flags

