Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/opencode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -48,7 +51,7 @@ export namespace LSPClient {
new StreamMessageWriter(input.server.process.stdin as any),
)

const diagnostics = new Map<string, Diagnostic[]>()
const diagnostics = new LRUMap<string, Diagnostic[]>(MAX_TRACKED_FILES)
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
l.info("textDocument/publishDiagnostics", {
Expand Down Expand Up @@ -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<string, number>(MAX_TRACKED_FILES)

const result = {
root: input.root,
Expand All @@ -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", {
Expand All @@ -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,
Expand Down Expand Up @@ -200,7 +202,7 @@ export namespace LSPClient {
text,
},
})
files[input.path] = 0
files.set(input.path, 0)
return
},
},
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/src/util/lru-map.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> {
private map = new Map<K, V>()
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<K> {
return this.map.keys()
}

values(): IterableIterator<V> {
return this.map.values()
}

entries(): IterableIterator<[K, V]> {
return this.map.entries()
}

clear(): void {
this.map.clear()
}
}
176 changes: 176 additions & 0 deletions packages/opencode/test/util/lru-map.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, boolean>(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<string, string>(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<string, number>(0)).toThrow(RangeError)
expect(() => new LRUMap<string, number>(-1)).toThrow(RangeError)
expect(() => new LRUMap<string, number>(1.5)).toThrow(RangeError)
expect(() => new LRUMap<string, number>(NaN)).toThrow(RangeError)
expect(() => new LRUMap<string, number>(Infinity)).toThrow(RangeError)
})

test("handles large number of entries", () => {
const capacity = 100
const map = new LRUMap<number, number>(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)
})
})