Yuzhe's Blog

yuzhes

同样的输入,为什么 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,而是保留每个关键边界的证据:

判断规则也必须 fail-closed:只有 raw Wasm 和必要 pack inputs 全部一致,而 final Wasm 不一致时,才能把首次漂移归到 pack stage。

第一个有用结论:问题发生在 pack stage

最初的双 builder 实验给出了很干净的边界:

随后,我让每个 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);

这个边界很重要:

本地 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 也全部一致。

哪些结论不能顺手扩大

这次结果来自累积实验。它支持以下判断:

  1. Dirent tail padding 是真实漂移贡献者;
  2. directory scratch-buffer residue 是较小但真实的贡献者;
  3. 在处理这些目录表示问题之后,pack-time monotonic clock 是剩余的必要机制;
  4. 固定 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 语义在边界处叠加。真正有效的办法仍然很朴素:冻结输入,保留中间产物,每次只改一个假设,并让失败继续失败,直到证据能够闭合。