diff --git a/docs/memory-leak-audit.md b/docs/memory-leak-audit.md index 463d39d12..424e04f88 100644 --- a/docs/memory-leak-audit.md +++ b/docs/memory-leak-audit.md @@ -8,12 +8,12 @@ - [ ] #1 图片处理无限内存增长 — 确认已实现 ✅ - [ ] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅ - [ ] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅ -- [ ] #4 空闲重新渲染循环 — 部分实现,keepAlive 集成完整性待确认 +- [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟,keepAlive 机制工作正常 - [ ] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅ - [ ] #6 管道模式超宽行过度分配 — 确认已实现 ✅ - [ ] #7 语言语法按需加载 — 已回退为静态导入,修正内存估计(~5-15MB 非 ~50MB) - [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests -- [ ] #9 Remote Control 权限条目保留 — 核心清理已实现,hook cleanup 完整性待确认 +- [x] #9 Remote Control 权限条目保留 — **已修复**:pendingPermissionHandlers 提升至 useEffect 作用域,cleanup 时显式 clear() - [ ] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅ - [ ] #11 LRU 缓存键保留大 JSON — **归类需修正**:实际已完整实现(sizeCalculation + maxSize 上限),非"未实现" - [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded(按 removedUuids 过滤)+ snipProjection(边界检测 + 视图投影),28 tests @@ -171,7 +171,7 @@ return [...kept, newMessage] ## 4. 空闲重新渲染循环 (v2.1.117) -**状态:部分实现** +**状态:已确认完整** **CHANGELOG 描述**:Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux @@ -364,7 +364,7 @@ const resetLoadingState = useCallback(() => { ## 9. Remote Control 权限条目保留 (v2.1.98) -**状态:部分实现** +**状态:已修复** **CHANGELOG 描述**:Fixed a memory leak where Remote Control permission handler entries were retained for the lifetime of the session @@ -567,8 +567,7 @@ if (snipResult !== undefined) { ## 总结 ``` -确认已实现 (8): #1 图片 #2 /usage #3 进度消息 #5 虚拟滚动器 #6 管道输出 #8 NO_FLICKER #10 MCP缓冲区 #12 snipCompact -部分实现 (2): #4 空闲渲染 #9 RC权限 +确认已实现 (10): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #8 NO_FLICKER #9 RC权限 #10 MCP缓冲区 #12 snipCompact 未实现/存根 (2): #7 语法加载(已回退) #11 LRU缓存键 ``` diff --git a/src/hooks/__tests__/replBridgePermissionHandlers.test.ts b/src/hooks/__tests__/replBridgePermissionHandlers.test.ts new file mode 100644 index 000000000..0daeea519 --- /dev/null +++ b/src/hooks/__tests__/replBridgePermissionHandlers.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test' + +/** + * Tests for the pendingPermissionHandlers cleanup pattern used in + * useReplBridge.tsx. The handlers Map tracks in-flight permission + * requests; the cleanup function must clear it on unmount to release + * closures that capture React state. + * + * The actual hook is deeply integrated with React/bridge lifecycle, + * so these tests validate the Map management pattern in isolation. + */ + +type PermissionHandler = (response: { approved: boolean }) => void + +function createPermissionHandlersMap() { + const handlers = new Map() + + return { + handlers, + onResponse(requestId: string, handler: PermissionHandler): () => void { + handlers.set(requestId, handler) + return () => { + handlers.delete(requestId) + } + }, + handleResponse(requestId: string, response: { approved: boolean }): boolean { + const handler = handlers.get(requestId) + if (!handler) return false + handlers.delete(requestId) + handler(response) + return true + }, + cleanup(): void { + handlers.clear() + }, + size(): number { + return handlers.size + }, + } +} + +describe('pendingPermissionHandlers cleanup pattern', () => { + test('onResponse registers a handler', () => { + const map = createPermissionHandlersMap() + map.onResponse('req-1', () => {}) + expect(map.size()).toBe(1) + }) + + test('onResponse returns a cancel function', () => { + const map = createPermissionHandlersMap() + const cancel = map.onResponse('req-1', () => {}) + expect(map.size()).toBe(1) + cancel() + expect(map.size()).toBe(0) + }) + + test('handleResponse dispatches to handler and removes it', () => { + const map = createPermissionHandlersMap() + let received: { approved: boolean } | null = null + map.onResponse('req-1', (resp) => { received = resp }) + const dispatched = map.handleResponse('req-1', { approved: true }) + expect(dispatched).toBe(true) + expect(received).toEqual({ approved: true }) + expect(map.size()).toBe(0) + }) + + test('handleResponse returns false for unknown requestId', () => { + const map = createPermissionHandlersMap() + const dispatched = map.handleResponse('unknown', { approved: true }) + expect(dispatched).toBe(false) + }) + + test('cleanup clears all registered handlers', () => { + const map = createPermissionHandlersMap() + map.onResponse('req-1', () => {}) + map.onResponse('req-2', () => {}) + map.onResponse('req-3', () => {}) + expect(map.size()).toBe(3) + + map.cleanup() + + expect(map.size()).toBe(0) + }) + + test('handlers are not dispatched after cleanup', () => { + const map = createPermissionHandlersMap() + let called = false + map.onResponse('req-1', () => { called = true }) + + map.cleanup() + + // Late-arriving response after cleanup should not find a handler + const dispatched = map.handleResponse('req-1', { approved: true }) + expect(dispatched).toBe(false) + expect(called).toBe(false) + }) + + test('cancel function is a no-op after cleanup', () => { + const map = createPermissionHandlersMap() + const cancel = map.onResponse('req-1', () => {}) + map.cleanup() + // Should not throw + expect(() => cancel()).not.toThrow() + }) + + test('cleanup can be called multiple times safely', () => { + const map = createPermissionHandlersMap() + map.onResponse('req-1', () => {}) + map.cleanup() + map.cleanup() + map.cleanup() + expect(map.size()).toBe(0) + }) +}) diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index fb05c1c94..df9669e2e 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -189,6 +189,12 @@ export function useReplBridge( } let cancelled = false + // Map of pending bridge permission response handlers, keyed by request_id. + // Defined at useEffect scope so the cleanup function can clear it on unmount. + const pendingPermissionHandlers = new Map< + string, + (response: BridgePermissionResponse) => void + >() // Capture messages.length now so we don't re-send initial messages // through writeMessages after the bridge connects. const initialMessageCount = messages.length @@ -461,13 +467,6 @@ export function useReplBridge( } } - // Map of pending bridge permission response handlers, keyed by request_id. - // Each entry is an onResponse handler waiting for CCR to reply. - const pendingPermissionHandlers = new Map< - string, - (response: BridgePermissionResponse) => void - >() - // Dispatch incoming control_response messages to registered handlers function handlePermissionResponse(msg: SDKControlResponse): void { const requestId = msg.response?.request_id @@ -818,6 +817,10 @@ export function useReplBridge( return () => { cancelled = true + // Release all pending permission handlers so their closures (which + // may capture React state/setters) can be GC'd immediately rather + // than waiting for the entire useEffect closure to become unreachable. + pendingPermissionHandlers.clear() clearTimeout(failureTimeoutRef.current) failureTimeoutRef.current = undefined if (handleRef.current) {