claude-mem Worker 服务可靠性修复:初始化竞态、Stale PID 与 systemd 信号三大隐患
【免费下载链接】claude-memPersistent Context Across Sessions for Every Agent – Captures everything your agent does during sessions, compresses it with AI, and injects relevant context back into future sessions. Works with Claude Code, OpenClaw, Codex, Gemini, Hermes, Copilot, OpenCode + More项目地址: https://gitcode.com/GitHub_Trending/cl/claude-mem
本篇基于 claude-mem 仓库中的问题分诊记录 TRIAGE-04-Worker-Service-Reliability 展开,系统梳理 worker 服务在“看似运行、实则已死”场景下的三类高严重度缺陷:session-init 与数据库初始化的竞态(Issue #1323)、陈旧 PID 文件导致的假“worker running”判定(Issue #1231)、以及 systemd 下 fork-then-exit 模式引发的 SIGKILL(Issue #1245)。读完本文,你可以理解 claude-mem worker 从进程启动、端口/PID 校验到就绪门禁的完整可靠性设计,并掌握一套可复用的“守护进程三重校验”排查方法。
为什么 worker 的静默失效最危险
claude-mem 的核心工作流是:各类 hook(session-init、observation、summarize 等)触发worker-service.cjs start,由常驻 worker 负责把会话内容写入 SQLite 数据库、生成观察(observations)与会话摘要,再在后续会话中把相关上下文注回去。整个数据管道的咽喉就是这个 worker 进程。
分诊文档开宗明义地指出了这类缺陷的危害等级:“这些 bug 会让 worker 服务静默失败、报告错误的健康状态,或被进程管理器杀掉。它们之所以是高危的,是因为用户以为 claude-mem 还在工作,实际上它已经死了——观察数据被静默丢弃。”换言之,这里的问题不是报错,而是没有任何报错:hook 成功返回,终端毫无异常,但后台管道已经断开。三个问题恰好覆盖了守护进程可靠性三个经典盲区:
- 启动竞态:进程“已启动”不等于“可用”,session-init 可能抢在数据库初始化完成前到达;
- 身份误判:PID 文件是上次的遗留物,进程号甚至可能被系统复用给了完全不同的进程;
- 进程管理器语义:fork 后立刻退出的启动模式,在 systemd 的 cgroup 视角下会被整体击杀。
以下逐个展开,每个问题都给出缺陷机制、修复方案与当前仓库中的源码印证。
问题一:session-init 与数据库初始化的竞态(#1323)
缺陷机制
从 worker-service.ts 的 start() 可以看到启动时序:先server.listen(port, host)绑定端口,随后writePidFile(...)写入 PID 文件,最后才调用this.initializeBackground()——注意这是一个不等待的 fire-and-forget 调用(第 440 行),错误只会被记录而不阻断启动。这意味着 worker 在 HTTP 层面“活着”的时间点,早于数据库真正可用的时间点:initializeBackground()(L445 起)内部要依次完成模式加载、依赖预检、dbManager.initialize()、SearchManager 构建与搜索路由注册等一系列重活。
而 hook 侧的 session-init 请求可能在 listen 之后、初始化完成之前就已到达。此时若路由直接执行数据库操作,就会得到 “Database not initialized” 一类的错误,且对 hook 调用方而言往往表现为一次无声的失败。
修复方案:就绪门禁 + 显式 503 契约
修复的核心是利用已经存在的就绪信号。WorkerService在构造函数中创建了一个initializationComplete: Promise<void>与配套的resolveInitialization(L228-L241),当后台初始化走到“DB + search ready”时统一置位:
// src/services/worker-service.ts(初始化尾部) this.initializationCompleteFlag = true; this.resolveInitialization(); logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');在此之上,服务注册了一个覆盖/api与/v1前缀的门禁中间件(L328-L351):
this.server.app.use(['/api', '/v1'], async (req, res, next) => { // 健康探测类端点豁免:初始化期间也必须能回答 if ( req.path === '/chroma/status' || req.path === '/health' || req.path === '/readiness' || req.path === '/version' || req.path === '/settings/dependency-health' ) { next(); return; } if (this.initializationCompleteFlag) { next(); return; } res.status(503).json({ error: 'Service initializing', message: 'Database is still initializing, please retry' }); });这个设计有三个值得注意的细节:
- 豁免清单是“存活探测”而非“业务端点”:
/health、/readiness等在初始化期间必须可达,否则启动侧的健康轮询(waitForHealth)会把正在加热的 worker 误判为死亡; - 503 是契约而非意外:分诊文档给出的备选方案(“未初始化时返回 HTTP 503 +
Retry-After: 1让 hook 重试”)与最终实现同向——把“暂时不可用”显式化,交给调用方重试,而不是让请求落进未就绪的数据库; - 分诊记录中最终落地的形态:在
worker-service.ts中新增了针对/sessions/*的守卫中间件,与既有/api/*守卫模式保持一致;遗留的 session 路由会等待initializationComplete,超时 30 秒后返回 503。当时配套新增了 5 个测试(文档记录为tests/worker/http/initialization-guard.test.ts),全量 1154 个测试通过,对应提交726afd12。
问题二:陈旧 PID 文件导致“worker 还在跑”的误判(#1231)
缺陷机制
worker 的启动入口start子命令会先做“是否已有 worker 在跑”的判定,历史实现依赖ProcessManager.readPidFile()返回的 PID。问题在于:PID 文件存在 ≠ 那个 worker 还活着,更危险的是PID 可能已被系统复用给了一个完全无关的进程。从 ProcessManager.ts 可以看到 PID 文件的完整生命周期:
- readPidFile():解析
~/.claude-mem数据目录下的 PID 文件,失败时记 warn 并返回 null; - writePidFile():写入 pid/port/startedAt,并附带
startToken(进程启动令牌)用于后续所有权校验; - removePidFileIfOwner():带“owner-or-dead”守卫的删除——只删自己(
expectedOwnerPid)的、或确认已死进程遗留的文件,绝不删一个存活进程的 PID 文件; - cleanStalePidFile():委托给 supervisor 的
validateWorkerPidFile()做陈旧文件清理。
修复方案:三段式校验,只有全部通过才跳过启动
分诊文档给出的目标校验序列是:
- 读 PID 文件 → 没有文件则直接 spawn 新 worker;
- 校验
isProcessAlive(pid)→ 进程已死则删除陈旧 PID 文件并 spawn; - 校验健康端点
/api/health→ 不健康则杀掉残留进程、删 PID 文件并 spawn; - 三项检查全部通过才允许跳过启动。
文档记录的两处落地修复都在worker-service.ts:
- 守护启动守卫:现在同时校验 PID 存活性与健康检查(经由
isPortInUse())——如果 PID 还活着但健康检查失败,删除陈旧 PID 文件而不是拒绝启动; ensureWorkerStarted()补漏:当健康检查失败但cleanStalePidFile()因某种原因保留了该文件(正是 PID 复用场景:进程活着但不是 worker)时,主动清除残留 PID 文件。
当时配套新增 7 个测试(文档记录为tests/infrastructure/stale-pid-detection.test.ts),全量 1033 个测试通过,对应提交840a7500。
当前仓库中可以看到这套理念已经沉淀为 daemon 入口的固定校验顺序,见 main() 的--daemon分支:
// 第一重:端口即事实(ground truth FIRST) // 一个活着的 worker 必然持有端口,陈旧/被覆盖的文件伪造不了端口 if (await isPortInUse(port)) { logger.info('SYSTEM', 'Port already in use, refusing to start duplicate', { port }); process.exit(0); } // 第二重:PID 文件仅作咨询性(advisory)校验 const existingPidInfo = readPidFile(); if (verifyPidFileOwnership(existingPidInfo)) { logger.info('SYSTEM', 'Worker already running (PID alive), refusing to start duplicate', { ... }); process.exit(0); }注释中把每一重的职责讲得很清楚:端口检查覆盖“活着的 worker”,PID 检查只兜住“端口已释放但 PID 文件尚未删除的垂死前任”这一窗口——而它明确不覆盖“刚 spawn、还没绑定端口的 worker”(因为writePidFile在server.listen之后才执行)。
更进一步的证据在status子命令(L1214-L1251):注释直接写明“事实来源是 GET /api/health:worker 自报 pid、version、uptime 与脚本路径。PID 文件只是诊断信息——它绝不应让status在两个方向上撒谎(把被覆盖的文件的健康 worker 报成 down,或把陈旧文件报告的死 worker 报成 up)”。这正好是 #1231 修复哲学的完整表述。
问题三:systemd 下 fork-then-exit 模式引发 SIGKILL(#1245)
缺陷机制
worker-service.cjs的start子命令传统上采用“fork 一个后台进程然后立即退出”的模式(spawnDaemon() 中,非 Windows 平台优先通过/usr/bin/setsid以detached: true派生--daemon子进程)。这个模式在 shell 场景下很自然,但 systemd 的默认KillMode=control-group会杀掉整个 cgroup 内的所有进程——包括那个被 fork 出来、systemd 根本不知道存在的 worker。结果就是:服务管理器以为主进程已正常退出,随后的一次 stop/重启把真正干活的 worker 一并 SIGKILL。
修复方案:识别 systemd 环境,改走前台模式
分诊文档记录的修复分两步:
- 在
worker-service.cjs中检测是否运行于 systemd 之下(通过INVOCATION_ID环境变量或NOTIFY_SOCKET)——若是,则不 fork,让 worker 以前台方式运行,使 systemd 跟踪到正确的 PID; - 增加一个“systemd 模式”:
process.env.INVOCATION_ID存在时跳过 fork-and-exit 逻辑直接运行。
文档同时要求:在注释中说明 systemd 用户应在服务文件中使用Type=exec或Type=simple而非Type=forking;并且这是定向修复——不引入完整的 systemd 服务文件或 socket activation。
落地实现记录为:在ProcessManager.ts中新增isRunningUnderSystemd()(通过INVOCATION_ID检测),worker-service.ts的main()中当start命令运行于 systemd 下时重定向到--daemon(前台)模式——复用既有全部守卫检查(PID 文件校验、端口占用检查、未处理错误处理器),配套 3 个测试(文档记录为tests/infrastructure/systemd-foreground.test.ts),对应提交5fd91ec0。
从当前仓库结构看,这个修复的关键收益在于没有为 systemd 另开一条启动路径:前台模式直接复用--daemon分支中已修复的端口优先校验、PID 咨询校验与异常兜底逻辑,避免了两套启动语义漂移的风险——这也与分诊文档“定向修复、不做过度设计”的约束一致。
验证流程:如何复现与确认修复
分诊文档的收尾任务给出了完整的验证清单,值得作为任何守护进程修复的验收模板:
- 全量测试:
npm test—— 记录结果为 1154 个测试全部通过(3 个跳过、0 失败); - 构建同步:
npm run build-and-sync—— 产物worker-service.cjs(1844 KB)、mcp-server.cjs(350 KB)、context-generator.cjs(71 KB)构建并同步到 marketplace; - 手工 PID 校验复现:停止 worker 后确认下一次会话启动会 spawn 新 worker 而不是被陈旧 PID 跳过。实际执行的复现步骤与结果值得记录:人为植入一个陈旧 PID 文件(PID 99999),worker 启动逻辑正确识别其为陈旧、执行清理,随后 spawn 出全新 worker(PID 91027),
/api/health返回健康。
这套“全量回归 + 构建同步 + 故障注入手工复现”的组合,本质上是在验证三件事:改动没有破坏既有行为、改动能到达用户的实际安装路径、目标缺陷场景能按预期被新逻辑接管。
实践清单:判断你的 worker 是否真的活着
综合三个修复点,可以提炼出一套适用于 claude-mem(以及结构类似的本地守护进程)的可靠性判断方法:
- 以健康端点为准,不以 PID 文件为准。用
status命令或直接请求GET /api/health:worker 自报 pid/version/uptime/workerPath 才算数。注意/api/health在队列降级时返回 503 但仍带完整字段——fetchWorkerHealth 的注释 明确说“降级中的 worker 仍然是运行中的 worker”,因此 200 与 503 的响应体都被视为有效答案; - 区分“正在初始化”与“死了”。初始化期间业务端点返回 503(
Service initializing)是预期行为,hook 应重试;而/health、/readiness永远可达,如果连它们都不通,才是真问题; - 警惕 PID 复用。发现 PID 文件存在且“进程活着”,还要确认该 PID 对应的确实是 worker(端口占用 + 健康检查双重印证),而不是恰好复用了同一 PID 的无关进程;
- 关注崩溃检测信号。worker 启动时会通过“上次运行的陈旧 PID 文件 + 优雅关闭哨兵”推导上次是否干净停止(detectPreviousShutdown()):存在哨兵
.worker-clean-shutdown为 clean,有陈旧 PID 文件而无哨兵为 crash,两者皆无为 unknown。哨兵在优雅关闭时写入(writeCleanShutdownSentinel),下次启动读取后立即删除,防止把后续崩溃误标为 clean; - systemd 用户检查服务类型。若用 systemd 托管 worker,服务文件应使用
Type=exec/Type=simple前台模式,避免Type=forking与KillMode=control-group组合杀死 fork 出的真实 worker。
小结
这份分诊记录的价值不仅在于修掉了三个具体 bug,更在于它沉淀了一组守护进程可靠性的通用判据:“启动成功”要拆成端口可用、初始化完成、身份可信三层分别验证;PID 文件永远只是诊断信息而非事实来源;与进程管理器的交互必须匹配其进程模型语义。从当前仓库的 worker-service.ts 与 ProcessManager.ts 实现可以看到,这些判据已经固化进了启动守卫、就绪门禁中间件、owner-or-dead 的 PID 删除守卫和重启交接(restart handoff)等代码路径中,构成了 claude-mem worker “静默失效”问题的系统性防线。
【免费下载链接】claude-memPersistent Context Across Sessions for Every Agent – Captures everything your agent does during sessions, compresses it with AI, and injects relevant context back into future sessions. Works with Claude Code, OpenClaw, Codex, Gemini, Hermes, Copilot, OpenCode + More项目地址: https://gitcode.com/GitHub_Trending/cl/claude-mem
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考