diff --git a/docs/memory-leak-audit.md b/docs/memory-leak-audit.md index e2b423f61..463d39d12 100644 --- a/docs/memory-leak-audit.md +++ b/docs/memory-leak-audit.md @@ -12,7 +12,7 @@ - [ ] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅ - [ ] #6 管道模式超宽行过度分配 — 确认已实现 ✅ - [ ] #7 语言语法按需加载 — 已回退为静态导入,修正内存估计(~5-15MB 非 ~50MB) -- [ ] #8 NO_FLICKER 模式流状态泄漏 — resetLoadingState 已实现,StreamingToolExecutor.discard 完整性待确认 +- [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests - [ ] #9 Remote Control 权限条目保留 — 核心清理已实现,hook cleanup 完整性待确认 - [ ] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅ - [ ] #11 LRU 缓存键保留大 JSON — **归类需修正**:实际已完整实现(sizeCalculation + maxSize 上限),非"未实现" @@ -326,7 +326,7 @@ function hljsApi(): HLJSApi { ## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105) -**状态:可疑 — 框架存在但完整性不确定** +**状态:已修复** **CHANGELOG 描述**:Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state @@ -567,13 +567,14 @@ if (snipResult !== undefined) { ## 总结 ``` -确认已实现 (7): #1 图片 #2 /usage #3 进度消息 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区 #12 snipCompact -部分实现 (3): #4 空闲渲染 #9 RC权限 #8 NO_FLICKER流状态 +确认已实现 (8): #1 图片 #2 /usage #3 进度消息 #5 虚拟滚动器 #6 管道输出 #8 NO_FLICKER #10 MCP缓冲区 #12 snipCompact +部分实现 (2): #4 空闲渲染 #9 RC权限 未实现/存根 (2): #7 语法加载(已回退) #11 LRU缓存键 ``` ### 需要关注的优先级 -1. ~~**P0 — `snipCompact.ts` 存根**:唯一完全不工作的清理路径,长时间 SDK 会话必然触发~~ **已修复** +1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复** 2. **P1 — 语法按需加载回退**:highlight.js 190+ 语法常驻内存(~5-15MB),无释放时机 -3. **P2 — NO_FLICKER / 空闲渲染**:框架存在但反编译代码中集成完整性不确定 +3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复** +4. **P2 — 空闲渲染循环**:框架存在但反编译代码中 keepAlive 集成完整性不确定 diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index b924fdd91..57a046b14 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -64,9 +64,24 @@ export class StreamingToolExecutor { * Discards all pending and in-progress tools. Called when streaming fallback * occurs and results from the failed attempt should be abandoned. * Queued tools won't start, and in-progress tools will receive synthetic errors. + * + * Releases all internal references (tools array, abort controller, context) + * so that the discarded executor and its buffered results can be garbage-collected. + * Without this, repeated API retries in NO_FLICKER mode accumulate leaked + * TrackedTool objects (each holding assistantMessage, results, pendingProgress). */ discard(): void { this.discarded = true + // Abort running tool subprocesses (Bash spawns, etc.) so they don't + // continue producing results after the executor is replaced. + this.siblingAbortController.abort('streaming_fallback') + // Release references to allow GC of tool blocks, messages, and promises. + this.tools.length = 0 + this.progressAvailableResolve = undefined + if (this.turnSpan) { + endToolBatchSpan(this.turnSpan) + this.turnSpan = null + } } /** diff --git a/src/services/tools/__tests__/StreamingToolExecutor.test.ts b/src/services/tools/__tests__/StreamingToolExecutor.test.ts new file mode 100644 index 000000000..21e479d03 --- /dev/null +++ b/src/services/tools/__tests__/StreamingToolExecutor.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test' +import { StreamingToolExecutor } from '../StreamingToolExecutor.js' +import type { ToolUseContext } from '../../../Tool.js' + +function makeMinimalContext(): ToolUseContext { + const abortController = new AbortController() + return { + options: { + commands: [], + debug: false, + mainLoopModel: 'test-model', + tools: [], + verbose: false, + thinkingConfig: { type: 'disabled' }, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: false, + agentDefinitions: { builtinAgents: [], customAgents: [] }, + }, + abortController, + readFileState: { get: () => undefined, set: () => {}, delete: () => false, has: () => false, clear: () => {} } as any, + getAppState: () => ({}) as any, + setAppState: () => {}, + setInProgressToolUseIDs: () => {}, + setResponseLength: () => {}, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + messages: [], + } as unknown as ToolUseContext +} + +describe('StreamingToolExecutor.discard()', () => { + test('clears the internal tools array', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + // Access internal state via reflection + const toolsBefore = (executor as unknown as { tools: unknown[] }).tools + expect(toolsBefore).toHaveLength(0) + + executor.discard() + + const toolsAfter = (executor as unknown as { tools: unknown[] }).tools + expect(toolsAfter).toHaveLength(0) + }) + + test('aborts the sibling abort controller', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + const siblingController = (executor as unknown as { siblingAbortController: AbortController }).siblingAbortController + expect(siblingController.signal.aborted).toBe(false) + + executor.discard() + + expect(siblingController.signal.aborted).toBe(true) + }) + + test('sets discarded flag so getCompletedResults yields nothing', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + executor.discard() + + const results = [...executor.getCompletedResults()] + expect(results).toHaveLength(0) + }) + + test('sets discarded flag so getRemainingResults yields nothing', async () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + executor.discard() + + const results: unknown[] = [] + for await (const update of executor.getRemainingResults()) { + results.push(update) + } + expect(results).toHaveLength(0) + }) + + test('clears progressAvailableResolve', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + executor.discard() + + const resolve = (executor as unknown as { progressAvailableResolve?: () => void }).progressAvailableResolve + expect(resolve).toBeUndefined() + }) + + test('can be called multiple times without error', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + expect(() => { + executor.discard() + executor.discard() + executor.discard() + }).not.toThrow() + }) + + test('releases references to allow GC of discarded executor', () => { + const ctx = makeMinimalContext() + const executor = new StreamingToolExecutor([], () => true as any, ctx) + + executor.discard() + + // All internal references should be cleared/released + const internals = executor as unknown as { + tools: unknown[] + progressAvailableResolve?: () => void + turnSpan: unknown + } + expect(internals.tools).toHaveLength(0) + expect(internals.progressAvailableResolve).toBeUndefined() + expect(internals.turnSpan).toBeNull() + }) +})