同样的输入,为什么 Wasm 每次都不一样?
我最近在做一件看起来不应该很难的事:给定完全相同的源码、工具链和虚拟文件系统,两台干净的 Linux builder 应该产出逐字节一致的 WebAssembly bundle。
第一次跑双 builder 时,raw Wasm 一致,最终 Wasm 却不一致。更麻烦的是,在同一台 runner 上,用同一个 raw Wasm 连续启动两个独立的 pack 进程,结果仍然不同。
最后定位到的主链路并不在编译器或链接器里,而是:
pack-time CLOCK_MONOTONIC
→ CPython 3.14 mimalloc 的 weak-random seed
→ ChaCha20 与 allocator 内部状态
→ Wizer snapshot guest linear memory
→ Core Wasm section 11 每次不同
修复只作用于 pack context:将打包期间的 monotonic clock 固定为 now = 0。之后,同 runner、跨 runner,以及整个 bundle 都恢复了 exact equality。
这篇文章记录的不是一句“固定时间就好了”,而是如何把一个五千多万字节的 Wasm 差异,逐层缩小到一条可以从源码解释、由实验反驳、再由 CI 验证的因果链。
先定义什么叫“可复现”
这里的目标不是“行为相同”,也不是“去掉时间戳后大致相同”,而是完整 bundle 的 SHA-256 必须一致。
构建链可以简化成四层:
锁定源码与工具链
↓
编译、静态链接
↓
raw.wasm
↓
wasi-vfs + Wizer pack
↓
final.wasm + manifest + SBOM + notices
如果只比较最终文件,任何一层都可能背锅。因此第一步不是修改 linker flags,而是保留每个关键边界的证据:
- pre-pack raw Wasm;
- frozen VFS manifest;
- wasi-vfs archive 与 linked object;
- pack CLI identity;
- 第一次和第二次独立 pack 的输出;
- final bundle 中所有文件的 digest。
判断规则也必须 fail-closed:只有 raw Wasm 和必要 pack inputs 全部一致,而 final Wasm 不一致时,才能把首次漂移归到 pack stage。
第一个有用结论:问题发生在 pack stage
最初的双 builder 实验给出了很干净的边界:
- raw Wasm 一致;
- VFS manifest 一致;
- source lock 一致;
- pack CLI 一致;
- final Wasm 不一致。
随后,我让每个 builder 对同一个 raw Wasm 再启动一个独立 pack 进程。四份 packed Wasm 的 digest 全部不同,而且六组 pairwise comparison 的首次差异都只出现在 Core Wasm section 11,也就是 data section。
这排除了“只是两个 GitHub runner 环境不同”的解释。新的 wasi-vfs/Wizer 进程本身就足以产生漂移。
但 section 11 只告诉我们“被 snapshot 的内存不同”,并不告诉我们是谁写了这些字节。
两个真实但不完整的目录问题
Dirent 的 3-byte tail padding
Wasmtime-WASI Preview 1 的 fd_readdir 会把一个 #[repr(C)] Dirent 的 host representation 复制到 guest memory。该结构的字段布局是:
d_next u64 bytes 0..8
d_ino u64 bytes 8..16
d_namlen u32 bytes 16..20
d_type u8 byte 20
padding bytes 21..24
字段只占到 byte 20,但 C ABI size 是 24,所以末尾有 3 bytes padding。这些 padding 不属于任何逻辑字段,却会跟着整个结构被复制进 guest buffer,最后进入 Wizer snapshot。
我只清零完整 dirent 的 [21, 24),不修改 inode、name、type、cookie 或遍历顺序。结果很明显:常态差异从约 27–28 个 data segments、379–438 bytes,下降到固定 6 个 segments、104–106 bytes。
因此 padding 是真实贡献者,但不是唯一来源。
fd_readdir scratch buffer 的残留
wasi-vfs 使用可复用的 4 KiB buffer 读取目录。完整 entry 的 padding 被处理后,truncated entry 和最后一次返回范围之外的旧内容仍可能留在 buffer 中。
在每个 walk_dir 正常返回前,我对当前 buffer 的完整 live length 做 volatile zero。新结果降到固定 5 个 segments、101–104 bytes,同时此前偶发的 section-size outlier 消失。
这又证明了一个真实贡献者,但剩下的约 100 bytes 显然来自别处。
关键线索:expand 32-byte k
将 raw Wasm 初始内存与 packed Wasm 对照后,剩余差异里有一块连续的高熵状态。它旁边出现了这串 ASCII:
expand 32-byte k
这是 ChaCha 状态初始化常量。继续对照 hash-locked 的 CPython 3.14.0 源码,来源落在 Objects/mimalloc/random.c:mimalloc 使用 ChaCha20 管理自己的随机上下文。
初始化路径的核心是:
static void mi_random_init_ex(mi_random_ctx_t* ctx, bool use_weak) {
uint8_t key[32] = {0};
if (use_weak || !_mi_prim_random_buf(key, sizeof(key))) {
uintptr_t x = _mi_os_random_weak(0);
// derive the key from x
ctx->weak = true;
}
chacha_init(ctx, key, (uintptr_t)ctx);
}
真正决定行为的是 mimalloc 的 WASI prim implementation:
bool _mi_prim_random_buf(void* buf, size_t buf_len) {
return false;
}
也就是说,在这个 CPython WASI build 中,mimalloc 必然进入 weak-random fallback。fallback 又这样取种子:
uintptr_t _mi_os_random_weak(uintptr_t extra_seed) {
uintptr_t x = (uintptr_t)&_mi_os_random_weak ^ extra_seed;
x ^= _mi_prim_clock_now();
// shuffle x
return x;
}
而 WASI 的 _mi_prim_clock_now() 最终调用 clock_gettime(CLOCK_MONOTONIC)。
因果链到这里闭合了:每个 pack 进程看到不同的 monotonic time,mimalloc 得到不同的 weak seed,继而产生不同的 ChaCha 和 allocator 状态;Wizer 再把这些本应只属于初始化过程的内存状态固化到 final Wasm。
为什么“固定随机数”没有用
在看到高熵字节时,第一反应通常是 random_get。我也测试过将 pack-only WasiCtxBuilder::secure_random 替换为 deterministic generator。
为了确认 patch 真的生效,我还构造了一个最小 Wasm:它的 init function 只调用一次 random_get(0, 16)。patched CLI 对这个模块连续 pack 两次,输出完全一致。
但同一个 CLI 用于真实 retained raw Wasm 时,剩余五段漂移原封不动。
这并不矛盾。mimalloc 的 WASI prim 根本没有走 random_get;它直接报告失败,然后使用 clock 与地址构造 weak seed。最小 probe 证明了 secure RNG treatment 有效,也同时证明了它不在真实故障路径上。
这是整个调查中很重要的一点:一个修改“看起来合理”不够,还要证明目标代码确实消费了这条输入。
在正确的边界固定时钟
最终 treatment 没有全局修改 guest 的时间 API,也没有改变 production runtime clock。它只对执行 Wizer pack 的 WASI context 注入固定 monotonic clock:
struct DeterministicPackMonotonicClock;
impl wasmtime_wasi::clocks::HostMonotonicClock
for DeterministicPackMonotonicClock
{
fn resolution(&self) -> u64 {
1
}
fn now(&self) -> u64 {
0
}
}
let mut wasi = wasmtime_wasi::WasiCtxBuilder::new();
wasi.monotonic_clock(DeterministicPackMonotonicClock);
这个边界很重要:
- build-time snapshot 得到稳定输入;
- production runtime 仍使用真实时钟;
- 没有全局 stub
random_get; - 没有在最终 Wasm 上做事后 canonicalization;
- exact full-bundle equality gate 完全保留。
本地 Linux preflight 中,同一个 retained raw Wasm 和 manifest-identical VFS 经两个独立 CLI 进程打包后,已经得到逐字节一致的结果。
随后,双 builder CI 也首次完整通过。
最终证据
最终受控 CI run 中:
| 检查项 | 结果 |
|---|---|
| 两侧 raw Wasm | 一致 |
| 两侧 frozen VFS 与 source lock | 一致 |
| 每侧两次独立 pack | 一致 |
| 两个 runner 的 final Wasm | 一致 |
| 完整 bundle equality | 通过 |
| equality gate | 未弱化 |
四份 packed Wasm 最终收敛到同一个 SHA-256:
97539f2fa73e2cad5ea4c2b122e7a859ecdeb90b8f8505420f590ef3990b9446
双 builder、每侧 repeat pack 和最终 exact comparison job 全部成功;下载后的 artifact digest、bundle contract 与本地 comparator replay 也全部一致。
哪些结论不能顺手扩大
这次结果来自累积实验。它支持以下判断:
Direnttail padding 是真实漂移贡献者;- directory scratch-buffer residue 是较小但真实的贡献者;
- 在处理这些目录表示问题之后,pack-time monotonic clock 是剩余的必要机制;
- 固定 pack context 的 clock 足以恢复当前实验序列中的 exact reproducibility。
但它不自动证明“当前五个 research treatments 就是最小 production patch set”。此前 deterministic HashMap hasher 和避免未消费的 filestat metadata 都被证明单独不足;若要推进生产修复,还应做 ablation,移除这些无效 treatment,并重新通过完整门禁。
同样,也不应该据此得出“所有 Wizer build 都必须冻结时间”。准确结论只适用于这条已验证的链:CPython 3.14 的 mimalloc WASI weak seed 在 pack-time 初始化,并被 snapshot 进最终模块。
我从这次调查里保留下来的方法
1. 先定位首次漂移的 stage
不要从 final binary 猜原因。保留 raw、pack inputs 和 repeat-pack evidence,先回答“第一次在哪里不同”。
2. 同 runner repeat 比跨 runner comparison 更有信息量
如果同一 runner 的独立进程已经不同,就不必先追内核版本、CPU 型号或 runner image。
3. 结构差异是线索,不是结论
“只差 section 11”仍然可能包含目录 padding、scratch residue、PRNG state 和 allocator metadata。必须继续映射到 guest memory 和源码。
4. 反证要确认 treatment 真正生效
secure RNG 实验之所以可信,不只是因为真实模块仍然漂移,还因为最小 random_get probe 已证明 patched path 确实工作。
5. 修复应放在产生非确定性的最窄边界
这里最合适的边界是 pack-only WASI context,而不是 production runtime,也不是最终二进制的字节重写。
可复现构建最麻烦的地方通常不是“有一个时间戳”,而是时间、随机性、未初始化表示和 snapshot 语义在边界处叠加。真正有效的办法仍然很朴素:冻结输入,保留中间产物,每次只改一个假设,并让失败继续失败,直到证据能够闭合。