From 707c0c9d55b8a1c9a23396c96668f316ffa208cd Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 17 Jan 2026 23:46:08 +0100 Subject: [PATCH 1/4] fix: Add LRU bounds to unbounded caches (memory leaks) Add bounded LRU cache implementation to prevent unbounded memory growth in long-running sessions. Changes: - Create new createLruCache utility in src/util/cache.ts with: - maxEntries limit for bounded cache size - LRU eviction policy when limit is reached - Optional onEvict callback for cleanup - SDK cache: limited to 50 entries with LRU eviction - Languages cache: limited to 100 entries with LRU eviction - Instance cache: limited to 20 entries with LRU eviction These caches previously grew unbounded, contributing to 70GB+ memory usage in long-running sessions. --- packages/opencode/src/project/instance.ts | 5 +- packages/opencode/src/provider/provider.ts | 21 ++++-- packages/opencode/src/util/cache.ts | 76 ++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/src/util/cache.ts diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ddaa90f1e2b..4a639c97a3e 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,9 @@ interface Context { project: Project.Info } const context = Context.create("instance") -const cache = new Map>() +const cache = createLruCache>({ + maxEntries: 20, +}) 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..268a34bfeb8 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, } }) @@ -980,7 +991,7 @@ export namespace Provider { const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options })) const existing = s.sdk.get(key) - if (existing) return existing + if (existing) return existing.value const customFetch = options["fetch"] @@ -1034,7 +1045,7 @@ export namespace Provider { name: model.providerID, ...options, }) - s.sdk.set(key, loaded) + s.sdk.set(key, loaded.value) return loaded as SDK } @@ -1053,7 +1064,7 @@ export namespace Provider { name: model.providerID, ...options, }) - s.sdk.set(key, loaded) + s.sdk.set(key, loaded.value) return loaded as SDK } catch (e) { throw new InitError({ providerID: model.providerID }, { cause: e }) diff --git a/packages/opencode/src/util/cache.ts b/packages/opencode/src/util/cache.ts new file mode 100644 index 00000000000..bcf1618c65b --- /dev/null +++ b/packages/opencode/src/util/cache.ts @@ -0,0 +1,76 @@ +/** + * 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 + }, + } +} From 8ff6be62d9156a668b8d330856d42aff03b60d87 Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 17 Jan 2026 23:49:05 +0100 Subject: [PATCH 2/4] feat(plugin): Add dispose() method to Hooks interface Add optional dispose callback to plugin Hooks interface to allow plugins to clean up resources when the Instance is disposed. --- packages/plugin/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 } From d2b08cb684481b11963bfd5a301d65789aa64f81 Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 17 Jan 2026 23:49:15 +0100 Subject: [PATCH 3/4] fix(instance): Add onEvict callback for proper Instance disposal When LRU cache evicts an Instance, call State.dispose() to clean up associated resources. Prevents memory leaks from evicted instances. --- packages/opencode/src/project/instance.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4a639c97a3e..ca481b63232 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -15,6 +15,14 @@ interface Context { const context = Context.create("instance") 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 = { From fd45d0beff8cdef1972c9a2d82b9132334f5645c Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 17 Jan 2026 23:49:26 +0100 Subject: [PATCH 4/4] fix(disposal): Complete Instance disposal chain for bootstrap and plugins Ensure proper cleanup when Instance is disposed: - Call plugin dispose() hooks - Clean up bootstrap subscriptions - Properly chain disposal through all components This completes the Instance lifecycle management, preventing memory leaks from orphaned subscriptions and plugin resources. --- packages/opencode/src/plugin/index.ts | 13 ++++++++++--- packages/opencode/src/project/bootstrap.ts | 21 +++++++++++++++------ packages/opencode/src/provider/provider.ts | 6 +++--- packages/opencode/src/util/cache.ts | 10 ++++++++++ 4 files changed, 38 insertions(+), 12 deletions(-) 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/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 268a34bfeb8..2661f034428 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -991,7 +991,7 @@ export namespace Provider { const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options })) const existing = s.sdk.get(key) - if (existing) return existing.value + if (existing) return existing const customFetch = options["fetch"] @@ -1045,7 +1045,7 @@ export namespace Provider { name: model.providerID, ...options, }) - s.sdk.set(key, loaded.value) + s.sdk.set(key, loaded) return loaded as SDK } @@ -1064,7 +1064,7 @@ export namespace Provider { name: model.providerID, ...options, }) - s.sdk.set(key, loaded.value) + s.sdk.set(key, loaded) return loaded as SDK } catch (e) { throw new InitError({ providerID: model.providerID }, { cause: e }) diff --git a/packages/opencode/src/util/cache.ts b/packages/opencode/src/util/cache.ts index bcf1618c65b..107e72172b6 100644 --- a/packages/opencode/src/util/cache.ts +++ b/packages/opencode/src/util/cache.ts @@ -72,5 +72,15 @@ export function createLruCache(opts: LruCacheOpts = {}) { 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]() + }, } }