fix(watcher): cap startup wait
This commit is contained in:
@@ -63,6 +63,8 @@ Non-obvious bits:
|
|||||||
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
|
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
|
||||||
root `.gitignore` parsed via the `ignore` package + a small fallback
|
root `.gitignore` parsed via the `ignore` package + a small fallback
|
||||||
for non-git workspaces. Nested gitignores aren't supported yet.
|
for non-git workspaces. Nested gitignores aren't supported yet.
|
||||||
|
- **Startup readiness.** The daemon waits for chokidar's initial scan, capped
|
||||||
|
at 5s, so first requests don't hang indefinitely on huge workspaces.
|
||||||
- **Debounce.** 50ms quiet period, capped at 500ms max wait so sustained
|
- **Debounce.** 50ms quiet period, capped at 500ms max wait so sustained
|
||||||
event streams (branch switches) still flush in bounded time.
|
event streams (branch switches) still flush in bounded time.
|
||||||
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
|
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ export class LspClient {
|
|||||||
// version numbers in didOpen/didChange. We track them so the daemon
|
// version numbers in didOpen/didChange. We track them so the daemon
|
||||||
// can resync files via notifyChange after on-disk edits.
|
// can resync files via notifyChange after on-disk edits.
|
||||||
private versions = new Map<string, number>();
|
private versions = new Map<string, number>();
|
||||||
// File Watcher Registrations - Servers announce which globs they care
|
|
||||||
// about via client/registerCapability("workspace/didChangeWatchedFiles").
|
|
||||||
// Stored by registration id so unregister can remove them; listeners
|
|
||||||
// are notified so the daemon can (re)build a chokidar watcher.
|
|
||||||
private fileWatchers = new Map<string, FileSystemWatcher[]>();
|
private fileWatchers = new Map<string, FileSystemWatcher[]>();
|
||||||
private watchersListeners = new Set<() => void>();
|
private watchersListeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -173,8 +169,6 @@ export class LspClient {
|
|||||||
workspace: {
|
workspace: {
|
||||||
workspaceFolders: true,
|
workspaceFolders: true,
|
||||||
configuration: true,
|
configuration: true,
|
||||||
// Dynamic Registration - Required for servers like gopls to send
|
|
||||||
// client/registerCapability("workspace/didChangeWatchedFiles").
|
|
||||||
didChangeWatchedFiles: { dynamicRegistration: true },
|
didChangeWatchedFiles: { dynamicRegistration: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -258,21 +252,14 @@ export class LspClient {
|
|||||||
return this.conn.sendRequest(method, params) as Promise<R>;
|
return this.conn.sendRequest(method, params) as Promise<R>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Raw LSP Notification - Used by the daemon's workspace watcher to
|
|
||||||
// push workspace/didChangeWatchedFiles batches without going through the
|
|
||||||
// typed command surface.
|
|
||||||
sendNotification(method: string, params: unknown): void {
|
sendNotification(method: string, params: unknown): void {
|
||||||
this.conn.sendNotification(method, params);
|
this.conn.sendNotification(method, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get File Watchers - Flat list of all watcher patterns the server has
|
|
||||||
// registered across all registration ids.
|
|
||||||
getFileWatchers(): FileSystemWatcher[] {
|
getFileWatchers(): FileSystemWatcher[] {
|
||||||
return Array.from(this.fileWatchers.values()).flat();
|
return Array.from(this.fileWatchers.values()).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Watchers Changed - Subscribe to register/unregister events for
|
|
||||||
// workspace/didChangeWatchedFiles. Returns an unsubscribe fn.
|
|
||||||
onWatchersChanged(listener: () => void): () => void {
|
onWatchersChanged(listener: () => void): () => void {
|
||||||
this.watchersListeners.add(listener);
|
this.watchersListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
type LaunchContext,
|
type LaunchContext,
|
||||||
} from "./daemonProtocol.ts";
|
} from "./daemonProtocol.ts";
|
||||||
|
|
||||||
// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs.
|
|
||||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const WATCHER_READY_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -38,9 +38,6 @@ interface ClientEntry {
|
|||||||
idleTimer: NodeJS.Timeout | null;
|
idleTimer: NodeJS.Timeout | null;
|
||||||
ttlMs: number;
|
ttlMs: number;
|
||||||
lastUsed: number;
|
lastUsed: number;
|
||||||
// watcher: Lazy - created on first registerCapability for watched files,
|
|
||||||
// disposed on eviction. Null if the server never registered any watchers
|
|
||||||
// (or if PI_LSP_DISABLE_WATCHERS is set).
|
|
||||||
watcher: WorkspaceWatcher | null;
|
watcher: WorkspaceWatcher | null;
|
||||||
unsubscribeWatchers: (() => void) | null;
|
unsubscribeWatchers: (() => void) | null;
|
||||||
}
|
}
|
||||||
@@ -109,10 +106,7 @@ async function getOrCreateEntry(
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach Watcher - Subscribes to the client's watcher-registration events.
|
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
|
||||||
// The first non-empty registration lazily creates the WorkspaceWatcher;
|
|
||||||
// subsequent register/unregister calls update its pattern set in place.
|
|
||||||
// Honors PI_LSP_DISABLE_WATCHERS for emergency rollback.
|
|
||||||
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 () => {
|
const sync = async () => {
|
||||||
@@ -128,17 +122,34 @@ async function attachWatcher(entry: ClientEntry): Promise<void> {
|
|||||||
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
|
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
|
||||||
}
|
}
|
||||||
entry.watcher.setPatterns(patterns);
|
entry.watcher.setPatterns(patterns);
|
||||||
if (patterns.length > 0) await entry.watcher.ready();
|
if (patterns.length > 0) await waitForWatcherReady(entry);
|
||||||
};
|
};
|
||||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync());
|
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync());
|
||||||
// Initial Sync - Server may have already sent registerCapability during
|
|
||||||
// initialize before we subscribed. Wait for chokidar's initial scan so
|
|
||||||
// externally-created files are not swallowed as ignoreInitial events.
|
|
||||||
await sync();
|
await sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward Events - Sends a batched workspace/didChangeWatchedFiles to the
|
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
|
||||||
// server. We catch errors so a downed transport doesn't crash the daemon.
|
if (!entry.watcher) return;
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
let timedOut = false;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
entry.watcher.ready(),
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
resolve();
|
||||||
|
}, WATCHER_READY_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
if (timedOut) {
|
||||||
|
log(`watcher ready timeout`, entry.server.id, entry.rootDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
||||||
try {
|
try {
|
||||||
if (process.env.LSP_DEBUG) {
|
if (process.env.LSP_DEBUG) {
|
||||||
|
|||||||
@@ -1,17 +1,3 @@
|
|||||||
// Workspace Watcher - Per-entry filesystem watcher that translates chokidar
|
|
||||||
// events into LSP `workspace/didChangeWatchedFiles` notifications. One
|
|
||||||
// instance lives on each daemon ClientEntry; it's torn down on eviction.
|
|
||||||
//
|
|
||||||
// Design:
|
|
||||||
// - Patterns come from the server via `client/registerCapability`. If the
|
|
||||||
// server registers nothing, we don't watch anything (no speculative
|
|
||||||
// watching - wastes inotify slots and risks event storms).
|
|
||||||
// - We honor the repo's `.gitignore` (root level only for v1) plus a tiny
|
|
||||||
// always-ignore baseline so the watcher tracks the same set the LSP
|
|
||||||
// server would naturally care about.
|
|
||||||
// - Events are debounced (50ms quiet period) but force-flushed every 500ms
|
|
||||||
// so sustained event streams (e.g. `git checkout` of a big branch)
|
|
||||||
// can't stall the batch indefinitely.
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import chokidar, { type FSWatcher } from "chokidar";
|
import chokidar, { type FSWatcher } from "chokidar";
|
||||||
@@ -20,26 +6,17 @@ import picomatch from "picomatch";
|
|||||||
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
||||||
import { pathToUri } from "./root.ts";
|
import { pathToUri } from "./root.ts";
|
||||||
|
|
||||||
// LSP Constants - Inlined to avoid pulling the enum into a hot path. See
|
|
||||||
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#fileChangeType
|
|
||||||
const FILE_CHANGE_CREATED = 1;
|
const FILE_CHANGE_CREATED = 1;
|
||||||
const FILE_CHANGE_CHANGED = 2;
|
const FILE_CHANGE_CHANGED = 2;
|
||||||
const FILE_CHANGE_DELETED = 3;
|
const FILE_CHANGE_DELETED = 3;
|
||||||
|
|
||||||
// WatchKind - Bitmask the server uses to opt out of specific event kinds.
|
|
||||||
// Default (when `kind` is omitted) is 7 = Create | Change | Delete.
|
|
||||||
const WATCH_KIND_CREATE = 1;
|
const WATCH_KIND_CREATE = 1;
|
||||||
const WATCH_KIND_CHANGE = 2;
|
const WATCH_KIND_CHANGE = 2;
|
||||||
const WATCH_KIND_DELETE = 4;
|
const WATCH_KIND_DELETE = 4;
|
||||||
|
|
||||||
// Debounce Settings - Quiet period before flushing, plus a max wait so a
|
|
||||||
// continuous stream of events still gets delivered in bounded time.
|
|
||||||
const DEBOUNCE_QUIET_MS = 50;
|
const DEBOUNCE_QUIET_MS = 50;
|
||||||
const DEBOUNCE_MAX_WAIT_MS = 500;
|
const DEBOUNCE_MAX_WAIT_MS = 500;
|
||||||
|
|
||||||
// Always-Ignore Baseline - Things that are never interesting to an LSP
|
|
||||||
// server regardless of gitignore contents. `.git/` is the obvious one
|
|
||||||
// (gitignore won't list itself); the rest are OS/editor noise.
|
|
||||||
export const BASELINE_IGNORES = [
|
export const BASELINE_IGNORES = [
|
||||||
"**/.git/**",
|
"**/.git/**",
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
@@ -47,8 +24,6 @@ export const BASELINE_IGNORES = [
|
|||||||
"**/.svn/**",
|
"**/.svn/**",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fallback Ignores - Used only when no `.gitignore` exists at rootDir.
|
|
||||||
// Conservative list to keep non-git workspaces from blowing up.
|
|
||||||
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
|
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
|
||||||
|
|
||||||
export interface FileEvent {
|
export interface FileEvent {
|
||||||
@@ -56,10 +31,6 @@ export interface FileEvent {
|
|||||||
type: 1 | 2 | 3;
|
type: 1 | 2 | 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build Ignore Matcher - Returns a function that takes a path relative to
|
|
||||||
// rootDir and returns true if it should be ignored. Reads root .gitignore
|
|
||||||
// once at construction; nested gitignores are intentionally not supported
|
|
||||||
// in v1 (covers ~95% of repos; add nesting if it becomes a real problem).
|
|
||||||
function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
|
function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
|
||||||
const gitignorePath = path.join(rootDir, ".gitignore");
|
const gitignorePath = path.join(rootDir, ".gitignore");
|
||||||
let ig: Ignore | null = null;
|
let ig: Ignore | null = null;
|
||||||
@@ -67,35 +38,24 @@ function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
|
|||||||
try {
|
try {
|
||||||
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
|
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
// Treat parse failure as no gitignore. The fallback will catch the
|
|
||||||
// worst offenders (node_modules) so we don't watch everything.
|
|
||||||
ig = null;
|
ig = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Baseline always applies; gitignore (or fallback) layers on top.
|
|
||||||
const baselineMatcher = picomatch(BASELINE_IGNORES);
|
const baselineMatcher = picomatch(BASELINE_IGNORES);
|
||||||
if (ig) {
|
if (ig) {
|
||||||
return (relPath) => {
|
return (relPath) => {
|
||||||
if (baselineMatcher(relPath)) return true;
|
if (baselineMatcher(relPath)) return true;
|
||||||
// `ignore` requires posix-style paths and bare relative paths.
|
|
||||||
const posixRel = relPath.split(path.sep).join("/");
|
const posixRel = relPath.split(path.sep).join("/");
|
||||||
if (!posixRel || posixRel === ".") return false;
|
if (!posixRel || posixRel === ".") return false;
|
||||||
return ig!.ignores(posixRel);
|
return ig.ignores(posixRel);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackMatcher = picomatch(NO_GITIGNORE_FALLBACK);
|
const fallbackMatcher = picomatch(NO_GITIGNORE_FALLBACK);
|
||||||
return (relPath) => baselineMatcher(relPath) || fallbackMatcher(relPath);
|
return (relPath) => baselineMatcher(relPath) || fallbackMatcher(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile Watcher Patterns - Turns the server-supplied FileSystemWatcher[]
|
|
||||||
// into a single matcher that returns the matched `kind` bitmask (or 0 if
|
|
||||||
// no pattern matched). Returning the kind lets us filter individual event
|
|
||||||
// types (Created/Changed/Deleted) per the spec.
|
|
||||||
//
|
|
||||||
// Servers send patterns in mixed forms: bare relative globs (`**/*.ts`),
|
|
||||||
// absolute path globs (gopls sends `/abs/root/**/*.go`), or RelativePattern
|
|
||||||
// objects. We try matching the candidate path in both relative and absolute
|
|
||||||
// form so we accept any of those without server-specific special casing.
|
|
||||||
function compileWatchers(
|
function compileWatchers(
|
||||||
watchers: FileSystemWatcher[],
|
watchers: FileSystemWatcher[],
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
@@ -109,6 +69,7 @@ function compileWatchers(
|
|||||||
kind: w.kind ?? (WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE),
|
kind: w.kind ?? (WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (relPath, absPath) => {
|
return (relPath, absPath) => {
|
||||||
const posixRel = relPath.split(path.sep).join("/");
|
const posixRel = relPath.split(path.sep).join("/");
|
||||||
const posixAbs = absPath.split(path.sep).join("/");
|
const posixAbs = absPath.split(path.sep).join("/");
|
||||||
@@ -120,10 +81,7 @@ function compileWatchers(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve Relative Pattern - LSP 3.17 added RelativePattern with a baseUri
|
// Relative Pattern - Servers may send baseUri as a string or WorkspaceFolder.
|
||||||
// that may be a string or WorkspaceFolder. We collapse to a glob relative
|
|
||||||
// to rootDir; out-of-root patterns fall back to the raw pattern (won't
|
|
||||||
// match anything inside rootDir, which is the safe behavior).
|
|
||||||
function resolveRelativePattern(
|
function resolveRelativePattern(
|
||||||
rp: { baseUri: string | { uri: string }; pattern: string },
|
rp: { baseUri: string | { uri: string }; pattern: string },
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
@@ -144,9 +102,6 @@ export class WorkspaceWatcher {
|
|||||||
private quietTimer: NodeJS.Timeout | null = null;
|
private quietTimer: NodeJS.Timeout | null = null;
|
||||||
private maxWaitTimer: NodeJS.Timeout | null = null;
|
private maxWaitTimer: NodeJS.Timeout | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
// readyPromise: Resolves on chokidar's initial-scan `ready` event. Tests
|
|
||||||
// await this to deflake; the daemon doesn't bother (the LSP handshake
|
|
||||||
// gives chokidar ample wall-time to finish its initial scan).
|
|
||||||
private readyPromise: Promise<void> = Promise.resolve();
|
private readyPromise: Promise<void> = Promise.resolve();
|
||||||
private resolveReady: (() => void) | null = null;
|
private resolveReady: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -157,18 +112,10 @@ export class WorkspaceWatcher {
|
|||||||
this.isIgnored = buildIgnoreMatcher(rootDir);
|
this.isIgnored = buildIgnoreMatcher(rootDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Patterns - Called whenever the server's registered watchers change.
|
|
||||||
// First non-empty call lazily starts chokidar; subsequent calls update
|
|
||||||
// the matcher in place (chokidar already watches everything under
|
|
||||||
// rootDir minus ignores, so we just recompile filtering).
|
|
||||||
setPatterns(watchers: FileSystemWatcher[]): void {
|
setPatterns(watchers: FileSystemWatcher[]): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.matchKind = compileWatchers(watchers, this.rootDir);
|
this.matchKind = compileWatchers(watchers, this.rootDir);
|
||||||
if (watchers.length === 0) {
|
if (watchers.length === 0) {
|
||||||
// Server unregistered everything - drop pending events and timers
|
|
||||||
// so a queued batch can't fire after the server stopped caring,
|
|
||||||
// then stop chokidar but keep this instance alive for possible
|
|
||||||
// re-registration.
|
|
||||||
this.cancelPending();
|
this.cancelPending();
|
||||||
void this.stopChokidar();
|
void this.stopChokidar();
|
||||||
return;
|
return;
|
||||||
@@ -176,40 +123,28 @@ export class WorkspaceWatcher {
|
|||||||
if (!this.chokidar) this.startChokidar();
|
if (!this.chokidar) this.startChokidar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ready - Resolves once chokidar has finished its initial scan and is
|
|
||||||
// emitting events. Useful for deflaking tests; production callers can
|
|
||||||
// skip it.
|
|
||||||
ready(): Promise<void> {
|
ready(): Promise<void> {
|
||||||
return this.readyPromise;
|
return this.readyPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private startChokidar(): void {
|
private startChokidar(): void {
|
||||||
this.readyPromise = new Promise<void>((resolve) => {
|
this.readyPromise = new Promise<void>((resolve) => {
|
||||||
// Resolved by the `ready` handler below. We capture the resolver
|
|
||||||
// here so stopChokidar() can swap promises without leaking.
|
|
||||||
this.resolveReady = resolve;
|
this.resolveReady = resolve;
|
||||||
});
|
});
|
||||||
this.chokidar = chokidar.watch(this.rootDir, {
|
this.chokidar = chokidar.watch(this.rootDir, {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
// Ignore Function - Called by chokidar for each path; returning true
|
|
||||||
// prevents both watching and event emission. Cheaper than emitting
|
|
||||||
// and filtering afterward.
|
|
||||||
ignored: (absPath: string) => {
|
ignored: (absPath: string) => {
|
||||||
if (absPath === this.rootDir) return false;
|
if (absPath === this.rootDir) return false;
|
||||||
const rel = path.relative(this.rootDir, absPath);
|
const rel = path.relative(this.rootDir, absPath);
|
||||||
return this.isIgnored(rel);
|
return this.isIgnored(rel);
|
||||||
},
|
},
|
||||||
// Atomic Writes - Many editors write-via-rename; awaitWriteFinish
|
|
||||||
// coalesces those into a single change event. Conservative thresholds
|
|
||||||
// keep latency low while still suppressing partial-write noise.
|
|
||||||
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
||||||
});
|
});
|
||||||
this.chokidar.on("add", (p) => this.queue(p, FILE_CHANGE_CREATED));
|
this.chokidar.on("add", (p) => this.queue(p, FILE_CHANGE_CREATED));
|
||||||
this.chokidar.on("change", (p) => this.queue(p, FILE_CHANGE_CHANGED));
|
this.chokidar.on("change", (p) => this.queue(p, FILE_CHANGE_CHANGED));
|
||||||
this.chokidar.on("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
|
this.chokidar.on("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
|
||||||
this.chokidar.on("ready", () => this.resolveReady?.());
|
this.chokidar.on("ready", () => this.resolveReady?.());
|
||||||
// ENOSPC and friends - log but don't crash the entry.
|
|
||||||
this.chokidar.on("error", (err) => {
|
this.chokidar.on("error", (err) => {
|
||||||
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
|
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
|
||||||
});
|
});
|
||||||
@@ -219,8 +154,6 @@ export class WorkspaceWatcher {
|
|||||||
if (!this.chokidar) return;
|
if (!this.chokidar) return;
|
||||||
const w = this.chokidar;
|
const w = this.chokidar;
|
||||||
this.chokidar = null;
|
this.chokidar = null;
|
||||||
// Reset Ready - A future startChokidar() will install a fresh promise.
|
|
||||||
// Resolve the old one so any awaiter doesn't hang.
|
|
||||||
this.resolveReady?.();
|
this.resolveReady?.();
|
||||||
this.resolveReady = null;
|
this.resolveReady = null;
|
||||||
this.readyPromise = Promise.resolve();
|
this.readyPromise = Promise.resolve();
|
||||||
@@ -239,10 +172,10 @@ export class WorkspaceWatcher {
|
|||||||
const rel = path.relative(this.rootDir, absPath);
|
const rel = path.relative(this.rootDir, absPath);
|
||||||
const kind = this.matchKind(rel, absPath);
|
const kind = this.matchKind(rel, absPath);
|
||||||
if (kind === 0) return;
|
if (kind === 0) return;
|
||||||
// Per-Spec Filter - Skip events the server explicitly opted out of.
|
|
||||||
if (type === FILE_CHANGE_CREATED && !(kind & WATCH_KIND_CREATE)) return;
|
if (type === FILE_CHANGE_CREATED && !(kind & WATCH_KIND_CREATE)) return;
|
||||||
if (type === FILE_CHANGE_CHANGED && !(kind & WATCH_KIND_CHANGE)) return;
|
if (type === FILE_CHANGE_CHANGED && !(kind & WATCH_KIND_CHANGE)) return;
|
||||||
if (type === FILE_CHANGE_DELETED && !(kind & WATCH_KIND_DELETE)) return;
|
if (type === FILE_CHANGE_DELETED && !(kind & WATCH_KIND_DELETE)) return;
|
||||||
|
|
||||||
const uri = pathToUri(absPath);
|
const uri = pathToUri(absPath);
|
||||||
const prev = this.pending.get(uri);
|
const prev = this.pending.get(uri);
|
||||||
const next = coalesce(prev, type);
|
const next = coalesce(prev, type);
|
||||||
@@ -265,6 +198,7 @@ export class WorkspaceWatcher {
|
|||||||
this.quietTimer = null;
|
this.quietTimer = null;
|
||||||
this.maxWaitTimer = null;
|
this.maxWaitTimer = null;
|
||||||
if (this.pending.size === 0) return;
|
if (this.pending.size === 0) return;
|
||||||
|
|
||||||
const events: FileEvent[] = Array.from(this.pending, ([uri, type]) => ({
|
const events: FileEvent[] = Array.from(this.pending, ([uri, type]) => ({
|
||||||
uri,
|
uri,
|
||||||
type,
|
type,
|
||||||
@@ -287,17 +221,6 @@ export class WorkspaceWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coalesce - Combines a pending event with a newly arrived one for the
|
|
||||||
// same URI. Returns the resulting type, or null to drop the entry entirely.
|
|
||||||
//
|
|
||||||
// The interesting cases:
|
|
||||||
// Created -> Deleted : drop (transient file the server never knew about)
|
|
||||||
// Deleted -> Created : Changed (file replaced; server already thinks it
|
|
||||||
// exists - or didn't, but Changed is the safe call
|
|
||||||
// and prompts a re-read)
|
|
||||||
// Created -> Changed : keep Created (server didn't know the file at all)
|
|
||||||
// Changed -> Deleted : Deleted (latter overrides)
|
|
||||||
// * -> * : latter overrides (default)
|
|
||||||
function coalesce(
|
function coalesce(
|
||||||
prev: 1 | 2 | 3 | undefined,
|
prev: 1 | 2 | 3 | undefined,
|
||||||
next: 1 | 2 | 3,
|
next: 1 | 2 | 3,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
// Watcher Integration Test — proves the FS-watcher → LSP wiring works
|
|
||||||
// end-to-end against gopls: a stale "undefined symbol" diagnostic clears
|
|
||||||
// after the missing file is created externally (no LSP query touches it).
|
|
||||||
//
|
|
||||||
// This is the canonical staleness scenario from `_scratch/plan-fs-watching.md`.
|
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
import * as assert from "node:assert/strict";
|
import * as assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
@@ -17,10 +12,6 @@ import {
|
|||||||
|
|
||||||
const skip = requireServer("gopls");
|
const skip = requireServer("gopls");
|
||||||
|
|
||||||
// Poll Until - Runs `fn` repeatedly until `predicate(result)` is true or
|
|
||||||
// the timeout elapses. We need this because gopls reanalysis after a
|
|
||||||
// workspace/didChangeWatchedFiles is asynchronous; diagnostics arrive on
|
|
||||||
// a `textDocument/publishDiagnostics` push that we then re-fetch.
|
|
||||||
async function pollUntil<T>(
|
async function pollUntil<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
predicate: (v: T) => boolean,
|
predicate: (v: T) => boolean,
|
||||||
@@ -53,12 +44,9 @@ describe("watcher: gopls picks up externally-created files", { skip: skip ?? und
|
|||||||
await stopTestDaemon(env);
|
await stopTestDaemon(env);
|
||||||
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
|
||||||
// Minimal Go Module - go.mod is gopls's primary root marker.
|
|
||||||
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
|
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
|
||||||
mainFile = path.join(tmpDir, "main.go");
|
mainFile = path.join(tmpDir, "main.go");
|
||||||
helperFile = path.join(tmpDir, "helper.go");
|
helperFile = path.join(tmpDir, "helper.go");
|
||||||
// main.go references Helper() which doesn't exist yet \u2014 should produce
|
|
||||||
// an "undefined: Helper" diagnostic.
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
mainFile,
|
mainFile,
|
||||||
"package main\n\nfunc main() {\n\tHelper()\n}\n",
|
"package main\n\nfunc main() {\n\tHelper()\n}\n",
|
||||||
@@ -72,9 +60,6 @@ describe("watcher: gopls picks up externally-created files", { skip: skip ?? und
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("initially reports undefined symbol", async () => {
|
it("initially reports undefined symbol", async () => {
|
||||||
// Poll - gopls's workspace load on a fresh tmp dir takes a beat; the
|
|
||||||
// first diagnostics call can return "No active builds contain ..."
|
|
||||||
// before the real analysis lands. Wait up to 15s for it to settle.
|
|
||||||
const result = await pollUntil(
|
const result = await pollUntil(
|
||||||
async () =>
|
async () =>
|
||||||
(await runCliJson(
|
(await runCliJson(
|
||||||
@@ -103,16 +88,11 @@ describe("watcher: gopls picks up externally-created files", { skip: skip ?? und
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clears the diagnostic after helper.go is created externally", async () => {
|
it("clears the diagnostic after helper.go is created externally", async () => {
|
||||||
// Create The Missing File Without Touching It Via LSP - This is the
|
|
||||||
// whole point: only chokidar + workspace/didChangeWatchedFiles can tell
|
|
||||||
// gopls about helper.go's existence.
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
helperFile,
|
helperFile,
|
||||||
"package main\n\nfunc Helper() {}\n",
|
"package main\n\nfunc Helper() {}\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Poll - Allow up to 15s for the watcher to fire, gopls to reanalyze,
|
|
||||||
// and the diagnostic to clear. Re-query the same file (main.go) only.
|
|
||||||
const result = await pollUntil(
|
const result = await pollUntil(
|
||||||
async () =>
|
async () =>
|
||||||
(await runCliJson(
|
(await runCliJson(
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// Watcher Unit Tests — exercises WorkspaceWatcher against a temp dir with
|
|
||||||
// real chokidar. We use real FS because mocking it would test the mock,
|
|
||||||
// not the actual behavior under inotify.
|
|
||||||
import { describe, it, before, after, beforeEach } from "node:test";
|
import { describe, it, before, after, beforeEach } from "node:test";
|
||||||
import * as assert from "node:assert/strict";
|
import * as assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
@@ -9,9 +6,6 @@ import * as path from "node:path";
|
|||||||
import { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
|
import { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
|
||||||
import { pathToUri } from "../../src/root.ts";
|
import { pathToUri } from "../../src/root.ts";
|
||||||
|
|
||||||
// Wait For - Polls a predicate up to timeoutMs. Returns true if it became
|
|
||||||
// true, false if it timed out. Used to wait on async chokidar events
|
|
||||||
// without arbitrary sleeps.
|
|
||||||
async function waitFor(
|
async function waitFor(
|
||||||
predicate: () => boolean,
|
predicate: () => boolean,
|
||||||
timeoutMs = 2000,
|
timeoutMs = 2000,
|
||||||
@@ -24,8 +18,6 @@ async function waitFor(
|
|||||||
return predicate();
|
return predicate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait Quiet - Waits for the debounce window to elapse so any pending
|
|
||||||
// events flush. Slightly longer than DEBOUNCE_MAX_WAIT_MS.
|
|
||||||
const FLUSH_WAIT_MS = 700;
|
const FLUSH_WAIT_MS = 700;
|
||||||
|
|
||||||
describe("WorkspaceWatcher", () => {
|
describe("WorkspaceWatcher", () => {
|
||||||
@@ -47,7 +39,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
watcher = null;
|
watcher = null;
|
||||||
}
|
}
|
||||||
received = [];
|
received = [];
|
||||||
// Reset Dir - Wipe contents between tests so file lists are predictable.
|
|
||||||
for (const entry of fs.readdirSync(tmpDir)) {
|
for (const entry of fs.readdirSync(tmpDir)) {
|
||||||
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
|
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -140,7 +131,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
|
|
||||||
it("respects WatchKind to filter event types", async () => {
|
it("respects WatchKind to filter event types", async () => {
|
||||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||||
// Kind 1 = Create only - delete events should be suppressed.
|
|
||||||
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
|
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
|
||||||
await watcher.ready();
|
await watcher.ready();
|
||||||
|
|
||||||
@@ -166,8 +156,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||||
await watcher.ready();
|
await watcher.ready();
|
||||||
|
|
||||||
// Write Several Files Quickly - within the 50ms debounce window they
|
|
||||||
// should all land in one batch, capped at 500ms max wait.
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
|
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
|
||||||
}
|
}
|
||||||
@@ -176,8 +164,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
|
|
||||||
const all = received.flat();
|
const all = received.flat();
|
||||||
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
|
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
|
||||||
// The batches should be << 5 (debounce coalesces). Allow up to 2 in
|
|
||||||
// case the loop straddled a flush boundary.
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
received.length <= 2,
|
received.length <= 2,
|
||||||
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
|
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
|
||||||
@@ -217,8 +203,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("matches absolute-path glob patterns (gopls-style)", async () => {
|
it("matches absolute-path glob patterns (gopls-style)", async () => {
|
||||||
// Regression - gopls registers e.g. `/tmp/.../**/*.{go,mod}` rather
|
|
||||||
// than a bare relative `**/*.go`. The matcher must accept both.
|
|
||||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||||
watcher.setPatterns([
|
watcher.setPatterns([
|
||||||
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
|
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
|
||||||
@@ -240,7 +224,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||||
await watcher.ready();
|
await watcher.ready();
|
||||||
|
|
||||||
// Create Then Delete - both within the debounce quiet window.
|
|
||||||
const file = path.join(tmpDir, "transient.ts");
|
const file = path.join(tmpDir, "transient.ts");
|
||||||
fs.writeFileSync(file, "x");
|
fs.writeFileSync(file, "x");
|
||||||
fs.unlinkSync(file);
|
fs.unlinkSync(file);
|
||||||
@@ -255,15 +238,12 @@ describe("WorkspaceWatcher", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("coalesces Deleted+Created (replacement) to Changed", async () => {
|
it("coalesces Deleted+Created (replacement) to Changed", async () => {
|
||||||
// Setup - file exists before the watcher starts, so chokidar's initial
|
|
||||||
// scan registers it (with ignoreInitial: true, no event fires).
|
|
||||||
const file = path.join(tmpDir, "replace.ts");
|
const file = path.join(tmpDir, "replace.ts");
|
||||||
fs.writeFileSync(file, "v1");
|
fs.writeFileSync(file, "v1");
|
||||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||||
await watcher.ready();
|
await watcher.ready();
|
||||||
|
|
||||||
// Delete Then Recreate - within one debounce window.
|
|
||||||
fs.unlinkSync(file);
|
fs.unlinkSync(file);
|
||||||
fs.writeFileSync(file, "v2");
|
fs.writeFileSync(file, "v2");
|
||||||
|
|
||||||
@@ -272,11 +252,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
const all = received.flat();
|
const all = received.flat();
|
||||||
const events = all.filter((e) => e.uri === pathToUri(file));
|
const events = all.filter((e) => e.uri === pathToUri(file));
|
||||||
assert.ok(events.length > 0, `Expected an event for replaced file`);
|
assert.ok(events.length > 0, `Expected an event for replaced file`);
|
||||||
// Expected: the coalesced single event is Changed (type=2). Some
|
|
||||||
// platforms may split the delete+create across debounce windows, in
|
|
||||||
// which case the server still sees correct state. Accept either:
|
|
||||||
// - one Changed event (coalesced), or
|
|
||||||
// - Deleted then Created in separate batches (not coalesced, fine)
|
|
||||||
const types = events.map((e) => e.type).sort();
|
const types = events.map((e) => e.type).sort();
|
||||||
const acceptable =
|
const acceptable =
|
||||||
JSON.stringify(types) === JSON.stringify([2]) ||
|
JSON.stringify(types) === JSON.stringify([2]) ||
|
||||||
@@ -293,8 +268,6 @@ describe("WorkspaceWatcher", () => {
|
|||||||
await watcher.ready();
|
await watcher.ready();
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
|
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
|
||||||
// Don't wait for the debounce - clear patterns immediately so the
|
|
||||||
// pending batch should be dropped, not flushed.
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
watcher.setPatterns([]);
|
watcher.setPatterns([]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user