diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65acb5..447211b75a3 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -12,8 +12,11 @@ import { NamedError } from "@opencode-ai/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" +import { LRUMap } from "../util/lru-map" const DIAGNOSTICS_DEBOUNCE_MS = 150 +// Maximum number of files to track per LSP client to prevent unbounded memory growth +const MAX_TRACKED_FILES = 1000 export namespace LSPClient { const log = Log.create({ service: "lsp.client" }) @@ -48,7 +51,7 @@ export namespace LSPClient { new StreamMessageWriter(input.server.process.stdin as any), ) - const diagnostics = new Map() + const diagnostics = new LRUMap(MAX_TRACKED_FILES) connection.onNotification("textDocument/publishDiagnostics", (params) => { const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) l.info("textDocument/publishDiagnostics", { @@ -132,9 +135,8 @@ export namespace LSPClient { }) } - const files: { - [path: string]: number - } = {} + // Track file versions for LSP synchronization with LRU eviction + const files = new LRUMap(MAX_TRACKED_FILES) const result = { root: input.root, @@ -152,7 +154,7 @@ export namespace LSPClient { const extension = path.extname(input.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[input.path] + const version = files.get(input.path) if (version !== undefined) { log.info("workspace/didChangeWatchedFiles", input) await connection.sendNotification("workspace/didChangeWatchedFiles", { @@ -165,7 +167,7 @@ export namespace LSPClient { }) const next = version + 1 - files[input.path] = next + files.set(input.path, next) log.info("textDocument/didChange", { path: input.path, version: next, @@ -200,7 +202,7 @@ export namespace LSPClient { text, }, }) - files[input.path] = 0 + files.set(input.path, 0) return }, }, diff --git a/packages/opencode/src/util/lru-map.ts b/packages/opencode/src/util/lru-map.ts new file mode 100644 index 00000000000..af192566e0f --- /dev/null +++ b/packages/opencode/src/util/lru-map.ts @@ -0,0 +1,72 @@ +/** + * A Map with LRU (Least Recently Used) eviction policy. + * Uses the fact that JavaScript Maps maintain insertion order. + * When capacity is exceeded, the oldest (first) entries are evicted. + * + * Calling {@link get} on an existing key moves that entry to the most recently + * used position and therefore affects the eviction order. In contrast, + * {@link has} only checks for the presence of a key and does not change the + * recency or eviction order of entries. + */ +export class LRUMap { + private map = new Map() + private readonly capacity: number + + constructor(capacity: number) { + if (!Number.isFinite(capacity) || !Number.isInteger(capacity) || capacity <= 0) { + throw new RangeError(`LRUMap capacity must be a positive integer, got: ${capacity}`) + } + this.capacity = capacity + } + + get(key: K): V | undefined { + if (!this.map.has(key)) { + return undefined + } + const value = this.map.get(key)! + // Move to end (most recently used) + this.map.delete(key) + this.map.set(key, value) + return value + } + + set(key: K, value: V): this { + // Delete first to ensure it goes to end if it exists + this.map.delete(key) + this.map.set(key, value) + // Evict oldest entry if over capacity (can only exceed by 1 since we add one at a time) + if (this.map.size > this.capacity) { + const oldest = this.map.keys().next().value as K + this.map.delete(oldest) + } + return this + } + + has(key: K): boolean { + return this.map.has(key) + } + + delete(key: K): boolean { + return this.map.delete(key) + } + + get size(): number { + return this.map.size + } + + keys(): IterableIterator { + return this.map.keys() + } + + values(): IterableIterator { + return this.map.values() + } + + entries(): IterableIterator<[K, V]> { + return this.map.entries() + } + + clear(): void { + this.map.clear() + } +} diff --git a/packages/opencode/test/util/lru-map.test.ts b/packages/opencode/test/util/lru-map.test.ts new file mode 100644 index 00000000000..ab81cddb27b --- /dev/null +++ b/packages/opencode/test/util/lru-map.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test" +import { LRUMap } from "../../src/util/lru-map" + +describe("LRUMap", () => { + test("basic get and set operations", () => { + const map = new LRUMap(10) + map.set("a", 1) + map.set("b", 2) + expect(map.get("a")).toBe(1) + expect(map.get("b")).toBe(2) + expect(map.size).toBe(2) + }) + + test("has returns correct value", () => { + const map = new LRUMap(10) + map.set("a", 1) + expect(map.has("a")).toBe(true) + expect(map.has("b")).toBe(false) + }) + + test("delete removes entry", () => { + const map = new LRUMap(10) + map.set("a", 1) + expect(map.has("a")).toBe(true) + map.delete("a") + expect(map.has("a")).toBe(false) + expect(map.size).toBe(0) + }) + + test("evicts oldest entries when over capacity", () => { + const map = new LRUMap(3) + map.set("a", 1) + map.set("b", 2) + map.set("c", 3) + expect(map.size).toBe(3) + + // Adding a 4th entry should evict the oldest ("a") + map.set("d", 4) + expect(map.size).toBe(3) + expect(map.has("a")).toBe(false) + expect(map.has("b")).toBe(true) + expect(map.has("c")).toBe(true) + expect(map.has("d")).toBe(true) + }) + + test("get moves entry to end (most recently used)", () => { + const map = new LRUMap(3) + map.set("a", 1) + map.set("b", 2) + map.set("c", 3) + + // Access "a" to make it most recently used + map.get("a") + + // Adding "d" should now evict "b" (oldest after "a" was accessed) + map.set("d", 4) + expect(map.has("a")).toBe(true) + expect(map.has("b")).toBe(false) + expect(map.has("c")).toBe(true) + expect(map.has("d")).toBe(true) + }) + + test("set updates value and moves to end", () => { + const map = new LRUMap(3) + map.set("a", 1) + map.set("b", 2) + map.set("c", 3) + + // Update "a" to make it most recently used + map.set("a", 100) + expect(map.get("a")).toBe(100) + + // Adding "d" should evict "b" + map.set("d", 4) + expect(map.has("a")).toBe(true) + expect(map.has("b")).toBe(false) + expect(map.has("c")).toBe(true) + expect(map.has("d")).toBe(true) + }) + + test("clear removes all entries", () => { + const map = new LRUMap(10) + map.set("a", 1) + map.set("b", 2) + map.clear() + expect(map.size).toBe(0) + expect(map.has("a")).toBe(false) + }) + + test("iterators work correctly", () => { + const map = new LRUMap(10) + map.set("a", 1) + map.set("b", 2) + + expect([...map.keys()]).toEqual(["a", "b"]) + expect([...map.values()]).toEqual([1, 2]) + expect([...map.entries()]).toEqual([ + ["a", 1], + ["b", 2], + ]) + }) + + test("handles capacity of 1", () => { + const map = new LRUMap(1) + map.set("a", 1) + expect(map.get("a")).toBe(1) + map.set("b", 2) + expect(map.has("a")).toBe(false) + expect(map.get("b")).toBe(2) + expect(map.size).toBe(1) + }) + + test("handles falsy but valid values (0, false, empty string)", () => { + // Test with 0 + const numMap = new LRUMap(3) + numMap.set("a", 0) + numMap.set("b", 1) + numMap.set("c", 2) + expect(numMap.get("a")).toBe(0) + + // After accessing "a", it should be most recently used + // Adding "d" should evict "b" (not "a") + numMap.set("d", 3) + expect(numMap.has("a")).toBe(true) + expect(numMap.has("b")).toBe(false) + + // Test with false + const boolMap = new LRUMap(3) + boolMap.set("a", false) + boolMap.set("b", true) + boolMap.set("c", true) + expect(boolMap.get("a")).toBe(false) + + boolMap.set("d", true) + expect(boolMap.has("a")).toBe(true) + expect(boolMap.has("b")).toBe(false) + + // Test with empty string + const strMap = new LRUMap(3) + strMap.set("a", "") + strMap.set("b", "x") + strMap.set("c", "y") + expect(strMap.get("a")).toBe("") + + strMap.set("d", "z") + expect(strMap.has("a")).toBe(true) + expect(strMap.has("b")).toBe(false) + }) + + test("throws on invalid capacity values", () => { + expect(() => new LRUMap(0)).toThrow(RangeError) + expect(() => new LRUMap(-1)).toThrow(RangeError) + expect(() => new LRUMap(1.5)).toThrow(RangeError) + expect(() => new LRUMap(NaN)).toThrow(RangeError) + expect(() => new LRUMap(Infinity)).toThrow(RangeError) + }) + + test("handles large number of entries", () => { + const capacity = 100 + const map = new LRUMap(capacity) + + // Add more entries than capacity + for (let i = 0; i < 200; i++) { + map.set(i, i * 2) + } + + expect(map.size).toBe(capacity) + // First 100 entries should be evicted + expect(map.has(0)).toBe(false) + expect(map.has(99)).toBe(false) + // Last 100 entries should remain + expect(map.has(100)).toBe(true) + expect(map.has(199)).toBe(true) + expect(map.get(150)).toBe(300) + }) +})