fix: 清理 useReplBridge pendingPermissionHandlers,修复 RC 权限条目保留内存泄漏

pendingPermissionHandlers Map 原定义在 async IIFE 内部,组件卸载时
cleanup 函数无法访问。修复方案:
- 将 Map 提升至 useEffect 顶层作用域
- cleanup 时显式调用 pendingPermissionHandlers.clear() 释放闭包引用
- 添加 8 个测试覆盖 handler 注册/取消/响应/cleanup 模式

同时确认 #4 空闲渲染循环已完整实现(所有 10 个 useAnimationFrame
调用者均正确传递 null 暂停时钟)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-28 23:38:47 +08:00
parent 0542ffea2f
commit 8d67b2250c
3 changed files with 129 additions and 13 deletions

View File

@@ -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、清理 turnSpan7 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缓存键
```

View File

@@ -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<string, PermissionHandler>()
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)
})
})

View File

@@ -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) {