From 14749a64495b3c592b06403fc6073924c00aff03 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Wed, 20 May 2026 00:22:30 -0400 Subject: [PATCH] fix(watcher): close deleted open documents --- src/client.ts | 8 +++++ src/daemon.ts | 6 ++++ test/integration/watcher-gopls.test.ts | 46 +++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index dc18a34..377b941 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,6 +247,14 @@ export class LspClient { return uri; } + closeDocument(uri: string): void { + this.versions.delete(uri); + this.diagnostics.delete(uri); + this.conn.sendNotification("textDocument/didClose", { + textDocument: { uri }, + }); + } + // Send Raw LSP Request - Passthrough used by the command dispatcher. sendRequest(method: string, params: unknown): Promise { return this.conn.sendRequest(method, params) as Promise; diff --git a/src/daemon.ts b/src/daemon.ts index bfe2deb..b36b62d 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -20,6 +20,7 @@ import { const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; const WATCHER_READY_TIMEOUT_MS = 5000; +const FILE_CHANGE_DELETED = 3; // Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping // needed to keep files in sync and evict on idleness. @@ -152,6 +153,11 @@ async function waitForWatcherReady(entry: ClientEntry): Promise { function forwardEvents(entry: ClientEntry, events: FileEvent[]): void { try { + for (const event of events) { + if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue; + entry.client.closeDocument(event.uri); + entry.opened.delete(event.uri); + } if (process.env.LSP_DEBUG) { log(`watcher fire`, entry.server.id, JSON.stringify(events)); } diff --git a/test/integration/watcher-gopls.test.ts b/test/integration/watcher-gopls.test.ts index 249641d..2225d84 100644 --- a/test/integration/watcher-gopls.test.ts +++ b/test/integration/watcher-gopls.test.ts @@ -32,7 +32,7 @@ interface DiagResult { [serverId: string]: { diagnostics?: { message: string }[] }; } -describe("watcher: gopls picks up externally-created files", { skip: skip ?? undefined }, () => { +describe("watcher: gopls picks up external file changes", { skip: skip ?? undefined }, () => { let tmpDir: string; let mainFile: string; let helperFile: string; @@ -122,4 +122,48 @@ describe("watcher: gopls picks up externally-created files", { skip: skip ?? und `Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`, ); }); + + it("closes an opened file when it is deleted externally", async () => { + fs.writeFileSync( + helperFile, + "package main\n\nfunc Helper(x int) {}\n", + ); + await runCliJson([helperFile, "diagnostics", '{"timeoutMs":3000}'], env); + + await pollUntil( + async () => + (await runCliJson( + [mainFile, "diagnostics", '{"timeoutMs":3000}'], + env, + )) as DiagResult, + (r) => { + const diags = r["gopls"]?.diagnostics ?? []; + return diags.some((d) => d.message.includes("not enough arguments")); + }, + 15000, + 500, + ); + + fs.rmSync(helperFile); + + const result = await pollUntil( + async () => + (await runCliJson( + [mainFile, "diagnostics", '{"timeoutMs":3000}'], + env, + )) as DiagResult, + (r) => { + const diags = r["gopls"]?.diagnostics ?? []; + return diags.some((d) => d.message.toLowerCase().includes("undefined")); + }, + 15000, + 500, + ); + + const finalDiags = result["gopls"]?.diagnostics ?? []; + assert.ok( + finalDiags.some((d) => d.message.toLowerCase().includes("undefined")), + `Expected undefined-symbol diagnostic after deleting opened helper.go, got: ${JSON.stringify(finalDiags)}`, + ); + }); });