diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e65d21bfd60..49ac8310a0b 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -100,6 +100,13 @@ export namespace Plugin { return { hooks, input, + unsubscribe: undefined as (() => void) | undefined, + } + }, + async (state) => { + state.unsubscribe?.() + for (const hook of state.hooks) { + await hook.dispose?.() } }) @@ -125,13 +132,13 @@ export namespace Plugin { } export async function init() { - const hooks = await state().then((x) => x.hooks) + const s = await state() const config = await Config.get() - for (const hook of hooks) { + for (const hook of s.hooks) { // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } - Bus.subscribeAll(async (input) => { + s.unsubscribe = Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) for (const hook of hooks) { hook["event"]?.({ diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f85..28f54d177d8 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -13,6 +13,20 @@ import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +const commandSubscription = Instance.state( + () => { + const unsubscribe = Bus.subscribe(Command.Event.Executed, async (payload) => { + if (payload.properties.name === Command.Default.INIT) { + await Project.setInitialized(Instance.project.id) + } + }) + return { unsubscribe } + }, + async (state) => { + state.unsubscribe() + }, +) + export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() @@ -24,10 +38,5 @@ export async function InstanceBootstrap() { Vcs.init() Snapshot.init() Truncate.init() - - Bus.subscribe(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - await Project.setInitialized(Instance.project.id) - } - }) + commandSubscription() } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f..a1938cd6849 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,7 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" +import { createLruCache } from "@/util/cache" interface Context { directory: string @@ -12,7 +13,17 @@ interface Context { project: Project.Info } const context = Context.create("instance") -const cache = new Map>() +const cache = createLruCache>({ + maxEntries: 20, + onEvict: async (_key, value) => { + const ctx = await value.catch(() => null) + if (ctx) { + await context.provide(ctx, async () => { + await State.dispose(ctx.directory) + }) + } + }, +}) const disposal = { all: undefined as Promise | undefined, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 022ec316795..96f798b2c45 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -17,6 +17,7 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" +import { createLruCache } from "@/util/cache" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -770,7 +771,9 @@ export namespace Provider { } const providers: { [providerID: string]: Info } = {} - const languages = new Map() + const languages = createLruCache({ + maxEntries: 100, + }) const modelLoaders: { [providerID: string]: CustomModelLoader } = {} @@ -1029,7 +1032,15 @@ export namespace Provider { return { models: languages, providers, - sdk, + sdk: createLruCache({ + maxEntries: 50, + onEvict: (key, sdk) => { + // SDK may have cleanup methods + if (sdk && typeof sdk === "object" && "destroy" in sdk) { + sdk.destroy?.() + } + }, + }), modelLoaders, } }) diff --git a/packages/opencode/src/util/cache.ts b/packages/opencode/src/util/cache.ts new file mode 100644 index 00000000000..107e72172b6 --- /dev/null +++ b/packages/opencode/src/util/cache.ts @@ -0,0 +1,86 @@ +/** + * LRU cache with max entries limit for preventing memory leaks + */ + +export type LruCacheOpts = { + maxEntries?: number + onEvict?: (key: any, value: any) => void +} + +type LruCacheEntry = { + value: V + lastAccess: number +} + +export function createLruCache(opts: LruCacheOpts = {}) { + const { maxEntries = Infinity, onEvict } = opts + const cache = new Map>() + + function evictOne() { + let oldestKey: K | null = null + let oldestAccess = Infinity + + for (const [key, entry] of cache) { + if (entry.lastAccess < oldestAccess) { + oldestAccess = entry.lastAccess + oldestKey = key + } + } + + if (oldestKey !== null) { + delete_(oldestKey) + } + } + + function delete_(key: K): boolean { + const entry = cache.get(key) + if (!entry) return false + onEvict?.(key, entry.value) + return cache.delete(key) + } + + return { + get(key: K): V | undefined { + const entry = cache.get(key) + if (!entry) return undefined + entry.lastAccess = Date.now() + return entry.value + }, + + set(key: K, value: V): void { + if (cache.size >= maxEntries && !cache.has(key)) { + evictOne() + } + cache.set(key, { value, lastAccess: Date.now() }) + }, + + has(key: K): boolean { + return cache.has(key) + }, + + delete(key: K): boolean { + return delete_(key) + }, + + clear(): void { + for (const [key, entry] of cache) { + onEvict?.(key, entry.value) + } + cache.clear() + }, + + get size() { + return cache.size + }, + + *[Symbol.iterator](): IterableIterator<[K, V]> { + for (const [key, entry] of cache) { + yield [key, entry.value] + } + }, + + entries(): IterableIterator<[K, V]> { + return this[Symbol.iterator]() + }, + } +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 76370d1d5a7..60e6ead0d8f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -231,4 +231,8 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + /** + * Called when the plugin is being disposed/cleaned up + */ + dispose?: () => Promise }