首页
/ claude-mem 修复实录:esbuild 冻结 __dirname 的根因剖析与 Worker 启动可靠性工程

claude-mem 修复实录:esbuild 冻结 __dirname 的根因剖析与 Worker 启动可靠性工程

2026-09-04 14:09:25作者:胡易黎Nicole

本篇技术文章基于 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 修复方案与关键设计约束

手册给出的修复要点:

  1. 在连接函数中加入轻量重试循环(当时参数为 3 次尝试、1 秒间隔);
  2. 重试必须尊重 hook 的超时预算(可通过环境变量 CLAUDE_MEM_HEALTH_TIMEOUT_MS 配置,默认 3s),总重试时间不得超出该预算;
  3. 重试要区分"worker 正在启动"(PID 文件新鲜 → 值得重试)与"worker 已死"(无 PID 文件 → 直接走 spawn),避免无谓等待。

手册记录的完成实现是一个"预算感知重试循环",并新增了 isWorkerStartingUp() 辅助函数(以 PID 文件存在且 30 秒内更新作为"启动中"判据),单次尝试超时取 min(800ms, budget/3)

3.3 当前源码中的演化形态

当前仓库的 worker-utils.tsensureWorkerRunning() 已经演化为更完整的生命周期状态机,但其核心思想——按预算轮询端口与就绪信号——一脉相承。可以看到冷启动等待被扩展为 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.tswarnIfVersionStillMismatched()),防止同一次 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() 失败时收集并输出四类诊断信息:

  1. 端口(手册记录为 37777)是否被其他进程占用(端口冲突);
  2. PID 文件是否存在、被哪个进程持有;
  3. 运行时(Bun)是否在 PATH 中可用;
  4. 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 版本回收测试进程管理器测试健康监控测试

构建级验收(手册第六项任务):

  1. 运行全量构建,确认所有目标(worker-service、mcp-server、context-generator、npx-cli 等)编译通过;
  2. plugin/scripts/worker-service.cjs grep 旧构建机路径,确认 0 条硬编码绝对路径、0 个 var __dirname 影子声明;
  3. 运行完整测试套件,确认无新增失败。

手册记录的最终验收数据:全量构建 6 个目标全部编译成功;构建产物 0 条硬编码路径;新增 13/13 测试全部通过,全量套件 1179 通过、32 个失败均为该阶段之前就存在的遗留问题(与本阶段无关)。

七、总结:这份修复手册沉淀出的工程经验

回顾 Phase 02,可以提炼出三条跨项目通用的经验:

  1. bundler 会改变运行时语义__dirname 这类"看起来无害"的全局变量在打包后可能被冻结为构建机路径。防御手段不是单一配置项,而是"define 防内联 + 产物 grep 验收 + 必要时 banner 兜底"的组合拳,且验收应写进 CI(构建后 grep 绝对路径计数为 0)。
  2. 进程生命周期竞态要用预算感知 + 单写者锁解决。重试不能无限等待(必须尊重 hook 的超时预算),并发重启必须有一个明确的赢家(原子锁 + TTL + 输家只等待),两者缺一就会退化为静默丢上下文或重启风暴。
  3. 失败必须可诊断。退出码语义(2 = 阻塞)+ stderr 结构化 JSON + 端口/PID/运行时/日志四类信息,把"hook 悄悄失败"变成宿主 Agent 和用户都能行动起来的故障现场。

如果你在自己的多进程 Agent 基础设施中遇到过"某台机器上必现、其他机器正常"的启动类 bug,建议先从"构建产物里是否内联了绝对路径"查起——这是 claude-mem 这次 triage 中报告量最大的单一根因,也是修复成本最低、收益最大的一类问题。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384