mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
fix: 完善 StreamingToolExecutor.discard() 释放内部状态,修复 NO_FLICKER 模式内存泄漏
discard() 原先仅设置 flag,不释放 tools 数组、siblingAbortController 和 turnSpan。 NO_FLICKER 模式 API 重试时旧工具结果堆积无法被 GC 回收。 修复内容: - 中止 siblingAbortController 以取消运行中的工具子进程 - 清空 tools 数组释放 TrackedTool 引用(block、assistantMessage、results、pendingProgress) - 清理 progressAvailableResolve 和 turnSpan - 添加 7 个测试覆盖 discard 后的各种状态验证 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 集成完整性不确定
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
119
src/services/tools/__tests__/StreamingToolExecutor.test.ts
Normal file
119
src/services/tools/__tests__/StreamingToolExecutor.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user