Merge pull request #2458 from code-yeongyu/fix/memory-leaks
fix: resolve 12 memory leaks (3 critical + 9 high)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import { AUTO_SLASH_COMMAND_TAG_OPEN } from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
import * as shared from "../../shared"
|
||||
|
||||
const executeSlashCommandMock = mock(
|
||||
async (parsed: { command: string; args: string; raw: string }) => ({
|
||||
success: true,
|
||||
replacementText: parsed.raw,
|
||||
})
|
||||
)
|
||||
|
||||
mock.module("./executor", () => ({
|
||||
executeSlashCommand: executeSlashCommandMock,
|
||||
}))
|
||||
|
||||
const logMock = spyOn(shared, "log").mockImplementation(() => {})
|
||||
|
||||
const { createAutoSlashCommandHook } = await import("./hook")
|
||||
|
||||
function createChatInput(sessionID: string, messageID: string): AutoSlashCommandHookInput {
|
||||
return {
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
function createChatOutput(text: string): AutoSlashCommandHookOutput {
|
||||
return {
|
||||
message: {},
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandInput(sessionID: string, command: string): CommandExecuteBeforeInput {
|
||||
return {
|
||||
sessionID,
|
||||
command,
|
||||
arguments: "",
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandOutput(text: string): CommandExecuteBeforeOutput {
|
||||
return {
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAutoSlashCommandHook leak prevention", () => {
|
||||
beforeEach(() => {
|
||||
executeSlashCommandMock.mockClear()
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
describe("#given hook with sessionProcessedCommandExecutions", () => {
|
||||
describe("#when same command executed twice for same session", () => {
|
||||
it("#then second execution is deduplicated", async () => {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("session-dedup", "leak-test-command")
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toBe("second")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given hook with entries from multiple sessions", () => {
|
||||
describe("#when dispose() is called", () => {
|
||||
it("#then both Sets are empty", async () => {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
await hook["chat.message"](
|
||||
createChatInput("session-chat", "message-chat"),
|
||||
createChatOutput("/leak-chat")
|
||||
)
|
||||
await hook["command.execute.before"](
|
||||
createCommandInput("session-command", "leak-command"),
|
||||
createCommandOutput("before")
|
||||
)
|
||||
executeSlashCommandMock.mockClear()
|
||||
|
||||
hook.dispose()
|
||||
const chatOutputAfterDispose = createChatOutput("/leak-chat")
|
||||
const commandOutputAfterDispose = createCommandOutput("after")
|
||||
await hook["chat.message"](
|
||||
createChatInput("session-chat", "message-chat"),
|
||||
chatOutputAfterDispose
|
||||
)
|
||||
await hook["command.execute.before"](
|
||||
createCommandInput("session-command", "leak-command"),
|
||||
commandOutputAfterDispose
|
||||
)
|
||||
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
|
||||
expect(chatOutputAfterDispose.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(commandOutputAfterDispose.parts[0].text).toContain(
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Set with more than 10000 entries", () => {
|
||||
describe("#when new entry added", () => {
|
||||
it("#then Set size is reduced", async () => {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const oldestInput = createChatInput("session-oldest", "message-oldest")
|
||||
await hook["chat.message"](oldestInput, createChatOutput("/leak-oldest"))
|
||||
|
||||
for (let index = 0; index < 10000; index += 1) {
|
||||
await hook["chat.message"](
|
||||
createChatInput(`session-${index}`, `message-${index}`),
|
||||
createChatOutput(`/leak-${index}`)
|
||||
)
|
||||
}
|
||||
|
||||
const newestInput = createChatInput("session-newest", "message-newest")
|
||||
await hook["chat.message"](newestInput, createChatOutput("/leak-newest"))
|
||||
executeSlashCommandMock.mockClear()
|
||||
const oldestRetryOutput = createChatOutput("/leak-oldest")
|
||||
const newestRetryOutput = createChatOutput("/leak-newest")
|
||||
|
||||
await hook["chat.message"](oldestInput, oldestRetryOutput)
|
||||
await hook["chat.message"](newestInput, newestRetryOutput)
|
||||
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(oldestRetryOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(newestRetryOutput.parts[0].text).toBe("/leak-newest")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
} from "./constants"
|
||||
import { createProcessedCommandStore } from "./processed-command-store"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
@@ -17,8 +18,22 @@ import type {
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
const sessionProcessedCommandExecutions = new Set<string>()
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | null {
|
||||
if (!isRecord(properties)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = properties.info
|
||||
if (!isRecord(info)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof info.id === "string" ? info.id : null
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
@@ -32,6 +47,13 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
pluginsEnabled: options?.pluginsEnabled,
|
||||
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||
}
|
||||
const sessionProcessedCommands = createProcessedCommandStore()
|
||||
const sessionProcessedCommandExecutions = createProcessedCommandStore()
|
||||
|
||||
const dispose = (): void => {
|
||||
sessionProcessedCommands.clear()
|
||||
sessionProcessedCommandExecutions.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -61,7 +83,9 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
const commandKey = input.messageID
|
||||
? `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
: `${input.sessionID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
@@ -101,7 +125,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
input: CommandExecuteBeforeInput,
|
||||
output: CommandExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
const commandKey = `${input.sessionID}:${input.command}:${Date.now()}`
|
||||
const commandKey = `${input.sessionID}:${input.command.toLowerCase()}:${input.arguments || ""}`
|
||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
@@ -145,5 +169,23 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
command: input.command,
|
||||
})
|
||||
},
|
||||
event: async ({
|
||||
event,
|
||||
}: {
|
||||
event: { type: string; properties?: unknown }
|
||||
}): Promise<void> => {
|
||||
if (event.type !== "session.deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionID = getDeletedSessionID(event.properties)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommands.cleanupSession(sessionID)
|
||||
sessionProcessedCommandExecutions.cleanupSession(sessionID)
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const MAX_PROCESSED_ENTRY_COUNT = 10_000
|
||||
|
||||
function trimProcessedEntries(entries: Set<string>): Set<string> {
|
||||
if (entries.size <= MAX_PROCESSED_ENTRY_COUNT) {
|
||||
return entries
|
||||
}
|
||||
|
||||
return new Set(Array.from(entries).slice(Math.floor(entries.size / 2)))
|
||||
}
|
||||
|
||||
function removeSessionEntries(entries: Set<string>, sessionID: string): Set<string> {
|
||||
const sessionPrefix = `${sessionID}:`
|
||||
return new Set(Array.from(entries).filter((entry) => !entry.startsWith(sessionPrefix)))
|
||||
}
|
||||
|
||||
export interface ProcessedCommandStore {
|
||||
has(commandKey: string): boolean
|
||||
add(commandKey: string): void
|
||||
cleanupSession(sessionID: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export function createProcessedCommandStore(): ProcessedCommandStore {
|
||||
let entries = new Set<string>()
|
||||
|
||||
return {
|
||||
has(commandKey: string): boolean {
|
||||
return entries.has(commandKey)
|
||||
},
|
||||
add(commandKey: string): void {
|
||||
entries.add(commandKey)
|
||||
entries = trimProcessedEntries(entries)
|
||||
},
|
||||
cleanupSession(sessionID: string): void {
|
||||
entries = removeSessionEntries(entries, sessionID)
|
||||
},
|
||||
clear(): void {
|
||||
entries.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HookDeps } from "./types"
|
||||
import type { HookDeps, RuntimeFallbackTimeout } from "./types"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
|
||||
@@ -9,8 +9,8 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
|
||||
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
|
||||
|
||||
export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
|
||||
|
||||
let capturedDeps: HookDeps | undefined
|
||||
|
||||
const mockCreateAutoRetryHelpers = mock((deps: HookDeps) => {
|
||||
capturedDeps = deps
|
||||
|
||||
return {
|
||||
abortSessionRequest: async () => {},
|
||||
clearSessionFallbackTimeout: () => {},
|
||||
scheduleSessionFallbackTimeout: () => {},
|
||||
autoRetryWithFallback: async () => {},
|
||||
resolveAgentForSessionFromContext: async () => undefined,
|
||||
cleanupStaleSessions: () => {},
|
||||
}
|
||||
})
|
||||
|
||||
const mockCreateEventHandler = mock(() => async () => {})
|
||||
const mockCreateMessageUpdateHandler = mock(() => async () => {})
|
||||
const mockCreateChatMessageHandler = mock(() => async () => {})
|
||||
|
||||
mock.module("./auto-retry", () => ({
|
||||
createAutoRetryHelpers: mockCreateAutoRetryHelpers,
|
||||
}))
|
||||
|
||||
mock.module("./event-handler", () => ({
|
||||
createEventHandler: mockCreateEventHandler,
|
||||
}))
|
||||
|
||||
mock.module("./message-update-handler", () => ({
|
||||
createMessageUpdateHandler: mockCreateMessageUpdateHandler,
|
||||
}))
|
||||
|
||||
mock.module("./chat-message-handler", () => ({
|
||||
createChatMessageHandler: mockCreateChatMessageHandler,
|
||||
}))
|
||||
|
||||
const { createRuntimeFallbackHook } = await import("./hook")
|
||||
|
||||
function createMockContext(): RuntimeFallbackPluginInput {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
directory: "/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("createRuntimeFallbackHook dispose", () => {
|
||||
const originalSetInterval = globalThis.setInterval
|
||||
const originalClearInterval = globalThis.clearInterval
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const createdIntervals: Array<ReturnType<typeof originalSetInterval>> = []
|
||||
const clearedIntervals: Array<Parameters<typeof originalClearInterval>[0]> = []
|
||||
const clearedTimeouts: Array<Parameters<typeof originalClearTimeout>[0]> = []
|
||||
const timeoutMapSizesDuringClear: number[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
capturedDeps = undefined
|
||||
createdIntervals.length = 0
|
||||
clearedIntervals.length = 0
|
||||
clearedTimeouts.length = 0
|
||||
timeoutMapSizesDuringClear.length = 0
|
||||
|
||||
mockCreateAutoRetryHelpers.mockClear()
|
||||
mockCreateEventHandler.mockClear()
|
||||
mockCreateMessageUpdateHandler.mockClear()
|
||||
mockCreateChatMessageHandler.mockClear()
|
||||
|
||||
const wrappedSetInterval = ((handler: () => void, timeout?: number) => {
|
||||
const interval = originalSetInterval(handler, timeout)
|
||||
createdIntervals.push(interval)
|
||||
return interval
|
||||
}) as typeof globalThis.setInterval
|
||||
|
||||
const wrappedClearInterval = ((interval?: Parameters<typeof clearInterval>[0]) => {
|
||||
clearedIntervals.push(interval)
|
||||
return originalClearInterval(interval)
|
||||
}) as typeof globalThis.clearInterval
|
||||
|
||||
const wrappedClearTimeout = ((timeout?: Parameters<typeof clearTimeout>[0]) => {
|
||||
timeoutMapSizesDuringClear.push(capturedDeps?.sessionFallbackTimeouts.size ?? -1)
|
||||
clearedTimeouts.push(timeout)
|
||||
return originalClearTimeout(timeout)
|
||||
}) as typeof globalThis.clearTimeout
|
||||
|
||||
globalThis.setInterval = wrappedSetInterval
|
||||
globalThis.clearInterval = wrappedClearInterval
|
||||
globalThis.clearTimeout = wrappedClearTimeout
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.setInterval = originalSetInterval
|
||||
globalThis.clearInterval = originalClearInterval
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
})
|
||||
|
||||
test("#given runtime-fallback hook created #when dispose() is called #then cleanup interval is cleared", () => {
|
||||
// given
|
||||
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
|
||||
|
||||
// when
|
||||
hook.dispose?.()
|
||||
|
||||
// then
|
||||
expect(createdIntervals).toHaveLength(1)
|
||||
expect(clearedIntervals).toEqual([createdIntervals[0]])
|
||||
})
|
||||
|
||||
test("#given hook with session state data #when dispose() is called #then all Maps and Sets are empty", () => {
|
||||
// given
|
||||
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
|
||||
const fallbackTimeout = setTimeout(() => {}, 60_000)
|
||||
|
||||
capturedDeps?.sessionStates.set("session-1", {
|
||||
originalModel: "anthropic/claude-opus-4-6",
|
||||
currentModel: "openai/gpt-5.4",
|
||||
fallbackIndex: 1,
|
||||
failedModels: new Map([["anthropic/claude-opus-4-6", 1]]),
|
||||
attemptCount: 1,
|
||||
})
|
||||
capturedDeps?.sessionLastAccess.set("session-1", Date.now())
|
||||
capturedDeps?.sessionRetryInFlight.add("session-1")
|
||||
capturedDeps?.sessionAwaitingFallbackResult.add("session-1")
|
||||
capturedDeps?.sessionFallbackTimeouts.set("session-1", fallbackTimeout)
|
||||
|
||||
// when
|
||||
hook.dispose?.()
|
||||
|
||||
// then
|
||||
expect(capturedDeps?.sessionStates.size).toBe(0)
|
||||
expect(capturedDeps?.sessionLastAccess.size).toBe(0)
|
||||
expect(capturedDeps?.sessionRetryInFlight.size).toBe(0)
|
||||
expect(capturedDeps?.sessionAwaitingFallbackResult.size).toBe(0)
|
||||
expect(capturedDeps?.sessionFallbackTimeouts.size).toBe(0)
|
||||
})
|
||||
|
||||
test("#given hook with pending fallback timeouts #when dispose() is called #then timeouts are cleared before Map is emptied", () => {
|
||||
// given
|
||||
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
|
||||
const fallbackTimeout = setTimeout(() => {}, 60_000)
|
||||
capturedDeps?.sessionFallbackTimeouts.set("session-1", fallbackTimeout)
|
||||
|
||||
// when
|
||||
hook.dispose?.()
|
||||
|
||||
// then
|
||||
expect(clearedTimeouts).toEqual([fallbackTimeout])
|
||||
expect(timeoutMapSizesDuringClear).toEqual([1])
|
||||
expect(capturedDeps?.sessionFallbackTimeouts.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackOptions } from "./types"
|
||||
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
|
||||
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { loadPluginConfig } from "../../plugin-config"
|
||||
@@ -8,8 +7,12 @@ import { createEventHandler } from "./event-handler"
|
||||
import { createMessageUpdateHandler } from "./message-update-handler"
|
||||
import { createChatMessageHandler } from "./chat-message-handler"
|
||||
|
||||
declare function setInterval(callback: () => void, delay?: number): RuntimeFallbackInterval
|
||||
declare function clearInterval(interval: RuntimeFallbackInterval): void
|
||||
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
|
||||
|
||||
export function createRuntimeFallbackHook(
|
||||
ctx: PluginInput,
|
||||
ctx: RuntimeFallbackPluginInput,
|
||||
options?: RuntimeFallbackOptions
|
||||
): RuntimeFallbackHook {
|
||||
const config = {
|
||||
@@ -60,8 +63,23 @@ export function createRuntimeFallbackHook(
|
||||
await baseEventHandler({ event })
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
clearInterval(cleanupInterval)
|
||||
|
||||
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
|
||||
clearTimeout(fallbackTimeout)
|
||||
}
|
||||
|
||||
deps.sessionStates.clear()
|
||||
deps.sessionLastAccess.clear()
|
||||
deps.sessionRetryInFlight.clear()
|
||||
deps.sessionAwaitingFallbackResult.clear()
|
||||
deps.sessionFallbackTimeouts.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
"chat.message": chatMessageHandler,
|
||||
dispose,
|
||||
} as RuntimeFallbackHook
|
||||
}
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
|
||||
export interface RuntimeFallbackInterval {
|
||||
unref: () => void
|
||||
}
|
||||
|
||||
export type RuntimeFallbackTimeout = object | number
|
||||
|
||||
export interface RuntimeFallbackPluginInput {
|
||||
client: {
|
||||
session: {
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>
|
||||
messages: (input: { path: { id: string }; query: { directory: string } }) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: {
|
||||
agent?: string
|
||||
model: { providerID: string; modelID: string }
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
showToast: (input: {
|
||||
body: {
|
||||
title: string
|
||||
message: string
|
||||
variant: "success" | "error" | "info" | "warning"
|
||||
duration: number
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
}
|
||||
|
||||
export interface FallbackState {
|
||||
originalModel: string
|
||||
currentModel: string
|
||||
@@ -26,10 +60,11 @@ export interface RuntimeFallbackOptions {
|
||||
export interface RuntimeFallbackHook {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
"chat.message"?: (input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, output: { message: { model?: { providerID: string; modelID: string } }; parts?: Array<{ type: string; text?: string }> }) => Promise<void>
|
||||
dispose?: () => void
|
||||
}
|
||||
|
||||
export interface HookDeps {
|
||||
ctx: PluginInput
|
||||
ctx: RuntimeFallbackPluginInput
|
||||
config: Required<RuntimeFallbackConfig>
|
||||
options: RuntimeFallbackOptions | undefined
|
||||
pluginConfig: OhMyOpenCodeConfig | undefined
|
||||
@@ -37,5 +72,5 @@ export interface HookDeps {
|
||||
sessionLastAccess: Map<string, number>
|
||||
sessionRetryInFlight: Set<string>
|
||||
sessionAwaitingFallbackResult: Set<string>
|
||||
sessionFallbackTimeouts: Map<string, ReturnType<typeof setTimeout>>
|
||||
sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
declare module "bun:test" {
|
||||
export interface Matchers {
|
||||
toBeDefined(): void
|
||||
toBeUndefined(): void
|
||||
toHaveLength(expected: number): void
|
||||
}
|
||||
}
|
||||
|
||||
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import * as actualSessionStateModule from "./session-state"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
|
||||
let createdSessionStateStore: SessionStateStore | undefined
|
||||
const createActualSessionStateStore = actualSessionStateModule.createSessionStateStore
|
||||
|
||||
const mockModule = mock as typeof mock & {
|
||||
module: (specifier: string, factory: () => unknown) => void
|
||||
}
|
||||
|
||||
mockModule.module("./session-state", () => ({
|
||||
...actualSessionStateModule,
|
||||
createSessionStateStore: () => {
|
||||
const sessionStateStore = createActualSessionStateStore()
|
||||
createdSessionStateStore = sessionStateStore
|
||||
return sessionStateStore
|
||||
},
|
||||
}))
|
||||
|
||||
const { createTodoContinuationEnforcer } = await import(".")
|
||||
|
||||
type PluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
|
||||
function createMockPluginInput(): PluginInput {
|
||||
return {
|
||||
directory: "/tmp/test",
|
||||
} as PluginInput
|
||||
}
|
||||
|
||||
function getCreatedSessionStateStore(): SessionStateStore {
|
||||
if (!createdSessionStateStore) {
|
||||
throw new Error("expected session state store to be created")
|
||||
}
|
||||
|
||||
return createdSessionStateStore
|
||||
}
|
||||
|
||||
describe("todo-continuation-enforcer dispose", () => {
|
||||
afterEach(() => {
|
||||
createdSessionStateStore?.shutdown()
|
||||
createdSessionStateStore = undefined
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mockModule.module("./session-state", () => actualSessionStateModule)
|
||||
})
|
||||
|
||||
it("#given todo-continuation-enforcer created #when dispose exists on return value #then it is a function", () => {
|
||||
// given
|
||||
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
|
||||
|
||||
// when
|
||||
const { dispose } = enforcer
|
||||
|
||||
// then
|
||||
expect(typeof dispose).toBe("function")
|
||||
|
||||
enforcer.dispose()
|
||||
})
|
||||
|
||||
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", () => {
|
||||
// given
|
||||
const originalClearInterval = globalThis.clearInterval
|
||||
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
|
||||
globalThis.clearInterval = ((timer?: Parameters<typeof clearInterval>[0]) => {
|
||||
clearIntervalCalls.push(timer)
|
||||
return originalClearInterval(timer)
|
||||
}) as typeof clearInterval
|
||||
|
||||
try {
|
||||
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
|
||||
const sessionStateStore = getCreatedSessionStateStore()
|
||||
|
||||
enforcer.markRecovering("session-1")
|
||||
enforcer.markRecovering("session-2")
|
||||
|
||||
expect(sessionStateStore.getExistingState("session-1")).toBeDefined()
|
||||
expect(sessionStateStore.getExistingState("session-2")).toBeDefined()
|
||||
|
||||
// when
|
||||
enforcer.dispose()
|
||||
|
||||
// then
|
||||
expect(clearIntervalCalls).toHaveLength(1)
|
||||
expect(sessionStateStore.getExistingState("session-1")).toBeUndefined()
|
||||
expect(sessionStateStore.getExistingState("session-2")).toBeUndefined()
|
||||
} finally {
|
||||
globalThis.clearInterval = originalClearInterval
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -56,5 +56,6 @@ export function createTodoContinuationEnforcer(
|
||||
markRecovering,
|
||||
markRecoveryComplete,
|
||||
cancelAllCountdowns,
|
||||
dispose: () => sessionStateStore.shutdown(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TodoContinuationEnforcer {
|
||||
markRecovering: (sessionID: string) => void
|
||||
markRecoveryComplete: (sessionID: string) => void
|
||||
cancelAllCountdowns: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
|
||||
Reference in New Issue
Block a user