WASM Runtime · 一个二进制到处跑的代价
CodeGraph 用 web-tree-sitter 跑 WASM 语法,不依赖任何原生编译——但"WASM 语法是巨大的模块"这件事,在 Node 上引出了一个反直觉的崩溃,以及一整套围绕它的内存与崩溃治理。本页基于 wasm-runtime-flags.ts、parse-worker.ts、grammars.ts 的实际源码拆解,纠正"按文件数回收"这类常见误解。
真正的敌人:V8 turboshaft 的 WASM Zone OOM
tree-sitter 语法是很大的 WebAssembly 模块。在 Node ≥ 22 上,V8 的 "turboshaft" 优化型 WASM 编译器会在后台线程编译这些语法时耗尽它每次编译用的 Zone arena,以 Fatal process out of memory: Zone 中止整个进程——哪怕系统还有几十 GB 空闲,因为这个 Zone 是 V8 内部的 arena,根本不是 JS 堆。文档记录:Node 22、24 都能复现,Node 25 因同一崩溃被 bin/node-version-check.ts 硬性拦截(见 issue #293、#298)。
解法:--liftoff-only 这一个 flag
--liftoff-only 强制每个 WASM 模块都走 Liftoff 基线编译器、永不进入 turboshaft,崩溃随之消失。代价只是放弃优化层那点(对语法而言很少触达的)边际加速,解析结果完全正确。源码把它做成单一事实源,连测试都从这里读、断言每个 flag 都是运行时真实存在的 flag,这样改名也不会悄悄回退修复:
export const WASM_RUNTIME_FLAGS: readonly string[] = ['--liftoff-only'];
关键约束:这个 flag 必须在 node 的命令行上,因为它在引擎 init 时被 V8 读取、早于任何 JS 运行。文档逐一记录了"看起来能用其实不行"的几条路:v8.setFlagsFromString('--liftoff-only') 运行时设置——太晚;Worker 的 execArgv: ['--liftoff-only']——被拒(ERR_WORKER_INVALID_EXEC_ARGV);NODE_OPTIONS=--liftoff-only——不在 Node 的 NODE_OPTIONS 允许名单上。而且 --no-wasm-tier-up / --no-wasm-dynamic-tiering 都挡不住崩溃——只有彻底关掉优化层才行。
怎么保证 flag 一定在:进程级 + 自我重启
投递分两条路:打包出来的 launcher(build-bundle.sh、npm-shim.js)直接把 flag 加在命令行上;而对任何其他启动方式(直接跑 dist、从源码跑…),CLI 会通过 relaunchWithWasmRuntimeFlagsIfNeeded 用带 flag 的参数把自己 re-exec 一次。buildRelaunchArgv 是纯函数(可单测):我们的 flag 在前,已有的 node flag 去重保留在后,再接脚本与参数。
为什么一次 re-exec 就够覆盖 worker?因为 V8 flag 是进程级的,而 parse worker 用默认(继承的)execArgv 创建——给主进程上 flag,就同时管住了 worker 的 WASM 编译。再用一个环境变量守卫防止无限重启:
// 重启的子进程设此 env,检测打滑也绝不会无限 re-exec;也供用户强制关闭
const RELAUNCH_GUARD_ENV = 'CODEGRAPH_WASM_RELAUNCHED';
// flag 是否已经在 execArgv 里
export function processHasWasmRuntimeFlags(execArgv = process.execArgv) {
return WASM_RUNTIME_FLAGS.every((flag) => execArgv.includes(flag));
}
re-exec 的副作用:被挡住的 PPID watchdog
自我重启不是没有代价。当 CLI 为了加 flag 而 re-exec 一次时,会在 MCP host 和真正的 server 之间插入一个中间进程——这个中间进程阻塞在 spawnSync 里、即使 host 被杀也还活着。于是 server 原本靠"观察自己的 process.ppid"来感知 host 死亡的 watchdog 就瞎了(它的父进程是那个还没退出的中间进程,而不是 host)。
解法是把真正的 host PID 透过 re-exec 传下去,让 watchdog 直接轮询它:
// 携带 host PID 穿过 re-exec;无 re-exec 路径(已带 flag / 打包 launcher)时不设
export const HOST_PPID_ENV = 'CODEGRAPH_HOST_PPID';
parse worker:独立线程 + 三类消息
解析跑在独立的 worker 线程里,让主线程不被阻塞、UI 动画流畅。worker 与主线程之间是一个极简的 postMessage 协议:
load-grammars(带 languages)→loadGrammarsForLanguages()→ 回grammars-loadedparse(带id / filePath / content / frameworkNames)→detectLanguage+extractFromSource→ 回parse-result(带同一 id)shutdown→ 回shutdown-ack
两层内存回收:周期 reset + 崩溃即重生
WASM 线性内存的特点是只增不减,所以 worker 用两层策略压住堆:
const PARSER_RESET_INTERVAL = 5000;
const parseCounts = new Map<Language, number>();
// …每次 parse 后:
const count = (parseCounts.get(language) ?? 0) + 1;
parseCounts.set(language, count);
if (count % PARSER_RESET_INTERVAL === 0) resetParser(language); // 回收 WASM 堆
// 解析抛错时:WASM 内存错误会让模块进入损坏态,后续 parse 会级联失败——
// 直接杀掉 worker,让主线程拉起一个干净堆的新 worker
if (message.includes('memory access out of bounds') ||
message.includes('out of memory')) process.exit(1);
- 主动:每个语言每 5000 次 parse 调一次
resetParser(language)回收堆——这是真实的回收阈值(不是"每 250 文件回收 Worker")。 - 被动:一旦命中
memory access out of bounds/out of memory,模块已损坏、再 parse 只会级联失败,于是process.exit(1)让主线程用干净堆重建 worker——崩溃即重生,而不是带病硬撑。
收尾的体面:过滤 Emscripten 的 Aborted() 噪声
WASM 中止时,Emscripten 会在 JS 的 catch 跑之前,直接往 stderr 打 Aborted() 和一行 Build with -sASSERTIONS for more info。worker 的 stderr 被父进程继承,于是每次崩溃都会给用户终端漏一行噪声——即便 JS 层已经干净地处理了失败。worker 在源头改写 process.stderr.write,只过滤这两类签名行,并谨守 Writable 契约(即便抑制写入也必须触发回调,否则等 drain 的上游会挂死)。文档还诚实记下接受的取舍:逐次 write() 独立匹配(同步 abort 会一次性打完整行,所以不缓冲跨调用拼接)。
按需编译:只为项目里真有的语言加载 grammar
grammars.ts 惰性加载语法——只为项目里实际出现的语言编译 WASM,从源头压低 V8 的 WASM 内存压力。语言到 .wasm 文件的映射写成一张静态表:
const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
typescript: 'tree-sitter-typescript.wasm',
python: 'tree-sitter-python.wasm',
rust: 'tree-sitter-rust.wasm',
swift: 'tree-sitter-swift.wasm',
// …go / java / c / cpp / csharp / php / ruby / kotlin / dart / pascal / scala / lua / r …
};
类型上还藏着一条设计线索:GrammarLanguage = Exclude<Language, 'svelte'|'vue'|'astro'|'liquid'|'razor'|…>——这些格式 tree-sitter 搞不定,所以它们不在 WASM grammar 表里,而是各有专用 extractor(svelte-extractor.ts、vue-extractor.ts、razor-extractor.ts…)。一张 Exclude 类型把"哪些语言走 WASM、哪些走手写抽取器"在编译期就钉死了。
意义:可移植性的账,记在运行时治理上
"一个二进制到处跑"的承诺,靠的是 WASM 而非原生编译;但 WASM 把成本转移到了运行时——巨大的语法模块撞上 V8 优化器的内部 arena、线性内存只增不减、崩溃会污染整个模块。CodeGraph 的应对是一层层确定性的工程对策:一个进程级 flag 根除崩溃根因、带 guard 的自我重启保证 flag 必达、跨 re-exec 传 host PID 修好 watchdog、每 5000 次 parse 主动回收 + 崩溃即重建 worker 兜底、stderr 过滤让失败收得体面、按需编译压住内存峰值。没有一处是"魔法",但合起来,就是让一个纯 WASM 的索引器能在真实的大仓库上稳定跑完的全部理由。