---
name: 2026-08-11-issue244-oracle-segv-unpack-int32
description: >
  定时巡检发现 issue #244(`bug` 标签,p1 `FuzzOracleDiff`),seed 是 `A(unpack({},0X80000000))`,分支
  `fix/244-unpack-int32-oracle-crash`,1 个 commit `a22de53`。**版本核对这次很干净**:失败 run 的 headSha
  正好就是当时的 master(`e0e7b34`),不可能是过期 seed,在当前 HEAD 上重放确实复现。**崩的是 oracle,不是
  望舒**:重放拿到 SIGSEGV 且栈迹在 cgo 里(`_Cfunc_wangshu_oracle_exec`),拿真的 `lua5.1` 二进制直接试同样
  dumped core,而望舒对同一输入抬 `too many results to unpack`、行为正确、从不崩。机制是:
  `luaB_unpack` 在 int 上算 `n = e - i + 1` 并用 `n <= 0` 检查。那个检查不可靠,但**不是**因为「回绕成正的巨大值」—— `i <= e` 时 int32 回绕结果恒 `<= 0`,那个检查本该拦住所有情形。真实机制是:这个减法本身是有符号溢出 UB,gcc -O2 因此把 `n <= 0` 当不可达**整段删掉**,随后 `lua_checkstack` 收到一个负的 size 而照单接受(`lapi.c` 里两个比较都为假)。同一份源码在 -O0 下会干净抬错 —— 也就是说这个崩溃是**依赖优化等级的**。修法是在 oracle prelude 里包一层 `unpack`,索引落在
  崩溃窗口时抬 `LimitSentinel`,让差分跳过这一类输入 —— 与仓库既有的几个 PUC UB range 处理方式一致。
  **但本轮复核推翻了那个窗口的口径,而且两个方向都不对**:注释与 commit message 都写「窗口是实测出来的,
  只有 -2147483648 与 -2147483647 会崩」,而实测证明窗口不是一对固定值,它由 `e - i + 1` 在 int32 上是否
  上溢决定、随 `e`(默认 `#t`)平移 —— `unpack({1,2,3},-2147483646)` 与 `unpack({},-1,2147483647)` 都让
  PUC dumped core,而现有守卫**不跳它们**,经真实 `FuzzOracleDiff` 确认整个测试二进制被 SIGSEGV 拿下(**守卫
  太窄:漏崩**);同时 `i` 落在 `[2147483648, ...)` 一律跳过,而 `unpack({1,2,3},4294967297)` 两侧都正常
  返回 3(**守卫太宽:漏比对**)。10 组「先预测再实测」全部吻合规则 `crash ⟺ i32 <= e32 且 (e32-i32+1) > INT_MAX`。
  三条教训:差分 harness 报的 crash 第一个要问「谁崩了」/ 「实测」与「推导」是互补而不是互斥,一个被称为
  「实测得来」的边界仍然需要机制来确定它的**形状**(否则实测只覆盖到采样点所在的那一格)/ 限制类守卫要用
  两侧用例验宽度,而且两侧都要沿着**机制**取,不能只沿着被报的那个写法取。
metadata:
  type: reflection
  date: 2026-08-11
---

# 崩的是参照实现,而那个「实测出来的边界」只测到了一格(2026-08-11,commit `a22de53`)

> 范围:issue #244(定时巡检发现,`bug` 标签,p1 `FuzzOracleDiff`),分支
> `fix/244-unpack-int32-oracle-crash`,1 个 commit `a22de53`。改动落在
> `internal/oracle/prelude.go`(包一层 `unpack` 抬 `LimitSentinel`)、`fuzz_244_test.go`(望舒侧行为契约)
> 与 `testdata/fuzz/FuzzOracleDiff/d951d3bd934fe9fc`(seed 入库)。
>
> **本轮是一次文档轮 + 复核轮,没有改任何代码或测试。** 复核的结论是那个守卫的**区间口径两个方向都不对**,
> 详见「窗口的形状」一节;`a22de53` 的方向(崩的是 oracle、该跳过而不是对齐)是对的,错的只有区间。

## 任务

定时巡检发现 nightly 自动开的 issue #244,seed 是:

```lua
A(unpack({},0X80000000))
```

## 本轮做了什么

### 0. 版本核对:这次很干净,而且干净本身是有信息量的

按 [[unreproducible-crasher-triage]]「第一步永远是版本核对」核了一遍:失败 run 的 headSha 正好**就是当时
的 master**(`e0e7b34`),所以「撞的是已修复代码」这条最便宜的解释一步就排除掉了,不可能是过期 seed;在当前
HEAD 上重放确实复现。

这一档落在那一节的**第二档**(失败 headSha 已含全部相关修复 → 便宜解释已排除,继续分诊)。与 #212–#219
那一轮的对照值得记:那一轮八个 run 全落在一个旧 commit 上、字面事实看起来像「过期」,结论却是五个如实复现;
本轮反过来,sha 就是 master、连「看起来像过期」这一步都不用走。两轮一起说明那三档确实只是**成本档**。

### 1. 崩的是 oracle,不是望舒

重放时拿到的是 **SIGSEGV,而且栈迹显示在 cgo 里**(`_Cfunc_wangshu_oracle_exec`)。这一条把调查方向整个
翻了个面:失败信号来自差分 harness,而崩的是 harness 里的**参照实现**。

三条独立证据:

| 证据 | 结果 |
|---|---|
| 内嵌 oracle(`internal/oracle`,cgo 编译的 5.1.5) | SIGSEGV,栈迹在 `_Cfunc_wangshu_oracle_exec` |
| 真的 `lua5.1` 二进制(本机 5.1.5) | `dumped core` |
| 望舒 | 抬 `too many results to unpack`,行为正确、从不崩 |

第三行是关键:望舒在**整个**这一段上都干净(本轮又核了七种写法,含 `-2147483648`、
`unpack({1,2,3},-2147483645)`、`unpack({},-2,2147483645)`、`unpack({},-1000,2147483000)`),所以望舒侧
没有任何东西要修。

### 2. 机制:签名溢出 UB 让编译器删掉了那个检查

`i` 与 `e` 都经 `luaL_optint` / `luaL_checkint` 窄化成 int,`n = e - i + 1` 在 int 上算,
后面用 `n <= 0` 拦「arith. overflow」。

**这里我第一版写的机制是错的,而且错得很自然,值得记下来。** 我写的是「这个减法本身是**有符号溢出 UB**,gcc `-O2` 把 `n <= 0` 检查当不可达删掉,`lua_checkstack` 随后接受一个**负的** `size`(拒绝条件对负值两个都为假)→ 段错误;**同一份源码在 -O0 下干净抬错,所以崩溃依赖优化等级**。这在算术上**不可能**:只要 `i <= e`(否则前面 `i > e` 已经
提前返回了),`e - i + 1` 在 int32 上的回绕结果**恒 `<= 0`**,那个检查本该拦住所有情形。穷举验证过。

**真实机制**:这个减法本身是**有符号溢出 UB**,所以 gcc `-O2` 把 `n <= 0` 当不可达**整段删掉**
(反汇编确认:`-O0` 下有 `cmpl $0x0,-0x4(%rbp); jle`,`-O2` 下这条不存在、直接调 `lua_checkstack`);
随后 `lua_checkstack` 收到一个**负的** `size`,而它的拒绝条件
(`lapi.c`:`size > LUAI_MAXCSTACK || (L->top - L->base + size) > LUAI_MAXCSTACK`)对负值**两个都为假**,
于是照单接受、按那个值走下去 → 段错误。同一份源码在 `-O0` 下干净抬 `too many results to unpack`,
**也就是说这个崩溃依赖优化等级**,而 cgo shim 用的是 `-O2`。

守卫条件不受这次更正影响 —— 它测的是「溢出在什么条件下发生」,两种解释下都是同一个条件。
但错的机制会让读者以为这与优化无关,从而在将来误判这个 skip 还需不需要。

### 3. 修法:在 oracle prelude 里跳过,而不是在望舒侧对齐

`a22de53` 在 prelude 里包一层 `unpack`,索引落在崩溃窗口时抬 `LimitSentinel`,于是差分**跳过**这一类
输入。这与仓库既有的几个 PUC UB range 处理方式一致(`cUnsignedCast` / `cCharCast` / `errorLevelUBRange`
那一族,执行体都是 `internal/oracle/prelude.go` 的 sentinel),判据来自
[[cross-backend-semantic-fix-sweep]]「对齐 PUC 之前先分清有定义还是 UB」——**会死的 oracle 不能当参照**,
一个连结果都给不出来的参照实现没有可对齐的对象。

方向是对的。**区间不对**,见下一节。

### 4. 窗口的形状:本轮复核推翻了「实测出来的一对值」这个口径,而且两个方向都不对

`a22de53` 的守卫是:

```lua
if i ~= nil and __type(i) == "number" and (i <= -2147483647 or i >= 2147483648) then
```

注释与 commit message 都写着这个窗口是**实测**出来的、只有 `-2147483648` 与 `-2147483647` 会崩、
`-2147483646` 及往下都给干净的错误因而应当继续比较,并且明确写「按溢出算术推导出的结论与实测不一致时以
二进制为准」。

**那些具体的测量都是真的,`{}` 上确实只有那两个值崩。** 但它们全部取自 `unpack({}, i)` 这一种写法,而
`{}` 让 `e = #t = 0`。上面那个机制说 `n = e - i + 1`,所以崩溃条件里**有 `e`**;`e` 一变,窗口就平移。
逐值实测确认:

| 写法 | `e` | 崩溃的最大 `i` | 相邻不崩的 `i` |
|---|---|---|---|
| `unpack({}, i)` | 0 | `-2147483647` | `-2147483646` |
| `unpack({1,2,3}, i)` | 3 | `-2147483644` | `-2147483643` |
| `unpack({}, i, 2147483647)` | 2147483647 | `0` | `1` |

于是**规则是可推导的**:`crash ⟺ i32 <= e32 且 (e32 - i32 + 1) > INT_MAX`(`i32`/`e32` 是窄化之后的
int32 值,`e` 默认 `#t`)。本轮按「先写下预测、再实测」的方式扫了 **10 组**,含负侧边界两侧各一格、三种
`e`、`j` 显式给到 `INT_MAX`、`i` 大到回绕、`j` 负到回绕,**10 组全部吻合**。

守卫的两个方向都因此错了,而且**两种失效方式相反**:

**(a) 太窄 —— 漏崩,而这是致命的那一侧。** `e > 0` 时崩溃窗口整段挪到 `-2147483647` **之上**,守卫看不见:

- `A(unpack({1,2,3},-2147483646))` —— 经真实 `FuzzOracleDiff`(`wangshu_oracle_cgo`,单个 seed)确认
  **整个测试二进制被 SIGSEGV 拿下**,不是 skip;
- `A(unpack({},-1,2147483647))` —— 同样 SIGSEGV;
- 真的 `lua5.1` 二进制对这两条也都 `dumped core`。

也就是说 #244 报的那个 crash **家族还活着**,nightly 随时可能以另一个采样点再开一个 issue。

**(b) 太宽 —— 漏比对,静默削覆盖面。** 守卫无条件跳过 `i >= 2147483648`,而这一段里的值窄化之后往往完全
无害:`unpack({1,2,3},4294967297)` 窄化成 `i32 = 1`,PUC 与望舒**都**返回 3;`4294967298` 两侧都返回 2。
经真实 `FuzzOracleDiff` 确认它们现在拿到的是
`oracle limit: ORACLE_LIMIT: unpack index at the int32 boundary (PUC segfaults)` → **SKIP**。这正是
[[prove-the-path-under-test]] §9 那条「单侧 skip 什么都不说」的又一次实现:测试绿的,覆盖面少了。

这一条与 #209 那一轮的教训同构 —— 那一轮差点按文件名把 `table.insert(t,4294967298,...)` 当成同类 seed
删掉,而它模 2^32 之后是 `+2`、是个普通的正位置插入。**窄化之后的值才是判据的输入,源码里的字面量不是。**

## 期望与实际

| 期望 | 实际 |
|---|---|
| 本轮是纯文档轮:写反思 + 沉淀 guide + 对齐设计文档 | 复核时发现守卫的区间口径两个方向都不对,#244 的 crash 家族还活着 |
| 「窗口是实测的不是推导的」这个判据成立,照抄进 guide | 那些测量本身都对,但只覆盖 `e = 0` 一格;机制里有 `e`,所以窗口是一条随 `e` 平移的界线,不是一对固定值 |
| `-2147483646` 给干净错误、应当继续比较 | 在 `{}` 上确实如此;在 `{1,2,3}` 上同一个 `i` 让 PUC dumped core |
| 「按溢出算术推导出好几个别的值也该崩」被实测否证 | 推导当时得到的**候选集合**是对的方向,只是没把 `e` 放进模型;补上 `e` 之后 10 组预测全部吻合实测 |
| 守卫太宽只是理论风险 | 实测有具体反例:`unpack({1,2,3},4294967297)` 两侧都返回 3,现在被 skip |

## 教训

### 教训 1(差分 harness 报的 crash,第一个要问的是「谁崩了」)

#244 的失败信号来自 `FuzzOracleDiff`,而崩的是 harness 里的**参照实现**;望舒侧行为完全正确,所以修的是
harness 而不是引擎。

**为什么容易错**:「fuzz 报了 crash」的默认读法是「被测实现有 bug」,这个默认在绝大多数情况下成立 ——
fuzz 的**存在理由**就是找被测实现的缺陷,所以第一动作是去被测代码里找根因。差分 harness 破坏这个默认:
它把参照实现也拉进了同一个进程,而参照实现崩的时候更容易被误判成我们的问题,因为失败信号长得一模一样
(一个 crasher issue、一个落盘 seed)。

**判据**:看到 SIGSEGV / panic 先读栈迹**属于哪一侧**,再决定往哪边查。自查办法:说「这个 seed 触发了一个
缺陷」之前,先说出「崩的是哪个实现」;本轮那三条证据(内嵌 oracle 的 cgo 栈迹 / 真 `lua5.1` 二进制也 core /
望舒抬干净错误)是最便宜的一组,拿真的参照实现二进制跑一次尤其便宜,而它单独就能定性。

这一条与 [[unreproducible-crasher-triage]]「真 crasher 但失败形式是层间分歧:先问 oracle 拿第三方真值」
是同一族的**另一格**:那条讲两层分歧时去问参照实现拿第三方真值(参照实现是**裁判**),本条讲参照实现自己
可能就是**被告**。合起来是:差分 harness 里出现失败信号时,「谁是裁判、谁是被告」这件事本身要先定下来。

### 教训 2(「实测」与「推导」不是互斥的:一个被称为「实测得来」的边界,仍然需要机制来确定它的形状)

`a22de53` 记的是「窗口是实测出来的,不是推导的;模型与二进制不一致时以二进制为准」。那些测量都是真的,
**但它们只覆盖 `e = 0` 这一格**;机制里 `n = e - i + 1` 带着 `e`,所以窗口是一条随 `e` 平移的界线。把
`e` 放进模型之后,10 组「先预测再实测」全部吻合 —— 推导与实测其实是一致的,当时不一致是因为模型漏了一项。

**为什么容易错**:「以实测为准」是一条**正确**的纪律,而它有一个容易被跳过的前置问题 —— **实测要沿着哪些
维度取样**。回答这个问题只能靠机制:哪些量参与决定这个边界,就得让每一个都变化一格。所以「实测」不能替代
「推导」,它消费推导的产物(候选维度清单)。更隐蔽的是这个错误会**自我确认**:在 `e = 0` 上逐值扫得再密,
也永远发现不了缺了一个维度,而「我逐值实测过两侧各一格」这句话读起来已经很扎实了。

**判据**:给一个 UB / 溢出类边界定位置时,先写出决定它的**表达式**,再让表达式里的**每一项**各变化一格
实测。自查办法:写下「窗口就是 X」之前,先说出「`X` 是哪个表达式的解,那个表达式里还有哪些量」;说不出
表达式,就说明手上只有一批采样点、没有边界。注释与 commit message 里要写清那个**规则**,只写测量值会让
下一个读者把一格当成全部。

这正是 [[prove-the-path-under-test]] §4.5「量参照实现的边界时,让决定它的那个量变化再测一遍」的又一次
命中,而且是同一个函数 —— #201 那一轮量 `unpack` 的**上限**时,`8000` 与 `8000 - nargs` 在单一写法上数据
不可区分,靠让参数个数变化一格才排除「硬编码 7997」。本轮量 `unpack` 的**崩溃窗口**时重犯了同一个错误,
只是这次漏掉的那个量是 `e`。同一条判据在同一个函数上第二次被需要,说明它值得在那一节里点名。

### 教训 3(限制类守卫要用两侧用例验宽度,而两侧都要沿着「机制」取,不能只沿着被报的那个写法取)

「该 skip 的确实 skip、紧邻不该 skip 的仍在比较」这个两侧检查,`a22de53` 是做了的 —— 确认了两个崩溃值被
跳过,而 `-2147483646`、`-2`、`INT_MAX`、显式区间、整表 unpack 全都仍在比较。**做了两侧检查,守卫仍然两个
方向都错**,原因是两侧的用例都取自被报的那个写法(`unpack({}, i)`),于是「紧邻不该 skip 的」那一侧取到
的是 `unpack({},-2147483646)` —— 它**确实**不该 skip;而真正该 skip 却没被 skip 的
`unpack({1,2,3},-2147483646)` 不在用例表里,因为选用例的依据是那个 seed 的形状,不是机制。

**为什么容易错**:两侧检查这个动作**看起来**就是在防「守卫比缺陷宽/窄」,做完之后心理上这件事已经结清。
但它防住的只是「在**已采样的那一格里**宽了或窄了」。而这两种失效的反馈完全不对称:太宽表现为**测试通过**
(静默削覆盖面),太窄表现为**下一次 nightly 再开一个 issue**(而那个 issue 会长得像一件新事情)—— 两种
都不会指着守卫说话。

**判据**:加 skip 守卫时,「该 skip 的」与「不该 skip 的」两组用例都要沿着**机制的每一个维度**取,而不是
沿着被报的那个写法取。自查办法:写下守卫的条件,再写下机制的条件,**逐字对照两者的自由变量** —— 机制里
有的量而守卫条件里没有,那个量就是漏掉的维度(本轮守卫只读 `i`,机制读 `i` 与 `e`)。这与
[[cross-backend-semantic-fix-sweep]]「C 侧的上限条件可能同时读调用当时的栈状态,别只抄阈值常数」是同一
判据换了位置:那条讲抄**产品上限**时要把条件表达式的每一项都问一遍,本条讲写 **harness skip** 时同样要。

补一条口径:skip 与产品规则的**形状**要逐字对应(见 `12-testing-difftest.md` §4.9d「形状一致、数值分开」)。
本轮的守卫连形状都没对上 —— 产品侧那条界线读 `i` 与 `e` 两个量,守卫只读 `i`,所以它既遮不住该遮的、又
遮住了不该遮的。

## Promotion 决策

- **教训 1 → [[unreproducible-crasher-triage]]**:「都真复现的那一档」那一族的新一格 —— 分诊入口之前
  还有半步「先判谁崩了」,判据是读栈迹属于哪一侧 + 拿真的参照实现二进制跑一次。
- **教训 2 → [[cross-backend-semantic-fix-sweep]]**:「PUC 语义由 C 实现定义」那一族(参照实现语义 / UB
  的刻度)。同时在 [[prove-the-path-under-test]] §4.5 补一句正向结算:同一条判据在同一个函数
  (`unpack`)上第二次被需要。
- **教训 3 → [[prove-the-path-under-test]]**:§9 差分 harness 判据那一族,挨着 §9.6 的「双向验证」与
  §4.5c 的「两个方向都要断」——它是那两条的近亲:两侧用例这件事本身还有一个「两侧沿哪些维度取」的前置
  问题。

## 收口(同轮已修)

上面那段「遗留」在同一轮里就补上了 —— 记录保留,因为**发现过程**比结论有价值。

守卫改为读 `i` 与 `e` 两个量(`e` 默认 `#t`、由第三参数覆盖),对窄化后的 int32 判
`i32 <= ev32 且 (ev32 - i32 + 1) > INT_MAX`,与机制一致。经真实 `FuzzOracleDiff` 逐条确认:

| 输入 | 期望 | 实测 |
|---|---|---|
| `A(unpack({},0X80000000))` | skip | skip |
| `A(unpack({1,2,3},-2147483646))` | skip | skip |
| `A(unpack({},-1,2147483647))` | skip | skip |
| `unpack({1,2,3},4294967297)` | **仍比较** | 比较(两侧 3) |
| `unpack({1,2,3},-2147483643)` | **仍比较** | 比较(两侧抬 too many results) |
| `unpack({},-2)` / 显式区间 / 整表 | **仍比较** | 比较 |

用例钉在 `fuzz_244_test.go::TestUnpackAtInt32BoundaryDoesNotCrash`(含随 `e` 平移的两条),
并用「把守卫改成 `if false`」实测确认 seed 会重新崩。

这一段本身是教训 3 的一个正面结算:两侧用例**沿机制的每个自由变量各取一次**之后,
两个方向的错误同时暴露出来;而只沿 `i` 取样时,任何加密采样都不会暴露缺的那一维。
