diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d..0ba8e4ba0dd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -92,6 +92,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?.() } }) @@ -117,13 +124,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 56fe4d13e66..8b4cc82d668 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,20 @@ import { Vcs } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" +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() @@ -22,10 +36,5 @@ export async function InstanceBootstrap() { FileWatcher.init() File.init() Vcs.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 ddaa90f1e2b..ca481b63232 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) + }) + } + }, +}) export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index bcb115edf41..2661f034428 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,6 +13,7 @@ import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" +import { createLruCache } from "@/util/cache" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -680,7 +681,9 @@ export namespace Provider { } const providers: { [providerID: string]: Info } = {} - const languages = new Map() + const languages = createLruCache({ + maxEntries: 100, + }) const modelLoaders: { [providerID: string]: CustomModelLoader } = {} @@ -948,7 +951,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 e57eff579e6..261dc744075 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -215,4 +215,8 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Called when the plugin is being disposed/cleaned up + */ + dispose?: () => Promise }