go build 缓存
packages/ttsc/src/plugin/internal/buildSourcePlugin.ts(~1560 LOC)是把 Go 插件源编译成缓存二进制的引擎。它解决一个棘手问题:在干净环境里、用打包的 Go SDK、按内容确定地缓存、跨并发进程只构建一次。本页是 ttsc 里算法密度最高的子系统之一。
总流程
缓存键:覆盖一切能改变二进制的输入
computeCacheKey(buildSourcePlugin.ts:1041)算一个确定性 SHA-256,截前 32 hex 字符。它哈希进:
几个值得注意的点:
- Go 编译器身份(
resolveGoCompilerIdentity,buildSourcePlugin.ts:1154)=go version输出 + 二进制 SHA-256。有 per-process memo(按resolved real path + size + mtime),因为一个 N 插件项目都指同一 toolchain 时,不 memo 会付 N 次go versionspawn + N 次 ~150MB 二进制哈希。 GOROOT特殊处理(resolveGoRootCacheIdentity,buildSourcePlugin.ts:1342):不是把路径字符串进键,而是哈希 GOROOT 里相关文件(bin/go、pkg/tool、src/go.mod、VERSION等,跳过.git、testdata、_test.go)。这样换个内容相同的 GOROOT 不会无谓失效,但 toolchain 真变了会。CC/CXX等命令类环境变量(GO_BUILD_COMMAND_ENV_KEYS)哈希进值 + 命令二进制 SHA-256,所以换了编译器实现会失效。- 贡献者按名字排序后哈希,让两个逻辑集合相同的消费者无论声明顺序都得同一键(
buildSourcePlugin.ts:1067)。 - 某些文件被排除(
shouldOmitSourceFile):go.work/go.work.sum(生成物)、.tgz/.tar.gz(npm pack tarball)、.DS_Store/Thumbs.db,否则每次无关npm pack或编辑器保存都会炸缓存。
跨进程构建锁
buildUnderPluginLock(buildSourcePlugin.ts:253)解决 fan-out 问题:一个程序扇出成多进程(pnpm -r 并行跑多套件、基准、worker pool),每个都继承同一冷缓存,会同时为同一 cache key 启动各自的完整 go build。
机制要点:
- 锁是对
<cacheDir>.lock的原子mkdir(非 recursive——recursive 是幂等的、不会抛 EEXIST,会废掉锁)。EEXIST 正是"别人持锁"信号。 - 赢家构建并发布二进制;输家轮询(50ms 间隔)等二进制出现就复用。
- 等超过
PLUGIN_BUILD_LOCK_STEAL_MS(600 秒)说明 builder 崩了没发布,输家偷锁重试,让 fan-out 永不卡死。 - 正确性最终靠
publishBuiltBinary的原子 rename,不靠锁;锁只是避免重复工作的优化。无法创建锁目录(如只读缓存)时退到无锁构建。
scratch 目录与 go.work 编排
compileSourcePlugin(buildSourcePlugin.ts:176)在 os.tmpdir() 建 scratch 目录,把插件源拷进去(materializeScratchDir,按 PRUNE_DIRS 剪掉 node_modules/.git/.ttsc),合并贡献者(见下),写 go.work,跑 go build,原子发布。
writeGoWork(buildSourcePlugin.ts:549)写一个 go.work,use 块包含 . 与 overlay 目录(ttsc 自己的 go.mod + shim 子模块),并对 ttsc 管理的模块加 replace 指令。validateSourceReplacements(buildSourcePlugin.ts:579)拒绝插件 go.mod 自己 replace ttsc 管理的模块("ttsc supplies its own compiler and shim modules"),防止插件偷换 shim。
findTtscOverlayDirs(buildSourcePlugin.ts:856)从 ttsc 包根收集 go.mod 与 shim/ 下所有 go.mod 子目录,作为 overlay 目录——这就是插件能 import shim/* 的根本:构建时 go.work 把 shim 模块 wire 进去。
贡献者合并
mergeContributors(buildSourcePlugin.ts:338)把每个贡献者源拷进 <scratch>/contrib/<name>/,并在 entry 包旁合成 ttsc_contributions.go(writeContributionsFile),内含每个贡献者的空白 import:
多处守卫保证不静默覆盖:
- 贡献者带
go.mod→ 报错(必须是包不是模块,供应链约束)。 - 宿主源已有
contrib/目录 → 报错(不能静默合并出无人声明的混合二进制)。 - 宿主 entry 已有
ttsc_contributions.go→ 报错(文件名保留给生成器)。 - 贡献者按名排序,让空白 import 顺序确定——与
computeCacheKey的排序匹配,否则同一 cache key 可能对应 init 顺序不同的两个二进制(buildSourcePlugin.ts:366注释)。
宿主模块路径从 materialize 后的 go.mod 读,所以 import 路径对宿主实际模块声明永远正确。
原子发布
publishBuiltBinary(buildSourcePlugin.ts:443)先拷到同目录的 .tmp 名(<binary>.<pid>.<时间>-<随机>.tmp),chmod 0755,再 renameSync 到最终路径。同目录保证 rename 是同文件系统原子操作。rename 撞 EEXIST/EPERM/EACCES 且目标已存在 → 视为别人已发布、成功返回。pruneOrphanPendingBinaries 只清本进程的遗留 .tmp,避免删并发 ttsc 进程正在 rename 的 pending 文件。
缓存根解析与全局 GC
resolvePluginCacheRoot(buildSourcePlugin.ts:906)优先级:--cacheDir 选项 → TTSC_CACHE_DIR 环境变量 → 平台全局用户缓存(resolveUserCacheRoot:XDG / LOCALAPPDATA / Library/Caches / .cache)。落到全局缓存时机会性触发 GC。
maybePruneGlobalPluginCache + pruneGlobalPluginCache(buildSourcePlugin.ts:1433):每 24 小时最多跑一次(.gc-last-run marker),先按年龄逐出(>30 天),再按总大小逐出(>2GB 时按 LRU 降到 1.6GB,保护 1 小时内用过的条目)。touchCacheEntry 写 .last-used 时间戳支持 LRU。
Go 编译器解析
resolveGoCompiler(buildSourcePlugin.ts:979)顺序:TTSC_GO_BINARY → @ttsc/{platform}-{arch}/bin/go/bin/go(打包 SDK,主路径)→ 多个相对/home 回退 → 系统 go。这让 ttsc 默认完全不依赖用户机器装没装 Go。
不变量与失败模式
维护者提示
- 改缓存键就是在改"什么时候重建"。漏掉一个影响二进制的输入会导致陈旧二进制;多加一个无关输入会导致缓存抖动(每次 npm pack/编辑器保存重建)。改前对照
GO_BUILD_ENV_KEYS与shouldOmitSourceFile。 - 锁的正确性靠原子 rename 不靠锁本身——别把锁当唯一守卫。
sleepSync(buildSourcePlugin.ts:317)用Atomics.wait阻塞而非忙等,改轮询逻辑时保留它。