From 3f3cb4cdbfaa3ce913e20a4e9040483c68cd9099 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Wed, 20 May 2026 06:38:21 -0400 Subject: [PATCH] fix(watcher): close deleted opened documents --- src/daemon.ts | 51 +++++---- test/integration/watcher-typescript.test.ts | 114 ++++++++++++++++++++ 2 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 test/integration/watcher-typescript.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index b36b62d..3394e2d 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,8 +5,9 @@ import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; +import type { FileSystemWatcher } from "vscode-languageserver-protocol"; import { LspClient } from "./client.ts"; -import { findRoot, findServerById, pathToUri } from "./root.ts"; +import { findRoot, findServerById, pathToUri, uriToPath } from "./root.ts"; import type { ServerConfig } from "./types.ts"; import { WorkspaceWatcher, type FileEvent } from "./watcher.ts"; import { @@ -21,6 +22,7 @@ import { const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; const WATCHER_READY_TIMEOUT_MS = 5000; const FILE_CHANGE_DELETED = 3; +const WATCH_KIND_DELETE = 4; // Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping // needed to keep files in sync and evict on idleness. @@ -110,23 +112,34 @@ async function getOrCreateEntry( // Attach Watcher - Registration can happen during initialize, before the daemon subscribes. async function attachWatcher(entry: ClientEntry): Promise { if (process.env.PI_LSP_DISABLE_WATCHERS) return; - const sync = async () => { - const patterns = entry.client.getFileWatchers(); - if (patterns.length === 0 && !entry.watcher) return; - if (!entry.watcher) { - entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) => - forwardEvents(entry, events), - ); - log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`); - } - if (process.env.LSP_DEBUG) { - log(`watcher patterns`, entry.server.id, JSON.stringify(patterns)); - } - entry.watcher.setPatterns(patterns); - if (patterns.length > 0) await waitForWatcherReady(entry); - }; - entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync()); - await sync(); + entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry)); + await refreshWatcher(entry); +} + +function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] { + const registered = entry.client.getFileWatchers(); + const openedDeletes = [...entry.opened.keys()].map((uri) => ({ + globPattern: uriToPath(uri).split(path.sep).join("/"), + kind: WATCH_KIND_DELETE, + })); + return [...registered, ...openedDeletes]; +} + +async function refreshWatcher(entry: ClientEntry): Promise { + if (process.env.PI_LSP_DISABLE_WATCHERS) return; + const patterns = watcherPatterns(entry); + if (patterns.length === 0 && !entry.watcher) return; + if (!entry.watcher) { + entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) => + forwardEvents(entry, events), + ); + log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`); + } + if (process.env.LSP_DEBUG) { + log(`watcher patterns`, entry.server.id, JSON.stringify(patterns)); + } + entry.watcher.setPatterns(patterns); + if (patterns.length > 0) await waitForWatcherReady(entry); } async function waitForWatcherReady(entry: ClientEntry): Promise { @@ -157,6 +170,7 @@ function forwardEvents(entry: ClientEntry, events: FileEvent[]): void { if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue; entry.client.closeDocument(event.uri); entry.opened.delete(event.uri); + void refreshWatcher(entry); } if (process.env.LSP_DEBUG) { log(`watcher fire`, entry.server.id, JSON.stringify(events)); @@ -206,6 +220,7 @@ async function syncFile( if (prev === undefined) { entry.client.openDocument(filePath); entry.opened.set(uri, stat.mtimeMs); + await refreshWatcher(entry); return { uri, changed: true }; } else if (prev !== stat.mtimeMs) { entry.client.notifyChange(filePath); diff --git a/test/integration/watcher-typescript.test.ts b/test/integration/watcher-typescript.test.ts new file mode 100644 index 0000000..5475ff1 --- /dev/null +++ b/test/integration/watcher-typescript.test.ts @@ -0,0 +1,114 @@ +import { describe, it, before, after } from "node:test"; +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + setTestSocket, + stopTestDaemon, + runCliJson, + requireServer, +} from "../helpers.ts"; + +const skip = requireServer("typescript-language-server"); + +async function pollUntil( + fn: () => Promise, + predicate: (v: T) => boolean, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: T = await fn(); + while (Date.now() < deadline) { + if (predicate(last)) return last; + await new Promise((r) => setTimeout(r, intervalMs)); + last = await fn(); + } + return last; +} + +interface DiagResult { + [serverId: string]: { diagnostics?: { message: string }[] }; +} + +describe("watcher: typescript handles deleted opened files", { skip: skip ?? undefined }, () => { + let tmpDir: string; + let mainFile: string; + let helperFile: string; + const env = { ...process.env }; + let cleanup: () => void; + + before(async () => { + delete env.NODE_OPTIONS; + cleanup = setTestSocket(env); + await stopTestDaemon(env); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-ts-watch-")); + fs.writeFileSync( + path.join(tmpDir, ".pi-lsp.json"), + JSON.stringify({ disable: ["oxlint"] }), + ); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + strict: true, + noEmit: true, + }, + include: ["*.ts"], + }, + null, + 2, + ), + ); + mainFile = path.join(tmpDir, "main.ts"); + helperFile = path.join(tmpDir, "helper.ts"); + fs.writeFileSync( + mainFile, + 'import { helper } from "./helper";\n\nconsole.log(helper());\n', + ); + fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n"); + }); + + after(async () => { + await stopTestDaemon(env); + cleanup(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("reports missing module after an opened imported file is deleted", async () => { + const initial = (await runCliJson( + [mainFile, "diagnostics", '{"timeoutMs":5000}'], + env, + )) as DiagResult; + assert.deepEqual(initial["typescript-language-server"]?.diagnostics ?? [], []); + + await runCliJson([helperFile, "diagnostics", '{"timeoutMs":5000}'], env); + fs.rmSync(helperFile); + + const result = await pollUntil( + async () => + (await runCliJson( + [mainFile, "diagnostics", '{"timeoutMs":3000}'], + env, + )) as DiagResult, + (r) => { + const diags = r["typescript-language-server"]?.diagnostics ?? []; + return diags.some((d) => d.message.includes("Cannot find module './helper'")); + }, + 15000, + 500, + ); + + const diags = result["typescript-language-server"]?.diagnostics ?? []; + assert.ok( + diags.some((d) => d.message.includes("Cannot find module './helper'")), + `Expected missing-module diagnostic after deleting opened helper.ts, got: ${JSON.stringify(diags)}`, + ); + }); +});