fix(anthropic-recovery): fix retry timer memory leak in context window recovery
🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user