fix(watcher): close deleted opened documents
This commit is contained in:
@@ -5,8 +5,9 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as net from "node:net";
|
import * as net from "node:net";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
||||||
import { LspClient } from "./client.ts";
|
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 type { ServerConfig } from "./types.ts";
|
||||||
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||||
const WATCHER_READY_TIMEOUT_MS = 5000;
|
const WATCHER_READY_TIMEOUT_MS = 5000;
|
||||||
const FILE_CHANGE_DELETED = 3;
|
const FILE_CHANGE_DELETED = 3;
|
||||||
|
const WATCH_KIND_DELETE = 4;
|
||||||
|
|
||||||
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
|
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
|
||||||
// needed to keep files in sync and evict on idleness.
|
// 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.
|
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
|
||||||
async function attachWatcher(entry: ClientEntry): Promise<void> {
|
async function attachWatcher(entry: ClientEntry): Promise<void> {
|
||||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||||
const sync = async () => {
|
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry));
|
||||||
const patterns = entry.client.getFileWatchers();
|
await refreshWatcher(entry);
|
||||||
if (patterns.length === 0 && !entry.watcher) return;
|
}
|
||||||
if (!entry.watcher) {
|
|
||||||
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
|
function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] {
|
||||||
forwardEvents(entry, events),
|
const registered = entry.client.getFileWatchers();
|
||||||
);
|
const openedDeletes = [...entry.opened.keys()].map((uri) => ({
|
||||||
log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`);
|
globPattern: uriToPath(uri).split(path.sep).join("/"),
|
||||||
}
|
kind: WATCH_KIND_DELETE,
|
||||||
if (process.env.LSP_DEBUG) {
|
}));
|
||||||
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
|
return [...registered, ...openedDeletes];
|
||||||
}
|
}
|
||||||
entry.watcher.setPatterns(patterns);
|
|
||||||
if (patterns.length > 0) await waitForWatcherReady(entry);
|
async function refreshWatcher(entry: ClientEntry): Promise<void> {
|
||||||
};
|
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync());
|
const patterns = watcherPatterns(entry);
|
||||||
await sync();
|
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<void> {
|
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
|
||||||
@@ -157,6 +170,7 @@ function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
|||||||
if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue;
|
if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue;
|
||||||
entry.client.closeDocument(event.uri);
|
entry.client.closeDocument(event.uri);
|
||||||
entry.opened.delete(event.uri);
|
entry.opened.delete(event.uri);
|
||||||
|
void refreshWatcher(entry);
|
||||||
}
|
}
|
||||||
if (process.env.LSP_DEBUG) {
|
if (process.env.LSP_DEBUG) {
|
||||||
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
||||||
@@ -206,6 +220,7 @@ async function syncFile(
|
|||||||
if (prev === undefined) {
|
if (prev === undefined) {
|
||||||
entry.client.openDocument(filePath);
|
entry.client.openDocument(filePath);
|
||||||
entry.opened.set(uri, stat.mtimeMs);
|
entry.opened.set(uri, stat.mtimeMs);
|
||||||
|
await refreshWatcher(entry);
|
||||||
return { uri, changed: true };
|
return { uri, changed: true };
|
||||||
} else if (prev !== stat.mtimeMs) {
|
} else if (prev !== stat.mtimeMs) {
|
||||||
entry.client.notifyChange(filePath);
|
entry.client.notifyChange(filePath);
|
||||||
|
|||||||
114
test/integration/watcher-typescript.test.ts
Normal file
114
test/integration/watcher-typescript.test.ts
Normal file
@@ -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<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
predicate: (v: T) => boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
intervalMs = 250,
|
||||||
|
): Promise<T> {
|
||||||
|
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)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user