# 内存泄漏排查报告 > 基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。 > 审计日期:2026-04-28 ## TODO - [x] #1 图片处理无限内存增长 — 确认已实现 ✅ - [x] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅ - [x] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅ - [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟,keepAlive 机制工作正常 - [x] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅ - [x] #6 管道模式超宽行过度分配 — 确认已实现 ✅ - [x] #7 语言语法按需加载 — **已修复**:改用 highlight.js/lib/core + 静态注册 26 个常用语言,从 190+ 语言降至 ~25,内存减少 ~80% - [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests - [x] #9 Remote Control 权限条目保留 — **已修复**:pendingPermissionHandlers 提升至 useEffect 作用域,cleanup 时显式 clear(),8 tests - [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 - [x] #17 LSP Opened Files Map 不收缩 — **已修复**:LSPServerManager 添加 closeAllFiles() 方法,postCompactCleanup 集成调用,compaction 后释放 openedFiles Map,5 tests ## 总览 --- ## 1. 图片处理无限内存增长 (v2.1.121) **CHANGELOG 描述**:Fixed unbounded memory growth (multi-GB RSS) when processing many images in a session ### 实现位置 - `src/utils/imageStore.ts` — 核心修复 - `src/commands/clear/caches.ts` — 缓存清理 - `src/screens/REPL.tsx` — UI 层释放 ### 修复方式 三层防护机制: 1. **LRU 内存缓存**:`storedImagePaths` Map 上限 200 条目(`MAX_STORED_IMAGE_PATHS`),超出自动驱逐最早条目 2. **磁盘持久化**:图片 base64 数据写入 `~/.claude/image-cache//`,内存中仅保留路径字符串 3. **立即释放**:`setPastedContents({})` 在消息提交/命令执行后清空 React state 中的 base64 数据 ### 关键代码 ```typescript // imageStore.ts:10 const MAX_STORED_IMAGE_PATHS = 200 // imageStore.ts:115-124 function evictOldestIfAtCap(): void { while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) { const oldest = storedImagePaths.keys().next().value if (oldest !== undefined) { storedImagePaths.delete(oldest) } else { break } } } // imageStore.ts:129-167 — 清理旧会话目录 export async function cleanupOldImageCaches(): Promise { ... } ``` --- ## 2. /usage 命令泄漏约 2GB (v2.1.121) **CHANGELOG 描述**:Fixed /usage leaking up to ~2GB of memory on machines with large transcript histories ### 实现位置 - `src/utils/sessionStoragePortable.ts:716-792` — 核心流式读取 - `src/utils/attribution.ts` — 调用方 ### 修复方式 1. **分块流式读取**:使用 `TRANSCRIPT_READ_CHUNK_SIZE = 1MB` 固定块大小,通过 `fd.read()` 逐块处理,避免一次性加载整个 transcript 2. **字节级过滤**:在 fd 层面直接跳过 `attribution-snapshot` 类型的行(占长会话 84% 的字节空间) 3. **边界截断**:搜索 `compact_boundary` 标记,只保留边界之后的数据 4. **缓冲区控制**:初始缓冲区限制 `Math.min(fileSize, 8MB)` ### 关键代码 ```typescript // sessionStoragePortable.ts:716-792 export async function readTranscriptForLoad( filePath: string, fileSize: number, ): Promise<{ boundaryStartOffset: number postBoundaryBuf: Buffer hasPreservedSegment: boolean }> { const s: LoadState = { out: { buf: Buffer.allocUnsafe(Math.min(fileSize, 8 * 1024 * 1024)), len: 0, cap: fileSize + 1, }, // ... } const chunk = Buffer.allocUnsafe(CHUNK_SIZE) const fd = await fsOpen(filePath, 'r') try { let filePos = 0 while (filePos < fileSize) { const { bytesRead } = await fd.read(chunk, 0, Math.min(CHUNK_SIZE, fileSize - filePos), filePos) if (bytesRead === 0) break filePos += bytesRead // ... 分块处理逻辑 } finalizeOutput(s) } finally { await fd.close() } } ``` --- ## 3. 长时间运行工具进度事件泄漏 (v2.1.121) **CHANGELOG 描述**:Fixed memory leak when long-running tools fail to emit a clear progress event ### 实现位置 - `src/screens/REPL.tsx:3054-3114` — progress 消息替换逻辑 - `src/utils/sessionStorage.ts:186-196` — 临时消息类型定义 ### 修复方式 1. **向后扫描替换**:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的 `parentToolUseID` + `type` 后替换(修复交错消息导致 13k+ 条目堆积) 2. **全屏模式硬上限**:`MAX_FULLSCREEN_SCROLLBACK = 500`,超出截断 3. **临时消息识别**:`isEphemeralToolProgress()` 区分 `bash_progress`、`sleep_progress` 等一次性消息与需要保留的 `agent_progress` 等 ### 关键代码 ```typescript // REPL.tsx:3094-3114 setMessages(oldMessages => { const newData = newMessage.data as Record; // Scan backwards to find the last ephemeral progress with matching // parentToolUseID and type. for (let i = oldMessages.length - 1; i >= 0; i--) { const m = oldMessages[i]! if (m.type !== 'progress') break const mData = m.data as Record | undefined if ( m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type ) { const copy = oldMessages.slice(); copy[i] = newMessage; return copy; } } return [...oldMessages, newMessage]; }); // REPL.tsx:3058-3064 — 全屏模式硬上限 const MAX_FULLSCREEN_SCROLLBACK = 500 const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK ? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK) : postBoundary return [...kept, newMessage] ``` --- ## 4. 空闲重新渲染循环 (v2.1.117) **状态:已确认完整** **CHANGELOG 描述**:Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux ### 实现位置 - `packages/@ant/ink/src/components/ClockContext.tsx` — 核心时钟管理 ### 已实现部分 `ClockContext` 的 `keepAlive` 订阅者分类机制完整存在: ```typescript // ClockContext.tsx:11-43 function createClock(tickIntervalMs: number): Clock { const subscribers = new Map<() => void, boolean>() let interval: ReturnType | null = null function updateInterval(): void { const anyKeepAlive = [...subscribers.values()].some(Boolean) if (anyKeepAlive) { // 有 keepAlive 订阅者时启动 interval interval = setInterval(tick, currentTickIntervalMs) } else if (interval) { // 无 keepAlive 订阅者时停止 interval clearInterval(interval) interval = null } } return { subscribe(onChange, keepAlive) { subscribers.set(onChange, keepAlive) updateInterval() return () => { subscribers.delete(onChange) updateInterval() } }, // ... } } ``` ### 不确定部分 无法确认 `useAnimationFrame` hook 是否在所有使用时钟的组件中正确传递了 `keepAlive` 参数。反编译代码中调用链可能不完整。 --- ## 5. 虚拟滚动器保留历史消息拷贝 (v2.1.101) **CHANGELOG 描述**:Fixed a memory leak where long sessions retained dozens of historical copies of the message list in the virtual scroller ### 实现位置 - `src/components/VirtualMessageList.tsx:276-296` ### 修复方式 增量式键值数组:使用 `useRef` 保存 keys 数组引用,流式追加而非每次 O(n) 全量重建。 ```typescript // VirtualMessageList.tsx:276-296 const keysRef = useRef([]) const prevMessagesRef = useRef(messages) const prevItemKeyRef = useRef(itemKey) if ( prevItemKeyRef.current !== itemKey || messages.length < keysRef.current.length || messages[0] !== prevMessagesRef.current[0] ) { // 全量重建(仅在 itemKey 变化、数组缩短等场景) keysRef.current = messages.map(m => itemKey(m)) } else { // 增量追加(正常流式场景) for (let i = keysRef.current.length; i < messages.length; i++) { keysRef.current.push(itemKey(messages[i]!)) } } prevMessagesRef.current = messages prevItemKeyRef.current = itemKey const keys = keysRef.current ``` 修复前 27k 消息时每次新消息添加产生 ~1MB 内存分配,修复后降为 O(1) 追加。 --- ## 6. 管道模式超宽行过度分配 (v2.1.110) **CHANGELOG 描述**:Fixed potential excessive memory allocation when piped (non-TTY) Ink output contains a single very wide line ### 实现位置 - `packages/@ant/ink/src/core/output.ts:200-207` ### 修复方式 在 `Output.reset()` 中当字符缓存超过 16384 条目时清空: ```typescript // output.ts:200-207 reset(width: number, height: number, screen: Screen): void { this.width = width this.height = height this.screen = screen this.operations.length = 0 resetScreen(screen, width, height) if (this.charCache.size > 16384) this.charCache.clear() // 关键修复 } ``` --- ## 7. 语言语法按需加载 (v2.1.108) **状态:已修复** **CHANGELOG 描述**:Reduced memory footprint for file reads, edits, and syntax highlighting by loading language grammars on demand ### 实现位置 - `packages/color-diff-napi/src/index.ts:21-37` ### 当前状态 延迟加载逻辑**已被移除**,改为顶层静态导入。代码注释说明原因: ```typescript // color-diff-napi/src/index.ts:21-37 // Static import — createRequire(import.meta.url) fails in Bun --compile mode // because the resolved path points to the internal bunfs binary path where // node_modules cannot be found. A top-level import ensures the module is // bundled and accessible at runtime. import hljs from 'highlight.js' // 顶层静态导入 type HLJSApi = typeof hljs let cachedHljs: HLJSApi | null = null function hljsApi(): HLJSApi { if (cachedHljs) return cachedHljs const mod = hljs as HLJSApi & { default?: HLJSApi } cachedHljs = 'default' in mod && mod.default ? mod.default : mod return cachedHljs! } ``` **影响**:highlight.js 包含 190+ 语言语法(约 50MB),现在在模块加载时即全部载入内存,无法按需释放。这是为了兼容 Bun `--compile` 模式做的妥协。 --- ## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105) **状态:已修复** **CHANGELOG 描述**:Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state ### 实现位置 - `src/screens/REPL.tsx:1841-1861` — `resetLoadingState()` - `src/screens/REPL.tsx:3568-3578` — finally 块调用 ### 已实现部分 `resetLoadingState()` 在 `onQuery` 的 finally 块中无条件调用,清理 `streamingText`、`streamingToolUses` 等: ```typescript // REPL.tsx:1841-1861 const resetLoadingState = useCallback(() => { setStreamingText(null); setStreamingToolUses([]); setSpinnerMessage(null); // ... }, [pickNewSpinnerTip]); // REPL.tsx:3568-3578 — finally 块 } finally { if (queryGuard.end(thisGeneration)) { resetLoadingState(); // 无条件清理 } } ``` ### 不确定部分 无法确认 `query.ts` 中 `StreamingToolExecutor.discard()` 的逻辑是否完整实现了旧工具结果的释放。 --- ## 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 ### 实现位置 - `src/hooks/useReplBridge.tsx:466-491` — 处理 + 删除 - `src/hooks/useReplBridge.tsx:712-717` — 注册 + 清理函数 ### 已实现部分 ```typescript // useReplBridge.tsx:466-491 const pendingPermissionHandlers = new Map void>() function handlePermissionResponse(msg: SDKControlResponse): void { const requestId = msg.response?.request_id if (!requestId) return const handler = pendingPermissionHandlers.get(requestId) if (!handler) return const parsed = parseBridgePermissionResponse(msg) if (!parsed) return pendingPermissionHandlers.delete(requestId) // 处理后删除 handler(parsed) } // useReplBridge.tsx:712-717 onResponse(requestId, handler) { pendingPermissionHandlers.set(requestId, handler) return () => { pendingPermissionHandlers.delete(requestId) // 取消时删除 } } ``` ### 不确定部分 hook 的 cleanup 函数(组件卸载时的 `replBridgePermissionCallbacks = undefined`)是否完整调用。 --- ## 10. MCP HTTP/SSE 缓冲区累积 (v2.1.97) **CHANGELOG 描述**:Fixed MCP HTTP/SSE connections accumulating ~50 MB/hr of unreleased buffers when servers reconnect ### 实现位置 - `src/services/api/claude.ts:1557-1564` — `releaseStreamResources()` - `src/cli/transports/SSETransport.ts:419` — `reader.releaseLock()` - `@modelcontextprotocol/sdk` (sse.js, streamableHttp.js) — `response.body?.cancel()` ### 修复方式 1. **主动释放响应体**:`releaseStreamResources()` 清理 stream 和 response ```typescript // claude.ts:1553-1564 // Release all stream resources to prevent native memory leaks. // The Response object holds native TLS/socket buffers that live outside the // V8 heap (observed on the Node.js/npm path; see GH #32920), so we must // explicitly cancel and release it regardless of how the generator exits. function releaseStreamResources(): void { cleanupStream(stream) stream = undefined if (streamResponse) { streamResponse.body?.cancel().catch(() => {}) streamResponse = undefined } } ``` 2. **SSE 读取器释放**: ```typescript // SSETransport.ts:418-419 } finally { reader.releaseLock() } ``` 3. **MCP SDK 层面**:在所有 HTTP 路径(成功/失败/重连)调用 `response.body?.cancel()` --- ## 11. LRU 缓存键保留大 JSON (v2.1.89) **状态:已确认完整实现** **CHANGELOG 描述**:Fixed memory leak where large JSON inputs were retained as LRU cache keys in long-running sessions ### 实现位置 - `src/utils/fileStateCache.ts:37-48` — 大小计算修复 - `src/utils/queryHelpers.ts:48-54` — 类型强制转换 ### 修复方式 1. **正确计算缓存大小**:处理 `content` 为嵌套对象的情况 ```typescript // fileStateCache.ts:37-48 sizeCalculation: value => { const c = value.content const s = typeof c === 'string' ? c : c === null || c === undefined ? '' : typeof c === 'object' ? JSON.stringify(c) : String(c) return Math.max(1, Buffer.byteLength(s, 'utf8')) } ``` 2. **强制类型转换**:确保 Write 工具 content 始终为字符串 ```typescript // queryHelpers.ts:48-54 function coerceToolContentToString(value: unknown): string { if (typeof value === 'string') return value if (value === null || value === undefined) return '' if (typeof value === 'object') return JSON.stringify(value) return String(value) } ``` --- ## 12. QueryEngine.mutableMessages 不收缩 **状态:已修复** **代码注释描述**:`markers persist and re-trigger on every turn, and mutableMessages never shrinks (memory leak in long SDK sessions)`(`src/QueryEngine.ts:929-930`) ### 实现位置 - `src/services/compact/snipCompact.ts` — **存根文件** - `src/QueryEngine.ts:925-962` — 消息处理逻辑 ### 问题详情 `mutableMessages` 数组只增不减,每轮对话 push 多条消息(assistant、progress、user、attachment 等)。清理依赖两条路径: **路径 1:API 返回 compact_boundary**(已实现) ```typescript // QueryEngine.ts:946-962 if (msg.subtype === 'compact_boundary' && msg.compactMetadata) { const mutableBoundaryIdx = this.mutableMessages.length - 1 if (mutableBoundaryIdx > 0) { this.mutableMessages.splice(0, mutableBoundaryIdx) // 清理旧消息 } } ``` **路径 2:本地 snip 压缩**(存根 — 永不执行) ```typescript // snipCompact.ts — 完整文件 // Auto-generated stub — replace with real implementation export {}; import type { Message } from 'src/types/message'; export const isSnipMarkerMessage: (message: Message) => boolean = () => false; export const snipCompactIfNeeded: ( messages: Message[], options?: { force?: boolean }, ) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({ messages, executed: false, // 永远 false — 清理从不执行 tokensFreed: 0, }); export const isSnipRuntimeEnabled: () => boolean = () => false; export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false; export const SNIP_NUDGE_TEXT: string = ''; ``` `snipReplay` 回调依赖 `HISTORY_SNIP` feature flag,且调用的 `snipCompactIfNeeded` 永远返回 `executed: false`。 ```typescript // QueryEngine.ts:933-942 const snipResult = this.config.snipReplay?.(msg, this.mutableMessages) if (snipResult !== undefined) { if (snipResult.executed) { // 永远是 false this.mutableMessages.length = 0 this.mutableMessages.push(...snipResult.messages) } break } ``` ### 风险评估 - 在长时间 SDK 会话中,如果 API 不频繁返回 `compact_boundary`,`mutableMessages` 会持续增长 - 每条消息可能包含大量内容(工具输出、文件内容等),长时间运行可能导致 GB 级内存占用 - 这是当前代码库中**最明确的未实现内存泄漏点** --- ## 17. LSP Opened Files Map 不收缩 **状态:已修复** **代码注释描述**:`closeFile()` 存在但未与 compact 流程集成(`LSPServerManager.ts:373-375` 显式标注为 TODO) ### 实现位置 - `src/services/lsp/LSPServerManager.ts:414-428` — `closeAllFiles()` 方法 - `src/services/compact/postCompactCleanup.ts:81-88` — 集成调用 ### 问题详情 `LSPServerManager` 中的 `openedFiles: Map` 追踪所有通过 `didOpen` 打开的文件。`closeFile()` 方法存在可以发送 `didClose` 通知并清理 Map 条目,但代码注释明确标注: ``` NOTE: Currently available but not yet integrated with compact flow. TODO: Integrate with compact - call closeFile() when compact removes files from context ``` 长时间会话中,每次读取/编辑文件都会通过 `openFile()` 添加条目,但 compaction 不会清理这些条目,导致 Map 无限增长。 ### 修复方式 1. **添加 `closeAllFiles()` 方法**:遍历 `openedFiles` Map,对每个文件发送 `didClose` 通知,然后清空 Map。Best-effort 错误处理。 ```typescript async function closeAllFiles(): Promise { const entries = [...openedFiles.entries()] openedFiles.clear() for (const [fileUri, serverName] of entries) { const server = servers.get(serverName) if (!server || server.state !== 'running') continue try { await server.sendNotification('textDocument/didClose', { textDocument: { uri: fileUri }, }) } catch { // Best-effort — server may have stopped } } } ``` 2. **集成到 `postCompactCleanup`**:在 compaction 后自动调用 `closeAllFiles()`,释放所有 LSP 服务器端的文件状态。 ```typescript // postCompactCleanup.ts try { const lspManager = getLspServerManager() if (lspManager) { await lspManager.closeAllFiles() } } catch { // LSP module may not be available in all environments } ``` --- ## 总结 ``` 确认已实现 (12): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区 已修复 (7): #7 语法加载 #8 NO_FLICKER #9 RC权限 #11 LRU缓存键 #12 snipCompact #17 LSP文件追踪 #18 Permission Polling ### 测试覆盖 | 修复项 | 测试文件 | 测试数 | |--------|----------|--------| | #12 snipCompact | `src/services/compact/__tests__/snipCompact.test.ts` | 17 | | #12 snipProjection | `src/services/compact/__tests__/snipProjection.test.ts` | 11 | | #8 StreamingToolExecutor | `src/services/tools/__tests__/StreamingToolExecutor.test.ts` | 7 | | #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 | | #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 | | #17 LSP Opened Files | `src/services/lsp/__tests__/closeAllFiles.test.ts` | 5 | | **总计** | **8 个测试文件** | **83** | ``` ### 需要关注的优先级 1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复** 2. ~~**P1 — 语法按需加载回退**~~ **已修复** 3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复** 4. ~~**P2 — 空闲渲染循环**~~ **已确认完整** 5. ~~**P2 — Permission Polling Interval**~~ **已修复** 6. ~~**P2 — LSP Opened Files Map**~~ **已修复**:closeAllFiles() 集成到 postCompactCleanup