diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/lsp/src/diagnostics.test.ts | 73 | ||||
| -rw-r--r-- | packages/lsp/src/diagnostics.ts | 34 |
2 files changed, 106 insertions, 1 deletions
diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts index 7f0365b..70b1ffa 100644 --- a/packages/lsp/src/diagnostics.test.ts +++ b/packages/lsp/src/diagnostics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DiagnosticsStore } from "./diagnostics.js"; +import { type Diagnostic, DiagnosticsStore } from "./diagnostics.js"; describe("diagnostics", () => { it("formats diagnostics with severity, location, and message", () => { @@ -84,4 +84,75 @@ describe("diagnostics", () => { // Other URIs are untouched. expect(store.format("file:///project/b.ts")).toBe(""); }); + + it("bounds pushDiagnostics — oldest background file is evicted past the cap (Bug 3 remainder)", () => { + // Language servers scan the workspace and push diagnostics for files the + // agent never opened. These never enter the client's openDocuments LRU, + // so without an independent cap the pushDiagnostics map grows forever. + const store = new DiagnosticsStore(); + const CAP = 100; + + const diag = (uri: string): readonly Diagnostic[] => [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 1, + message: `err in ${uri}`, + }, + ]; + + // Push `CAP` distinct background files (at the cap — none evicted yet). + for (let i = 0; i < CAP; i++) { + store.setPushDiagnostics({ uri: `file:///bg/file${i}.ts`, diagnostics: diag(`file${i}`) }); + } + expect(store.hasReceivedPush("file:///bg/file0.ts")).toBe(true); + expect(store.format("file:///bg/file0.ts")).toContain("err in file0"); + + // One past the cap → the least-recently-pushed (file0) is evicted. + store.setPushDiagnostics({ + uri: "file:///bg/file100.ts", + diagnostics: diag("file100"), + }); + + // file0 (oldest) is gone — no didClose, no retained diagnostics, no flag. + expect(store.hasReceivedPush("file:///bg/file0.ts")).toBe(false); + expect(store.format("file:///bg/file0.ts")).toBe(""); + expect(store.getMerged("file:///bg/file0.ts")).toHaveLength(0); + + // The newest (file100) and a middle entry are retained. + expect(store.hasReceivedPush("file:///bg/file100.ts")).toBe(true); + expect(store.format("file:///bg/file100.ts")).toContain("err in file100"); + expect(store.format("file:///bg/file50.ts")).toContain("err in file50"); + }); + + it("re-pushing a URI refreshes its LRU position so it is not evicted", () => { + const store = new DiagnosticsStore(); + const CAP = 100; + + const diag = (uri: string): readonly Diagnostic[] => [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 1, + message: uri, + }, + ]; + + for (let i = 0; i < CAP; i++) { + store.setPushDiagnostics({ uri: `file:///bg/file${i}.ts`, diagnostics: diag(`file${i}`) }); + } + + // Re-push file0 (an actively-edited file gets re-pushed on each change): + // this should move it to the tail (most-recently-used), NOT leave it at + // the head where it would be the first eviction candidate. + store.setPushDiagnostics({ uri: "file:///bg/file0.ts", diagnostics: diag("file0-updated") }); + + // Now push one past the cap. file1 (now the oldest untouched) should be + // evicted; file0 (refreshed) must survive. + store.setPushDiagnostics({ uri: "file:///bg/file100.ts", diagnostics: diag("file100") }); + + expect(store.hasReceivedPush("file:///bg/file0.ts")).toBe(true); + expect(store.format("file:///bg/file0.ts")).toContain("file0-updated"); + + expect(store.hasReceivedPush("file:///bg/file1.ts")).toBe(false); + expect(store.format("file:///bg/file1.ts")).toBe(""); + }); }); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts index 09ec1ae..cce261f 100644 --- a/packages/lsp/src/diagnostics.ts +++ b/packages/lsp/src/diagnostics.ts @@ -35,10 +35,44 @@ export class DiagnosticsStore { private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); private pushReceived = new Set<string>(); + /** + * Bounded push-diagnostics set: once more than this many URIs have cached + * push diagnostics, the least-recently-pushed is evicted (just deleted — + * no didClose, since these are often background-scanned files the agent + * never opened). Language servers (tsserver, rust-analyzer, …) scan the + * workspace and emit textDocument/publishDiagnostics for files the client + * never touched; those never enter the client's openDocuments LRU, so + * without this cap the map grew without bound over a long session. + */ + private static readonly MAX_PUSH_DIAGNOSTICS = 100; setPushDiagnostics(params: PublishDiagnosticsParams): void { + // Delete-then-set to refresh LRU recency: a URI re-pushed (e.g. an + // actively edited file) moves to the tail (most-recently-used), so + // background-scanned files pushed once drift to the head and are evicted + // first. Mirrors the openDocuments LRU in client.ts. (A plain `set` on + // an existing key updates the value but leaves its insertion position — + // and thus its eviction priority — unchanged.) + this.pushDiagnostics.delete(params.uri); this.pushDiagnostics.set(params.uri, params.diagnostics); this.pushReceived.add(params.uri); + this.evictPushIfOverCap(); + } + + /** + * If the push-diagnostics set exceeds MAX_PUSH_DIAGNOSTICS, drop the + * least-recently-pushed entry (the first key in insertion order) from + * both `pushDiagnostics` and `pushReceived`. No didClose is sent: these + * entries are frequently for files the agent never opened, and even for + * an opened file the next push re-caches it. + */ + private evictPushIfOverCap(): void { + while (this.pushDiagnostics.size > DiagnosticsStore.MAX_PUSH_DIAGNOSTICS) { + const oldest = this.pushDiagnostics.keys().next().value; + if (oldest === undefined) break; + this.pushDiagnostics.delete(oldest); + this.pushReceived.delete(oldest); + } } setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { |
