claude-mem Windows 平台加固全解:Hook 超时、僵尸端口、PowerShell 引号与 CRLF 修复地图
【免费下载链接】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 仓库中的 Windows 平台加固专项(Phase-03-Windows-Platform-Hardening)展开,系统梳理 12 个 Windows 专属缺陷簇——Hook 无限挂起、TIME_WAIT 僵尸端口、PowerShell 引号转义失败、CRLF 破坏 shebang、进程清理失败——的根因、修复方案与当前仓库中的实际落地状态。读完本文,你可以理解 claude-mem 在 Windows 上每一处平台条件代码路径的设计动机,并掌握在 hook 超时、端口释放、守护进程派生与进程树清理四个维度上排查同类问题的方法。
背景:为什么 Windows 是 claude-mem 的第二大缺陷簇
claude-mem 的核心架构是「短命 hook 进程 + 常驻 worker 守护进程」:Claude Code 等宿主在每次会话事件时拉起一个 hook 进程,hook 进程再通过 HTTP 调用本地 worker(Bun 运行时上的 Express 服务)完成记忆注入、观察与摘要。这套机制在 Unix 上相当顺滑,但在 Windows 上会撞上五类操作系统级差异:
- Hook 挂起:
AbortSignal.timeout()在 Bun 上曾触发 libuv 断言崩溃,Promise.race回退方案又会泄漏定时器,导致 hook 进程无限挂起、终端标签页堆积; - 僵尸端口:worker 关闭 HTTP 服务后,Windows 的 TCP 协议栈会因 TIME_WAIT 让端口继续被占用数秒,重启时立刻撞上
EADDRINUSE; - PowerShell 引号规则:Windows 守护进程派生走 PowerShell
Start-Process,其引号、路径展开规则与 Unix shell 完全不同,含空格或$的路径会直接派生失败; - CRLF 换行:
autocrlf=true的 Git 检出会把脚本变成\r\n行尾,破坏 shebang 与 JSON 解析; - 进程清理:
taskkill、Get-CimInstance在长命令行、系统进程、进程树已退出等边缘场景下行为特殊。
专项文档 Phase-03-Windows-Platform-Hardening.md 将该问题群定位为「第二大 issue 簇」,并明确其业务影响:这些 bug 让 claude-mem 对相当一部分 Windows 用户「基本不可用」。修复策略统一为:平台条件代码路径 + 正确的超时处理、转义与进程生命周期管理。
任务地图:七项任务与完成状态
专项共列出 7 项任务,其中前 2 项(hook 超时挂起、僵尸端口时序)在文档中已标记完成,其余为规划中的加固项:
| 任务 | 文档状态 | 当前仓库对应实现 |
|---|---|---|
| 修复 Windows hook 超时与挂起 | 已完成 | hook-constants.ts、worker-utils.ts、bun-runner.js |
| 修复僵尸端口清理时序(EADDRINUSE 重试) | 已完成 | Server.ts、GracefulShutdown.ts、HealthMonitor.ts |
| PowerShell 引号与转义修复 | 未勾选 | ProcessManager.ts 中的buildWindowsDaemonStartCommand |
CRLF shebang 问题(.gitattributes) | 未勾选 | 仓库根目录 .gitattributes 已存在 |
ProcessManager.ts进程枚举边缘场景 | 未勾选 | kill-process-tree.ts、process-registry.ts |
| Windows 专项测试 | 未勾选 | health-monitor.test.ts 等 |
| 构建与产物验证 | 未勾选 | npm run build-and-sync流程 |
注:该 playbook 是 2026-03-29 批次 issue triage 的阶段性规划文档,复选框状态反映当时的执行进度;后文逐项对照当前仓库源码的实际状态。
Hook 超时与挂起修复:从常量到全链路
平台条件超时常量
所有 hook 侧超时统一收口在 hook-constants.ts 中,当前值如下:
| 常量 | 值 | 用途(源码注释) |
|---|---|---|
HEALTH_CHECK | 3000ms | worker 健康检查(健康 worker 响应 <100ms) |
API_REQUEST | 30000ms | hook 的 API 调用,长于健康探测但低于宿主 hook 上限 |
HOOK_READINESS_WAIT | 10000ms | 单 hook 等待「正在启动中」的 worker 完成 DB/搜索初始化 |
POST_SPAWN_WAIT | 15000ms | spawn 后等待 daemon 起来(Linux <1s,macOS+Chroma 6-8s) |
READINESS_WAIT | 30000ms | spawn 后等待 DB + 搜索初始化(通常 <5s) |
PORT_IN_USE_WAIT | 3000ms | 端口被占但健康检查失败时的等待 |
POWERSHELL_COMMAND | 10000ms | PowerShell 进程枚举(通常 <1s 完成) |
WINDOWS_MULTIPLIER | 1.5 | Windows 平台放大系数 |
getTimeout()按平台放大基准超时(hook-constants.ts):
export function getTimeout(baseTimeout: number): number { return process.platform === 'win32' ? Math.round(baseTimeout * HOOK_TIMEOUTS.WINDOWS_MULTIPLIER) : baseTimeout; }需要特别指出的是,仓库中实际存在两套 Windows 放大系数:hook 侧HOOK_TIMEOUTS.WINDOWS_MULTIPLIER = 1.5(用于 hook 进程内的所有 HTTP 超时),以及 ProcessManager.ts 中getPlatformTimeout()的WINDOWS_MULTIPLIER = 2.0(用于 worker 生命周期管理侧,如waitForPortFree的超时)。专项文档中「用现有getPlatformTimeout()的 2.0x 系数把waitForPortFree的 Windows 超时从 3 秒提到 6 秒」正是指后一套;从当前源码看,hook-constants.ts 中PORT_IN_USE_WAIT的基准值仍为 3000ms,读者对照时需注意两套系数各自的作用域。
超时如何落到每一次 fetch
worker-utils.ts 是 hook 侧唯一的 HTTP 出口。它提供了三层防护:
- 带超时的 fetch 封装(worker-utils.ts):
export async function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> { try { // AbortSignal.timeout (Node 18+) replaces the manual setTimeout/clearTimeout // race. On expiry it aborts with a TimeoutError DOMException. return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } catch (err: unknown) { // Preserve the historical timeout-error message ("...timed out...") that // callers match on (hook-command.ts, server-beta-client.ts) if (err instanceof DOMException && err.name === 'TimeoutError') { throw new Error(`Request timed out after ${timeoutMs}ms`); } throw err; } }从源码演进看,这里经历过从「Promise.race+setTimeout(Windows 下规避AbortSignal.timeout的 Bun 崩溃)」到「统一AbortSignal.timeout+ 归一化TimeoutError消息」的迁移:注释说明保留Request timed out after Nms这一历史错误文案,是因为hook-command.ts、server-beta-client.ts等调用方按该文案做字符串匹配。无论底层机制如何变化,不变式是——hook 代码路径上的每一个fetch()都必须有超时,且超时必须来自下文的分级常量,杜绝裸fetch。
- 可覆盖的分级超时:健康检查、就绪等待、API 请求三档超时可分别用环境变量覆盖,且带上下界校验(worker-utils.ts、worker-utils.ts):
| 环境变量 | 默认值 | 合法范围 |
|---|---|---|
CLAUDE_MEM_HEALTH_TIMEOUT_MS | getTimeout(3000) | 500 ~ 300000 |
CLAUDE_MEM_HOOK_READINESS_TIMEOUT_MS | getTimeout(10000) | 0 ~ 300000 |
CLAUDE_MEM_API_TIMEOUT_MS | getTimeout(30000) | 500 ~ 300000 |
readSettingsBackedTimeout()还允许从 worker 的settings.json读取同名键,优先级为:环境变量 > settings 文件 > 默认值;任何非法值都会记录Invalid <name>, using default警告并回退默认,而不是崩溃。
- worker 不可达的「大声失败」机制:worker-utils.ts 的
recordWorkerUnreachable()把连续失败计数原子写入hook-failures.json,达到阈值(默认 3 次,可用CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD调整)后通过 bypass 通道写 stderr 并以退出码 2 终止。退出码语义同样定义在 hook-constants.ts:SUCCESS: 0、BLOCKING_ERROR: 2。
bun-runner:stdin 缓冲超时与 Windows 标签页策略
hook 的实际入口是 bun-runner.js——一个 Node 进程,负责找到 Bun、读 stdin 载荷、再 spawn Bun 执行真正的 hook 脚本。它与 Windows 挂起问题直接相关的实现有三处:
stdin 缓冲超时(bun-runner.js):collectStdin()在 5 秒后强制结束等待并 resolve,防止父进程不发end事件时 hook 永久卡死在输入阶段:
setTimeout(() => { process.stdin.removeAllListeners(); process.stdin.pause(); resolve(chunks.length > 0 ? Buffer.concat(chunks) : null); }, 5000);注意这里 resolve 后没有对定时器做显式clearTimeout——进程即将退出,事件循环随之销毁,泄漏影响有限;专项文档要求「成功路径上通过clearTimeout清理泄漏的setTimeout引用」,属于该文件的加固方向。
cmd.exe shim 的 8191 字符环境上限(bun-runner.js):只有.cmd/.bat垫片才需要经cmd.exe(shell: true);解析出的bun.exe必须直接 spawn。注释引用 issue #3196:hook 通过 login-shell 预置 PATH 使环境翻倍,超过 cmd.exe 单变量约 8191 字符上限时,cmd 会静默看到空 PATH,报"bun" is not recognized,而此前where bun明明成功。
退出码 0 策略(bun-runner.js、bun-runner.js):hook 执行失败时以process.exit(0)收尾,避免 Windows Terminal 标签页堆积——持久化的失败信号是CAPTURE_BROKEN标记文件与runner-errors.log,而不是退出码。唯一例外是 Bun 未安装(child.on('error')中退出 1),因为那是用户环境问题,必须让用户看见 stderr。专项文档中「给 bun-runner 增加 30 秒进程级硬超时(超时杀子进程并以 0 退出)」在文档中标记为完成项;从当前 bun-runner.js 源码结构看,子进程挂死的兜底目前主要依靠 stdin 5s 超时与宿主侧 hook 超时,读者可在当前检出中以grep验证该硬超时是否存在。
僵尸端口:TIME_WAIT、双 500ms 延迟与 EADDRINUSE 探测
关闭时序:Windows 特有的前后置延迟
Windows 上close()之后端口并不会立刻释放。仓库在两处关闭路径都做了平台条件延迟:
- Server.ts 的
close():closeAllConnections()之后,若为win32先等 500ms 再调用server.close(),close 完成后再等 500ms; - GracefulShutdown.ts 的
closeHttpServer():同样的「500ms → close → 500ms」结构,并在后延迟处记录Waited for Windows port cleanup。
closeHttpServer()还修复了另一个关键错误路径(issue #3380 注释):server.close(cb)在句柄未处于 listening 状态时会报ERR_SERVER_NOT_RUNNING,旧代码在 reject 会中断整个收尾级联(session 排空、MCP 关闭、Chroma 停止、DB 关闭、supervisor 停止),现在将其视为「已达目的态」并 resolve。
专项文档规划的更激进参数——把两处延迟从 500ms+500ms 提到 1500ms+1000ms(合计 2.5s),理由是「Windows TCP 协议栈对 localhost 的 TIME_WAIT 最长需要 4 秒」;并在其「Done」备注中声称已实施、且给Server.ts的listen()增加了「Windows 下 EADDRINUSE 时等待 2s、最多重试 3 次」的循环。当前仓库检出中:两处延迟仍是 500ms+500ms,Server.ts 的listen()也是失败即 reject(注释仅强调 #3380 的「绑定失败的句柄不得残留在 graceful shutdown 中」),未见 EADDRINUSE 重试循环。从源码结构看,规划中的重试参数在当前主干尚未生效(或已回退),这正是阅读 playbook 与实际代码对照时的典型分歧点,建议在移植或排查前先以git log核实。
端口探测与释放等待
HealthMonitor.ts 是端口状态判定的中心:
isPortInUse(port)(HealthMonitor.ts)在 Windows 上走两级探测:先向http://host:port/api/health发 HTTP 快速路径——活的 claude-mem worker 应答即判定占用;非 ok 或抛错(ECONNREFUSED、超时)则落回net.createServer真实 bind 探测,因为「只有确定性的 bind 尝试才能分辨端口是否真的空闲」(端口可能被非 HTTP 进程占用)。Unix 上直接 bind 探测。waitForPortFree(port, timeoutMs)(HealthMonitor.ts)每 500ms 轮询一次isPortInUse,直到超时返回 false。调用方 worker-service.ts 传入getPlatformTimeout(15000),即 Unix 15s、Windows 30s(2.0x 系数)。waitForHealth/waitForReadiness以 500ms 间隔轮询/api/health、/api/readiness,同样受平台放大后的超时约束。
测试侧,health-monitor.test.ts 用setTimeout(() => cb({ code: 'EADDRINUSE' }), 0)mock 了「HTTP 探测抛错 → socket 探测命中 EADDRINUSE → 判定端口占用」的完整降级链;worker-daemon-port-race.test.ts 则以源码断言(expect(source).toContain("code === 'EADDRINUSE'"))守护端口竞争检测不被误删。这两处验证了专项文档「测试须可在任意平台运行、不依赖真实 Windows」的约定。
配套机制:端口占用时的 hook 行为
worker 侧启动时若端口被旧进程占用,worker-service.ts 显式识别EADDRINUSE(并保证竞态中不会覆盖胜出者的 PID 文件);hook 侧 worker-utils.ts 的waitForWorkerPortClosed()在 SIGKILL 陈旧 worker 后以「连接被拒绝」作为端口已释放的信号轮询等待(默认 5s)。整套设计的不变式是:任何一次 hook 事件内最多回收一次陈旧 worker,且陈旧 worker 必须用不可捕获的 SIGKILL 树杀(killProcessTree(pid, { signalMode: 'immediate' })),防止旧版本执行自己的 handoff 逻辑引发重启风暴——这些是 Windows 加固之外、但与其深度耦合的生命周期约束。
PowerShell 引号与转义:Windows 守护进程派生全解
单引号转义与 Start-Process 参数拼接
Windows 下 worker 守护进程的派生实现在 ProcessManager.ts 的spawnDaemon()win32 分支。核心是buildWindowsDaemonStartCommand()(ProcessManager.ts):
export function buildWindowsDaemonStartCommand(runtimePath: string, scriptPath: string): string { const psSingleQuote = (value: string) => value.replace(/'/g, "''"); // Windows PowerShell 5.1 joins -ArgumentList elements with spaces WITHOUT // quoting them ... a script path under a spaced %USERPROFILE% splits into // multiple argv entries and bun exits instantly with "Module not found" (#3195). return `Start-Process -FilePath '${psSingleQuote(runtimePath)}' -ArgumentList @('"${psSingleQuote(scriptPath)}"','--daemon') -WindowStyle Hidden`; }这段代码浓缩了专项文档审计出的全部引号规则:
- PowerShell 单引号串内单引号必须翻倍(
''),这是 PS 单引号串唯一的转义方式; -ArgumentList元素在 PS 5.1 拼接子进程原生命令行时不加引号,所以含空格的%USERPROFILE%下脚本路径会被拆成多个 argv、Bun 报 "Module not found"(issue #3195)。解法是在单引号串内嵌字面双引号,使路径保持单一参数;-FilePath是单字符串参数、不经过该拼接,故无需处理;- 整段脚本以
Buffer.from(psScript, 'utf16le').toString('base64')编码后经powershell -NoProfile -EncodedCommand执行(ProcessManager.ts),绕开外层 shell 对引号、$的二次解释,-NoProfile避免用户 profile 干扰,stdio: 'ignore'+windowsHide: true隐藏控制台窗口。
对照专项文档提出的通用规则:路径含空格须在单引号串内双引号包裹(已落实);路径反斜杠在JSON 序列化时需要\\转义但 PowerShell 直接调用不需要(该规则针对 CursorHooksInstaller 的 hook 命令生成,即文档所指src/services/integrations/CursorHooksInstaller.ts中escapedBunPath.replace(/\\/g, '\\\\')的 JSON 专用转义,需验证其回读时不会二次转义);路径中$(如$HOME)必须转义或包裹在单引号内以防变量展开(PS 单引号串天然不展开$,上述-EncodedCommand方案已规避)。文档建议新增src/utils/下的escapeForPowerShell()统一工具——在当前src/目录中检索不到该函数,实际实现以内联的psSingleQuote形式存在于buildWindowsDaemonStartCommand中,可视为「尚未抽公共工具」。
派生链的其他环节
- Unix 分支用
setsid(若存在)+detached+stdio: 'ignore'实现守护化,Windows 分支则完全交给 PowerShellStart-Process,两条路径最终都写入统一的环境(sanitizeEnv过滤,ProcessManager.ts); - spawn.ts 的
spawnHidden()对非 Windows 命令一律附加windowsHide: true,是「Windows 上绝不弹出控制台窗口」的基线约定; - hook 派生 worker 时受 worker-spawn-gate.ts 的 spawn 锁约束(
acquireSpawnLock/releaseSpawnLock,见 worker-utils.ts),保证 hook、MCP server、CLI 三条启动路径同一时刻只有一个派生者。
CRLF 与 shebang:当前仓库的 .gitattributes 实况
专项文档建议在仓库根创建.gitattributes,为 shell 脚本与 JS 入口强制 LF 行尾。当前仓库已存在根目录 .gitattributes,实际内容为:
* text=auto eol=lf plugin/scripts/*.cjs eol=lf plugin/scripts/*.js eol=lf *.png binary *.jpg binary *.jpeg binary *.ico binary *.gif binary *.woff binary *.woff2 binary *.ttf binary *.eot binary *.otf binary与 playbook 建议稿的差异值得注意:当前文件用全局* text=auto eol=lf覆盖了*.sh、*.js、install/public/*.sh等建议条目(即所有文本文件检出为 LF),再显式声明plugin/scripts/*.cjs|*.js,并把字体、图片列为 binary 防止行尾转换污染。文档提到的两个检查点仍然有效:
- bun-runner.js 首行若带 shebang 必须是 LF 行尾,否则脚本执行失败——它在
plugin/scripts/下,恰好被eol=lf规则覆盖; - hooks.json 等 JSON 文件若混入
\r,会出现在字符串值内部造成解析差异——全局eol=lf同样兜底。
验证手段即文档「Run build and verify」任务所述:提交.gitattributes后在 Windows 侧重新 clone,file命令或十六进制检查确认无\r。
进程枚举与清理的边缘场景
Windows 侧的进程操作依赖两条 PowerShell/WMI 通道,当前仓库中的使用点与专项文档列出的边缘场景可以逐条对照:
Get-CimInstance Win32_Process用于两处关键场景:- process-identity.ts:按
ProcessId过滤取CreationDate构造「启动时间令牌」(start token),用于 PID 文件所有权校验——防止 PID 复用后误杀无辜进程(process-registry.ts 注释明确指出:拿到复用 PID 去taskkill /PID n /T /F会连无辜进程整棵子树一起杀); - kill-process-tree.ts:一次性枚举全量进程的
ProcessId/ParentProcessId/StartToken生成 CSV,重建进程树。 文档指出的边缘场景是:超 8000 字符命令行会被 WQL 的CommandLineLIKE 截断、系统进程可能返回 Access denied——前者建议wmic process兜底,后者需在 PowerShell 层 try-catch。
- process-identity.ts:按
taskkill /PID <pid> /T /F:kill-process-tree.ts 执行树杀时显式处理「进程已不存在」语义——注释说明 taskkill 对不存在的 PID 以退出码 128 退出,且该退出码对「已退出」与部分失败两种情形都会发出,需要结合 stderr 与后续存活探测区分;taskkill报告进程已消失时仅记 debug 日志,不抛错。这正是文档要求「检查退出码并抑制静默失败」的落地。- 跨平台存活探测:文档建议新增
isProcessAlive(pid)工具(Unixprocess.kill(pid, 0)/ Windowstasklist /FI "PID eq <pid>")。当前检索src/未见该函数名,等价的存活判定由 Server.ts/api/admin/doctor路由里的isPidAlive()(来自 process-registry.ts)承担,用于向诊断接口报告每个注册进程是 alive 还是 dead。
测试与验证策略
专项文档对测试的要求可归纳为三条,且已在现有测试资产中部分兑现:
- 约定优先:先找现有
*.test.ts的目录结构与命名约定。仓库测试按tests/<src 对应目录>/镜像组织,例如tests/infrastructure/、tests/services/、tests/shared/,Bun 环境下以*.test.ts命名。 - 纯 mock、跨平台可运行:health-monitor.test.ts 通过给
net.Server的 error 事件注入{ code: 'EADDRINUSE' }来验证端口占用判定,不需要真实 Windows;端口重试逻辑的同类测试应遵循同一模式(mockEADDRINUSE→ 断言带延迟重试 → 断言端口释放后成功)。 - 构建产物验证:
npm run build-and-sync构建后 grep 产物中的 Windows 不安全模式(PowerShell 串内未转义的$、缺失超时回退的 fetch),并确认.gitattributes已提交。
对于文档提出的escapeForPowerShell()单测(空格、美元符、反斜杠、Unicode 四类路径),在公共工具抽取之前,等效覆盖对象就是 ProcessManager.ts 中的buildWindowsDaemonStartCommand——直接断言其输出对C:\Users\John Doe$\AppData\Local\claude-mem这类路径生成「单引号翻倍 + 内嵌双引号」的正确 PS 语句即可。
小结:Windows 加固的四条不变式
对照专项文档与当前源码,claude-mem 的 Windows 平台加固可以压缩为四条工程不变式:
- hook 路径上的任何 I/O 都有界——fetch 必带分级超时(可经
CLAUDE_MEM_*_TIMEOUT_MS覆盖,带上下界校验)、stdin 读取 5s 上限、超时统一归一化错误消息; - 端口生命周期容忍 TIME_WAIT——关闭路径在 Windows 上前后各留延迟、
isPortInUse双级探测、waitForPortFree按 2.0x 系数放大超时,并以「连接被拒绝」作为端口释放的判据; - PowerShell 命令按 PS 语义构造——单引号翻倍、
-ArgumentList内嵌双引号防拆分、-EncodedCommand避免外层解释、cmd.exe 垫片才走shell: true且警惕 8191 字符环境上限; - 失败信号不依赖退出码——hook 失败退出 0 防 Windows Terminal 标签页堆积,持久信号是
CAPTURE_BROKEN标记与错误日志;进程树清理用启动时间令牌防 PID 复用误杀,taskkill的「已消失」退出码被显式吸收。
排查 Windows 用户问题时,建议按此顺序定位:先看 hook 是否卡死(stdin/超时常量),再看端口是否未释放(waitForPortFree日志与 EADDRINUSE 计数),然后检查派生命令(Start-Process的引号与%USERPROFILE%空格路径),最后核对检出行尾(.gitattributes是否生效)。这四层分别对应 hook-constants.ts、HealthMonitor.ts、ProcessManager.ts 与 .gitattributes,均附相对路径,可沿当前仓库继续深入。
【免费下载链接】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),仅供参考