claude-mem 修复实录:esbuild 冻结 __dirname 的根因剖析与 Worker 启动可靠性工程
本篇技术文章基于 claude-mem 仓库中的一份真实修复手册(Phase-02-Fix-Dirname-And-Worker-Startup.md)展开,系统讲解本地后台 worker 进程的两大核心故障:esbuild 打包时 __dirname 被冻结为构建机绝对路径导致的"幽灵路径" bug,以及冷启动竞态引发的空上下文、僵尸进程与重启风暴。读完本文,你将理解为什么 bundler 对 Node.js 内建全局变量的处理会成为跨用户安装的隐形杀手,以及如何在 hook 侧实现预算感知的重试、用原子锁协调并发重启,并用结构化诊断输出把"静默失败"变成可定位的故障。
一、背景:Worker 是 claude-mem 的中枢进程
claude-mem 的工作模式是:宿主 Agent(Claude Code、Codex 等)的每个生命周期事件(SessionStart、UserPromptSubmit、PostToolUse、Stop)都会触发一个 hook,hook 通过 HTTP 与本地常驻 worker 通信,由 worker 完成观测采集、AI 摘要与上下文注入。正如修复手册开篇所述,worker 启动不可靠会同时造成冷启动失败、竞态条件与僵尸进程,"修复它能解锁所有其他子系统"。
其中优先级最高的根因是一个 __dirname 硬编码 bug——它对应了 Issue 追踪中报告量最大的重复 issue(约 7 份重复报告)。手册将 Phase 02 拆解为六个任务,下文逐一结合当前仓库源码进行展开。
二、Bug 剖析:esbuild 打包时 __dirname 被冻结到构建机路径
2.1 问题机制
原始故障点在 worker-service 源文件 中类似 path.join(__dirname, 'mcp-server.cjs') 的写法:当 esbuild 以 CJS 格式把源码打包为 plugin/scripts/worker-service.cjs 时,构建器会把 __dirname 解析为构建机上的绝对路径(例如 /Users/xxx/...)并内联进产物。于是所有安装者运行 worker 时,拼出来的脚本路径指向一个在他们机器上根本不存在的目录,进程启动即失败或行为错乱。
手册给出的排查与修复思路包括:
- 阅读 esbuild 配置,确认
worker-service.cjs的打包方式; - 在源码中找出所有
__dirname/__filename使用点; - 在构建产物中 grep 验证是否存在硬编码绝对路径;
- 修复方案三选一:(a) 在 esbuild 配置中用
define: { '__dirname': '__dirname' }阻止内联;(b) 用banner选项注入运行时 polyfill;(c) 把路径抽离到独立的非打包配置中; - 同时检查 mcp-server 源文件 是否存在同类用法(手册记录该文件零处使用,无需修改)。
2.2 当前仓库中的实现证据
手册记录的完成方案是:在 build-hooks.js 的 3 个 CJS esbuild 配置(worker-service、mcp-server、context-generator)中加入显式 define,防止未来 esbuild 行为变化导致路径被内联——分析确认 platform: 'node' + format: 'cjs' 本身会保留这两个标识符为 CJS 运行时全局变量。
当前仓库中该修复已经进一步演化,build-hooks.js 中存在一道产物后处理防线 stripHardcodedDirname():构建完成后用正则扫描产物,若发现 var __dirname = '<绝对路径>' 这类被内联的声明,直接剥离掉,使其回落到 Node.js CJS 原生的运行时全局:
// scripts/build-hooks.js (L35-L55)
function stripHardcodedDirname(filePath) {
let content = fs.readFileSync(filePath, 'utf-8');
const before = content.length;
const str = `(?:"[^"]*"|'[^']*')`;
for (const id of ['__dirname', '__filename']) {
content = content.replace(new RegExp(`\\bvar ${id}\\s*=\\s*${str},\\s*`, 'g'), 'var ');
// ... 剥离被内联的绝对路径声明
}
if (removed > 0) {
fs.writeFileSync(filePath, content);
console.log(` ✓ Stripped hardcoded __dirname/__filename paths (${removed} bytes)`);
}
}
同时,构建脚本在需要 ESM 语义的上下文(如 Bun 运行时入口)中还会用 banner 注入带 fallback 的 polyfill,保证 __dirname 一定在运行时按脚本自身位置解析(见 build-hooks.js):
'var __filename = __filename || require("node:path").resolve(process.argv[1] || "");',
'var __dirname = __dirname || require("node:path").dirname(__filename);'
可以推断,这种"define 防内联 + 产物扫描剥离 + banner 兜底"的三重防御,正是从最初"显式 define"方案在实践中迭代出来的产物。手册要求的验收标准也值得直接抄进任何 bundling 流程的 checklist:构建后 grep 产物,0 条硬编码绝对路径,且 __dirname 引用数等于源码中的运行时引用数。
三、冷启动竞态:hook 连接 Worker 的预算感知重试
3.1 问题
手册描述的第二类故障:worker 冷启动需要 3~5 秒,而 hook 侧的连接函数原来只做单次健康检查、失败即返回 false。在 worker 尚未就绪的窗口内触发的 hook 会静默拿到空上下文——用户侧表现就是"记忆注入丢了"。
3.2 修复方案与关键设计约束
手册给出的修复要点:
- 在连接函数中加入轻量重试循环(当时参数为 3 次尝试、1 秒间隔);
- 重试必须尊重 hook 的超时预算(可通过环境变量
CLAUDE_MEM_HEALTH_TIMEOUT_MS配置,默认 3s),总重试时间不得超出该预算; - 重试要区分"worker 正在启动"(PID 文件新鲜 → 值得重试)与"worker 已死"(无 PID 文件 → 直接走 spawn),避免无谓等待。
手册记录的完成实现是一个"预算感知重试循环",并新增了 isWorkerStartingUp() 辅助函数(以 PID 文件存在且 30 秒内更新作为"启动中"判据),单次尝试超时取 min(800ms, budget/3)。
3.3 当前源码中的演化形态
当前仓库的 worker-utils.ts 中 ensureWorkerRunning() 已经演化为更完整的生命周期状态机,但其核心思想——按预算轮询端口与就绪信号——一脉相承。可以看到冷启动等待被扩展为 waitForWorkerPort({ attempts: 6, backoffMs: 500 }),注释明确解释了原因:重启后首次会话的 macOS + Chroma 冷启动 worker 需要约 7 秒才能绑定端口,旧的 ~0.75s 预算远不够用,等待被拉长到约 15.5 秒(worker-utils.ts):
// src/shared/worker-utils.ts (L596-L603)
// Cold boot (#2795): ... a cold macOS+Chroma worker needs ~7s to bind.
// The old 3-attempt/250ms budget (~0.75s) expired long before that,
// so the context (and session-init) hooks raced boot and soft-failed to empty...
const alive = await waitForWorkerPort({ attempts: 6, backoffMs: 500 });
配套的 worker-utils-timeout 测试 专门验证超时预算的行为边界。此外还有"单次调用最多回收一次"的放大保护(worker-utils.ts 的 warnIfVersionStillMismatched()),防止同一次 hook 事件内反复重启 worker 造成"重启风暴"——这是重试机制与下述并发协调机制之间的关键衔接。
四、版本不匹配的重启踩踏:从 PID 文件年龄启发式到原子锁
4.1 问题
当多个 hook 同时检测到"worker 版本 ≠ 插件版本"(典型场景:用户刚升级插件,旧 worker 还占着端口)时,每个 hook 都可能发起一次重启,形成"重启踩踏"(stampede)。手册指出旧协调机制依赖 PID 文件年龄判断——"文件存在且小于 15 秒 = 有别的进程正在重启"——这一启发式过于脆弱:时钟漂移、残留文件都会造成误判。
当前仓库中该启发式仍可在 ProcessManager.ts 中见到其默认 15000ms 的阈值签名 isPidFileRecent(thresholdMs = 15000),对应的单测在 process-manager 测试。
4.2 手册方案:原子锁文件 + TTL
手册给出的目标设计是教科书级别的并发协调方案:
- 首个发起重启的 hook 创建原子锁文件
~/.claude-mem/.worker-restart.lock,其余 hook 检查锁而非自行重启; - 锁带 TTL(最长 30 秒),持锁进程崩溃后锁可被过期覆盖,不会永久死锁;
- 看到锁的 hook 改为轮询健康检查,等待赢家把 worker 拉起来;
- 锁必须在 worker 启动序列与 GracefulShutdown 的关闭流程中都被清理。
手册记录的完成实现使用 O_EXCL 原子创建 + mtime 作为 TTL 判据,并提供 acquireRestartLock() / releaseRestartLock() / isRestartLockHeld() 三个 API;持锁者赢下竞争后负责 spawn,输家在所有退出路径(成功、端口占用失败、健康检查超时)上释放锁。
4.3 当前源码中的对应机制
从当前源码结构看,这一类"多启动者竞争"的协调已经沉淀为独立的 worker-spawn-gate 模块(配套单测 worker-spawn-gate 测试),ensureWorkerRunning() 在 spawn 前的调用清晰体现了"锁只允许一个启动者"的语义(worker-utils.ts):
// src/shared/worker-utils.ts
const spawnLockHeld = acquireSpawnLock();
try {
if (spawnLockHeld) {
// 赢家:lazy-spawn worker
const proc = spawnHidden(runtimePath, [scriptPath, '--daemon'], { detached: true, ... });
} else {
// 输家:跳过 spawn,直接等赢家的 worker 就绪
logger.info('SYSTEM', 'Another launcher holds the spawn lock — skipping lazy-spawn and waiting for its worker');
}
...
} finally {
if (spawnLockHeld) releaseSpawnLock();
}
注意其中"输家不失败、只等待"的设计:抢锁失败绝不使 hook 报错,而是复用既有的端口/就绪等待逻辑等赢家完成启动——这正是手册"看到锁就轮询健康而非自行重启"要求的落地形态。
五、结构化启动诊断:把静默失败变成可定位的故障
手册的第四项任务针对"worker 启动失败时 hook 只打一句通用日志就退出"的体验问题,要求 ensureWorkerStarted() 失败时收集并输出四类诊断信息:
- 端口(手册记录为 37777)是否被其他进程占用(端口冲突);
- PID 文件是否存在、被哪个进程持有;
- 运行时(Bun)是否在 PATH 中可用;
- worker stderr 的最后若干行日志。
输出约定为结构化 JSON 写到 stderr,退出码 2 表示阻塞性错误,让宿主 Agent(Claude)也能读取诊断信息参与排障。手册记录的实现是 collectStartupDiagnostics(port):汇总端口占用状态、PID 文件状态(存在与否、pid、进程是否存活)、运行时可用性/版本、以及 worker.log 最后 5 行,并刻意使用动态 import 加载 fs/child_process,避免在启动成功路径上引入额外开销。
当前 hook-command 仍是 hook 错误上报的统一入口,上述诊断输出约定与它的 exit code 语义保持一致,读者可以在该文件中核对 hook 的 stdout/stderr 纪律(配套约束脚本见 check-hook-io-discipline)。
六、测试策略与验收标准
手册第五、六项任务给出了完整的验证方法论,对任何涉及进程生命周期的修复都有直接参考价值:
单测覆盖矩阵(13 个用例,2 个测试文件):
- 重试路径:mock 健康端点连续失败两次后成功,验证重试在超时预算内完成;
- 无重试路径:无 PID 文件时验证直接触发 spawn 而非空转重试;
- 并发协调:模拟两个并发重启者,验证只有一个真正 spawn;
- 锁 TTL:模拟 30 秒以上的过期锁,验证会被覆盖。
当前仓库中可对照的测试资产包括 worker-utils 超时测试、worker 版本回收测试、进程管理器测试 与 健康监控测试。
构建级验收(手册第六项任务):
- 运行全量构建,确认所有目标(worker-service、mcp-server、context-generator、npx-cli 等)编译通过;
- 对
plugin/scripts/worker-service.cjsgrep 旧构建机路径,确认 0 条硬编码绝对路径、0 个var __dirname影子声明; - 运行完整测试套件,确认无新增失败。
手册记录的最终验收数据:全量构建 6 个目标全部编译成功;构建产物 0 条硬编码路径;新增 13/13 测试全部通过,全量套件 1179 通过、32 个失败均为该阶段之前就存在的遗留问题(与本阶段无关)。
七、总结:这份修复手册沉淀出的工程经验
回顾 Phase 02,可以提炼出三条跨项目通用的经验:
- bundler 会改变运行时语义。
__dirname这类"看起来无害"的全局变量在打包后可能被冻结为构建机路径。防御手段不是单一配置项,而是"define 防内联 + 产物 grep 验收 + 必要时 banner 兜底"的组合拳,且验收应写进 CI(构建后 grep 绝对路径计数为 0)。 - 进程生命周期竞态要用预算感知 + 单写者锁解决。重试不能无限等待(必须尊重 hook 的超时预算),并发重启必须有一个明确的赢家(原子锁 + TTL + 输家只等待),两者缺一就会退化为静默丢上下文或重启风暴。
- 失败必须可诊断。退出码语义(2 = 阻塞)+ stderr 结构化 JSON + 端口/PID/运行时/日志四类信息,把"hook 悄悄失败"变成宿主 Agent 和用户都能行动起来的故障现场。
如果你在自己的多进程 Agent 基础设施中遇到过"某台机器上必现、其他机器正常"的启动类 bug,建议先从"构建产物里是否内联了绝对路径"查起——这是 claude-mem 这次 triage 中报告量最大的单一根因,也是修复成本最低、收益最大的一类问题。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00