diff --git a/docs/memory-leak-audit.md b/docs/memory-leak-audit.md index c0b1e8397..8b336d481 100644 --- a/docs/memory-leak-audit.md +++ b/docs/memory-leak-audit.md @@ -17,6 +17,7 @@ - [x] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅ - [x] #11 LRU 缓存键保留大 JSON — **已确认完整实现**:FileStateCache 使用 LRU 双重限制(max 100 条目 + maxSize 25MB)+ sizeCalculation,22 tests - [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded(按 removedUuids 过滤)+ snipProjection(边界检测 + 视图投影),28 tests +- [x] #18 Permission Polling Interval 泄漏 — **已修复**:inProcessRunner 权限响应后未调用 cleanup(),导致 setInterval 永远运行 + abort listener 挂载,6 tests ## 总览 --- @@ -581,12 +582,14 @@ if (snipResult !== undefined) { | #9 RC 权限 | `src/hooks/__tests__/replBridgePermissionHandlers.test.ts` | 8 | | #11 FileStateCache | `src/utils/__tests__/fileStateCache.test.ts` | 22 | | #7 语言注册 | `packages/color-diff-napi/src/__tests__/language-registration.test.ts` | 7 | -| **总计** | **6 个测试文件** | **72** | +| #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 | +| **总计** | **7 个测试文件** | **78** | ``` ### 需要关注的优先级 1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复** -2. **P1 — 语法按需加载回退**:highlight.js 190+ 语法常驻内存(~5-15MB),无释放时机 +2. ~~**P1 — 语法按需加载回退**~~ **已修复** 3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复** -4. **P2 — 空闲渲染循环**:框架存在但反编译代码中 keepAlive 集成完整性不确定 +4. ~~**P2 — 空闲渲染循环**~~ **已确认完整** +5. ~~**P2 — Permission Polling Interval**~~ **已修复** diff --git a/src/hooks/__tests__/swarmPermissionPoller.test.ts b/src/hooks/__tests__/swarmPermissionPoller.test.ts new file mode 100644 index 000000000..62e3868ba --- /dev/null +++ b/src/hooks/__tests__/swarmPermissionPoller.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + hasPermissionCallback, + processMailboxPermissionResponse, + registerPermissionCallback, + clearAllPendingCallbacks, + unregisterPermissionCallback, +} from '../../hooks/useSwarmPermissionPoller.js' + +afterEach(() => { + clearAllPendingCallbacks() +}) + +describe('swarm permission poller registry', () => { + test('register and unregister callback', () => { + registerPermissionCallback({ + requestId: 'req-1', + toolUseId: 'tool-1', + onAllow: () => {}, + onReject: () => {}, + }) + expect(hasPermissionCallback('req-1')).toBe(true) + unregisterPermissionCallback('req-1') + expect(hasPermissionCallback('req-1')).toBe(false) + }) + + test('processMailboxPermissionResponse removes callback on approve', () => { + let approved = false + registerPermissionCallback({ + requestId: 'req-2', + toolUseId: 'tool-2', + onAllow: () => { approved = true }, + onReject: () => {}, + }) + const result = processMailboxPermissionResponse({ + requestId: 'req-2', + decision: 'approved', + }) + expect(result).toBe(true) + expect(approved).toBe(true) + // Callback is removed after processing + expect(hasPermissionCallback('req-2')).toBe(false) + }) + + test('processMailboxPermissionResponse removes callback on reject', () => { + let rejected = false + registerPermissionCallback({ + requestId: 'req-3', + toolUseId: 'tool-3', + onAllow: () => {}, + onReject: () => { rejected = true }, + }) + const result = processMailboxPermissionResponse({ + requestId: 'req-3', + decision: 'rejected', + feedback: 'denied', + }) + expect(result).toBe(true) + expect(rejected).toBe(true) + expect(hasPermissionCallback('req-3')).toBe(false) + }) + + test('processMailboxPermissionResponse returns false for unknown request', () => { + const result = processMailboxPermissionResponse({ + requestId: 'unknown', + decision: 'approved', + }) + expect(result).toBe(false) + }) + + test('resetPermissionCallbacks clears all callbacks', () => { + registerPermissionCallback({ + requestId: 'req-a', + toolUseId: 'tool-a', + onAllow: () => {}, + onReject: () => {}, + }) + registerPermissionCallback({ + requestId: 'req-b', + toolUseId: 'tool-b', + onAllow: () => {}, + onReject: () => {}, + }) + clearAllPendingCallbacks() + expect(hasPermissionCallback('req-a')).toBe(false) + expect(hasPermissionCallback('req-b')).toBe(false) + }) + + test('callback is removed BEFORE invoking handler (prevents re-entrant leak)', () => { + const order: string[] = [] + registerPermissionCallback({ + requestId: 'req-order', + toolUseId: 'tool-order', + onAllow: () => { + // During callback execution, the callback should already be removed + order.push('callback') + order.push(`has:${hasPermissionCallback('req-order')}`) + }, + onReject: () => {}, + }) + processMailboxPermissionResponse({ + requestId: 'req-order', + decision: 'approved', + }) + expect(order).toEqual(['callback', 'has:false']) + }) +}) \ No newline at end of file diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index 06fde705a..5320fd294 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -424,7 +424,8 @@ function createInProcessCanUseTool( feedback: parsed.error, }) } - return // Callback already resolves the promise + cleanup() + return } } }