审计确认 #11 FileStateCache 已完整实现(LRU 双重限制 max+maxSize + sizeCalculation),归类从"未实现"修正为"已确认完整"。 - 添加 16 个 FileStateCache 测试覆盖 LRU 驱逐、大小计算、路径归一化 - 添加 6 个 coerceToolContentToString 测试覆盖类型强制转换 - 修复 replBridgePermissionHandlers 测试的类型断言错误 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
18 KiB
内存泄漏排查报告
基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。 审计日期:2026-04-28
TODO
- #1 图片处理无限内存增长 — 确认已实现 ✅
- #2 /usage 命令泄漏约 2GB — 确认已实现 ✅
- #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅
- #4 空闲重新渲染循环 — 已确认完整:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟,keepAlive 机制工作正常
- #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅
- #6 管道模式超宽行过度分配 — 确认已实现 ✅
- #7 语言语法按需加载 — 已回退为静态导入,修正内存估计(~5-15MB 非 ~50MB)
- #8 NO_FLICKER 模式流状态泄漏 — 已修复:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests
- #9 Remote Control 权限条目保留 — 已修复:pendingPermissionHandlers 提升至 useEffect 作用域,cleanup 时显式 clear()
- #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅
- #11 LRU 缓存键保留大 JSON — 已确认完整实现:FileStateCache 使用 LRU 双重限制(max 100 条目 + maxSize 25MB)+ sizeCalculation 正确计算嵌套对象大小 + coerceToolContentToString 确保类型一致
- #12 QueryEngine.mutableMessages 不收缩 — 已修复:实现 snipCompactIfNeeded(按 removedUuids 过滤)+ snipProjection(边界检测 + 视图投影),28 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 层释放
修复方式
三层防护机制:
- LRU 内存缓存:
storedImagePathsMap 上限 200 条目(MAX_STORED_IMAGE_PATHS),超出自动驱逐最早条目 - 磁盘持久化:图片 base64 数据写入
~/.claude/image-cache/<sessionId>/,内存中仅保留路径字符串 - 立即释放:
setPastedContents({})在消息提交/命令执行后清空 React state 中的 base64 数据
关键代码
// 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<void> { ... }
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— 调用方
修复方式
- 分块流式读取:使用
TRANSCRIPT_READ_CHUNK_SIZE = 1MB固定块大小,通过fd.read()逐块处理,避免一次性加载整个 transcript - 字节级过滤:在 fd 层面直接跳过
attribution-snapshot类型的行(占长会话 84% 的字节空间) - 边界截断:搜索
compact_boundary标记,只保留边界之后的数据 - 缓冲区控制:初始缓冲区限制
Math.min(fileSize, 8MB)
关键代码
// 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— 临时消息类型定义
修复方式
- 向后扫描替换:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的
parentToolUseID+type后替换(修复交错消息导致 13k+ 条目堆积) - 全屏模式硬上限:
MAX_FULLSCREEN_SCROLLBACK = 500,超出截断 - 临时消息识别:
isEphemeralToolProgress()区分bash_progress、sleep_progress等一次性消息与需要保留的agent_progress等
关键代码
// REPL.tsx:3094-3114
setMessages(oldMessages => {
const newData = newMessage.data as Record<string, unknown>;
// 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<string, unknown> | 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 订阅者分类机制完整存在:
// ClockContext.tsx:11-43
function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | 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) 全量重建。
// VirtualMessageList.tsx:276-296
const keysRef = useRef<string[]>([])
const prevMessagesRef = useRef<typeof messages>(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 条目时清空:
// 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
当前状态
延迟加载逻辑已被移除,改为顶层静态导入。代码注释说明原因:
// 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 等:
// 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— 注册 + 清理函数
已实现部分
// useReplBridge.tsx:466-491
const pendingPermissionHandlers = new Map<string, (response: ...) => 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()
修复方式
- 主动释放响应体:
releaseStreamResources()清理 stream 和 response
// 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
}
}
- SSE 读取器释放:
// SSETransport.ts:418-419
} finally {
reader.releaseLock()
}
- 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— 类型强制转换
修复方式
- 正确计算缓存大小:处理
content为嵌套对象的情况
// 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'))
}
- 强制类型转换:确保 Write 工具 content 始终为字符串
// 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(已实现)
// 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 压缩(存根 — 永不执行)
// 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。
// 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 级内存占用
- 这是当前代码库中最明确的未实现内存泄漏点
总结
确认已实现 (11): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #8 NO_FLICKER #9 RC权限 #10 MCP缓冲区 #11 LRU缓存键 #12 snipCompact
已知限制 (1): #7 语法加载(Bun --compile 兼容性,已回退为静态导入,~5-15MB)
需要关注的优先级
P0 —已修复snipCompact.ts存根- P1 — 语法按需加载回退:highlight.js 190+ 语法常驻内存(~5-15MB),无释放时机
P2 — NO_FLICKER 流状态已修复- P2 — 空闲渲染循环:框架存在但反编译代码中 keepAlive 集成完整性不确定