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

关键词:PHP 8.4、Property Hook、Asymmetric Visibility、OPcache、JIT、opcache.jit=1205FETCH_OBJ_FUNC_ARGSIMPLE_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 个错误方向。这里如实记录,供后来人参考:

阶段假设验证方法结果
1PHP 版本太老,不支持 property hookphp -v❌ 是 8.4.23,早支持了
2是已知 issue GH-17376(JIT + property hook)检查 changelog❌ 8.4.6 已修,不是这条
3Swoole 协程内存踩坏单进程 CLI 复现❌ CLI 也崩,与协程无关
4Property hook 引擎实现 bug写 minimal repro❌ 简化后不触发
5私有 CurlClient 踩堆换成 PHP 原生 curl❌ 依然崩
6响应体过大换成 mock 数据❌ 依然崩
7opcache.file_cache 缓存了旧 opcode检查 ini❌ 未启用
8opcache.preload检查 ini❌ 未启用
9JIT 层 bugopcache.jit=disable不崩了!
10OPcache 优化器 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 条必要条件,缺一不可

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

三种"看起来毫无关联"的错误,实际上是同一颗子弹打出不同弹孔的问题。


真正的最小侵入修复只需要一处判断 —— 在 Zend/zend_object_handlers.czend_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 照样启用,性能无损。

如果你还没升级到 patch 后的 PHP,或者不方便升 PHP,可以在应用代码或运行时配置侧做以下任一改动来规避:

#改法破坏的条件改动量推荐度
1给 hook 加 backing storepublic string $filename; + 构造函数一次性赋值条件 42 行⭐⭐⭐⭐⭐
2\ 前缀@\file_get_contents(...)条件 31 处⭐⭐⭐⭐⭐
3use function:文件顶部 use function file_get_contents;条件 31 行⭐⭐⭐⭐⭐
4局部变量中转$p = $this->filename; @file_get_contents($p);条件 51 行⭐⭐⭐⭐
5去掉 @:改成 try/catch 或忽略 warning条件 6视代码⭐⭐⭐
6hook 改成方法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$actIdprotected(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

保底验证方式:把 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 / 优化器时,必须做多组正交对照

四组都跑过,才能定位到底哪一层。

写 phpt 复现时,我一开始用了 strlen($this->path),结果发现 strlenzend_compile.c:5339 特殊编译成一元 ZEND_STRLEN opcode 的,不走 INIT_NS_FCALL_BY_NAME。类似的还有 is_nullis_intintvalcountget_class 等一大批"看起来是普通函数、其实编译成专用 opcode"的名字。

教训:写 JIT 相关的 opcode 级复现时,一定要用没有 special compile 的函数(如 file_get_contents)。


从这个 bug 出发,可以总结出几条更通用的 PHP 8.4 使用建议:

  1. 虚拟 property hook 慎用:能加 backing store 就加,纯 virtual hook(get-only + 无 backing)在 8.4 早期版本容易踩边缘情况。

  2. 命名空间里的函数调用加 \ 前缀\file_get_contents(...)file_get_contents(...) 更快(生成 INIT_FCALL 而不是 INIT_NS_FCALL_BY_NAME),还能绕开这次的 bug。php-cs-fixernative_function_invocation 规则可以一次性搞定:

    'native_function_invocation' => [
       'include' => ['@all'],
       'scope' => 'namespaced',
       'strict' => true,
    ],
    
  3. 升级前跑一轮混沌测试:涉及 property hook + asymmetric visibility 的类,升到 8.4.x 后跑一遍完整业务用例,别指望"编译过 + 单测过"就万事大吉。

  4. 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,希望本文能帮你少走几条弯路。


zhaohao

大家好,欢迎来到赵豪博客!赵豪,94年生人,PHP程序员一枚,因为对PHP开发有着相对比较浓厚的兴趣,所以现在从事着PHP程序员的工作。 今天再次开通这个博客,这里将记录我的职业生涯的点点滴滴,感谢来访与关注!如果我的博客能给您带来一些帮助那真是一件非常荣幸的事情~

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

微信扫一扫

微信扫一扫

微信扫一扫,分享到朋友圈

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

显示

忘记密码?

显示

显示

获取验证码

Close