Skip to content

Latest commit

 

History

History
420 lines (364 loc) · 66.2 KB

File metadata and controls

420 lines (364 loc) · 66.2 KB

P1 实现进度

状态:P1 全里程碑 M0-M14 + 收尾轮(原"已知简化"清单)已完成(2026-06-12)。 本文记录每个完成里程碑的产出、验收结果,以及与设计文档的对账。

设计文档参考路径见 00-overview;每个里程碑列对应文档 §。

已完成里程碑(M0-M14)

M 内容 主要文件 单测 提交
M0 工程地基:go.mod/Makefile/.githooks/ci.yml/.golangci.yml/oracle 校验脚本 Makefile, .githooks/, .github/workflows/ci.yml, scripts/ hook 拦截手测、make all 通过 eaf944c
M1 arena 分配器:bump + grow + 双视图 backing + null GCRef 保留 + 注入点 internal/arena/arena.go 11 用例 a34c680
M2 NaN-boxed Value:8 个 tag、IsNumber/IsCollectable/Truthy、NaN 规范化 internal/value/value.go 9 用例 e209dba
M3 object 布局:GCHeader 位字段 + 六类对象读写 helper internal/object/ 8 用例 0a62aab
M4 bytecode ISA:38 opcode + 编解码 + Proto + ICSlot + int2fb internal/bytecode/ 9 用例 e8f24a0
M5 mark-sweep GC:STW + 双白 + gray stack + R1..R9 根 + shadow stack + JSHash intern internal/gc/ 9 用例 336e98e
M6 lexer:21 关键字 + 全 token + 数字/字符串/注释 + 四种换行 internal/frontend/{token,lex}/ 11 用例 c726e33
M7 parser:递归下降 + 优先级爬升 + AST(后补 ParenExpr) internal/frontend/{ast,parse}/ 全文法覆盖 4ff15cc
M8 codegen:expdesc/freereg 水位线 + 跳转回填 + 常量去重 + 黄金字节码 internal/frontend/compile/ 9 黄金测试(02 §8 逐字节) 6f63bce
M9 解释器最小循环:大 switch + reentry(Lua 调用不增 Go 栈) internal/crescent/ 7 端到端 f6df2ab
M10 GC 接入:根注入 + 分配点 safepoint + LinkSweep/计费 internal/crescent/alloc.go 4 GC 压力 10b3e87
M11 元表 + pcall:__index/__newindex 链、算术元方法、callLuaFromHost internal/crescent/meta.go 9 端到端 f7812b1
M12 stdlib + host fn:base/math/string 最小集、HostFn 注册与同步调用 internal/stdlib/ 9 端到端 96a9b7f
M13 公共 API:Compile/Program/NewState/Value、vararg 完整化 wangshu.go 7 公共 API 0d1c211
M14 三套测试:conformance + difftest(差分测试 5.1.5)+ 三档基准 test/, benchmarks/baseline/ 全绿 5cc5a6a

收尾轮(原"已知简化"清单,全部完成)

完成内容 设计文档 提交
table 存储 arena 原生 array+hash(主位置+冲突链、迁位插入、rehash 选最优 asize、border 二分、rawNext 迭代);旁路 Go map 移除 01 §5.2 1ab4beb
generic for TFORLOOP 完成 + next/pairs/ipairs 05 §10.2 1ab4beb
IC 命中路径 icGetTable/icSetTable 五类指令(同表+同代次+同键校验,mono IC,array/node 直达);LoadProgram 改 State 私有浅拷贝(IC/常量不跨 State 串台) 05 §6 c04010e
string pattern 完整 lstrlib 引擎(字符类/集合/量词/锚点/捕获/%b/%1-%9)+ find/match/gmatch/gsub/format/byte/char;string 库表挂 per-type __index(("x"):upper()) 10 §7 / 07 §1.2 c167ad6
stdlib 必做列 table(insert/remove/concat/sort/getn/unpack)、math 补全(fmod/modf/random/三角/deg/rad + pi/huge)、os(time/clock/date/getenv)、io.write、unpack/xpcall;io 三个标准流 + io.read/io.lines 与 debug 的 traceback/getinfo 在 2026-07-29(#205)补上(见下方对账条目) 10 裁剪表 / 10 §10.1.1 / 09 §13.4 72a09d5 + #205 一轮
协程 路线 B:yield 哨兵经显式错误通道冒泡(08 §3.4 对称机制),pendingResume 记录恢复点,resume 参数写回 yield CALL 结果寄存器;coroutine.create/resume/yield/status/wrap/running;跨 thread upvalue 经 uvOwner 08 §3 c0f2ba9
错误目录 chunkname:line: 位置前缀(error level 语义,level=0 不加)+ traceback(顶层未捕获自动附) 09 473e4dd
弱表/finalizer __mode 解析缓存进 GCHeader flags(setmetatable 唯一写入口),GC clear 弱分支激活;SetFinalizerRunner + __gc 创建逆序执行 06 §8.4/§10、07 §13 7078fcf
arena ABI wangshu.Arena 四类型列(float64/int64/bool/string)+ presence bitmap + 字符串池去重;Program.Call(state, arena, args);脚本侧 arena.col[i] 零拷贝即时装箱、只读、ColInt64 2^53 护栏 11 §3-§5 5122ae8
difftest 生成器 受控文法随机脚本(类型化局部池),200 确定性种子差分测试官方 5.1.5 全部逐字节一致 12 §3.2 e1ddf2f
per-item drop-in 子集 State.SetGlobal/GetGlobal/Call(fn,args...) + Register/RegisterModule + 公共 HostFn 类型;ValuekFunction kind(外部不可构造,只能 GetGlobal 取);State pin 表 + GC 根接入(PinRef/UnpinRef/visitExtraRefs),globals 覆盖与 freelist 复用下旧 fn Value 仍安全可调;Value.Release() 显式释放 pin 槽 11 §7.1 / §9.1 (issue #1) 87031c2 + cb6e1ae
公共 Table API State.NewTable + Value.AsTable + Table.Set/SetIndex/Get/GetIndex/Len;ValuekTable kind(同 kFunction 经 pin 表挂 GC 根);fromInner 升级为 fromInnerWithPin,Program.Run/Call 与 State.Call 返回路径能携带 table/function 引用;支持嵌套 table 与 mixed-type list 作 Lua 表 round-trip 11 §4.5 (issue #2) 2b55e11
严格沙箱模式 Options.HideFileLoaders bool:从 globals 刮除 loadfile/dofile/loadstring/load 四件套(置 Nil);脚本调用 fatal attempt to call global 'X' (a nil value),对位 gopher-lua 嵌入式沙箱传统;与 AllowFileLoad=true 同设 NewState panic fail-fast。默认行为不变(PUC 5.1.5 oracle 差分测试不退化) 10 §12.1 LibsSafe 思路最小完成 (issue #3) 09fdd72
context cancellation 钩子 State.SetContext(ctx) / RemoveContext:VM 在 chargeStep 同一抢占点(回边 + 函数进帧 + TFORLOOP)检查 ctx.Err(),事件触发(wall-clock timeout / 上游 Cancel)中止 Run/Call 返回 Go error(pcall 可捕获);跨 goroutine 由 atomic.Pointer 保护;chargeStep 三处调用点合一(stepBudget 外层 if 拿掉,内部短路),零额外抢占点 11 §10 / 与 SetStepBudget 并存 (issue #4) 27b4f2e
Table.ForEach 任意 key 迭代 func (t *Table) ForEach(fn func(key, val Value) bool) error:转发 internal RawNext 循环(raw 迭代,与 stdlib next/pairs 同源,迭代序确定性);fn 返 false 提前终止;key/val 走 fromInnerWithPin 自动登记 pin 槽。issue #2 SetIndex 写入的对称读出能力,完整读写闭环 11 §4.5 (issue #5) 4f855d2
globals baseline 状态隔离 State.MarkGlobalsBaseline 拍当前 _G 字符串 key 快照、ResetGlobalsToBaseline 非 baseline key 清空 + baseline key 复原;baseline 复合值经 visitExtraValues 入 GC 根(与 pin 表是 GCRef-bearing value 契约级不变式两面:pin 管「公共 API 暴露的长持 GCRef」、baseline 管「内部状态恢复需要的长持 GCRef」);对位 gopher-lua statePool snapshotBaselineValues + resetToBaseline 模式 10 §12.1 hardening (issue #6) 3d34839
CallInto 零分配边界路径 State.CallInto(dst []Value, fn, args...) (n int, err):返回值写进调用方拥有的 dst,标量(bool/number)整条 round-trip 0 alloc。消除旧 Call 的双拷贝地板成本(VM 栈→inner slice→public slice,72 B / 2 allocs/call,与脚本复杂度无关)——内部 callOnStack 零拷贝切 th.stack 活动区(runningThread 复位后 mainTh 仍是常驻根 → GC 下可达),门面层复用 innerArgsBuf + 写调用方 dst。Call 保留为独立拷贝便捷形(返回值跨下次 Call 仍可读),内部走 callOnStack 后 append 一次。⚠️ 契约:CallInto 返回值底层是复用栈,下次进入 VM 前消费完;string 仍拷 arena 字节、复合值仍经 pin 表 boundary-dominated 嵌入优化 (issue #8) CallInto
step budget 按字节工作量记账 共享 doConcat(internal/crescent/call.go,fast path + slow-path plain fold)调 chargeBulkWork(len)(internal/crescent/state.go 新增),按 len >> 6(1 步 / 64 字节)把 CONCAT 拷贝 + intern 的字节工作量折算进 step budget,使预算成为字节工作量的度量而非仅指令条数。动机:preempt() 原本每指令边界只把 stepUsed 加 1,单条 CONCAT 能做与字符串长度成正比的无界工作 ⟹ for i=1,N do glob=cat(i) end(cat 内 return "<~15KB 字面量>"..i)每次迭代只扣约 2 步却拷贝约 15KB,1<<20 预算允许约 50 万次迭代、单次 prog.Run 约 2.7s wall-clock,4×run 撞 Go fuzz 10s per-input 看门狗(concat 风暴 crasher 家族 #166/#167 根因)。三层(P1 executeLoop / P3 wasm h_concat / P4 native host.Concat)全部路由同一 doConcat,单点记账覆盖所有 backend,差分对称性不破;<64B 记 0 步、1MiB concat 记约 16K 步(约 1.5% 预算),正常程序不受影响。比率 >>6 是按最慢的 CI runner(比本地慢约 10×)实测收紧后的值。当时点名的下一批候选无界单指令算子(string.rep / string.format / table.concat)已于 2026-08-03 结算,见下一行 concat 风暴家族根因 (#166/#167,PR #168) 88e724f + ab27936
三个批量字符串构造函数按字节记账 internal/crescent/state.gochargeBulkWork 导出成 ChargeBulkWork,string.rep(stdlib.go::stringFnRep,len(s)*n)/ string.format(stringlib.go::stringFnFormat,len(out))/ table.concat(tablelib.go::tableFnConcat,分隔符字节 + 各元素字节之和)各自按产出字节数同一个计量器,于是「批量工作」在 step budget 里只有一个定义、而不是三个会互相漂移的阈值。动机:上一行点名的三个候选确实是同类风险——实测在 1<<20 step budget 内、且完全没有触发预算的情况下,三者各自的紧循环分别跑 21 秒 / 20 秒 / 53 秒,单次 prog.Run 就已超过 Go fuzz 的 10 秒 per-input 看门狗,而 FuzzAutoPromote 每个输入要跑四次 Run(concat 风暴家族的一样的机制)。记账后 21/20/53 秒变 46/90/70 毫秒且预算正确触发;八种普通写法(含 1 MiB 的 string.rep、10000 元素的 table.concat)与 lua5.1 逐字节一致——1 步 / 64 字节的比率下 1 MiB 产出约记 16K 步、占 1<<20 预算约 1.5%。table.concat 必须按字节而不是元素个数记账:它的遍历本来就被表的长度界住,所以按个数看永远便宜——256 个元素听起来微不足道,而每个元素 2 KiB 时实际要 53 秒。这与 §4.9 那一类 hardening 上限(string.rep 的 1 GiB、string.format 的 width/precision)是两件事:那些是「宿主进程不可崩」的 fail-fast 上限,本行是预算记账,一次调用可以既在上限之内又把预算耗尽 concat 风暴家族的下一批 (#222,commit 7119391) 7119391

P1 总验收结果(roadmap §4 / 12 §10)

  • 三档 ≥2x over gopher-lua:✅ Xeon 6982P-C 实测(IC 完成后): simple 275ns vs 874ns = 3.18x;arith 311ns vs 966ns = 3.10x; loop 15.1µs vs 34.4µs = 2.28x。分配 5 allocs/op vs gopher 8-124。
  • benchmark-game 真实负载(benchmarks/realworld,P1 性能轮后): fib 1.31x、binary-trees 1.09x、spectral-norm 1.43x、fannkuch 0.82x、 nbody 1.08x over gopher-lua——五项中四项反超(性能轮前 0.77x-1.17x, binary-trees/fannkuch/spectral-norm 曾落后)。剩余短板 fannkuch(表 索引/交换密集):IC 命中仍付 accessor 间接层,直达偏移方案(DataOff) 实测因校验复杂度反噬被否决,记 P2 IC 演进输入。五脚本返回值与官方 lua5.1 逐字节一致(TestRealWorld_OracleParity)。
  • P1 性能轮(同 commit 区间):closeUpvals maxOpenIdx 快路径 (binary-trees -30%)、GC pacing 补附属块统计、根扫描免 map 分配、 callHost 实参池(spectral-norm -36%)、State.Call 跨 Run 复用主 thread (simple 275→98ns)、表槽初始化批量化。
  • 与官方 Lua 5.1.5 输出逐字节一致:✅ seed corpus 70 用例 + 随机生成 500 种子全部 byte-equal。oracle 源码编译供给(~/.local/bin/lua5.1)。
  • 官方测试套移植(test/luasuite):lua-5.1-tests 13 文件原样运行, vararg/sort/pm 整文件通过,其余 10 个截断到豁免线(setfenv/debug/ io 对象/setlocale/string.dump/require,均对应豁免注册表),前缀全过; stopAt 表强制登记、豁免线只许前移。
  • 长稳承诺:freelist 循环复用(22000 轮分配密集脚本 arena 稳定 17.4KB);深递归 stack overflow 可恢复(LUAI_MAXCALLS=20000 等价); pcall 自递归 C stack overflow(LUAI_MAXCCALLS=200 等价)先于 Go 栈 fatal;-race 下 Program 跨 16 goroutine 共享验证。
  • make all 门禁:✅ gofmt 空、golangci-lint 0 issues、go test -race 全绿; 三平台交叉编译冒烟(386/windows-amd64/darwin-arm64)进 CI。

审查核销轮(外部逐提交审查 → 集中修复)

外部代码审查逐函数对照官方源码,12 轮报告共发现 22+ 项真实问题, 集中修复轮全量核销(每项独立提交,95b51a3..)。重点:

  • DoS 级:constFold 丢弃带跳转链的 eKNum((true and 7 or -1)+1 一行 Go panic 崩宿主,潜伏自 M8)→ isnumeral 同构 + Program.call recover 兜底; SETLIST 批号超 9-bit 截断挂死 → 官方 C=0+裸批号路径;深嵌套/无限循环 → parse 深度护栏 + 回边指令预算。
  • 静默错果:lexer 数字非贪心(return 1or 2 被接受执行)→ 官方 read_numeral 贪心重写;(a)=5 被接受 → ParenExpr 全包;table.remove 越界删末元素;math.max 首参吞错;Fb2Int 缺 &31 掩码。
  • 内存/资源:arena 尺寸入口 uint32 回绕(4GiB 请求"成功"切 8 字节)→ uint64 域检查 fail-fast;对象尺寸公式四处手写 → object.SizeOf 单源; hostFn 注册表无界增长(gmatch/mountArena)→ 引用计数槽回收。
  • 官方测试套驱动(test/luasuite 移植扫出):break/repeat 漏发 CLOSE (闭包捕获循环变量后 break 读脏值)、return 短路链快路径挂死、pattern %z/未闭合捕获 panic/%q 格式、gsub/sort 走元方法、gmatch 空匹配推进、 near 原文(txtToken)、luaO_chunkid 同构、错误措辞 luaL_checknumber 格式、5.0 兼容别名(math.mod/foreach/gfind)。

与设计文档的对账(实现形式差异,均为接口等价)

设计点 设计文档形式 实现形式 对账结论
值栈/CallInfo 位置 住 arena(05 §1.2),Thread 对象 word 字段 Go slice(crescent.thread struct) 接口等价、P3 迁移点已留:backing 注入点(arena.Options.NewBacking,06 §1.1 唯一硬性前瞻义务)已就位;协程"状态冻结"语义已可工作(yield 保留 CallInfo 链)。物理搬迁是 P3 wazero memory 收养时的工作,届时 stack/cis 切 arena 视图不动 opcode 语义
per-item API 栈机风格 PushNumber/ToNumber/Top/Pop/GetGlobalFn/CallFn 等(11 §7.1 草图,gopher-lua 栈机) State.SetGlobal/GetGlobal/Call(fn,args...) + Register/RegisterModule(列表风格) 形式裁剪、能力等价:pineapple 一类「fn 一次取出 + 循环 per-item Call」用法由 GetGlobal+Call 覆盖;Push/Pop 栈机风格未做(若未来 gopher-lua 迁移负载明确需要再补)。HostFn 收 args 中 table/function/userdata 仍映射 Nil(本期 fromInner 收紧)、host closure 从 Go 端直接 Call 仍未开
host closure 从 Go 端 Call 任意 closure 一视同仁可被 state.Call 调起(11 §1.5) internal State.Call 见 host closure 直接报错(call.go:hostCheck) 裁口、不影响主线:Register 注册的 host fn 仍由 Lua 内调用闭环工作;Go 端「state.Call(hostFn,…)」用法未开,等真有需求时补 callHost 入口的脚手架(临时栈帧)
开放 upvalue 链 按 stackIdx 降序单链(05 §8.3) Go map(stackIdx → uvRef)+ uvOwner(uv → thread) 共享语义等价(同槽同 uv);降序链是值栈 arena 化的配套,一并留 P3
executeSignal 三态 sigReturn/sigYield/sigError 枚举(08 §3.3) 显式 *LuaError 返回 + errYieldSentinel 哨兵 同一冒泡通道,哨兵区分;08 §3.4 "yield↔error 对称"的最小实现
协程对象 Thread 对象住 arena(01 §5.6) lightuserdata 句柄 + Go 注册表 type() 返回 "thread" 语义一致;арena Thread 对象随值栈 arena 化一并做
xpcall handler 时机 栈展开前调用(09) 捕获后调用(栈已回滚) 已知微差:P1 不支持 handler 内 inspect 出错栈帧;traceback 仍可经 Traceback() 取
ephemeron 键活则值无条件活(07 §13.5 P1 简化,自带) 同设计 一致(设计本身即简化)

重要实现决策与差分修偏记录

  • 字符串字面量惰性 intern(Proto.StringLits/StringLitIdx):Program 跨 State 共享, 每 State 私有 intern + 私有 IC(11 §1.3 定稿的并发细化)。

  • 错误传播:显式 *LuaError 返回贯穿主循环;host→Lua 重入边界(callLuaFromHost) 负责 CallInfo 回滚(05 §9 定稿)。yield 复用同一通道(哨兵)。

  • 差分修偏实例(12 §0 机制起效的证据):rawEqual NaN bits、%.14g 的 inf/nan 措辞、 and/or 的 VCALL 单值收敛、VARARG 落点回填、ParenExpr 强制单值、return-vararg 多值、break 双层块、多值 return 末位 eCall 的 A 覆盖。全部由 conformance/ difftest 捕获后当步修复。

  • oracle 差分巡检的 stdlib 语义修偏(2026-07-28,#192/#193/#194/#196 一轮六个根因): 四个 issue 报四处,实际修了六处,另有一处是本分支自己 45 秒 fuzz 冒烟撞出的。

    根因 落点 修法要点
    C99 nan(n-char-sequence) 未被消费(#192) internal/crescent/number.go 只在 ) 真的存在时才消费整组;未闭合的 nan( 保留裸词照旧被拒;inf(...) 无此形式仍拒
    tonumber(x, 10) 整条路由错(无 issue,扫描发现) internal/stdlib/stdlib.go PUC 在校验范围之前、读 arg 1 之前就判 base == 10 并走标准转换。一个原因造出六条分歧("1.5"/"0x10"/"1e3"/"inf"/"nan"/number 实参),五条没有任何 issue 记录;现在无 base 与 base 10 共用一个 helper
    %d/%i 精度 0 配值 0 丢符号(#196) internal/stdlib/stringlib.go C 转换出零个数字但仍输出 +/空格 flag 的符号,Go 连符号一起丢。新增 cSignedFormat 只在这个角落手写,与隔壁 cUnsignedFormat 同一手法(10 §5.2.1b)
    table.insert 的 5.1 语义本来没有边界检查(#194) internal/stdlib/tablelib.go position out of bounds 属 5.2+;5.1 的 tinsert 无检查,e = #t+1pos > e 时抬 e(10 §7.2 真值表)
    table.concat 错误文本(#194) internal/stdlib/tablelib.go PUC 的 addfield"invalid value (%s) at index %d ...",%s 是元素的 luaL_typename;原先括号括错范围且丢了类型名
    string.char 的两步转换(#193) internal/stdlib/stringlib.go + internal/oracle/prelude.go luaL_checkint(int)luaL_checkinteger;这是 C UB 且跨 arch 不一致,产品侧钉 x86-64、差分侧跳该区间(12 §4.9b,10 §5.4b)
    strtoul 的无符号取反与溢出饱和(无 issue,扫描发现) internal/stdlib/stdlib.go 有定义的 C,所以对齐而非跳过。原有注释声称「已登记为 diff 豁免」但没有任何代码实现它,分歧一直是活的
    table 库缺 , got no value 从句(无 issue,fuzz 冒烟发现) internal/stdlib/tablelib.go luaL_typerror"%s expected, got %s",lua_typenameLUA_TNONE 映射成 "no value";显式 nil 与「没传」是两种情况(10 §2.4)

    扫描规模:8640 种带符号 verb 写法(d/i × 12 flag 组合 × 6 宽度 × 6 精度 × 10 值)、 164 种 tonumber/char/insert/concat 组合、45 种负数 × base 组合,全部零差异; 60 秒 fuzz 无 crash;23 条 seed 入 testdata/fuzz/FuzzOracleDiff/,故意包含负例 (nan( 仍拒、char(-1) 仍报错、inf(0) 仍拒)——只钉住「修好的方向」的 seed 分不出 「正确的修复」和「什么都接受的修复」。过程反思见 llmdoc/memory/reflections/2026-07-28-four-diff-divergence-issues.md

  • oracle 差分巡检的 stdlib 语义修偏(2026-07-28,#197/#198/#199 一轮):承上一条同日那轮 (#199 那组分歧本身就是上一轮第六次审计在别的子系统找到、开成 issue 的)。

    根因 落点 修法要点
    error(msg, level) 解析了 level 却不用它选帧(#197) internal/crescent/errors.go + state.go + frame.go + meta.go 每个 level ≥ 2 都拿到最内层位置。两层认识:host 边界是一个 level 不是终点(PUC 对 pcall(f) 的栈是 [f, pcall(C), caller],level 2 落 C 帧空前缀、level 3 到 caller 有前缀)+ 一个边界可代表多个叠起来的 host 帧(pcall(pcall,f) 在 PUC 是两个 C 帧,而 nCcalls 是运行总数区分不了),所以每帧的 host 帧计数进 callInfo word2 bit 51-54(四位饱和,pendingHostFrames 递增 / 下次压帧消费,verifyCISeg 覆盖),常数偏移在 pcall(pcall,f) 上必然错(09 §3.2.1,05 §1.2 布局)
    %g 用 Go 的最短往返而不是 C 的默认精度 6(#198) internal/stdlib/stringlib.go 显式精度本来就对,%e/%f 的默认也本来就对(Go 与 C 都是 6),只有 %g 的默认不同;没有显式精度时补 .6(10 §5.2.1a)
    gmatch 的前导 ^ 被当成锚(#199) internal/stdlib/pattern.go + stringlib.go PUC 的 gmatch_aux 直接调 match()、没有 anchor 处理(不像 str_find_aux),所以 ^ 在 gmatch 里是普通字符;给顶层驱动加 allowAnchor 选项而不是写第二份 matcher,find/match/gsub 保持 anchor(10 §6.3)
    assert 第二参数什么都收(#199) internal/stdlib/stdlib.go PUC 是 luaL_error(L, "%s", luaL_optstring(L, 2, ...)),luaL_optstring 只收 string 或 number,table/boolean 是参数错误而不是被 stringify;原来用 valueToString 并把 table 本身当错误值(09 §4.1)
    math.modf / frexp / rad 三处 Go math 包与 C 的差(#199) internal/stdlib/mathx.go 无穷的小数部分 C 给带符号的 0、Go 给 NaN;floor(log2)+1 推指数在 DBL_MAX 处 off-by-one(改用 math.Frexp);x*π/180 的乘法先溢出(改乘单个常数 π/180,PUC 的 RADIANS_PER_DEGREE)(10 §8.5)
    io.write 一个值都不返回(#199) internal/stdlib/tablelib.go 5.1 的 g_write 压的是布尔成功标志,写失败压 false 不抬错;issue 里写的「返回文件句柄」是错的,那是 5.2+。原先 type(io.write(""))value expected(10 §10.3)
    os.date 只认六个指令、*t! 前缀压根不认(#199) internal/stdlib/tablelib.go strings.Replacer 写法让其余指令原样透出(os.date("%j") 返回 "%j");改成指令循环覆盖 Y y m d e H M S I p j a A b B c x X Z w n t %%,未定义指令原样输出(glibc 就是这样),*t/!*t 返回九字段表(10 §9.2)
    print 对内嵌 NUL 截断——刻意不改 internal/stdlib/stdlib.go(注释) PUC 的 luaB_printfputs 停在第一个 NUL,而 PUC 自己的 io.write 用带长度的 fwrite 不截断,参照实现内部不一致;5.1 手册明确字符串 8-bit clean,那个截断是 C 调用的产物,对齐它等于故意丢用户数据(10 §4.2,12 §4.9b 第四格)

    harness 侧同轮撤 skip:为 #197 加的那条 skip 覆盖任何提到 error 第二参数的输入,修好后 撤掉——24 种 error level 写法现在零 skip 参与比对,只剩非有限 level 跳过(luaL_checkint 窄化的真 UB),函数改名 errorLevelUBRange(12 §4.9c)。 验证与扫描规模:与系统 lua5.1 比对 error level 30 种写法(五种嵌套 × level 0-5,含 pcall(pcall,f))、%g 13 种、gmatch/find/gsub 若干、assert 7 种、math 12 种、os.date 九种;另主动生成 296 个探针直接与 lua5.1 比对(%g/浮点 verb × 12 值 × 13 spec、 gmatch/find/gsub × 9 pattern、assert × 7 消息、math × 9 值 × 7 函数、os.date × 16 格式、 io.write),289 个可比对项零真实分歧(4 个差异是探针自身产物:地址文本、表里的 tostring(nil)io.write 的副作用落到 stdout)。过程反思见 llmdoc/memory/reflections/2026-07-28-issue197-199-stdlib-semantics.md

  • nightly crasher + 预存缺口对账(2026-07-28,#201/#202/#203 一轮):三个 issue 性质不同——#201 与 #203 是 nightly 自动开的 go-fuzz crasher,#202 是上一轮修 #197-#199 时顺手记下的一组预存缺口。

    落点 结论与修法要点
    unpack 的上限是 LUAI_MAXCSTACK 减参数个数(#201) internal/stdlib/tablelib.go 原先按固定 8000 比较,接受了 PUC 拒绝的 7998..8000 那一段。PUC 的 luaB_unpacklua_checkstack(L, n),拒绝条件含 (L->top - L->base + size) > LUAI_MAXCSTACK,对 C 函数 L->top - L->base 就是参数个数。结论是量出来的:阈值随参数个数变化正是排除「硬编码 7997」的证据(10 §4.5)
    error 自己是被调 host 函数时位置前缀完全丢失(#202) internal/crescent/meta.go + errors.go + state.go host 抛错经 callHost 直接返回、不进解释器循环,annotateError 从来看不到它(#197 那套按帧计数在这条路径上一次都没被调用)。改在边界标注,另外两件要对:level 1 是 host raiser 的调用者(不占 Lua 帧,所以 level 1 该裸、level 2 该带前缀;第一版从 Level-1 消费使每个 level 偏一格)+ 只对显式 level >= 2 生效(PUC 的库错误经 pcall 抛出时不带位置,TestTableConcat_ErrorTextMatchesPUC 立刻抓到)(09 §3.2.2)
    os.time 忽略 isdst 字段(#202) internal/stdlib/tablelib.go PUC 把它填进 struct tmtm_isdst 交给 mktime,用来确定 DST 相关本地时间的解释;只在与该日期在该时区的自然状态不一致时才有影响。要区分「字段不存在」与「显式 false」(新增 getBoolField 返回 (value, present))。TZ=Europe/London 下双向实测(10 §9.1.1)
    移位区间的 skip 与产品上限拆成两个数(#203) internal/oracle/prelude.go 同一写法的第三个 nightly crasher(约 100M 的移位跨度、刚好在 2^27 之下,三条都正确且对称、只是耗数秒,而 coordinator 并行重放整个 corpus)。第三次说明该改的是被接受的区间,不是再挪一个 seed:产品侧留 2^27(正确性——lua5.1 会做这个移位),harness skip 降到 2^20(资源——什么输入能待在并行重放里)(12 §4.9d)
    不成立的三项(#202) coroutine.wrap 缺前缀 / %#g 指数交界 / math.deg 差 1 ulp,实测都与 lua5.1 一致。那个 issue 是自己开的,七项里三项不成立
    挪去 #205 的两项(#202) io.stdout/io.read 缺失(需要 file-handle userdata 基础设施,10 §10.1.1)、debug.traceback[C] 帧(debug 库整个不存在,在 debug 表出现之前无从谈起)

    io.stdout 撤回的完整理由(10 §10.1.1):已经做出来了——三个标准流做成 file-handle userdata + 共享 metatable(write/close),为此补了三处 VM 缺口(metaFieldOfValue 不认 userdata、indexWithMeta 没有 userdata 分支、getmetatable 对 userdata 无条件返回 nil),补完之后 io.stdout:write():close()getmetatable 都对了。但随后 TestGCStress_RandomScripts 失败,创建句柄之后一个 collectgarbage("collect") 就以 arena 索引越界 panic:句柄在仍从 io 表可达的情况下被清扫,说明构造 方式没有正确进入 GC 的根 / 追踪路径(internal/gc/mark.go 确实会把 OBJ_USERDATA 追到它的 meta 与 env ref,所以问题在构造侧不在收集器)。这是运行时里第一个在 __gc finalizer 之外创建的 userdata, 分配与 rooting 的约定得先搞清楚,所以整段撤回、开 #205,而不是带着一个会破坏 arena 的改动继续。

    验证与扫描规模:84 种 unpack 写法与 oracle 一致(覆盖 7995-8001 那一段、1e9 量级范围、空范围与倒置 范围、非有限边界);66 种 error level 写法与 lua5.1 一致(含 pcall(error,...)pcall(pcall,error,...) across level 0-6);另主动生成 85 个探针直接与系统 lua5.1 比对(unpack 边界 × 12 值 × 4 写法、error level × 5 × 5 写法含 host raiser、os.time 的 isdst 六种组合、六种「host callee 抛的库错误必须保持裸」),81 个可比对项零差异(4 个是 lua 侧也抬错、在那种探针写法里无法捕获的,已由 84 种 oracle 比对覆盖);两条 seed 入 testdata/fuzz/FuzzOracleDiff/。过程反思见 llmdoc/memory/reflections/2026-07-28-issue201-203-unpack-skip-thresholds.md

  • file-handle userdata + debug 子集 + string.byte 的上限(2026-07-29,#205/#206/#208 一轮): 上一轮撤回的那一块交付,同时补一处 stdlib 上限、一处 harness 捕获缺口。

    落点 结论与修法要点
    三个标准流做成真 file-handle userdata(#205) internal/crescent/alloc.go + internal/stdlib/tablelib.go 上一轮撤回的根因只有一行:原型直接调 object.AllocUserdata跳过了 collector 的 LinkSweep,对象 header 里既没有颜色也没有 sweep 链,收集器根本看不见它;创建句柄后一次 collectgarbage("collect") 就以 arena 索引越界 panic 而句柄仍从 io 表可达。alloc.go 里每个分配器都是 AllocX + LinkSweep + AllocCharge 三件一起,现在 State.NewUserdata 把它固定成唯一入口(06 §2.1.1,10 §10.2.1)。必须是真 userdata 而不是表,因为 PUC 报的是 userdatatype() 会露馅
    两处 VM 缺口 + getmetatable(#205) internal/crescent/meta.go + internal/stdlib/stdlib.go metaFieldOfValue 不认 userdata(所以 userdata 的 __index 从来没被查过)、indexWithMeta 压根没有 userdata 分支(userdata 没有裸字段,索引直接走 __index,无 metatable 时报 attempt to index a userdata value);getmetatable 对 userdata 原先无条件返回 nil,现在还会遵守 __metatable(07 §1.3)
    io.read / io.lines / file 方法(#205) internal/stdlib/tablelib.go readFormats 一份实现同时供 io.readfile:read;stdinReader一个共享的 bufio.Reader(标准输入的位置是全局状态,两个 reader 各自持缓冲预读会静默丢字节);"*a" 在 EOF 返回 "" 而不是 nil。io.lines(filename) 抬错而不是静默返回空迭代器(10 §10.3)
    debug 表注册 traceback + getinfo(#205) internal/stdlib/tablelib.go + internal/crescent/errors.go getinfo 只填 P1 能诚实回答的字段(currentline/source/short_src/what/func),nups/activelines/namewhat 宁缺不假造;FrameInfo 有一个 off-by-one——getinfo 是 host 函数、host 帧不进 cis,所以 level 1 是最内层 cis 帧,直接减 level 会让最常见的 getinfo(1) 返回 nil(09 §13.4)
    string.bytelua_checkstack 上限,而且消息被包了一层(#206) internal/stdlib/stringlib.go 原先完全没有上限,string.byte(string.rep("a",9000),1,8000) 返回 8000 个值而 PUC 抬错;上限与 unpack 一样是 8000 - nargs,但 luaL_checkstack 把调用者的文本套成 stack overflow (%s),所以 PUC 输出的是 stack overflow (string slice too long)。第一版上限对了、文本照抄了裸串,65 个 oracle 用例里仍有 12 个分歧(10 §5.4c)
    捕获累加器接上 file-handle 的 :write internal/oracle/prelude.go 加了 io.stdout 之后脚本可以经一条 harness 没有捕获的路径输出,那段文本在两侧捕获里都不存在——两侧仍然一致、不报分歧,而比较已经不覆盖这条路径写出的任何东西。这是「因为错误的原因而变绿」(12 §3.1.1)
    #208 一行代码没改 它的 fuzz run 跑在 cbd0512 上,早于上一轮把 harness skip 降到 2^20 的 c07ba58;现在这个 seed 0.00 秒就跳过,作为回归防线留在 corpus 里。上一轮「该改的是被接受的区间,不是再挪一个 seed」的正向结算

    仍然缺的:io.open / io.popen / io.tmpfile / f:seek / io.input / io.output / io.type (需要真实文件,__gc 关文件那一环要跟 io.open 一起做);debugsethook/getlocal/setlocal/getupvalue/setupvalue/getregistry(需要解释器没有暴露的内省钩子); 遍历全部 5.1 函数名的存在性差分用例本身仍然没写(10 §12.3)。test/difftest/corners_test.go::exemptions 里那三条已同轮改写成如实描述现在缺什么。

    验证与扫描规模:56 个探针直接与系统 lua5.1 比对(句柄 type / 相等性 / :write 返回类型 / :close(nil,msg) / 非句柄 receiver 的措辞 / 只写句柄读出的 errno 三元组 / debug.traceback 的四种 message 类型 / getinfo 的 level 与无参错误),54/56 一致(剩下 2 个是故意不提供的 debug.sethook/getlocal);io.read 八种写法(*l/*n/*a/字节数/EOF/默认/io.stdin:read/ io.lines)用真实二进制 + 真实 stdin 与 lua5.1 一致——go test 不转发 stdin,在 go test 里 写的探针全部返回 nil、量到的是 harness 而不是代码;65 个 string.byte 上限用例与 oracle 一致; io_handles_test.go 三个测试(TestIOHandles_SurviveGC / TestIOHandles_MatchPUC / TestDebugLibrary_MatchPUC)。过程反思见 llmdoc/memory/reflections/2026-07-29-issue205-206-208-io-userdata-debug.md

  • 把两个阈值的拆分固定下来(2026-07-29,#209 一轮,零产品代码改动):#209 是 insert 移位那一写法的 第三个 nightly crasher(#203 / #208 / #209 三个不同夜晚),而它本身也是过期的——fuzz run 跑在 cbd0512 上、早于把 harness skip 降到 2^20 的 c07ba58,现在这个 seed 0.00 秒就跳过,seed 入 testdata/fuzz/FuzzOracleDiff/ 作回归防线(第一档命中的第二个实例,第一个是 #208)。

    落点 结论与要点
    拆分本身缺一个执行体 test/regression/insert_shift_cost_test.go 上一轮把产品上限(2^27,正确性)与 harness skip(2^20,资源)拆成两个数是对的,但修完之后没有任何东西固定住「这两个阈值是两个数」:入 corpus 的 seed 只能表达「这个输入不崩」,表达不了「那个决定还在」——合到 2^20 则产品开始拒绝一段 lua5.1 能完成的移位而昂贵区 seed 只是被 skip,合到 2^27 则昂贵那一段重新进并行重放而现有 seed 恰好都在跳过区(12 §4.9d)
    TestInsertShiftThresholdsStayDistinct 同上 按行为断言而不是比对字面量:两个常数一个在 internal/stdlib 一个在 internal/oracle/prelude.go、都不导出也不同包,写死 1<<20/1<<27 只会与任一侧各自漂移。断的是区间里一个输入的行为——跨度约 2M 落在两个阈值之间,①必须由产品执行(Run 不返错)②必须便宜(耗时上界)。两个方向都要断:只断①时合到 2^27 仍然全绿,只断②时合到 2^20 也全绿
    昂贵的那一段只在「远低于索引 1」这一侧 五个 seed 同一写法,没有只确认 #209 那一个:大的位置、table.remove 位置远低于 1、table.remove 位置远高于 #t、中等跨度实测都是 1-2 ms 且两侧对称,所以 skip 覆盖的就是单次调用里真实的那一类;摊到多次调用上的同一份工作是第二次审计补的缺口(见文末补记)
    corpus 没有按文件名模式清理 testdata/fuzz/FuzzOracleDiff/ 四个 table.insert(t,4... 看起来同类,逐个算窄化值才发现 4294967298 = 2^32 + 2 模 2^32 之后是 +2——普通的正位置插入、根本不走 skip,其余三个都是约 -100M。按模式合并会删掉唯一的用例,最后全部保留(全量重放 0.62 秒)

    验证规模:120 秒引导式 fuzz(17.7 万次执行)干净;corpus 全量重放 0.62 秒(此前单个这类 seed 就要数秒,正是压垮 worker 的原因);全套测试、test/、oracle 单测、corpus 全绿。另记:两次跑在修复 之后的 nightly run(921d3ec5383aec)当时仍在 in_progress,所以「修法已在 nightly 里被 验证过」这句话当时不能说,支撑结论的是本地那 120 秒 fuzz 与 corpus 重放。过程反思见 llmdoc/memory/reflections/2026-07-29-issue209-stale-crasher-threshold-pin.md

  • 八个 nightly crasher、六个 seed、三个真缺陷(2026-08-02,#212–#219 一轮):八个 run 的 headSha 全是 5383aec,与 #209 那一轮字面事实几乎一样,但结论相反——实际重放之后五个如实复现,三个是真产品缺陷。 八个 issue 只有六个不同的 seed(#212/#215 是同一个 hash 1d42242f157c9754,#217/#219 是同一个 0150a245c776b8ed)。

    落点 结论与要点
    error() 的 level 参数没做类型检查(#212/#213/#215) internal/stdlib/stdlib.go 原先写成「转换成功才用,失败静默保留默认值 1」,而 PUC 的 luaL_optint显式传了一个转不动的值抬错的:error("", 0>0) 报空消息而 lua5.1 报 bad argument #2 to 'error' (number expected, got boolean)luaL_opt*两条规则——缺省 / 显式 nil 取默认值,显式非法值抬错,而数字字符串仍然强制转换。差分细节:同一个错误在 Lua 函数内部抬出时带位置前缀,经 pcall(error, ...) 直接调用时不带、函数名退化成 '?'(09 §3.1a)
    调用的行号取了被调用表达式那一行(#214) internal/frontend/parse/expr.go ast.CallExpr{Line: e.Pos()} 用被调用表达式的起始行,而 PUC 记的是参数列表开始那一行:(0\n)() lua5.1 报第 2 行、望舒报第 1 行。改成在 parseArgs 之前取 p.tok.Line只有跨行的被调用表达式才有差别,单行时两者相同,所以只能靠 fuzz 撞出来;第一版把 Line 整个挪到参数列表那一行,而它同时喂 callee 物化 / 参数物化 / CALL 三个点,于是 callee 的 GETTABLE 也被挪走(t.x\n{1} 把索引 nil 报到第 4 行)——审计发现。改成两个行号:Line 给物化、ArgsLine 只给 CALL。MethodCallExpr 同样有这个问题且早于本分支(SELF 与 CALL 共用一行,t:nope\n{} 报第 2 行而非第 3 行),一并修好(09 §3.5.1)
    string.gsub 的 repl 类型是惰性校验的(#216) internal/stdlib/stringlib.go 不是少了一个检查,是检查放错了位置:类型判断写在替换循环里面,而第 4 个参数把循环次数压成 0,于是循环一次都没跑、非法参数从来没被看到,gsub("", "", nil, .0) 成功返回而 lua5.1 抬 bad argument #3。PUC 是在循环之前luaL_argcheck(tr) 校验的。是第 4 个参数让这条路径可达的(10 §6.5.1)
    math.mod 不是产品缺陷,是 harness 的别名漏网(#217/#219) internal/oracle/prelude.go mathFn2 报第一个缺失参数是刻意决定:两个官方构建互相不一致(x86-64 报 #2、arm64 报 #1,C 不规定实参求值顺序),没有可对齐的对象。但 __wrapArgOrder 只包了 math.fmod,没包它的 LUA_COMPAT_MOD 别名 math.mod(同一个 C 函数),于是同一写法被开成两个 issue;math.atan2 也从来没被包过。现在按「math 表里所有取两个数的入口」全部包上(12 §4.9e,10 §8.6)
    #218 不是缺陷,是重 workload 进了 corpus test/regression/p4_hot_loop_promote_test.go 一亿次迭代、既不崩也不分歧,只是 p4 corpus 里最重的一个 seed(1.8 秒,其余都在 0.3 秒以内,约 6 倍),而 coordinator 并行重放整个 corpus。按 triage guide 走显式回归测试。第一版做错了:只抄了脚本、没抄 harness 的 SetStepBudget(1 << 20),跑到循环结束耗 87 秒(harness 的 50 倍)——那 1.8 秒不是一亿次迭代的代价,是一百万步预算的代价。镜像 budget 与 arena cap 之后是 1.82 秒。按 guide 规定没有自建 in-test deadline(失败模式是「永不返回」,交给包级 go test -timeout)

    验证规模:全套测试 + -racetest/、p3 / p4 两个 build tag 全部 0 失败;oracle 单测与 corpus 全量 重放全绿;45 秒引导式 fuzz 干净;五个 seed 全部从 FAIL 变 PASS。过程反思见 llmdoc/memory/reflections/2026-08-02-issue212-219-fuzz-crasher-batch.md(四条教训:版本核对的三档是 成本档不是缺陷档 / 豁免判据必须覆盖别名 / 参照实现在循环之前做的校验不能挪进循环 / 把 seed 转成回归 测试时要连 harness 的限制一起抄)。

  • concat 风暴家族点名的三个候选算子结算(2026-08-03,#221 / #222 一轮):#221 过期——seed 是 print(pcall(math.mod)),正是上一轮给 __wrapArgOrder 补上 math.mod 别名之后修掉的那个,它的 run 跑在 947fbda 上、早于 ac21b91;但仍按上一轮的教训在当前 HEAD 上实际重放确认 PASS,一行代码没改。 #222 是真的,而且是这个家族(#123–#167)的下一批:seed 是一个 777777776 次迭代的拼接循环,target FuzzAutoPromote,run 跑在上一轮的合并提交 093f7d1 上。

    落点 结论与要点
    死因不是「seed 太重」也不是内存 两条都被实测否掉:seed 本地重放只要 0.77 秒、在自己 corpus 里只是第三重的(1.22s / 1.21s / 0.77s),两个更重的早该先死;单 seed 峰值 RSS 只有 106 MB,而 CI 用的是 GOMEMLIMIT=512MiB(纯软限制),整个 corpus 并行重放峰值 525 MB。定性靠的是读 llmdoc/guides/unreproducible-crasher-triage.md 里这个家族自己的结论:#166 那轮已经查清死因是 CPU wall-clock 撞 Go fuzz 的 10 秒 per-input 看门狗、不是内存,而那一节还把剩下的候选按名字列了出来
    三个候选算子确实是同类风险 internal/stdlib/{stdlib,stringlib,tablelib}.go 在 1<<20 step budget 内、并且完全没有触发预算的情况下,string.rep / string.format / table.concat 各自的紧循环分别跑 21 秒 / 20 秒 / 53 秒——单次 prog.Run 就已超过看门狗,而 FuzzAutoPromote 每个输入要跑四次 Run
    三者走同一个计量器 internal/crescent/state.go::ChargeBulkWork chargeBulkWork 导出后三个函数各自按产出字节数记账,于是「批量工作」在预算里只有一个定义;各自定一个阈值的话它们迟早互相漂移,而哪个先触发会变得难以预测,并且预算本身是可加的量、多个阈值表达不了「几种批量操作叠加起来超了」。21/20/53 秒变 46/90/70 毫秒且预算正确触发
    table.concat字节而不是元素个数 tablelib.go::tableFnConcat 它的遍历本来就被表的长度界住,所以按元素个数看永远便宜——256 个元素听起来微不足道,而每个元素 2 KiB 时实际要 53 秒。代价是拼出来的字节数,记账就要读那个量
    普通写法不受影响 test/regression/issue222_bulk_builder_test.go 八种常规写法(string.rep("ab",3)1 MiB 的 string.repstring.format("%s-%d",…)table.concat({1,2,3},",")、1000 与 10000 元素的 table.concat 等)与 lua5.1 逐字节一致;1 步 / 64 字节的比率下 1 MiB 产出约记 16K 步、占 1<<20 预算约 1.5%。按 triage guide 的规定没有自建 in-test deadline

    验证规模:两个回归测试全绿(0.20 秒 / 0.00 秒);#222 的 seed 入 testdata/fuzz/FuzzAutoPromote/ad96f441153507ff。过程反思见 llmdoc/memory/reflections/2026-08-03-issue221-222-bulk-builder-budget.md(四条教训:一个家族的第 N 次复发先读那个家族自己的结论而不是从现象重新推 / 记账的计量单位要与真实成本同量纲 / 新增的资源判据 要复用既有的计量器不要自建第二个阈值 / 「本地重放干净」对这个家族天然无效,落盘的必然是最小化后的 轻输入)。

  • 同一族的下半句:预算界住了,但余量不够(2026-08-04,#224 / #225 一轮,产品代码零改动):这一族的 第七、第八个 nightly crasher,两个 seed 都是「约 90 字节的字面量在 for i=1,777777776 循环里拼接」 且赋值目标被 fuzzer 写错(qut 而不是 out,所以累加器根本不增长——最小化后的 seed 天然是轻的)。 两个都既不崩也不分歧,字节记账正常把它们界住,本地重放各约 1 秒就正确抬「instruction budget exceeded」。

    落点 结论与要点
    「已被上一轮修好」这个归因是错的 两个 run 的 headSha 都是 093f7d1、早于 #222 那一轮的合并,按前几轮的流程就该判第一档结案。这一轮多做了一步:把 seed 也拿到 093f7d1 上跑,结果它们在那里也已经被界住——所以 #222 那一轮不是修好它们的原因,「过期、已修复」这个结论不成立;run 时间确实早于合并,但那个事实与这两个 issue 为什么被开出来无关
    真正的问题是 harness 的余量 internal/fuzzbudget / fuzz_auto_test.go / fuzz_p4_test.go 1<<20 在 1 步 / 64 字节下允许约 64 MiB 的 concat,本地每个 fuzz 子测试 0.7–1.3 秒;而 CI 运行器比本地慢约 10 倍——这个倍率 internal/crescent/state.gochargeBulkWork 注释里早就写着(>>6 那个比率本身就是按最慢的 CI runner 收紧出来的)。最慢的家族 seed 投射到 CI 是 12–13 秒10 秒看门狗:六个 seed 两个已经超过、四个余量不到 1.4 倍。机制是日志印证的而非推断:nightly 日志里就是 panic: deadlocked
    修法:四处 SetStepBudget 共用一个常量,终值 1<<16 internal/fuzzbudget.Steps build tag 与消费方一致(`(wangshu_p3
    第一版的 1<<19 被审计推翻:上限要按「计费相同时最贵的写法」定 internal/fuzzbudget.Steps 减半到 1<<19 只修好了 corpus 里那六个 seed(它们确实都拿到 ≥1.8 倍余量),而邻域仍在看门狗之上——按四次 Run × 慢 10 倍投射,1<<19out=out.."x" 循环 14 秒(余量 0.70 倍)、t[tostring(i)]=i 循环 18 秒(0.56 倍)、s:gsub("%a","x") 循环 41 秒(0.24 倍,是看门狗的四倍、比被修的 seed 还糟)。根因:gsub 每次调用按大约两倍主串计费,实际工作远多于等额计费的 concat——等额计费不等于等额 wall-clock1<<16 让最坏那条降到 5.2 秒、余量 1.93 倍,是第一个满足数量级判据的值
    覆盖不损失这次是实测的,不是断言 harness 自己的 seed corpus 在 1<<20 / 1<<19 / 1<<16 三个预算下 PromotionCount 完全相同——那些写法在几次调用之后就升层,从不接近任何一个上限。注入一个真实的 P1-vs-P4 分歧(把升层侧的返回值截断)确认 1<<16 下 harness 仍然 FAIL、撤掉注入后通过;90 秒引导式 fuzz 干净
    据实记下变窄的一处(收窄而不是空洞) test/regression/issue144_regression_test.go 1<<20 时 corpus 里有两个 seed 会把 arena 推到上限,1<<16 时没有,所以那条 arena-cap 错误分支在这里覆盖到的写法变少了。issue144 的回归直接覆盖 arena cap;而且实测两个预算下 step budget 都先于 arena cap 触发,所以这条路径本来就不是靠 step budget 到达的
    回归测试原先并不防它声称防的东西 test/regression/issue224_watchdog_margin_test.go 它在 harness 自己的预算设定下量代价(不是量 corpus 的耗时),但审计实测把预算调回 1<<20(正是那个回归)时它照旧通过:上界写了 1 秒而它自己的注释里推导出的是 250 毫秒(10 秒 / 10 倍 / 4 次 Run),而且它拿「四次 Run 的投射」比「一次 Run 的测量」;只用被开成 issue 的那两个 seed 也分辨不出 1<<161<<19。现在上界改成推导出的 250 毫秒、用例表加进上面那三个真正约束预算的写法,能同时抓住 1<<191<<20,已用变异实测确认

    验证规模:两个 seed 入 testdata/fuzz/FuzzAutoPromote/6ed94d7f9fe6248a841bbefecf338b0d; 五种构建组合 vet 干净;3 个 commit(4c89799 第一版减半、f844bf1 文档、d3928f5 审计之后改按最贵 写法定值)。判据落点 12 §4.9a2(一个预算「界住」还不够,它允许的量必须与 外部看门狗差一个数量级;「可达的最坏」要按同一计费额度下最贵的写法量)与 ../p4-method-jit/08-testing-strategy.md §3.4(P4 fuzz harness 的 step budget 设定)。过程反思见 llmdoc/memory/reflections/2026-08-04-issue224-225-watchdog-margin.md(六条教训:版本核对之后还要把 seed 拿到那个旧 commit 上跑一遍以验证归因 / 一个上限只要界住还不够,它允许的量必须与外部看门狗差一个 数量级 / 上限要由「计费相同时最贵的写法」定,不能由「恰好被开成 issue 的那个写法」定 / 一个「防住某个数值」的回归测试必须用变异实测确认它真的会因为那个数值变化而变红 / 降低 fuzz 预算 不等于降低覆盖,但要用注入缺陷加白盒计数器证明 / 常量要与它的消费方共享 build tag)。

  • 两个真缺陷、互不相关(2026-08-04,#228 / #229 一轮,a11334b):两个 nightly crasher 分别来自 p1 腿的 FuzzOracleDiff(#228)与 p4 腿的 FuzzP4ForceAllPromote(#229),run 都在 efa9aa6 上, 但版本核对这一轮不是关键——两个都在当前 master 上如实复现、都是真缺陷,而且互不相关(seed hash 不同、子系统不同,「会不会被同一个改动一起解决」这一格给出的是否)。

    落点 结论与要点
    unfinished capture 抬得太早(#228) internal/stdlib/pattern.go + stringlib.go 不是少了检查,是抬错的位置错了:PUC 从 push_onecapture 抬,也就是一个捕获真的被读出来的时候,而 add_value 只有表替换 / 函数替换 / %n 展开三条路径走到那里——纯字符串或数字替换走 add_s,只展开 %n、从不读捕获。所以 lua5.1 里 gsub("abc","(","r") 返回 "rarbrcr", 4match/find 抬错。望舒在 collectCaptures(生产侧)就抬了,于是所有消费者都早抬。修法:capResultunfinished 标记,capsToValues(本文件对应 push_onecapture 的函数)在读取时抬(10 §6.4.1)
    第一版修法漏了表替换那条路径 internal/stdlib/stringlib.go 我只在 %n 展开那条路径加了检查,而表替换会无条件读捕获 1(PUC 的 add_valuelua_gettable 之前就调 push_onecapture),于是 gsub("alo","(.",{}) 本该抬错却成功。抓到它的是官方测试套 test/luasuite/testdata/pm.lua:193,不是 oracle seed——那个 seed 只覆盖「不该抬错」这一侧。判据:懒抬错要在每个物化点各加一次检查;改语义边界之后必须跑官方套(seed 单向、官方套双向)
    嵌套 executeFrom 返回后没恢复 caller 的 top(#229) internal/crescent/call.go::doReturn gibbous 的 TailCall helper 以 entryDepth = ciDepth-1 开一层嵌套 executeFrom,于是「离开这一层的 entry 帧」不等于「离开最后一个 Lua 帧」;doReturn 在终止分支仍按 dst + wantedN 收窄 top,caller 的活寄存器留在 top 之上,GC 的 visitThreadValues[top, size) 清成 nil,A={0} 的 NEWTABLE 结果被清掉、SETLIST 报「not a table」。是既有缺陷(把 #228 的改动 stash 掉同样复现,从 origin/master 就在)。对照 lvm.c OP_RETURNif (b) L->top = L->ci->top;同一条纪律的第四处,前三处(doReturn 非终止分支 / gibbous DoReturn / callHost)早就在做(05 §7.2.1,P4 implementation-progress §27)
    分诊三次走错方向 ① peroptranslator 里那段注释逐字写着 NEWTABLE head + SETLIST → "SETLIST: not a table",探针一行没打出来,那条路径根本没走到;② stale base,每个副作用后都 RefreshJitCtxAddrs,不解决;③ 在 doSetList 抬错点打栈迹拿到 executeLoop -> doSetList没有任何 JIT 帧,才定位到破坏在更早的已升层调用里。坏值 tag=65528 就是 value.TagNil,而 nil 是清理动作的值、不是任何指令的自然产物

    验证规模:两个回归测试(fuzz_228_test.go 十条用例 —— 四条不该抬错 + 六条必须抬错; fuzz_229_test.go 比 P1 与 P4 forceAll 两路结果)都用「把修复还原掉」实测确认会变红; 官方测试套含 pm.lua 整文件全绿;两个 seed 入 testdata/fuzz/FuzzOracleDiff/b38e375dae54ee4btestdata/fuzz/FuzzP4ForceAllPromote/6e4264d640c40292。过程反思见 llmdoc/memory/reflections/2026-08-04-issue228-229-lazy-capture-and-nested-tailcall-top.md (五条教训:懒抬错要在每个物化点各加检查、两侧都要有用例 / fuzz seed 单向、改语义边界后必须跑官方套 / 「有一段既有注释正好描述了这个症状」是最容易走错的线索,先用探针证明那条路径真的被执行 / 抬错的位置 不是缺陷的位置,栈迹里缺少哪一层本身就是信息 / 一个共享层的恢复动作已有 N 处兄弟路径在做时,第 N+1 处 漏做就是缺陷)。

  • 三个 crasher、两个根因(2026-08-05,#232 / #233 / #234 一轮,2b1aeb4):定时巡检任务(每天 03:07,前一天刚建,辅助脚本见 .claude/patrol/)的第一次实际处理轮。三个 FuzzOracleDiff crasher 全部在当前 HEAD 上如实复现,而 #232 与 #233 是同一个根因家族(引用值地址的宽度),所以实际是 两个修复加一个既有兄弟缺陷。

    落点 结论与要点
    地址逃过了归一化(#232) internal/oracle/compare.go addrRe\b 锚定类型名,而 \b 的 word 字符包含数字和下划线:io.write 不带换行,于是 io.write(0)print(print) 的输出是 0function: 0x...,0function 之间没有 \b,地址整段逃过归一化——凡是这种写法都必然分歧。锚点本身有正当理由(防止把脚本自己的 hex 归一掉),坏在写法。改成「不是字母」,替换随之改成只重写匹配内部的 0x... 再把前导字符放回去(12 §4.3a)
    脚本测量地址的长度,归一化到不了(#233) internal/oracle/prelude.go t={0}print(#tostring(t)):PUC 21(%p 在本平台给 12 位)、望舒 17(0x%08x 给 8 位),长度在归一化之前就分歧,NormalizeOutput 拿到的字节流里只有 2117——与 string.len(0/0) 4 对 3 完全一样的机制。修在渲染处:prelude 包一层 tostring 把 PUC 自己的地址渲染成望舒的 8 位,与 NaN 符号位是同一个选择;望舒侧的宽度成为契约。归一化管值、渲染处管宽度(12 §4.3a)
    gsub 替换串里的 % 转义(#234) internal/stdlib/stringlib.go::st2gsubRepl PUC add_s% 分支有三个出口加一个边界情况,望舒只对了两个:① 末尾的裸 % 让它 i++ 然后越过长度读 news[i],读到 lua_tolstring 保证的 NUL、每次匹配吐一个 NUL 字节(gsub("aaa","a","x%")x\0x\0x\0,hexdump 实测),望舒要求 i+1 < len(rb) 把它当成了字面量;② %任何非数字原样吐出,gsub("a","a","%z")"z" 而不是报错——这一半是既有缺陷,在 base 上同样分歧、只是没有 issue 记它(10 §6.5.2)
    「照抄一个越界读」的判据 这不是 C 的 UB(lua_tolstring 保证那里有 NUL,读它有定义)也不是参照实现自相矛盾,而是有定义但可疑;决定照抄的是口径——oracle 逐字节比较,不照抄的话凡是以 % 结尾的替换串就永远不可比,而且那个差的字节还会流进长度、比较、表键。注释里写清了这是怪癖不是规则

    #233 为什么看起来是间歇的:#232 那种「原始形式」是否分歧,取决于真实地址恰好需要几位十六进制 (正好 8 位时两侧碰巧一样长);而 #233 那种「测量长度」是稳定分歧的。同一个根因、两种可见度—— 分组时不要按症状把它们分成两件事。

    验证规模:三处修复各自用「把该修复还原掉」实测确认会变红—— internal/oracle/normalize_addr_test.go::TestNormalizeAddrPrefixValidation(六条必须归一 + 四条必须 原样,两侧都要有:只写前半时「把锚点整个删掉」也会通过)、 fuzz_234_test.go::TestGsubReplacementEscapeMatchesPUC(四条改了行为 + 三条必须不变:%%%1、越界 下标仍抬错)、fuzz_234_test.go::TestAddressLengthIsComparable(望舒侧的宽度契约:#tostring({}) 是 17、#tostring(print) 是 20)。三个 seed 入 testdata/fuzz/FuzzOracleDiff/。过程反思见 llmdoc/memory/reflections/2026-08-05-issue232-234-address-width-and-gsub-escape.md(五条教训:一批 crasher 里同一个根因可能以不同的可见度出现,不要按症状分成两件事 / 归一化只能救「被打印出来」的 差异,救不了「被测量」的差异 / 一个正则锚点的反例集合要用它自己的失败输入去检验,不能靠读它的 注释 / 修参照实现的语义时「只修被报的那一半」会留下已知的洞,一个分支的所有出口都要对 / 照抄 参照实现的越界读怪癖是可以的,前提是判据来自「比较的是什么」)。

  • 六个 issue、一个根因、而且第一次判错了(2026-08-09,#236–#241 一轮,81acb57,产品代码与测试零 改动):六个 nightly 自动开的 issue,标签 ci 不是 bug —— 这一轮不涉及任何语义,全部落在 工程侧,记在这里是因为它改变了怎么读 nightly 的失败这一条口径(12 §8.1)。

    落点 结论与要点
    诊断先判错了(头条) 失败步骤报 exit code 28,当天早上的巡检把它读成 ENOSPC(磁盘写满,errno 28)并据此提了「在 oracle 源码构建前腾空间或缓存构建产物」的建议。28 是 curlCURLE_OPERATION_TIMEDOUT:那一步是 curl,shell 报的退出码来自 curl 自己的退出码表、不是 errno 表,两张表恰好在 28 这个数字上都有条目而且都在讲一种资源类失败(空间 / 时间),所以错的解释读起来完全自然。判据:看到一个数字退出码,先定位是哪个命令退出的,再查那个命令自己的表
    两条免费的反证一开始就在日志里 apt 在 16:55:03 成功结束,然后沉默 2 分 15 秒才报 exit 28——磁盘写满是立刻失败的,会先卡的只有等待类失败;② no space left / ENOSPC / disk full 在两个 run 的日志里出现次数都是 0。判据:分类一个 CI 失败时先把时间戳减一遍,时间形式往往比错误码更能定类,而且它在日志里免费
    真根因 .github/workflows/nightly-diff-fuzz.yml 等四处 裸的 curl -sLO https://www.lua.org/ftp/lua-5.1.5.tar.gz,没有 --max-time、没有 --connect-timeout、没有 retry,上游一次可达性抖动就挂到 shell 放弃
    后果比「一次红」更糟 同上 这一步失败让后面三个差分 fuzz 步骤被 skip(step 5/7/9 是 skipped),那一轮报 failure 而实际什么都没测、探索预算为零——而红色的默认含义是「跑了并且发现了问题」,两者在 Actions 页面上是同一个红叉(12 §8.1)
    一次抖动开了六个 issue 同上,triage 段 infra issue 标题嵌了 ${{ matrix.variant }},而 infra 失败天然横跨所有 tier(divergence 失败才天然属于某个 tier),p1/p3/p4 三个标题让按标题去重看不出它们是同一件事:两次抖动 × 三个 tier = 六个。改成按 ${{ github.run_id }}(三个 job 共享)去重,第二三个 job 改为评论,tier 挪进正文——去重键与信息量是两件事(engineering §3.2)
    取包收口 scripts/fetch-lua-tarball.sh(新增) 四处 call site(ci.yml ×2、bench-acceptance.yml ×1、nightly-diff-fuzz.yml ×1)统一改用它。四个性质:限时 / 重试(--retry 才是关键 —— 它的默认值是 0,所以旧的裸 curl 根本不重试。--retry-all-errors 只是额外放宽,不是超时重试的前提:curl 手册写的是「transient error means either: a timeout, an FTP 4xx ... 」,所以单靠 --retry 就能覆盖 #236–#241 那次失败。此前把它写成前提是错的,记在这里因为那曾是保留这个 flag 的唯一理由。
    自测本身不能是新的抖动源 scripts/test-fetch-lua-tarball.sh(新增) 挂进 make test-scripts(#179 定下的门禁纪律),五个用例全部离线(用 file:// origin 冒充上游)——一个「防住外部抖动」的测试如果自己依赖上游,它加的是噪声不是防线。每个用例都用变异实测过
    那个自测第一版假绿 同上 「curl 调用带限时标志」这一条第一版 grep 整个文件:把 --max-time 从调用里删掉后照旧通过,因为那个词在脚本顶部的注释里还在(注释正好在解释「bounded: --connect-timeout and --max-time」)。现在只截取那条 curl 调用再检查。判据:检查代码属性的测试要作用在代码本身上,一个注释就能满足的测试没有在测代码

    已知缺口(如实记):取包这一环收口了,去重也修好了,但**「本轮未执行任何差分」这句话仍然没有 出现在任何地方** —— infra issue 的 body 说的是失败原因的类别,不是「这一轮的探索预算为零」。 记入 engineering §7 文档缺口。

    过程反思见 llmdoc/memory/reflections/2026-08-09-issue236-241-curl-timeout-misread-as-enospc.md (五条教训:同一个数字在不同的退出码表里含义不同、先确认这个退出码是谁的 / 时间形式是区分故障 类别的免费证据 / 一个失败步骤让后续步骤 skip 会造出「报红但什么都没测」 / 自动开 issue 的 去重键必须与「一次事件」的粒度对齐 / 检查「某个标志存在」的测试不能 grep 整个文件)。

  • 崩的是参照实现,望舒零改动(2026-08-11,#244 一轮,a22de53):定时巡检发现的 p1 FuzzOracleDiff crasher,seed 是 A(unpack({},0X80000000))版本核对这次很干净——失败 run 的 headSha 正好就是当时的 master(e0e7b34),不可能是过期 seed;在当前 HEAD 上重放确实复现。

    落点 结论与要点
    崩的是 oracle,不是望舒(头条) 重放拿到 SIGSEGV 且栈迹在 cgo 里(_Cfunc_wangshu_oracle_exec),拿真的 lua5.1 二进制直接试同样 dumped core,而望舒对同一输入抬 too many results to unpack、行为正确、从不崩。判据:差分 harness 报 crash 时先读栈迹属于哪一侧再决定往哪边查——「fuzz 报了 crash」不等于「被测实现有 bug」,参照实现也会崩而且更容易被误判成我们的问题(拿真参照实现二进制跑一次最便宜、单独就能定性)
    机制 internal/oracle/_lua515/src/lbaselib.c(luaB_unpack)+ lapi.c(lua_checkstack) i/eluaL_optint/luaL_checkint 窄化成 int,n = e - i + 1 的减法是有符号溢出 UB;gcc -O2n <= 0 检查当不可达删掉,lua_checkstack 收到负 size 而拒绝条件对负值两个都为假 → 段错误。-O0 下同一份源码干净抬错,崩溃依赖优化等级(此前记的「回绕成正的巨大值绕过检查」是错的:i <= e 时回绕恒 <= 0)
    修法:差分侧跳过 internal/oracle/prelude.go prelude 包一层 unpack,索引落在崩溃窗口时抬 LimitSentinel,与其余 PUC UB range 一致(会死的 oracle 不能当参照,12 §4.9b 第三格)。望舒侧零改动——它本来就正确
    望舒侧的行为契约 fuzz_244_test.go::TestUnpackAtInt32BoundaryDoesNotCrash 边界两侧 + INT_MAX 起点 + 小负起点 + 显式区间 + 整表 unpack:该抬干净错的抬、该给正确答案的给,从不崩(10 §4.5)
    守卫经六轮审计收口:区间读 ie、窄化走 __ckint0、助手在脚本前捕获、非表首参不跳;用例见 internal/oracle/unpack_guard_test.go(13 skip / 12 compare,两向变异确认)。补上 e 后规则完全可推导:i32 <= e32 且 (e32 - i32 + 1) > INT_MAX,10 组「先预测再实测」全部吻合。太窄:A(unpack({1,2,3},-2147483646)) 经真实 FuzzOracleDiff 实测让整个测试二进制 SIGSEGV(家族还活着);太宽:unpack({1,2,3},4294967297) 窄化成 i32 = 1、两侧都返回 3,却拿到 sentinel 被 SKIP。口径见 12 §4.9f,待修

    2026-08-11 那一轮本身是文档轮 + 复核轮,没有改任何代码或测试;上面「已知缺口」那一行是复核的产出。 判据落点 12 §4.9b 第三格(UB 且参照实现直接崩)与 §4.9f(一条 skip 的 区间要按机制定,不能按被报的那个写法实测出来)、10 §4.5(unpack 的索引语义)、 engineering §3.2 与 §7。过程反思见 llmdoc/memory/reflections/2026-08-11-issue244-oracle-segv-unpack-int32.md(三条教训:差分 harness 报的 crash 第一个要问「谁崩了」 / 「实测」与「推导」不是互斥的——一个被称为「实测得来」的边界仍然需要机制来 确定它的形状,否则实测只覆盖到采样点所在的那一格,而且这个错误会自我确认 / 限制类守卫的两侧用例要 沿机制的每个维度取,不能只沿被报的那个写法取,两种失效反馈还不对称——太宽表现为测试通过、太窄表现 为下一次 nightly 再开一个 issue)。

相关

00-overview · ../engineering · 12-testing-difftest

补记(同轮第二次审计):上面那条「兄弟写法都便宜」的结论只对单次调用成立。把同样的工作摊到 多次调用上——99 次、每次 2^20-6 个元素——仍然完全参与比对,两侧合计约 11 秒,而指令计数钩子拦不住它 (每次移位都在一次 C 调用内部完成)。所以 prelude 又加了累计预算(__shiftTotal,上限 2^22), 并由 internal/oracleTestInsertShiftCumulativeBudget 固定;实测把预算去掉或调得过紧都会让它变红。