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

关键词: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
对象指针(如 LoggerInterfaceTypeError: expected string, got <adjacent class>
破坏了堆头的字节zend_mm_heap corruptedSIGABRT

<?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;

    // ③ constructor promoted + 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 / asymmetric visibility / promoted asym)叠在一起,正好命中了 function JIT 里一个还没被发现的 bug。


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

组别opcache.enableopcache.jit结果
A0✅ 干净
B1disable✅ 干净
C1tracing✅ 干净
D11205❌ 稳定复现

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


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

#条件
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 属性

一个 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 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();

对应的 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 的关键。


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

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 跑完再回来。

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 的

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,切走"这一分支。

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 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() 当路径处理,就出现了:

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

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


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

思路:既然 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 code>@arnaud-lb 和 code>@iliaal 的反馈把这版打回:

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

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

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

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;
        }
    }

关键点:

这个 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;
}

要点:

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

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

维度引擎侧方案JIT-only 方案(最终)
落点Zend/zend_object_handlers.cext/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)
上游态度RejectedAccepted — Fixes GH-22857

PR 提交之后,gh22857.phpt 在 CI debug build 上报了 200 个 64 字节泄漏。第一反应是"补丁有问题"。本地插桩 zend_alloc.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,泄一个漏一个。

处理方式:

顺带一提:这个泄漏对内存布局非常敏感——不同脚本路径 / 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 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');
    }
}

/* 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-upr = @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 (\Throwablee) {
        printf("iter=%d threw %s: %s\n", i,e::class, $e->getMessage());
        exit(1);
    }
}
echo "OK\n";
?>
--EXPECT--
OK

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

本地结果:


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

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


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

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

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

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

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

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

早期用 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,希望本文能帮你少走几条弯路——尤其是:下刀之前,先把污染源盘清楚,再决定该在污染源修还是在消费侧修。



zhaohao

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

相关推荐

发表回复

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

微信扫一扫

微信扫一扫

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

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

显示

忘记密码?

显示

显示

获取验证码

Close