Commit 2092a2e213bdb68b04648fecb33b417049a226a2
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>
Showing
2 changed files
with
31 additions
and
3 deletions
server/routes/agent-chat.js
| ... | ... | @@ -2772,8 +2772,12 @@ ${resultsText.join('\n\n')} |
| 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('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = |
| 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 }); | ... | ... |
-
mentioned in commit d8b4346d1b9a693b6325855e89eeb3aa871157cd