Commit 2092a2e213bdb68b04648fecb33b417049a226a2

Authored by yibo
1 parent b0b2b62f

fix(agent-chat): 跑挂的会话连子进程一起杀,堵住上一版修复漏掉的那条路径

310a2b2 / 3e718aa 把「正常跑完的对话」补进了回收路径,但 catch 分支没跟上:

    } catch (err) {
        dropSession(key);      // 只摘出池子,不杀子进程
    } finally {
        if (kind?.[2]) closeSession(key, ...);   // 池子里已经没了,返回 false,空转
        else armSessionIdle(key);
    }

dropSession 的语义是「子进程已经自己死了」,可这里子进程活得好好的 —— 错的是它背后的
API 调用(529 / DNS / 证书),CLI 一个都不会自己退。于是会话先被摘出池子,再也没人
杀得掉它:一个既没有引用、也没有回收路径的孤儿进程。

实测:一个跑了 23 小时的实例攒下 45 个孤儿 / 9.6 GB RSS,其中 38 个是心跳。抽查泄漏的
convId,status **无一例外**是 failed;对照 cto 最近 60 次心跳是 55 completed / 5 failed
—— 8% 的失败率,一次漏一个,数字对得上。成功的心跳一个都没漏,说明 310a2b2 本身是对的。

改法:catch 里 dropSession → closeSession。可观测行为完全不变(两者都把会话摘出池子、
都让下一轮冷启动),只是多杀一个本该死的进程。

守卫:agent-chat-session-reclaim.test.js 新增一条。这里必须说清楚为什么破例去 spy
disconnect —— **旧探针对这个 bug 是瞎的**:dropSession 和 closeSession 都会让会话从
池子里消失,`/chat/:convId/stop` 两种情况都返回 success:false,差别只在子进程死没死。
上一版修复正是因此漏过了这条路径。用 vi.spyOn 让 sendPrompt reject 来复现「CLI 活着、
API 挂了」,再断言 disconnect 被调过。做过变异检验:退回 dropSession 后该用例转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/routes/agent-chat.js
... ... @@ -2772,8 +2772,12 @@ ${resultsText.join(&#39;\n\n&#39;)}
2772 2772 meta.error = err.message;
2773 2773 saveMeta(agentId, convId, meta);
2774 2774  
2775   - // 清理断开的 session
2776   - dropSession(`${agentId}:${convId}`);
  2775 + // 出错的会话不留给下一轮复用 —— 但必须**连子进程一起杀**。
  2776 + // 这里原本是 dropSession(只摘出池子、不杀进程),而 finally 里的 closeSession
  2777 + // 查不到条目就直接返回 false:子进程于是活着、却再没有任何引用指向它。
  2778 + // 而且错的通常是 API(529 / DNS / 证书),CLI 子进程好端端的,一个都不会自己退。
  2779 + // 实测 23 小时漏 38 个心跳进程,且失败的心跳**无一例外**是这条路径(status 全是 failed)。
  2780 + closeSession(`${agentId}:${convId}`, '会话出错');
2777 2781  
2778 2782 sendSSE('error', { message: err.message });
2779 2783 console.log(`[SSE-DEBUG] 🚨 sendSSE('error') called:`, err.message.substring(0, 200));
... ...
server/tests/unit/agent-chat-session-reclaim.test.js
... ... @@ -15,7 +15,7 @@
15 15 process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须早于 require:acp-core 的 mock 开关是模块级常量
16 16 process.env.AGENT_SESSION_IDLE_MS = '400'; // 同理,SESSION_IDLE_MS 是模块级常量
17 17  
18   -import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  18 +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
19 19 import fs from 'fs';
20 20 import path from 'path';
21 21 import express from 'express';
... ... @@ -80,6 +80,30 @@ describe(&#39;agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)&#39;, () =
80 80  
81 81 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
82 82  
  83 + /**
  84 + * 上一版修复漏掉的就是这条路径,而且**旧的探针查不出来**:
  85 + * 修之前 catch 里走 dropSession,会话同样从池子里消失,所以 alive() 照样返回 false ——
  86 + * 黑盒探针对「摘出池子」和「杀掉子进程」一视同仁,两者的差别只在子进程死没死。
  87 + * 所以这里破例盯一次 disconnect:它是「进程被杀」唯一可观测的落点。
  88 + */
  89 + it('跑挂的心跳同样要杀子进程(不是只摘出池子)', async () => {
  90 + const conv = 'heartbeat-failed';
  91 + const { ClaudeCliProtocol } = require_('@mas/acp-core');
  92 + // 现实里的失败长这样:CLI 子进程活得好好的,错的是它背后的 API 调用。
  93 + const send = vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt')
  94 + .mockRejectedValue(new Error('API Error: 529 Overloaded'));
  95 + const disconnect = vi.spyOn(ClaudeCliProtocol.prototype, 'disconnect');
  96 +
  97 + try {
  98 + await chat(conv, { isHeartbeat: true });
  99 + expect(disconnect).toHaveBeenCalled(); // ← 修之前这里是红的
  100 + expect(await alive(conv)).toBe(false); // ← 修之前这里就是绿的,所以没拦住
  101 + } finally {
  102 + send.mockRestore();
  103 + disconnect.mockRestore();
  104 + }
  105 + });
  106 +
83 107 it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => {
84 108 const conv = 'heartbeat-fixed';
85 109 await chat(conv, { isHeartbeat: true });
... ...