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

PHP 8.4+ JIT 属性 Hook 踩堆全解析:Property Hook + Asymmetric Visibility 的边缘触发条件&&虚拟 property 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


一、事故现象:一个 PHP Warning 引出的血案

线上活动服务运行几个小时后,日志开始随机出现三种”看起来毫无关联”的异常,接着 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 的三种投影。


二、代码定位:一个再普通不过的 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;

    // ③ promoted properties + 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(8.4 新特性)
  • Asymmetric visibility(8.4 新特性)
  • Constructor promoted properties + asymmetric visibility(8.4 新特性)

问题就出在这三张”新牌”叠在一起,正好命中了 PHP 8.4 OPcache JIT 里一个还没被发现的 bug。


三、排查过程:走了 10 条弯路

从”看到崩溃”到”锁定 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:把 8 万行的项目砍到 100 行的 phpt

有了触发方向,接下来就是最小复现。我写了一套 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 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();

运行结果:

$ 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 的完整机制:Zend + JIT 两层的合谋

搞清楚 bug 需要把 PHP 引擎内部的三块拼图拼起来。

5.1 拼图一:SIMPLE_GET 缓存位

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 返回后回来继续。

5.2 拼图二:FETCH_OBJ_FUNC_ARGFETCH_OBJ_R 共享 handler

问题的第一颗种子在这里:

// 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

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

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 了就退出”这一分支。

5.4 三张拼图拼起来 —— Bug 的完整触发流程

┌─────────────────────────────────────────────────────────────────┐
│ 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_ARGEX_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    │    │
│  └───────────────────┴─────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

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


六、修复方案:一处 opline 检查,堵住污染源

真正的最小侵入修复只需要一处判断 —— 在 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 忘加了)。

6.1 为什么这就够了

  • 纯 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,可以在应用代码或运行时配置侧做以下任一改动来规避:

7.1 应用代码:打破 8 条件里任一条

# 改法 破坏的条件 改动量 推荐度
1 给 hook 加 backing storepublic 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 string $filename;

    protected mixed $prevCfg = null;

    public function __construct(
        public protected(set) string $serviceType,
        public protected(set) string $actId,
    ) {
        $this->logger   = StdoutLogger::getInstance();
        // 构造时算一次,之后每次读直接取属性槽
        $this->filename = self::getConfigFileName($serviceType, $actId);
    }
    // ...
}

为什么这个改动”语义等价”?因为 $serviceType$actIdprotected(set),构造后不能改;类内部也没有任何地方给它们赋新值 → $filename 在对象生命周期内本来就是常量 → 构造时缓存 vs 每次算完全等价,甚至更省 CPU

7.2 运行时配置:全局兜底

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 全局兜底,双保险。


八、Patch 与测试

8.1 Patch 文件清单

一次 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(+)

8.2 phpt 回归测试(Zend/tests/property_hooks/virtual_hook_as_func_arg.phpt)

--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 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 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 那么难查

事后回看,这个 bug 有几个”反直觉”的特性让 debug 特别费劲:

9.1 三种症状看起来完全无关

ValueError 讲的是”null byte”、TypeError 讲的是”类型不对”、heap corrupted 讲的是”内存管理”——从错误信息你根本猜不到它们指向同一个 root cause(读了错位的属性槽)。

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

9.2 Minimal repro 出奇的难

前后写了 5 版 repro_*.php,简化到只剩 30 行的 hook + asymmetric visibility,都不触发。最后靠 delta debugging 才发现”复杂类环境”这个条件里,其实关键是”namespace + 未加 \ 的函数调用 + @ 静默”这一组特定 opcode 组合。

教训:JIT bug 的 minimal repro 常常需要精确到 opcode 层面的组合,光看 PHP 代码往往简化不下去。

9.3 中间被自己的错误结论误导

我在第 10 步曾错误宣称”是 opcache 优化器 bug”,因为我只跑了 opcache.enable=0 的对照,没跑 opcache.enable=1 + jit=disable 的对照。用户一句”怎么验证出来的”直接把我打回原形。

教训:涉及 JIT / 优化器时,必须做多组正交对照

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

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

9.4 strlen 是隐形陷阱

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


附录:延伸阅读


本作品采用《CC 协议》,转载必须注明作者和本文链接
codezhao
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 4
kinyou

的确是很有见解

6天前 评论
jiangjun

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

5天前 评论

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