fix: 修复内存溢出问题,compact 时清理持久增长数据结构

- compact 时清理 contentReplacementState(seenIds/replacements)
- logError() 使用 shortErrorStack 替代完整 err.stack,减少 GC 压力
- permissionDenials 每次 submitMessage 清空,防止无限增长
- SSE 缓冲区添加 1MB 上限,防止畸形数据无限累积

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-05-01 22:24:18 +08:00
parent a81995052f
commit ab0bbbc4b5
5 changed files with 35 additions and 3 deletions

View File

@@ -248,6 +248,7 @@ export class QueryEngine {
} = this.config } = this.config
this.discoveredSkillNames.clear() this.discoveredSkillNames.clear()
this.permissionDenials = []
setCwd(cwd) setCwd(cwd)
const persistSession = !isSessionPersistenceDisabled() const persistSession = !isSessionPersistenceDisabled()
const startTime = Date.now() const startTime = Date.now()

View File

@@ -350,6 +350,7 @@ export class SSETransport implements Transport {
const reader = body.getReader() const reader = body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
const MAX_BUFFER_BYTES = 1024 * 1024 // 1MB — SSE frames include event/data/id prefixes
try { try {
while (true) { while (true) {
@@ -357,6 +358,14 @@ export class SSETransport implements Transport {
if (done) break if (done) break
buffer += decoder.decode(value, STREAM_DECODE_OPTS) buffer += decoder.decode(value, STREAM_DECODE_OPTS)
if (buffer.length > MAX_BUFFER_BYTES) {
logForDebugging(
`SSETransport: Buffer exceeded ${MAX_BUFFER_BYTES} bytes — dropping connection`,
{ level: 'error' },
)
logForDiagnosticsNoPII('error', 'cli_sse_buffer_overflow')
break
}
const { frames, remaining } = parseSSEFrames(buffer) const { frames, remaining } = parseSSEFrames(buffer)
buffer = remaining buffer = remaining

View File

@@ -305,8 +305,9 @@ import {
import { deserializeMessages } from '../utils/conversationRecovery.js'; import { deserializeMessages } from '../utils/conversationRecovery.js';
import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js';
import { resetMicrocompactState } from '../services/compact/microCompact.js'; import { resetMicrocompactState } from '../services/compact/microCompact.js';
import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'; import { runPostCompactCleanup, registerCompactCleanup } from '../services/compact/postCompactCleanup.js';
import { import {
createContentReplacementState,
provisionContentReplacementState, provisionContentReplacementState,
reconstructContentReplacementState, reconstructContentReplacementState,
type ContentReplacementRecord, type ContentReplacementRecord,
@@ -1778,6 +1779,9 @@ export function REPL({
const [contentReplacementStateRef] = useState(() => ({ const [contentReplacementStateRef] = useState(() => ({
current: provisionContentReplacementState(initialMessages, initialContentReplacements), current: provisionContentReplacementState(initialMessages, initialContentReplacements),
})); }));
registerCompactCleanup(() => {
contentReplacementStateRef.current = createContentReplacementState();
});
const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold); const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold);
const [vimMode, setVimMode] = useState<VimMode>('INSERT'); const [vimMode, setVimMode] = useState<VimMode>('INSERT');

View File

@@ -10,6 +10,17 @@ import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js' import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
import { resetMicrocompactState } from './microCompact.js' import { resetMicrocompactState } from './microCompact.js'
/**
* Compact-scoped cleanup callbacks registered by REPL or other long-lived
* components. Called during runPostCompactCleanup() so instance-scoped state
* (e.g. contentReplacementState) is freed alongside module-level caches.
*/
const compactCleanupCallbacks: Array<() => void> = []
export function registerCompactCleanup(callback: () => void): void {
compactCleanupCallbacks.push(callback)
}
/** /**
* Run cleanup of caches and tracking state after compaction. * Run cleanup of caches and tracking state after compaction.
* Call this after both auto-compact and manual /compact to free memory * Call this after both auto-compact and manual /compact to free memory
@@ -88,4 +99,11 @@ export function runPostCompactCleanup(querySource?: QuerySource): void {
}) })
} }
clearSessionMessagesCache() clearSessionMessagesCache()
for (const cb of compactCleanupCallbacks) {
try {
cb()
} catch (error) {
logError(error)
}
}
} }

View File

@@ -17,7 +17,7 @@ import {
import { CACHE_PATHS } from './cachePaths.js' import { CACHE_PATHS } from './cachePaths.js'
import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js' import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js'
import { isEnvTruthy } from './envUtils.js' import { isEnvTruthy } from './envUtils.js'
import { toError } from './errors.js' import { toError, shortErrorStack } from './errors.js'
import { isEssentialTrafficOnly } from './privacyLevel.js' import { isEssentialTrafficOnly } from './privacyLevel.js'
import { jsonParse } from './slowOperations.js' import { jsonParse } from './slowOperations.js'
@@ -175,7 +175,7 @@ export function logError(error: unknown): void {
return return
} }
const errorStr = err.stack || err.message const errorStr = shortErrorStack(err)
const errorInfo = { const errorInfo = {
error: errorStr, error: errorStr,