fix(anthropic-recovery): fix retry timer memory leak in context window recovery

🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
YeonGyu-Kim
2026-03-31 17:33:24 -07:00
parent 990095d22e
commit 116b1f9e4e
6 changed files with 85 additions and 12 deletions
@@ -90,6 +90,7 @@ describe("executeCompact lock management", () => {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
retryTimerBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
@@ -5,6 +5,7 @@ import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
import { clearSessionState } from "./state"
import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
@@ -17,6 +18,7 @@ function createRecoveryState(): AutoCompactState {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(),
retryTimerBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
@@ -30,7 +32,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const pluginConfig = options?.pluginConfig!
const pluginConfig = options?.pluginConfig ?? {} as OhMyOpenCodeConfig
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -45,12 +47,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
}
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
autoCompactState.compactionInProgress.delete(sessionInfo.id)
clearSessionState(autoCompactState, sessionInfo.id)
}
return
}
@@ -28,6 +28,11 @@ export function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
): void {
const retryTimer = autoCompactState.retryTimerBySession.get(sessionID)
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
autoCompactState.retryTimerBySession.delete(sessionID)
}
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID)
@@ -36,6 +41,26 @@ export function clearSessionState(
autoCompactState.compactionInProgress.delete(sessionID)
}
export function setRetryTimer(
autoCompactState: AutoCompactState,
sessionID: string,
timeout: ReturnType<typeof setTimeout>,
): void {
const existingTimer = autoCompactState.retryTimerBySession.get(sessionID)
if (existingTimer !== undefined) {
clearTimeout(existingTimer)
}
autoCompactState.retryTimerBySession.set(sessionID, timeout)
}
export function clearRetryTimer(autoCompactState: AutoCompactState, sessionID: string): void {
const retryTimer = autoCompactState.retryTimerBySession.get(sessionID)
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
autoCompactState.retryTimerBySession.delete(sessionID)
}
}
export function getEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string,
@@ -12,6 +12,7 @@ function createAutoCompactState(): AutoCompactState {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map<string, RetryState>(),
retryTimerBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
@@ -97,10 +98,11 @@ describe("runSummarizeRetryStrategy", () => {
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 0,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 119900,
firstAttemptTime: Date.now() - 100000,
})
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
@@ -117,6 +119,36 @@ describe("runSummarizeRetryStrategy", () => {
//#then
expect(timeoutCalls.length).toBe(1)
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(300)
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(2000)
})
test("#given pending retry timer after session cleanup #when scheduled callback fires #then it does not recreate retry state", async () => {
//#given
let scheduledCallback: (() => void) | undefined
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => {
scheduledCallback = () => callback()
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
autoCompactState.pendingCompact.add(sessionID)
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID)
//#when
scheduledCallback?.()
//#then
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
})
})
@@ -2,7 +2,13 @@ import type { AutoCompactState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { RETRY_CONFIG } from "./types"
import type { Client } from "./client"
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
import {
clearRetryTimer,
clearSessionState,
getEmptyContentAttempt,
getOrCreateRetryState,
setRetryTimer,
} from "./state"
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery"
@@ -19,6 +25,11 @@ export async function runSummarizeRetryStrategy(params: {
errorType?: string
messageIndex?: number
}): Promise<void> {
if (!params.autoCompactState.pendingCompact.has(params.sessionID)) {
clearRetryTimer(params.autoCompactState, params.sessionID)
return
}
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
const now = Date.now()
@@ -42,6 +53,8 @@ export async function runSummarizeRetryStrategy(params: {
return
}
clearRetryTimer(params.autoCompactState, params.sessionID)
if (params.errorType?.includes("non-empty content")) {
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
if (attempt < 3) {
@@ -52,9 +65,11 @@ export async function runSummarizeRetryStrategy(params: {
messageIndex: params.messageIndex,
})
if (fixed) {
setTimeout(() => {
const timeout = setTimeout(() => {
params.autoCompactState.retryTimerBySession.delete(params.sessionID)
void runSummarizeRetryStrategy(params)
}, 500)
setRetryTimer(params.autoCompactState, params.sessionID, timeout)
return
}
} else {
@@ -138,9 +153,11 @@ export async function runSummarizeRetryStrategy(params: {
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
setTimeout(() => {
const timeout = setTimeout(() => {
params.autoCompactState.retryTimerBySession.delete(params.sessionID)
void runSummarizeRetryStrategy(params)
}, cappedDelay)
setRetryTimer(params.autoCompactState, params.sessionID, timeout)
return
}
} else {
@@ -23,6 +23,7 @@ export interface AutoCompactState {
pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError>
retryStateBySession: Map<string, RetryState>
retryTimerBySession: Map<string, ReturnType<typeof setTimeout>>
truncateStateBySession: Map<string, TruncateState>
emptyContentAttemptBySession: Map<string, number>
compactionInProgress: Set<string>