go build 缓存

packages/ttsc/src/plugin/internal/buildSourcePlugin.ts(~1560 LOC)是把 Go 插件源编译成缓存二进制的引擎。它解决一个棘手问题:在干净环境里、用打包的 Go SDK、按内容确定地缓存、跨并发进程只构建一次。本页是 ttsc 里算法密度最高的子系统之一。

总流程

缓存键:覆盖一切能改变二进制的输入

computeCacheKeybuildSourcePlugin.ts:1041)算一个确定性 SHA-256,截前 32 hex 字符。它哈希进:

ttsc 版本 + tsgo 版本 + 平台/架构 + entry 包
+ Go 编译器身份 (resolveGoCompilerIdentity)
+ Go 构建环境变量 (GO_BUILD_ENV_KEYS, ~50 个)
+ 外部 Go 构建环境变量 (EXTERNAL_GO_BUILD_ENV_KEYS, C 工具链相关)
+ 插件源目录的每个文件内容
+ 每个 overlay 模块目录的文件内容
+ 每个贡献者源目录的文件内容 (按名字排序)

几个值得注意的点:

  • Go 编译器身份resolveGoCompilerIdentitybuildSourcePlugin.ts:1154)= go version 输出 + 二进制 SHA-256。有 per-process memo(按 resolved real path + size + mtime),因为一个 N 插件项目都指同一 toolchain 时,不 memo 会付 N 次 go version spawn + N 次 ~150MB 二进制哈希。
  • GOROOT 特殊处理resolveGoRootCacheIdentitybuildSourcePlugin.ts:1342):不是把路径字符串进键,而是哈希 GOROOT 里相关文件(bin/gopkg/toolsrc/go.modVERSION 等,跳过 .gittestdata_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 或编辑器保存都会炸缓存。

跨进程构建锁

buildUnderPluginLockbuildSourcePlugin.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 编排

compileSourcePluginbuildSourcePlugin.ts:176)在 os.tmpdir() 建 scratch 目录,把插件源拷进去(materializeScratchDir,按 PRUNE_DIRS 剪掉 node_modules/.git/.ttsc),合并贡献者(见下),写 go.work,跑 go build,原子发布。

writeGoWorkbuildSourcePlugin.ts:549)写一个 go.workuse 块包含 . 与 overlay 目录(ttsc 自己的 go.mod + shim 子模块),并对 ttsc 管理的模块加 replace 指令。validateSourceReplacementsbuildSourcePlugin.ts:579)拒绝插件 go.mod 自己 replace ttsc 管理的模块("ttsc supplies its own compiler and shim modules"),防止插件偷换 shim。

findTtscOverlayDirsbuildSourcePlugin.ts:856)从 ttsc 包根收集 go.modshim/ 下所有 go.mod 子目录,作为 overlay 目录——这就是插件能 import shim/* 的根本:构建时 go.work 把 shim 模块 wire 进去。

贡献者合并

mergeContributorsbuildSourcePlugin.ts:338)把每个贡献者源拷进 <scratch>/contrib/<name>/,并在 entry 包旁合成 ttsc_contributions.gowriteContributionsFile),内含每个贡献者的空白 import:

// Code generated by ttsc — DO NOT EDIT.
package main
import (
	_ "<host-module>/contrib/<name>"
)

多处守卫保证不静默覆盖:

  • 贡献者带 go.mod → 报错(必须是包不是模块,供应链约束)。
  • 宿主源已有 contrib/ 目录 → 报错(不能静默合并出无人声明的混合二进制)。
  • 宿主 entry 已有 ttsc_contributions.go → 报错(文件名保留给生成器)。
  • 贡献者按名排序,让空白 import 顺序确定——与 computeCacheKey 的排序匹配,否则同一 cache key 可能对应 init 顺序不同的两个二进制(buildSourcePlugin.ts:366 注释)。

宿主模块路径从 materialize 后的 go.mod 读,所以 import 路径对宿主实际模块声明永远正确。

原子发布

publishBuiltBinarybuildSourcePlugin.ts:443)先拷到同目录的 .tmp 名(<binary>.<pid>.<时间>-<随机>.tmp),chmod 0755,再 renameSync 到最终路径。同目录保证 rename 是同文件系统原子操作。rename 撞 EEXIST/EPERM/EACCES 且目标已存在 → 视为别人已发布、成功返回。pruneOrphanPendingBinaries 只清本进程的遗留 .tmp,避免删并发 ttsc 进程正在 rename 的 pending 文件。

缓存根解析与全局 GC

resolvePluginCacheRootbuildSourcePlugin.ts:906)优先级:--cacheDir 选项 → TTSC_CACHE_DIR 环境变量 → 平台全局用户缓存(resolveUserCacheRoot:XDG / LOCALAPPDATA / Library/Caches / .cache)。落到全局缓存时机会性触发 GC。

maybePruneGlobalPluginCache + pruneGlobalPluginCachebuildSourcePlugin.ts:1433):每 24 小时最多跑一次(.gc-last-run marker),先按年龄逐出(>30 天),再按总大小逐出(>2GB 时按 LRU 降到 1.6GB,保护 1 小时内用过的条目)。touchCacheEntry.last-used 时间戳支持 LRU。

Go 编译器解析

resolveGoCompilerbuildSourcePlugin.ts:979)顺序:TTSC_GO_BINARY@ttsc/{platform}-{arch}/bin/go/bin/go(打包 SDK,主路径)→ 多个相对/home 回退 → 系统 go。这让 ttsc 默认完全不依赖用户机器装没装 Go。

不变量与失败模式

检查行为位置
source 不存在plugin source does not existresolveSourceBuildTarget:502
3 层内无 go.modmust be inside a Go moduleresolveSourceBuildTarget:521
插件 replace ttsc 模块报错validateSourceReplacements:599
贡献者带 go.mod报错(包不是模块)mergeContributors:378
宿主已有 contrib/ 或 contributions 文件报错(防静默覆盖)mergeContributors:359/415

维护者提示

  • 改缓存键就是在改"什么时候重建"。漏掉一个影响二进制的输入会导致陈旧二进制;多加一个无关输入会导致缓存抖动(每次 npm pack/编辑器保存重建)。改前对照 GO_BUILD_ENV_KEYSshouldOmitSourceFile
  • 锁的正确性靠原子 rename 不靠锁本身——别把锁当唯一守卫。
  • sleepSyncbuildSourcePlugin.ts:317)用 Atomics.wait 阻塞而非忙等,改轮询逻辑时保留它。

接下来