Compare commits

...

4 Commits

Author SHA1 Message Date
62fc80c70f fix(watcher): cap startup wait 2026-05-20 00:09:07 -04:00
b7e421483d fix(watcher): stabilize daemon readiness and tests 2026-05-19 23:58:18 -04:00
0aa44bedc4 fix(watcher): coalesce edge cases, drain on unregister, expose ready()
Addresses review feedback on 7787626:

1. Deleted->Created at the same path is a file replacement, not a no-op.
   Previous coalescing dropped both Created->Deleted and Deleted->Created;
   the latter left the server with no signal to re-read replaced content.
   Now: Deleted->Created collapses to Changed, Created->Changed keeps
   Created (server didn't know the file at all). Extracted coalesce() so
   the matrix is reviewable in one place.

2. setPatterns([]) (server unregistered all watchers) stopped chokidar
   but left pending events + timers intact, so a queued batch could
   still fire after the server stopped caring. Now drains via
   cancelPending() before stopping chokidar.

3. Added ready() returning a promise resolved by chokidar's initial-scan
   'ready' event. Production daemon doesn't need to await it (LSP
   handshake gives chokidar ample wall-time), but tests now use it
   instead of fixed 200ms sleeps - deflakes the suite on slower
   filesystems and addresses the (narrow) startup race where a file
   created during chokidar's initial crawl could be missed.

4. Unit tests replace 11 hardcoded sleeps with watcher.ready(), and add
   coverage for the two coalesce fixes plus the unregister-drains case.
2026-05-19 23:51:32 -04:00
77876264ee feat(watcher): forward FS events as workspace/didChangeWatchedFiles
LSP servers maintain their own workspace index built at initialize time
and rely on the client to push file-system events. Previously the daemon
only synced the single file being queried, so externally created/changed
files (codegen, build scripts, git checkout, the agent's own writes from
the perspective of other open files) left the server's index stale until
manual /lsp-destroy.

Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch)
that translates FS events into workspace/didChangeWatchedFiles batches.
Patterns come from the server via client/registerCapability (no
speculative watching). Ignores layer a tiny baseline (.git, .DS_Store)
over the repo's root .gitignore, with a fallback list for non-git
workspaces. Events debounce 50ms quiet / 500ms max wait.

Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather
than relative ones, so compileWatchers() matches each event against both
relative and absolute path forms. Caught by the integration test; unit
regression test added.

Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation.

- src/client.ts: honor register/unregisterCapability for
  workspace/didChangeWatchedFiles; advertise dynamicRegistration;
  expose getFileWatchers/onWatchersChanged/sendNotification
- src/watcher.ts: new WorkspaceWatcher with layered ignores,
  debounce+batch, Created+Deleted coalescing, dual-form glob matching
- src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS,
  LSP_DEBUG-gated pattern/event logging
- test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir
- test/integration/watcher-gopls.test.ts: end-to-end against gopls
- AGENTS.md: new "Workspace File Watching" section
- flake.nix: add go (required by gopls integration test)
2026-05-19 23:43:32 -04:00
11 changed files with 877 additions and 14 deletions

View File

@@ -41,6 +41,40 @@ The daemon tracks opened files per-entry in a `Map<uri, mtimeMs>`. On each reque
A per-entry `serializer` promise chain prevents concurrent syncs from racing.
### Workspace File Watching
Each `ClientEntry` lazily owns a `WorkspaceWatcher` (`src/watcher.ts`,
chokidar + picomatch) that translates filesystem events into
`workspace/didChangeWatchedFiles` notifications. This keeps the server's
workspace index fresh when files are created/changed/deleted **outside** of
LSP tool calls (build scripts, codegen, `git checkout`, the agent's own
file writes).
Non-obvious bits:
- **Patterns come from the server.** We honor `client/registerCapability`
for `workspace/didChangeWatchedFiles` and store the registrations on the
`LspClient`. **Don't re-stub those handlers**; they look harmless but
break the entire feature. If a server doesn't register, we don't watch.
- **Servers send mixed pattern forms.** Gopls registers absolute-path
globs (`/abs/root/**/*.go`); others send relative (`**/*.ts`) or
`RelativePattern` objects. `compileWatchers()` tries both relative and
absolute matching against each event so we accept all forms.
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
root `.gitignore` parsed via the `ignore` package + a small fallback
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
event streams (branch switches) still flush in bounded time.
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
emit `didChangeWatchedFiles` *and* the next request's `syncFile` will
send a `didChange`. Servers treat the two as orthogonal (workspace
index vs. editor buffer) and dedupe internally. This matches VS Code.
- **Rollback.** `PI_LSP_DISABLE_WATCHERS=1` short-circuits all watcher
creation — if something goes wrong in a real workspace, this restores
the prior "only the queried file is synced" behavior.
### Extension vs Daemon Responsibilities
| Concern | Where |
@@ -60,7 +94,8 @@ cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
daemon.ts — Entrypoint that starts the daemon process
src/
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync, file-watcher registrations
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
commands.ts — CLI command dispatcher (maps command names → LSP methods)
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)

View File

@@ -25,6 +25,7 @@
typescript-language-server
# Tests
go
gopls
pyright
];

52
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "@evan/pi-lsp",
"version": "0.1.0",
"dependencies": {
"chokidar": "^5.0.0",
"ignore": "^7.0.5",
"picomatch": "^4.0.4",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5"
},
@@ -17,6 +20,7 @@
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.72.0",
"@types/node": "^22.10.0",
"@types/picomatch": "^4.0.3",
"oxlint": "^1.62.0",
"tsx": "^4.19.2",
"typebox": "^1.1.37",
@@ -2789,6 +2793,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
@@ -2967,6 +2978,21 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cli-highlight": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
@@ -3642,7 +3668,6 @@
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@@ -4085,6 +4110,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@@ -4180,6 +4217,19 @@
"once": "^1.3.1"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@@ -21,12 +21,16 @@
"test:integration": "NODE_OPTIONS='--import=tsx' node --test test/integration/**/*.ts"
},
"dependencies": {
"chokidar": "^5.0.0",
"ignore": "^7.0.5",
"picomatch": "^4.0.4",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.72.0",
"@types/node": "^22.10.0",
"@types/picomatch": "^4.0.3",
"oxlint": "^1.62.0",
"tsx": "^4.19.2",
"typebox": "^1.1.37",

View File

@@ -7,8 +7,11 @@ import {
type MessageConnection,
} from "vscode-jsonrpc/node.js";
import type {
FileSystemWatcher,
InitializeParams,
PublishDiagnosticsParams,
Registration,
Unregistration,
} from "vscode-languageserver-protocol";
import type { ServerConfig } from "./types.ts";
import { ServerNotFoundError } from "./types.ts";
@@ -40,6 +43,8 @@ export class LspClient {
// version numbers in didOpen/didChange. We track them so the daemon
// can resync files via notifyChange after on-disk edits.
private versions = new Map<string, number>();
private fileWatchers = new Map<string, FileSystemWatcher[]>();
private watchersListeners = new Set<() => void>();
constructor(private readonly server: ServerConfig) {}
@@ -115,8 +120,34 @@ export class LspClient {
);
},
);
this.conn.onRequest("client/registerCapability", () => null);
this.conn.onRequest("client/unregisterCapability", () => null);
this.conn.onRequest(
"client/registerCapability",
(params: { registrations?: Registration[] }) => {
let changed = false;
for (const reg of params.registrations ?? []) {
if (reg.method !== "workspace/didChangeWatchedFiles") continue;
const opts = reg.registerOptions as
| { watchers?: FileSystemWatcher[] }
| undefined;
this.fileWatchers.set(reg.id, opts?.watchers ?? []);
changed = true;
}
if (changed) for (const l of this.watchersListeners) l();
return null;
},
);
this.conn.onRequest(
"client/unregisterCapability",
(params: { unregisterations?: Unregistration[] }) => {
let changed = false;
for (const unreg of params.unregisterations ?? []) {
if (unreg.method !== "workspace/didChangeWatchedFiles") continue;
if (this.fileWatchers.delete(unreg.id)) changed = true;
}
if (changed) for (const l of this.watchersListeners) l();
return null;
},
);
this.conn.listen();
@@ -135,7 +166,11 @@ export class LspClient {
publishDiagnostics: {},
synchronization: { didSave: true },
},
workspace: { workspaceFolders: true, configuration: true },
workspace: {
workspaceFolders: true,
configuration: true,
didChangeWatchedFiles: { dynamicRegistration: true },
},
},
};
await this.conn.sendRequest("initialize", {
@@ -217,6 +252,21 @@ export class LspClient {
return this.conn.sendRequest(method, params) as Promise<R>;
}
sendNotification(method: string, params: unknown): void {
this.conn.sendNotification(method, params);
}
getFileWatchers(): FileSystemWatcher[] {
return Array.from(this.fileWatchers.values()).flat();
}
onWatchersChanged(listener: () => void): () => void {
this.watchersListeners.add(listener);
return () => {
this.watchersListeners.delete(listener);
};
}
// Clear Diagnostics - Drops the cached diagnostics for a URI so callers
// can force waitForDiagnostics to await a fresh publish after didChange.
clearDiagnostics(uri: string): void {

View File

@@ -8,6 +8,7 @@ import * as path from "node:path";
import { LspClient } from "./client.ts";
import { findRoot, findServerById, pathToUri } from "./root.ts";
import type { ServerConfig } from "./types.ts";
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
import {
logPath,
socketPath,
@@ -17,8 +18,8 @@ import {
type LaunchContext,
} from "./daemonProtocol.ts";
// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs.
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
// needed to keep files in sync and evict on idleness.
@@ -37,6 +38,8 @@ interface ClientEntry {
idleTimer: NodeJS.Timeout | null;
ttlMs: number;
lastUsed: number;
watcher: WorkspaceWatcher | null;
unsubscribeWatchers: (() => void) | null;
}
const entries = new Map<string, ClientEntry>();
@@ -88,6 +91,8 @@ async function getOrCreateEntry(
idleTimer: null,
ttlMs,
lastUsed: Date.now(),
watcher: null,
unsubscribeWatchers: null,
};
entries.set(key, entry);
try {
@@ -96,10 +101,68 @@ async function getOrCreateEntry(
entries.delete(key);
throw err;
}
await attachWatcher(entry);
bumpIdle(entry);
return entry;
}
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
async function attachWatcher(entry: ClientEntry): Promise<void> {
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();
}
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
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 {
try {
if (process.env.LSP_DEBUG) {
log(`watcher fire`, entry.server.id, JSON.stringify(events));
}
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
changes: events,
});
} catch (err) {
log(`watcher send failed`, entry.server.id, (err as Error).message);
}
}
// Bump Idle - Resets the idle eviction timer. Called on every request that
// touches the entry. We log evictions so the daemon's behavior is visible.
function bumpIdle(entry: ClientEntry) {
@@ -113,6 +176,8 @@ function evict(entry: ClientEntry, reason: string) {
log(`evict`, entry.key, reason);
entries.delete(entry.key);
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
// Auto Shutdown - If this was the last entry, there's nothing left to
// manage. Tear down the daemon so it doesn't sit idle forever.
@@ -292,6 +357,8 @@ function shutdownDaemon(reason: string) {
if (server) server.close();
for (const entry of entries.values()) {
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
}
entries.clear();

237
src/watcher.ts Normal file
View File

@@ -0,0 +1,237 @@
import * as fs from "node:fs";
import * as path from "node:path";
import chokidar, { type FSWatcher } from "chokidar";
import ignore, { type Ignore } from "ignore";
import picomatch from "picomatch";
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
import { pathToUri } from "./root.ts";
const FILE_CHANGE_CREATED = 1;
const FILE_CHANGE_CHANGED = 2;
const FILE_CHANGE_DELETED = 3;
const WATCH_KIND_CREATE = 1;
const WATCH_KIND_CHANGE = 2;
const WATCH_KIND_DELETE = 4;
const DEBOUNCE_QUIET_MS = 50;
const DEBOUNCE_MAX_WAIT_MS = 500;
export const BASELINE_IGNORES = [
"**/.git/**",
"**/.DS_Store",
"**/.hg/**",
"**/.svn/**",
];
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
export interface FileEvent {
uri: string;
type: 1 | 2 | 3;
}
function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
const gitignorePath = path.join(rootDir, ".gitignore");
let ig: Ignore | null = null;
if (fs.existsSync(gitignorePath)) {
try {
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
} catch {
ig = null;
}
}
const baselineMatcher = picomatch(BASELINE_IGNORES);
if (ig) {
return (relPath) => {
if (baselineMatcher(relPath)) return true;
const posixRel = relPath.split(path.sep).join("/");
if (!posixRel || posixRel === ".") return false;
return ig.ignores(posixRel);
};
}
const fallbackMatcher = picomatch(NO_GITIGNORE_FALLBACK);
return (relPath) => baselineMatcher(relPath) || fallbackMatcher(relPath);
}
function compileWatchers(
watchers: FileSystemWatcher[],
rootDir: string,
): (relPath: string, absPath: string) => number {
const compiled = watchers.map((w) => {
const pattern = typeof w.globPattern === "string"
? w.globPattern
: resolveRelativePattern(w.globPattern, rootDir);
return {
match: picomatch(pattern, { dot: true }),
kind: w.kind ?? (WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE),
};
});
return (relPath, absPath) => {
const posixRel = relPath.split(path.sep).join("/");
const posixAbs = absPath.split(path.sep).join("/");
let kind = 0;
for (const c of compiled) {
if (c.match(posixRel) || c.match(posixAbs)) kind |= c.kind;
}
return kind;
};
}
// Relative Pattern - Servers may send baseUri as a string or WorkspaceFolder.
function resolveRelativePattern(
rp: { baseUri: string | { uri: string }; pattern: string },
rootDir: string,
): string {
const baseUri = typeof rp.baseUri === "string" ? rp.baseUri : rp.baseUri.uri;
if (!baseUri.startsWith("file://")) return rp.pattern;
const basePath = decodeURIComponent(baseUri.slice("file://".length));
const relBase = path.relative(rootDir, basePath);
if (relBase.startsWith("..") || path.isAbsolute(relBase)) return rp.pattern;
return relBase ? `${relBase.split(path.sep).join("/")}/${rp.pattern}` : rp.pattern;
}
export class WorkspaceWatcher {
private chokidar: FSWatcher | null = null;
private isIgnored: (relPath: string) => boolean;
private matchKind: (relPath: string, absPath: string) => number = () => 0;
private pending = new Map<string, 1 | 2 | 3>();
private quietTimer: NodeJS.Timeout | null = null;
private maxWaitTimer: NodeJS.Timeout | null = null;
private disposed = false;
private readyPromise: Promise<void> = Promise.resolve();
private resolveReady: (() => void) | null = null;
constructor(
private readonly rootDir: string,
private readonly onEvents: (events: FileEvent[]) => void,
) {
this.isIgnored = buildIgnoreMatcher(rootDir);
}
setPatterns(watchers: FileSystemWatcher[]): void {
if (this.disposed) return;
this.matchKind = compileWatchers(watchers, this.rootDir);
if (watchers.length === 0) {
this.cancelPending();
void this.stopChokidar();
return;
}
if (!this.chokidar) this.startChokidar();
}
ready(): Promise<void> {
return this.readyPromise;
}
private startChokidar(): void {
this.readyPromise = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
this.chokidar = chokidar.watch(this.rootDir, {
ignoreInitial: true,
followSymlinks: false,
ignored: (absPath: string) => {
if (absPath === this.rootDir) return false;
const rel = path.relative(this.rootDir, absPath);
return this.isIgnored(rel);
},
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
});
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("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
this.chokidar.on("ready", () => this.resolveReady?.());
this.chokidar.on("error", (err) => {
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
});
}
private async stopChokidar(): Promise<void> {
if (!this.chokidar) return;
const w = this.chokidar;
this.chokidar = null;
this.resolveReady?.();
this.resolveReady = null;
this.readyPromise = Promise.resolve();
await w.close();
}
private cancelPending(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.quietTimer = null;
this.maxWaitTimer = null;
this.pending.clear();
}
private queue(absPath: string, type: 1 | 2 | 3): void {
const rel = path.relative(this.rootDir, absPath);
const kind = this.matchKind(rel, absPath);
if (kind === 0) 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_DELETED && !(kind & WATCH_KIND_DELETE)) return;
const uri = pathToUri(absPath);
const prev = this.pending.get(uri);
const next = coalesce(prev, type);
if (next === null) this.pending.delete(uri);
else this.pending.set(uri, next);
this.scheduleFlush();
}
private scheduleFlush(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
this.quietTimer = setTimeout(() => this.flush(), DEBOUNCE_QUIET_MS);
if (!this.maxWaitTimer) {
this.maxWaitTimer = setTimeout(() => this.flush(), DEBOUNCE_MAX_WAIT_MS);
}
}
private flush(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.quietTimer = null;
this.maxWaitTimer = null;
if (this.pending.size === 0) return;
const events: FileEvent[] = Array.from(this.pending, ([uri, type]) => ({
uri,
type,
}));
this.pending.clear();
try {
this.onEvents(events);
} catch (err) {
process.stderr.write(
`[pi-lsp:watcher] onEvents threw: ${(err as Error).message}\n`,
);
}
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.cancelPending();
await this.stopChokidar();
}
}
function coalesce(
prev: 1 | 2 | 3 | undefined,
next: 1 | 2 | 3,
): 1 | 2 | 3 | null {
if (prev === undefined) return next;
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_DELETED) return null;
if (prev === FILE_CHANGE_DELETED && next === FILE_CHANGE_CREATED) {
return FILE_CHANGE_CHANGED;
}
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_CHANGED) {
return FILE_CHANGE_CREATED;
}
return next;
}

View File

@@ -25,10 +25,11 @@ export const tsx = path.resolve(
"cli.mjs",
);
// Unique Test Socket — each test run gets its own Unix socket so we don't
// touch any real session daemon.
// Unique Test Socket — each suite gets its own Unix socket so parallel
// integration tests don't race through the same daemon.
export function testSocket(): string {
return path.join(os.tmpdir(), `pi-lsp-test-${process.pid}.sock`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-test-"));
return path.join(dir, "daemon.sock");
}
// Set Test Socket — sets PI_LSP_SOCKET_PATH for the current process and
@@ -39,7 +40,7 @@ export function setTestSocket(env: Record<string, string | undefined>): () => vo
return () => {
delete env.PI_LSP_SOCKET_PATH;
try {
fs.unlinkSync(sock);
fs.rmSync(path.dirname(sock), { recursive: true, force: true });
} catch {
// Socket may not exist — that's fine.
}

View File

@@ -13,15 +13,15 @@ describe("cli daemon lifecycle", () => {
const env = { ...process.env };
let cleanup: () => void;
before(() => {
before(async () => {
cleanup = setTestSocket(env);
// Stop any stale daemon on this socket before tests run.
stopTestDaemon(env);
await stopTestDaemon(env);
});
after(() => {
after(async () => {
// Tear down daemon and clean up socket after all tests.
stopTestDaemon(env);
await stopTestDaemon(env);
cleanup();
});

View File

@@ -0,0 +1,125 @@
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("gopls");
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: gopls picks up externally-created files", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
cleanup = setTestSocket(env);
await stopTestDaemon(env);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
mainFile = path.join(tmpDir, "main.go");
helperFile = path.join(tmpDir, "helper.go");
fs.writeFileSync(
mainFile,
"package main\n\nfunc main() {\n\tHelper()\n}\n",
);
});
after(async () => {
await stopTestDaemon(env);
cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("initially reports undefined symbol", async () => {
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") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const diags = result["gopls"]?.diagnostics ?? [];
const hasUndefined = diags.some((d) =>
d.message.toLowerCase().includes("undefined") || d.message.includes("Helper"),
);
assert.ok(
hasUndefined,
`Expected undefined-symbol diagnostic, got: ${JSON.stringify(diags)}`,
);
});
it("clears the diagnostic after helper.go is created externally", async () => {
fs.writeFileSync(
helperFile,
"package main\n\nfunc Helper() {}\n",
);
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") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const finalDiags = result["gopls"]?.diagnostics ?? [];
const stillUndefined = finalDiags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
assert.ok(
!stillUndefined,
`Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`,
);
});
});

293
test/unit/watcher.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import { describe, it, before, after, beforeEach } 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 { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
import { pathToUri } from "../../src/root.ts";
async function waitFor(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 25));
}
return predicate();
}
const FLUSH_WAIT_MS = 700;
describe("WorkspaceWatcher", () => {
let tmpDir: string;
let received: FileEvent[][] = [];
let watcher: WorkspaceWatcher | null = null;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-watcher-"));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(async () => {
if (watcher) {
await watcher.dispose();
watcher = null;
}
received = [];
for (const entry of fs.readdirSync(tmpDir)) {
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
}
});
it("emits Created event for new file matching pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x");
const ok = await waitFor(() => received.length > 0);
assert.ok(ok, "Expected at least one batch");
const all = received.flat();
const match = all.find((e) => e.uri === pathToUri(path.join(tmpDir, "foo.ts")));
assert.ok(match, `Expected event for foo.ts, got ${JSON.stringify(all)}`);
assert.strictEqual(match.type, 1);
});
it("emits Deleted event when file removed", async () => {
const file = path.join(tmpDir, "del.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.unlinkSync(file);
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 3 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected delete event, got ${JSON.stringify(received)}`);
});
it("emits Changed event when file content changes", async () => {
const file = path.join(tmpDir, "chg.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(file, "y");
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 2 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected change event, got ${JSON.stringify(received)}`);
});
it("skips files not matching the pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(
all.length,
0,
`Expected no events for .txt, got ${JSON.stringify(all)}`,
);
});
it("honors .gitignore at root", async () => {
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n");
fs.mkdirSync(path.join(tmpDir, "ignored-dir"));
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x");
fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const uris = all.map((e) => e.uri);
assert.ok(
uris.includes(pathToUri(path.join(tmpDir, "watched.ts"))),
"watched.ts should fire",
);
assert.ok(
!uris.some((u) => u.includes("ignored-dir")),
`ignored-dir/x.ts should be filtered, got ${JSON.stringify(uris)}`,
);
});
it("respects WatchKind to filter event types", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
await watcher.ready();
const file = path.join(tmpDir, "only-create.ts");
fs.writeFileSync(file, "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
all.some((e) => e.type === 1),
"Expected create event",
);
assert.ok(
!all.some((e) => e.type === 3),
`Did not expect delete events, got ${JSON.stringify(all)}`,
);
});
it("batches multiple rapid events into one onEvents call", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
for (let i = 0; i < 5; i++) {
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
}
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
assert.ok(
received.length <= 2,
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
);
});
it("ignores .git/ even when not in gitignore", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*" }]);
await watcher.ready();
fs.mkdirSync(path.join(tmpDir, ".git"));
fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri.includes("/.git/")),
`.git contents should be ignored, got ${JSON.stringify(all)}`,
);
});
it("stops emitting after dispose", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
await watcher.dispose();
watcher = null;
fs.writeFileSync(path.join(tmpDir, "post-dispose.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 0, `Expected no events after dispose`);
});
it("matches absolute-path glob patterns (gopls-style)", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n");
const ok = await waitFor(() =>
received
.flat()
.some((e) => e.uri === pathToUri(path.join(tmpDir, "helper.go"))),
);
assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`);
});
it("coalesces Created+Deleted to no-op", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
const file = path.join(tmpDir, "transient.ts");
fs.writeFileSync(file, "x");
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri === pathToUri(file)),
`Transient file should not surface, got ${JSON.stringify(all)}`,
);
});
it("coalesces Deleted+Created (replacement) to Changed", async () => {
const file = path.join(tmpDir, "replace.ts");
fs.writeFileSync(file, "v1");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.unlinkSync(file);
fs.writeFileSync(file, "v2");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const events = all.filter((e) => e.uri === pathToUri(file));
assert.ok(events.length > 0, `Expected an event for replaced file`);
const types = events.map((e) => e.type).sort();
const acceptable =
JSON.stringify(types) === JSON.stringify([2]) ||
JSON.stringify(types) === JSON.stringify([1, 3]);
assert.ok(
acceptable,
`Expected [2] or [1,3], got ${JSON.stringify(types)}`,
);
});
it("drops pending events when patterns are cleared", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
await new Promise((r) => setTimeout(r, 10));
watcher.setPatterns([]);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(
received.length,
0,
`Pending batch should be dropped, got ${JSON.stringify(received)}`,
);
});
it("empty pattern set means no watching", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(received.length, 0);
});
});