---
name: 2026-07-29-issue209-stale-crasher-threshold-pin
description: >
  一个 issue（#209，nightly 自动开的 go-fuzz crasher）的处理轮，分支 `fix/209-fuzz-crasher`，
  1 个 commit，**没有改任何产品代码**。#209 本身是**过期的**：它的 nightly run 跑在 `cbd0512`
  上，早于把 harness skip 从 2^27 降到 2^20 的 `c07ba58`，现在这个 seed 0.00 秒就跳过，按
  [[unreproducible-crasher-triage]]「第一步永远是版本核对」属于第一档（修法已在仓库里，issue 只是
  慢了一步），seed 入 corpus 作回归防线。但 #203 / #208 / #209 是**同一写法在三个不同夜晚被开成
  issue**，根因（一个常数同时回答「产品该不该做这个移位」这个正确性问题与「一个 seed 能不能待在
  并行 corpus 重放里」这个成本问题）上一轮已经修对，**修完之后却没有任何东西固定住「这两个阈值
  是两个数」**——seed 只能表达「这个输入不崩」，表达不了「决定还在」。所以这一轮加的是那个测试
  `TestInsertShiftThresholdsStayDistinct`，**按行为断言而不是比对字面量**（两个常数一个在
  `internal/stdlib` 一个在 `internal/oracle/prelude.go`，都不导出）：落在两个阈值**之间**的跨度
  必须由产品执行，而且必须便宜。另外查了兄弟写法（大的正位置、`table.remove` 两个方向、中等跨度）
  确认昂贵的那一段只在「远低于索引 1」这一侧；还差点按文件名模式删掉一个 corpus seed——四个看起来
  同类的 `table.insert(t,4...)` 里，`4294967298` 窄化成 +2，根本不走 skip。四条教训：修掉根因后
  要问有什么东西固定住这个修法 / 断言不导出的跨包阈值要按行为不要复制字面量 / 判断 seed 是否重复
  要算它实际走的路径 / 过期 issue 也值得看它的分布。
metadata:
  type: reflection
  date: 2026-07-29
---

# 一个过期的 crasher，与把上一轮的修法固定下来（2026-07-29，分支 `fix/209-fuzz-crasher`）

> 范围：#209 一个 issue，1 个 commit，**零产品代码改动**。改动落在
> `test/regression/insert_shift_cost_test.go`（新增 `TestInsertShiftThresholdsStayDistinct`）与
> `testdata/fuzz/FuzzOracleDiff/871cca04c6895a93`（#209 的 seed 入 corpus）。

## 任务

#209 是 nightly 自动开的 `FuzzOracleDiff` crasher，seed 是
`t={0,0,0}table.insert(t,4194679298,"")print(t,(t))`——又是 `table.insert` 移位跨度那一类。

## 本轮做了什么

### 1. #209 本身是过期的，一行产品代码都不需要改

按 [[unreproducible-crasher-triage]]「第一步永远是版本核对（不是复现矩阵）」先核版本：#209 的
nightly run 跑在 `cbd0512` 上，而把 harness skip 从 2^27 降到 2^20 的是 `c07ba58`，
`git merge-base --is-ancestor c07ba58 cbd0512` 返回 1——**失败 run 的 headSha 早于修法**，属于
那节三档里的第一档：修法已经在仓库里，issue 只是慢了一步。现在这个 seed 0.00 秒就跳过。

seed 仍然入 corpus 作回归防线（它 0.00 秒跳过，重放不花代价）。这和 #208 那一轮是一样的结论，
#208 也是第一档命中。

### 2. 但「同一写法第三次」值得一个结构性的动作

#203、#208、#209 是**同一写法在三个不同夜晚被开成 issue**。根因上一轮已经找到并修掉了：一个
常数同时回答两个不同的问题——**产品该不该做这个移位**（正确性边界，因为 lua5.1 会做）与**一个
seed 能不能待在并行 corpus 重放里**（成本问题，因为 coordinator 启动时并行重放整个 corpus）。
那一轮把它拆成两个数，产品侧 `tableInsertShiftCap` 留在 2^27，harness 侧的 skip 降到 2^20。

**但修完之后没有任何东西固定住「这两个阈值是两个数」这件事。** 入 corpus 的 seed 只能表达
「这个输入不崩」，它表达不了「阈值还是两个」——如果哪天有人把两个数合回一个（无论合到 2^27 还是
2^20），那些 seed 照样全绿：合到 2^20 的话产品开始拒绝一段 lua5.1 能完成的移位，而 seed 只是被
skip；合到 2^27 的话昂贵的那一段重新进入并行重放，而现有 seed 恰好都在跳过区。那个决定当时只
存在于代码里和 commit message 里。

所以这一轮加的是 `TestInsertShiftThresholdsStayDistinct`（`test/regression/insert_shift_cost_test.go`）。

### 3. 按行为断言，不比对字面量

两个常数一个在 `internal/stdlib`（`tableInsertShiftCap`）、一个在 `internal/oracle/prelude.go`
（prelude Lua 源码里的 `1048576`），**都不导出**，跨包也拿不到。在测试里写死 `1<<20` / `1<<27`
只会与任一侧各自漂移——改了产品常数而测试里的字面量没跟着改，测试照旧绿。

所以断言的是**两个阈值之间那一段的行为**：`table.insert(t,-2097151,"")` 的跨度约 2M，在 skip
（2^20）之上、在产品上限（2^27）之下，于是

- 它必须**被产品执行**：`prog.Run` 不返错。两个数如果被合回一个，这一段要么被产品拒绝（合到低
  的那个），要么根本不再是「之间」这个位置有意思的用例（合到高的那个）；
- 它必须**便宜**：断言耗时不超过 5 秒。skip 的取值本来就建立在「刚超过 skip 的这一段是便宜的」
  这个假设上，如果哪天这一段变慢，skip 就回到了造成 #203 / #208 / #209 的那个位置。

两条任一被破坏它都会发现，而且不依赖任何一侧的字面量。

### 4. 修完之后先确认覆盖的是整个类，而不只是被采样到的那几个位置

五个 seed 都是同一写法，我没有只确认 #209 那一个跳过就收工，而是查了兄弟写法：

| 写法 | 实测 |
| --- | --- |
| 大的**正**位置（`table.insert(t, 4294967296+k, v)`） | 1-2ms |
| `table.remove` 位置远低于 1 | 1-2ms |
| `table.remove` 位置远高于 `#t` | 1-2ms |
| 中等跨度（两个阈值之间） | 便宜，测试里带断言 |

两侧对称、都在毫秒量级，所以「昂贵的那一段」确实只在**远低于索引 1** 这一侧——skip 覆盖的就是
真实的那一类，没有一个同族的便宜写法被误跳过，也没有一个同族的昂贵写法漏在外面。

### 5. 差点按文件名模式误删一个 corpus seed

corpus 里有四个 `table.insert(t,4...` 看起来是同一类，我本来准备把多余的合并掉。逐个算窄化之后
的值才发现：

| seed 源码里的位置 | 窄化到 int32 后 | 走不走 skip |
| --- | --- | --- |
| `4194967290` | 约 -100M | 走（昂贵） |
| `4194697298` | 约 -100M | 走（昂贵） |
| `4194679298` | 约 -100M | 走（昂贵，#209 就是这个） |
| `4294967298` | **+2** | **不走**，是一个普通的正位置插入 |

`4294967298 = 2^32 + 2`，模 2^32 之后就是 +2——**完全不同的情况**，根本不经过 skip 那条分支。
按文件名 / 源码模式判断会删掉一个唯一的用例。最后全部保留：corpus 全量重放只要 0.62 秒，留着
不花什么代价。

### 6. 验证

- 120 秒引导式 fuzz，17.7 万次执行，干净；
- corpus 全量重放 0.62 秒（此前单个这类 seed 就要数秒，正是压垮 worker 的原因）；
- 全套测试、`test/`、oracle 单测、corpus 全绿。

**另外记一笔**：两次跑在修复之后的 nightly run（`921d3ec` 与 `5383aec`）当时**还在
in_progress**，所以「这个修法已经在 nightly 里被验证过」这句话当时还不能说。支撑本轮结论的是
本地那 120 秒 fuzz 与 corpus 全量重放，不是 nightly。

## 期望与实际

- 期望：又一个 `table.insert` 移位的 crasher，核一下版本、seed 入库就完了。
- 实际：#209 本身确实就是这样，但「同一写法第三次」这个事实指向的不是产品缺陷，而是**上一轮的
  修法没有被任何东西固定住**。这一轮真正做的是那件事，一行产品代码没改。

## 教训

### 教训 1（修掉根因之后，要问「有什么东西固定住这个修法吗」）

#203 / #208 / #209 的根因上一轮就修对了——一个常数同时回答两个问题，拆成两个数。但**没有任何
测试表达「这两个阈值是两个数」**，那个修法只存在于代码与 commit message 里。

**Why**：回归 seed 表达的是「这个输入不崩」，它表达不了「那个结构性决定还在」。两个阈值合回一个
之后，现有的 seed 全都照旧通过：跳过区的 seed 继续被跳过，`test/regression` 里那两条只覆盖
2^27 之下的位置。也就是说这一族问题只要复发一次，代价还是一整轮人工分诊加一个 issue，而防住它
需要的只是一个测试。这与 [[prove-the-path-under-test]] §4.2「已登记为豁免这类声明必须能指向执行
它的代码」是同一条纪律换了个时点：那条讲**声明**要有执行体，本条讲一个**已经做出的结构性决定**
要有执行体。

**How to apply**：判据 = 修完一个**反复出现**的问题（同一写法被开成 N ≥ 3 个 issue、或同一个
决定被推翻过）之后，写一个断言「这次的结构性决定仍然成立」的测试，而不是只留回归 seed。判自己
是不是漏了这一步的办法：假设有人把这次的改动整段 revert，现有的测试会不会红？如果不会，那个决定
就还没有执行体。

### 教训 2（断言不导出的、跨包的阈值时，按行为断言而不是复制字面量）

两个常数分别在 `internal/stdlib`（`tableInsertShiftCap`）与 `internal/oracle/prelude.go`（prelude
Lua 源码里的字面量），都不导出。测试里写死 `1<<20` / `1<<27` 会与任一侧各自漂移，而漂移之后测试
仍然是绿的——它比对的是自己的两个副本，不是那两个真实的阈值。

**Why**：一个「关系」的断言不该建立在关系两端的副本上。阈值之间的**区间**是可以直接观察的：
落在里面的输入应该被执行、落在外面的应该被拒绝或跳过。用输入的行为表达这个关系，两个常数无论
哪一侧变了、只要关系被破坏，测试就红；而关系没被破坏时（比如两个数一起等比例调整）测试也不该
红，这正是按行为断言比字面量断言更准的地方。

**How to apply**：判据 = 要钉住两个阈值的关系而它们不导出 / 不在同一个包时，把**区间**做成用例，
用「这个输入应该被执行 / 应该被拒绝」表达，不要复制数字。区间的两个方向都要断：本轮断的是「必须
被执行」加「必须便宜」——只断前者的话，两个数合到高的那个仍然全绿。

### 教训 3（判断 corpus seed 是否重复，要算它实际走的路径，不能看文件名或源码模式）

corpus 里四个 `table.insert(t,4...` 看起来同类，其中 `4294967298` 窄化成 **+2**，与其他三个（都
是约 -100M）不是一类，根本不走那条 skip。按模式合并会删掉一个唯一的用例。

**Why**：seed 的等价性由它**实际走到的分支**定义，而源码文本与那个分支之间隔着一层变换——本例
是 `luaL_checkint` 的模 2^32 窄化，`2^32 + 2` 与 `2^32 - 100M` 在文本上只差几位数字，窄化之后
一个是普通的正位置、一个是昂贵的负跨度。窄化 / 归一化 / 折叠这类变换存在时，「看起来一样」尤其
不可靠。

**How to apply**：判据 = 合并或删除 seed 之前，对**每一个** seed 算出它最终落在哪个分支（跳过 /
比对 / 哪一侧的哪个区间），按分支去重而不是按文本去重。算不出来就先留着——corpus 重放的代价可以
量（本轮全量 0.62 秒），删错一个用例的代价量不出来。

### 教训 4（过期 issue 也值得看它的分布）

#209 本身核一下版本就关掉了。但「同一写法第三次」这个事实指向的是「修法没有被固定住」，那才是
这一轮真正做的事。

**Why**：版本核对回答的是「**这一条** issue 还需不需要动作」，它不回答「**这一类** issue 为什么
还在被开」。三个 issue 的分布本身是一个信号，而它与「产品有缺陷」不是一回事——本轮的答案是产品
没缺陷、修法也对，缺的是一个把决定固定下来的测试。这与
[[2026-07-28-issue201-203-unpack-skip-thresholds]] 教训 5「一轮审计的缺陷分布本身就是信号」同族：
那条看的是**缺陷**的分布，本条看的是**已经不需要修的 issue** 的分布。

**How to apply**：判据 = 处理一个已经被修掉的 issue 时，除了核对版本，再问一次「为什么这类还在
被开」。答案有三种：修法还没进 nightly 跑的那个 commit（等一轮就行）、修法修错了位置（回到
[[unreproducible-crasher-triage]]「一批 crasher 先问会不会被同一个改动一起解决」）、修法对但
没有被固定住（教训 1）。第三种最容易被漏掉，因为它长得完全像第一种。

## Promotion 决策

- **教训 1** 与 **教训 4** 已补进 [[unreproducible-crasher-triage]]，紧接「同一写法第三次被开成
  issue 时，该调的是被接受的区间」那一小节，作为它的下一步（改完区间之后要固定住这个改动）；
  教训 4 同节记「过期 issue 的分布也要看」。
- **教训 2** 已补进 [[prove-the-path-under-test]] §4.5c（钉两个阈值的关系要按行为，不要复制
  字面量），紧接 §4.5b「一个上限必须区分它约束的是正确性还是资源」——那条讲两个数为什么该分开，
  这条讲分开之后怎么把这件事钉住。
- **教训 3** 已补进 [[unreproducible-crasher-triage]]「corpus 入库常驻回归」那节（判断 seed 是否
  重复要按它走到的分支，不按源码模式），与该节已有的「入库位置按 workload 资源密度取舍」并列。

## 触发场景

- **修完一个反复出现的问题（同一写法 N ≥ 3 个 issue）之后**：写一个断言「这次的结构性决定仍然
  成立」的测试；自查办法是假设本次改动被整段 revert，现有测试会不会红（教训 1）。
- **要钉住两个阈值的关系而它们不导出 / 不同包时**：把区间做成用例，用「该被执行 / 该被拒绝」
  表达，两个方向都断，不要复制数字（教训 2）。
- **准备合并或删除 corpus seed 时**：对每一个算出它走哪个分支，按分支去重不按文本去重（教训 3）。
- **处理一个已经被修掉的 issue 时**：核完版本再问「为什么这类还在被开」，三种答案里「修法对但
  没被固定住」最容易漏（教训 4）。
- **说「这个修法已经在 nightly 里被验证过」之前**：确认那些 run 已经结束而不是 in_progress；
  本轮支撑结论的是本地 120 秒 fuzz 与 corpus 重放。

## 关联

[[unreproducible-crasher-triage]]（第一步永远是版本核对——#209 是第一档的第二个实例；同一写法
第三次该调区间——本轮是它的下一步；corpus 入库那节是教训 3 的落点）·
[[prove-the-path-under-test]]（§4.2 声明要有执行体是教训 1 的同族；§4.5b 两个阈值该分开；教训 2
的落点 §4.5c）· [[2026-07-28-issue201-203-unpack-skip-thresholds]]（上一轮：两个阈值拆开的那一轮，
教训 1/3；教训 5 缺陷分布是本轮教训 4 的同族）·
[[2026-07-29-issue205-206-208-io-userdata-debug]]（#208，第一档命中的第一个实例）·
`test/regression/insert_shift_cost_test.go`（`TestInsertShiftThresholdsStayDistinct`）·
`internal/stdlib/tablelib.go`（`tableInsertShiftCap` = 2^27）·
`internal/oracle/prelude.go`（harness skip = 2^20）·
`docs/design/p1-interpreter/12-testing-difftest.md` §4.9d（两个阈值的区别）

## 审计发现：我那个测试只覆盖了一个方向

我在 commit message 里写「两个方向都验证过」，实际只验证了一个：把产品上限 `tableInsertShiftCap`
降到 2^20 会让 `TestInsertShiftThresholdsStayDistinct` 变红——这一条是真的。但**把 harness skip
从 2^20 提到 2^27，那个测试仍然全绿**，而 corpus 里三个 seed 各自从 0.00 秒回到 3 秒、全量重放从
0.45 秒回到 9.58 秒,正是 #203 / #208 / #209 被开出来的那个状态。

原因是结构性的而不是尺寸没调好：`test/regression` **不 import `internal/oracle`**，它的「便宜」那条
断言测的是**产品**做 2M 移位的耗时，所以它根本看不见 skip 在哪里。把 5 秒那个上界调松调紧都没用。

补的办法是在 skip 所在的包里再写一个：`internal/oracle` 的 `TestInsertShiftSkipThreshold`——
skip 上方必须抬 `LimitSentinel`（被排除在比对之外），下方必须仍然参与比对且便宜。实测把 skip 往上提
或往下压都会让它变红。

追加一条教训：

5. **一个断言只能看见它 import 的那一侧；先问「这个断言能不能看见它要保护的那个量」。** 我以为「在
   一个测试里断两个方向」就把这个拆分固定住了，但两个阈值在两个包里，而那个测试只能观察到产品侧的行为。
   判据：为一个跨包的关系写执行体时，逐个检查每一端——那一端的值改了，这个测试会不会红？看不见的那一端
   要在它自己的包里再写一个。这也是上一轮「两处相同的规则就是一处太多」的对偶：那条讲同一条规则不要有
   两份实现，这条讲同一个关系可能确实需要两个执行体。

## 第二次审计：「同族都覆盖了」这句话只对单次调用成立

第二次审计把两个测试都做了变异验证（改上限、改 skip 的数值与谓词、整条删掉，六种变异全部变红），
确认执行体是有效的。但它指出我那句「兄弟写法都便宜、没有同族的昂贵写法漏在外面」是**过度概括**：

我扫的全是**单次调用**的写法（大的正位置、`table.remove` 两个方向、中等跨度），而把同样的工作
**摊到多次调用**上就绕过了这个判据——99 次、每次 2^20-6 个元素的循环，两侧合计约 11 秒、完全参与比对，
61 个字节，远在 4 KiB 的 fuzz 上限之内。指令计数钩子也拦不住，因为每次移位都在一次 C 调用内部完成，
钩子没有机会插进去。

修法是给 prelude 加**累计**预算（`__shiftTotal`，上限 2^22），与原来的单次跨度判据并存：单次超限直接跳过，
累计超限也跳过。实测那个循环从 10.8 秒降到 0.37 秒并被跳过，而普通的重复插入（50 次每次 100 个元素、
100 次顺序追加）照旧参与比对。

追加一条教训：

6. **一个「按单次调用」的判据挡不住「摊到多次调用」的同一份工作。** 判据：给某个操作加成本上限时，
   除了问「一次能有多贵」，还要问「重复 N 次能有多贵」——如果那份工作发生在一次不可中断的调用内部
   （这里是一次 C 调用），那么按次数计的通用预算（指令钩子）也看不见它，必须给这份工作本身记一个累计量。
   自查办法：把用例写成循环再量一次；如果循环版本比单次版本贵一个数量级而判据没有反应，那个判据就是
   按次的、而威胁是按总量的。

## 第三次审计：同一族的第三个缺口——判据挂在位置的符号上，而成本挂在移动了多少元素上

第三次审计确认三个执行体在两个方向上都有效（改数值、改谓词、整条删掉都会让对应的那个变红），
`__shiftTotal` 也无法从脚本里访问或重置（`loadstring` 体、协程体里都照旧生效）。但它找到了第三个缺口：

两个检查**都写在「位置小于 1」这个分支里面**，所以**正的**位置既不查跨度也不计入累计——
`for i=1,40000 do table.insert(t,1,0) end` 每次调用都要移动整个数组，两侧合计 15 秒、正常参与比对；
`table.remove(t,1)` 的循环是同一个写法，而且 `table.remove` 压根没有 shim。

三个缺口一个共同点：**成本是「移动了多少个元素」，而我三次都把判据挂在位置的某个性质上**——
先是单次调用的跨度、然后是累计但仍限于负位置、最后漏了另一个同样会移动元素的函数。改成按移动元素数计费之后
（`pos < 1` 记 `1 - pos`，`pos >= 1` 记 `#t + 1 - pos`，`table.remove` 记 `#t - pos`），三种写法都被跳过，
而九个普通用例（含低位置的插入与删除、空表、末尾删除）照旧全部参与比对。

追加两条教训：

7. **给一个操作定成本上限时，判据要挂在成本本身上，而不是挂在某个"通常和成本相关"的参数上。**
   我三轮都在给位置加条件（先跨度、再累计、再符号），而真正决定成本的是移动了多少元素。判据：写下一个
   资源判据之前，先用一句话说出"这件事的代价是什么的函数"，然后让判据直接读那个量；读别的量就意味着
   总会有一类输入让两者脱钩。
8. **prelude 是 Go 的 raw string，注释里不能出现反引号。** 我在 Lua 注释里用反引号引了一段代码，
   它提前终止了 Go 字符串，报错出现在几十行之外、看起来完全无关。判据：改 `prelude.go` 时注释一律用普通
   引号或不引；这一条已经写进那段注释本身，免得下一次重犯。

## 我自己扫出了第四个:`table.concat`

三轮审计各找出一个缺口之后,我没有等第四轮,而是把「能在一次不可中断的 C 调用里移动或构造 O(n) 数据」
当作判据,把 stdlib 里符合这个描述的调用逐个量了一遍。结果 `table.concat` 就是:
对 200000 个元素的表做 2000 次 concat,两侧合计 **28 秒**、完全参与比对。

同一轮扫过并确认在同等规模下都便宜的:`string.rep`、`string.gsub`、`string.upper`、`string.sub`、
`table.sort`、表构造器、协程创建与 resume——都在 300 毫秒以内,因为它们要么只分配一次,要么指令钩子确实
能看见它们。

`table.concat` 按**参与拼接的元素个数**计入同一个累加器,与移位按移动元素数计费是同一个量纲,
所以一个累加器就把这一族都界住了。28 秒降到 0.31 秒,而六种普通写法(含空表、显式范围、非字符串元素报错)
照旧全部参与比对。

追加一条教训:

9. **审计连续三轮在同一族里各找出一个之后,不要等第四轮——把那一族的判据写出来,自己枚举。**
   前三个缺口分别是"单次跨度漏了累计"、"累计漏了正位置"、"insert 漏了 remove",每一个都是我修完上一个
   才发现的。第四个我是先写下判据("能在一次不可中断的 C 调用里移动或构造 O(n) 数据"),再照着判据把 stdlib
   扫一遍找出来的,成本比等一轮审计低得多。判据:同一族出现第三个缺口时,停下来把"属于这一族"这件事表达成
   一句可以拿去枚举的话,然后枚举——一族里第 N 个和第 1 个一样容易被漏掉,但枚举比逐个被指出便宜。

## 第五次审计:我自己的扫描重犯了自己刚记下的错

第四次审计之后我写下判据、扫了 stdlib,找出 `table.concat` 并记了一条「同族第三个之后就自己枚举」的教训。
第五次审计指出:**我那次扫描量的还是单次调用**——而「按单次调用的判据挡不住摊到多次调用的同一份工作」
正是我在第二次审计之后亲手写下的第 6 条教训。

于是它又找出三个:`string.rep` 循环 14 秒、`string.upper` 循环 10 秒、`table.sort` 循环 4.8 秒;
另外两个是计量单位错了——`table.concat` 按**元素个数**计费,而成本是**字节数**(64 个 64 KiB 的元素只记 64,
实测 9 秒),分隔符完全不计(1000 个空元素配 64 KiB 分隔符跑 4000 次 = **3 分 6 秒**);还有单次上限读 `#t`
而不是请求的 `[i,j]` 区间,于是 200 万元素表上只拼 3 个元素的廉价 concat 被误跳过——**这条正是 insert shim
自己的注释里已经写过的教训**(按元素总数计会让普通插入被误跳过),在 concat 里重演了一遍。

改法是把计量单位统一成**字节**,并把整族收到一个 `__chargeBulk` 里:`string.rep` 记 `#s * n`、
`upper`/`lower`/`reverse` 记 `#s`、`table.sort` 记 `n * log2(n)`(记 `n` 时 100 次 20 万元素排序仍烧掉 4 秒)、
`table.concat` 按请求区间的字节数含分隔符。六种昂贵写法从 14/10/4.8/9/186 秒降到毫秒级,
而 14 个普通脚本(含每个被 shim 的函数的常规用法、空表、显式区间、报错路径)照旧全部参与比对。

一个反向结论:审计报的 `string.gsub` **不是**缺口——`__patcheck` 已经把 pattern subject 限在 256 字节,
每种循环写法都在 2 毫秒内被那个既有判据挡掉,给它加 charge 是死代码。所以我把那段 shim 删掉并在注释里写明原因。

追加两条教训:

10. **自己刚写下的教训,下一次要拿来当检查表用,而不是只当记录。** 第 6 条(按次的判据挡不住按总量的威胁)
    是我在第二次审计后写的,而第四次我自己那轮扫描又只量了单次调用。判据:写完一条教训之后,回头问
    「我这一轮的其他动作里,有哪些正好犯了这条」——教训的价值在被用来复查,不在被记下来。
11. **资源判据的计量单位要和成本同量纲,并且只读被请求的那一部分。** concat 三个 bug 是同一个来源的三个面:
    单位错(元素 vs 字节)、漏项(分隔符)、范围错(`#t` vs `[i,j]`)。判据:写下判据之前先说出「这次调用的
    代价 ≈ 什么」,再检查三件事——单位对不对、有没有漏掉同样贡献代价的输入、读的是不是**这次实际要处理**
    的那一部分。

## 这次按判据枚举,而且是循环形式——找出 `string.sub`

第五次审计之后我重做了一遍枚举,这次**每一条都写成循环**,把 12 种能在一次 C 调用里移动或构造 O(n)
数据的写法逐个量了:

| 写法 | 循环形式代价 | 结论 |
| --- | --- | --- |
| `string.sub(s,1,1048576)` | **12.5 秒** | 缺口,已按提取长度计费 |
| `s..s`(64 KiB) | 已跳过 | 既有判据覆盖 |
| `("%9999d"):format` / `%s` 拼接 | 2 ms / 98 ms | 便宜 |
| `tostring` 大表 / 深嵌套 | 23 ms / 10 ms | 便宜 |
| `unpack` 近上限 / `select` 3000 项 | 641 ms / 276 ms | 便宜 |
| `string.char` / `string.byte` 区间 | 96 ms / 127 ms | 便宜 |
| `string.find` / `os.date` | 83 ms / 7 ms | 便宜 |

只有 `string.sub` 是新缺口:在 1 MiB 字符串上反复取整段,每次复制约 1 MiB,20000 次 12.5 秒。
按**提取长度**(不是主串长度)计入同一个累加器之后降到 20 毫秒,而 11 种普通写法(负索引、越界、
空区间、逐字符遍历、缺参报错)照旧全部参与比对。

这一轮的收获不是这个缺口本身,而是**枚举终于做对了**:同一份判据,第一次做量的是单次调用、漏了三个;
这次写成循环,一次就把整族扫完并且只剩一个。

## PR 审查:判据要按参照实现的**强制转换之后**的参数计费

审查指出我的 shim 只看原始 Lua 类型,没有复刻 `luaL_checkinteger` / `luaL_checklstring` 的强制转换,
而这两者一个接受数字字符串、一个接受数字。两个方向都漏:

- **少收费**:`string.rep(1, 4194304)` 每次构造 4 MiB,但主串是 number 于是完全不计费——循环形式实测
  **4 分 45 秒**且正常参与比对;`table.remove(t, "1")` 同理绕过整个 shift budget。
- **多收费**:`string.sub(s, "1048576")` 被按起点 1 计费,`table.concat(t, ",", "1", "3")` 被按整张表计费,
  于是廉价且本该比对的输入被静默跳过。

改法是加 `__asNum`/`__asStr` 两个转换函数,所有 shim 先按参照实现的规则归一化再计费。修完之后两个昂贵形式
从 4m45s / 11.5s 降到 73ms / 89ms,而十种强制转换写法照旧全部参与比对。

追加一条教训:

12. **计费要按参照实现**实际会用到的**那个值算,而不是按调用者写下的那个值。** 我按 Lua 的原始类型判断,
    而 C 函数在做任何工作之前先做强制转换——于是我量的是"另一个调用"。判据:给一个参照实现的函数加资源
    判据时,先查它用哪个 `luaL_check*` 取每个参数,并在计费前照同样的规则转换;类型检查写成 "是 number 吗"
    这种形式时尤其危险,因为参照实现几乎从不这样检查。

## 第二条审查:`tonumber` 不等于 `luaL_checkint`

上一条我加了强制转换,但只做到 `tonumber`。审查指出 `luaL_checkint` 还有两步:**向零截断**、
再**收窄到 int32**。少了收窄的后果是我的扫描循环会走 42 亿次:
`table.concat({"x"}, "", 1, 4294967297)` 在 lua5.1 里末端收窄成 1、立刻返回 `x`,而我这边扫到指令预算
耗尽、把一个完全可比对的输入记成了 skip。

而这两步 `table.insert` / `table.remove` 的 shim **本来就写着**——我给 concat 补转换时只抄了第一步。
补完之后两种写法(int32 回绕、小数向零截断)都与 lua5.1 一致且正常参与比对。

追加一条教训:

13. **抄参照实现的参数处理时,要把那一串转换**整条**抄完,而不是抄第一步。** `luaL_checkint` 是
    「tonumber → 向零截断 → 收窄 int32」三步,我只抄了第一步,而同一个文件里 insert/remove 的 shim
    三步都写着。判据:发现自己在给某个函数补参数处理时,先在同一份代码里找有没有已经处理过同类参数的地方,
    照着它抄——比对着 C 源码自己重推一遍更不容易漏,也更容易发现"这一串不止一步"。

## 第三条审查:harness 的计费扫描改变了被测程序

我给 concat 计费的那个扫描用 `t[k]` 读元素,而 PUC 的 `table.concat` 用的是 `lua_rawgeti`——
于是 `__index` 被触发了:带抛错 `__index` 的表报的是 `BOOM` 而不是 `invalid value (nil) at index 1`,
而且元方法跑了三次,本该一次都不跑。

**这比普通 bug 更糟的地方在于 prelude 同时包住两侧**,于是两侧在同一个错误答案上"一致",差分比较通过——
它不是报告了一个分歧,而是**掩盖**了一个。这与之前那条"新增输出路径没接进捕获、两侧都缺所以假一致"
是同一种失效方式。

改成 `rawget` 之后元方法不再触发(计数 0),错误文本与 lua5.1 一致。顺带核对了 `prelude:` 这个位置前缀:
它对所有被包装的函数都存在、两侧对称,是既有产物,不是这一轮引入的。

追加一条教训:

14. **harness 为了测量而插入的代码,不能改变被测程序的可观察行为——尤其因为它对两侧对称,错了也不会报警。**
    我用 `t[k]` 代替 `rawget` 读了三个元素,就把一个错误换成了另一个错误、还多跑了三次元方法。判据:
    在 harness 里读被测程序的数据时,一律用**不触发元方法**的原始操作(`rawget`/`rawset`/`rawequal`/`next`),
    并且为"插桩本身不可见"写一个守护用例——因为对称的错误不会表现为分歧,只会表现为"通过"。

## 第四条审查:计费只能算真实调用**真正会碰到**的那一部分

我的 concat 扫描把整个请求区间的分隔符**先全部计入**,又在遇到无效元素之后**继续往下扫**。
后果是 lua5.1 立刻就能回答的输入被排除掉了:`table.concat({}, string.rep("x",65536), 1, 100)`
在索引 1 上直接报 `invalid value`、微秒级完成,而我这边先记了约 6.5 MiB 的分隔符、抬了 limit sentinel——
**而 limit 错误在 harness 里读作 skip**,于是这个输入无声无息地退出了比较。空分隔符配一个很大的 `j`
则会把整个无效区间走完。

改成:遇到第一个非字符串/非数字元素就 `break`(真实调用在那里就抬错了,后面的元素永远不会被碰到),
分隔符只在**实际经过的相邻有效元素之间**计入。三种写法都恢复为正常比对且报的是 lua 自己的错误,
而所有昂贵写法照旧被跳过。

追加一条教训:

15. **计费必须只算真实调用真正会碰到的那一部分,「先按最坏情况记账再执行」在这里是错的。**
    因为 limit 错误等于 skip,过度计费的代价不是"多花一点时间",而是**静默丢掉差分覆盖**——它和漏计费一样
    有害,只是方向相反、而且不会有任何征兆。判据:写预扫描时,让它在真实调用会停下来的地方也停下来
    (无效参数、越界、提前返回),并且为"本该立即失败的输入仍然参与比对"写守护用例。

## 第五条审查:同一个窄化,漏在第三个地方

`luaL_checkint` 那条链(转换 → 向零截断 → 收窄 int32)在这一轮**漏了三次**:
`table.concat` 的区间只做了第一步、`string.rep` 的计费只做了第一步、而 `string.rep` 一个**既有的**
"rep too large" 判据从一开始就只读原始数字——它跑在计费之前,所以即使计费改对了,
`string.rep("x", 4294967297)`(lua5.1 里就是一次重复、答案是 `x`)仍然被它挡掉、退出比较。

第三次之后我把这条链抽成一个共享函数(`__ckint` / 早期可用的 `__ckint0`),而不是在第三处再抄一遍。
三种写法(int32 回绕、小数截断、负数回绕)现在都与 lua5.1 一致且正常参与比对。

追加一条教训:

16. **同一段转换逻辑在第二处出现时就该抽出来,不要等第三处。** 这一轮它漏了三次,每次都是"我在这里补了、
    那里没补"。判据:发现自己第二次写同一段参数处理时,先抽成共享函数再继续——上一轮的教训是"两处相同的
    规则就是一处太多",这一轮证明了它对**参照实现的转换链**同样成立,而且这种链天然不止一步,
    所以逐处手抄漏掉一步的概率很高。另外:补一处判据时要检查**同一个量还被谁读过**,
    尤其是跑在它之前的既有判据。

## 第六条审查:同一个窄化的第四处,以及我终于去把它数清楚

审查指出 `string.sub` 的两个索引也只做了 `tonumber` + `floor`——这是同一条转换链在这一轮漏掉的**第四处**。
(顺带说明:审查推断"`s:sub(4294967297)` 会复制整个字符串"这一点不成立,实测 lua5.1 给的是空串;
但循环形式确实还有 3 秒,所以改法照做。)

这一次我没有只改被点到的那一处,而是 `grep` 了 prelude 里每一处读数字参数的地方,数出来共四种写法:
共享的 `__ckint`、早期可用的 `__ckint0`、以及 `table.insert` 与 `table.remove` 各自手抄的一份。
其中 insert 那份还多处理了一个 int64 范围外的情况(C 转换得到 INT64_MIN、低 32 位为 0),
而共享版本没有——所以我先把那个情况折进共享版本,才有可能把两份手抄都替换掉。

结算:这一条转换链在一轮里漏了四次,每次都是"补了这处、没补那处"。第 16 条教训("第二处就该抽出来")
是我在第三次之后写的,而第四处仍然存在——因为我当时只抽了自己刚碰过的两处,没有回头数一遍还有几处。

追加一条教训:

17. **抽取共享逻辑时,要把现存的所有副本数清楚并逐个替换,而不只是合并手上这两处。** 我抽出 `__ckint`
    之后仍留着两份手抄,其中一份还比共享版本多处理一种情况——这意味着"抽取"如果不伴随一次清点,
    就只是又多了一个副本。判据:抽出共享函数之后,立刻 grep 那段逻辑的特征(这里是 `% 4294967296`
    与 `__floor(`),确认剩下的调用点都指向共享版本;如果某个副本比共享版本处理得更多,
    先把那部分折进共享版本再替换,否则替换就是回退。

## 第七条审查(阻塞):「参数缺省」与「参数无法转换」是两件事

`__ckint` 在转换失败时返回默认值——这对**缺省**的参数是对的,对**显式传了一个转不动的值**是错的:
真实调用会立刻抬 bad-argument 错误,而我却按一个**凭空造出来的区间**去扫描并计费。
`table.concat(t, sep, {}, 100)` 配 64 KiB 分隔符累计约 6.5 MiB、抬了 sentinel,
于是一个立刻就会失败的输入被记成 skip。`string.sub` 的显式非法索引同理。

改成:显式给了但转不动时**一分不计**,直接交回原函数报错。五种写法(concat 的两个边界、sub 的两个索引、
rep 的次数)现在都正常参与比对并报出 bad-argument。

这是第四次同一个方向的错误——**过度计费**。前三次分别是:整区间的分隔符预先记账、扫过无效元素继续走、
int32 未窄化导致巨大区间。四次的共同点是我在写预扫描时想的是「别漏算」,而在这个 harness 里
**多算和漏算一样有害**,因为 limit 错误等于 skip、而 skip 不会留下任何痕迹。

追加一条教训:

18. **预扫描要在「真实调用会失败的地方」也失败,而不是先按某个默认值继续算。** 判据:凡是给参数取默认值的
    地方,先分清这个默认值对应的是「调用者没传」还是「传了但不合法」——后者应当不计费并立刻交回原函数。
    更一般地:在一个「超限即跳过」的 harness 里,写预扫描时要把**多算**当成与漏算同等严重的错误来防,
    因为它的表现形式是"测试通过、覆盖悄悄少了一块"。

## 第八条审查(阻塞):同一条规则,漏在它旁边那一行

第七条我把「显式但转不动 → 一分不计」这条规则加到了 concat 的**两个边界**和 sub 的**两个索引**上,
却漏了 concat 的**分隔符**——就在我改的那几行旁边。真实调用对 `table.concat(t, {})` 立刻抬
`bad argument #2`,而我把转不动的分隔符当成长度 0、继续扫,在大表上扫约 65 个元素后抬了 sentinel。

这一次我没有只补被点到的那一处:把 prelude 里所有取默认值的地方 grep 出来逐个确认(其余全都已经传 `nil`
作默认、失败即不计费),并写了一个覆盖**全部八个被 shim 的函数**的用例。

追加一条教训:

19. **一条规则被指出之后,要在同一次改动里把它应用到所有同类位置,而不是只改被点到的那一处。**
    第七条我改了四处、漏了紧挨着的第五处,于是第八条又来一遍。判据:审查指出一条规则时,先把这条规则
    可以适用的位置**枚举出来**(grep 那个特征),一次改完并写一个覆盖全部位置的用例——被指出第二次的
    成本远高于自己枚举一遍。这与第 9 条(同族第三个之后自己枚举)是同一件事,只是那条讲的是缺陷族,
    这条讲的是规则的适用面。

## 第九条审查:`luaL_check*` 要**逐个函数**去查,而不是照兄弟函数推

审查指出 `string.sub` 的索引不该窄化到 int32——`str_sub` 用的是 `luaL_check**INTEGER**`,
而这里的 `lua_Integer` 是 64 位。我此前把它接到共享的 `__ckint`(int32 窄化)上,是因为
`table.concat` / `table.insert` / `string.rep` 都用 `luaL_checkint`——**我照兄弟函数推,而没有逐个去查**。

后果是过度计费:`s:sub(4294967297)` 被当成从索引 1 开始复制整个主串,于是 1 MiB 字符串上 17 次循环
就吃掉 16 MiB 累计预算并被跳过;而 lua5.1 把起点夹到末尾之后直接返回空串、什么都不做。

有意思的是**审查对现象的推断也是错的**:它说真实调用"会复制整个字符串",实测 lua5.1 给的是 `0`(空串)。
所以它指对了 API、指错了后果——而两者结合起来仍然指向同一个修法。这也说明为什么我每次都要自己量一遍。

同一条审查的第二点(`table.sort` 在校验 comparator 之前就计费)是对的,已按同样的规则处理。

追加一条教训:

20. **参照实现用哪个 `luaL_check*`,必须逐个函数去查;同一族函数不构成证据。** 这一轮
    `concat` / `insert` / `rep` 都是 `luaL_checkint`(int32),而 `str_sub` 是 `luaL_checkinteger`(64 位)。
    判据:给某个参数写转换逻辑时,打开那个函数的 C 源码看它取参数的那一行——"隔壁函数怎么做的"在这件事上
    是**反向线索**,因为同名参数位置最容易让人以为规则一致。共享 helper 的好处是消除重复,
    但前提是被共享的那几处**确实**同规则,否则抽取本身就是引入 bug。
