pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,605 lines • 72.9 kB
JavaScript
/**
* LSP Client for pi-lens
*
* Handles JSON-RPC communication with language servers:
* - Initialize/shutdown lifecycle
* - Document synchronization (didOpen, didChange)
* - Diagnostics with debouncing
* - Request/response handling
*/
import { spawn as nodeSpawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { access, readFile } from "node:fs/promises";
import * as os from "node:os";
import { pathToFileURL } from "node:url";
import { withTimeout } from "../deadline-utils.js";
import { logLatency } from "../latency-logger.js";
// vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
// `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
import { getAmbientAbortSignal } from "../safe-spawn.js";
import { applyWorkspaceEdit } from "./edits.js";
import { recordLspChild, removeLspChild } from "../instance-registry.js";
import { normalizeMapKey, uriToPath } from "./path-utils.js";
import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
import { getStrategy } from "./server-strategies.js";
import { WatchedFilesQueue } from "./watch-queue.js";
// Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
// hot-path cost. Surfaces each server's publish behavior (version + count) to
// diagnose the clean-file affirmative-signal question (#240): which servers
// publish an empty-with-version set on a clean scan vs go silent.
const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
/**
* #472/#449: extract a per-spawn-unique "marker" from an LSP server's resolved
* args, for the instance registry's command-line re-identification fallback
* (used when a recorded child's pid is dead/recycled but its process tree
* grandchild — e.g. ast-grep's native exe behind a dead node wrapper — is
* still alive under a different pid).
*
* Generalized, NOT ast-grep-specific (uniformity requirement — no per-server
* special casing): the value immediately following a `--config`/`-c` flag, if
* that value looks like a path under a temp directory (`os.tmpdir()`). This
* covers ast-grep's `lsp --config <tmp sgconfig path>` (clients/sgconfig.ts)
* today, and any other server later launched with a temp-file `--config`/`-c`
* argument, without new server-specific code.
*/
function extractSpawnMarker(args) {
const tmpDir = os.tmpdir();
for (let i = 0; i < args.length - 1; i++) {
const flag = args[i];
if (flag === "--config" || flag === "-c") {
const value = args[i + 1];
if (value?.startsWith(tmpDir))
return value;
}
}
return undefined;
}
// --- Constants ---
const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
/**
* The client capabilities advertised in every `initialize`. The textDocument set
* is intentionally COMPLETE and spec-compliant: servers built on
* OmniSharp.Extensions.LanguageServer (PowerShell Editor Services, #278)
* dereference these sub-capabilities while handling `initialize` and throw a
* NullReferenceException when an expected one is absent, hanging the handshake. A
* partial textDocument object (the old `synchronization: {didOpen, didChange}` —
* not even valid TextDocumentSyncClientCapabilities fields) triggered exactly
* that. Declaring the full set is harmless to other servers (they act only on the
* requests we actually send), so this is the single, server-agnostic shape.
* Exported for the regression guard in client-internals tests.
*/
export const CLIENT_CAPABILITIES = {
general: { positionEncodings: ADVERTISED_POSITION_ENCODINGS },
window: { workDoneProgress: true },
workspace: {
workspaceFolders: true,
configuration: true,
didChangeWatchedFiles: { dynamicRegistration: true },
},
textDocument: {
synchronization: {
dynamicRegistration: false,
willSave: false,
willSaveWaitUntil: false,
didSave: true,
},
completion: {
dynamicRegistration: false,
completionItem: { snippetSupport: false },
},
hover: { dynamicRegistration: false },
signatureHelp: { dynamicRegistration: false },
definition: { dynamicRegistration: false },
typeDefinition: { dynamicRegistration: false },
implementation: { dynamicRegistration: false },
references: { dynamicRegistration: false },
documentSymbol: { dynamicRegistration: false },
codeAction: { dynamicRegistration: false },
rename: { dynamicRegistration: false },
publishDiagnostics: {
relatedInformation: true,
versionSupport: true,
},
},
};
const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
// Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
// NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
// stream, so a pull-mode server that is alive but hung (accepts the request, never
// replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
// the diagnostics flush. On timeout the request is treated as `unavailable`, which
// (per #240) is NOT read as clean and falls through to the bounded push backstop.
const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
// Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
// (30s): the command is mutating and legitimately long-running (a real server
// refactor / organize-imports), so this must not truncate valid work — it only
// stops a hung server from blocking the caller forever. On timeout the command
// may still be applying server-side; we surface that rather than pretend it ran.
const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
const LSP_CRASH_CODES = new Set([
"ERR_STREAM_DESTROYED",
"ERR_STREAM_WRITE_AFTER_END",
"EPIPE",
"ECONNRESET",
]);
let crashGuardInstalled = false;
function isIgnorableLspRuntimeCrash(err) {
if (!(err instanceof Error))
return false;
const code = err.code;
if (code && LSP_CRASH_CODES.has(code))
return true;
const msg = err.message.toLowerCase();
const stack = (err.stack ?? "").toLowerCase();
return (msg.includes("stream") ||
msg.includes("write after end") ||
stack.includes("vscode-jsonrpc/lib/node/ril.js"));
}
function installCrashGuard() {
if (crashGuardInstalled)
return;
crashGuardInstalled = true;
process.on("uncaughtException", (err) => {
if (isIgnorableLspRuntimeCrash(err)) {
return;
}
throw err;
});
process.on("unhandledRejection", (reason) => {
if (isIgnorableLspRuntimeCrash(reason)) {
return;
}
throw reason instanceof Error ? reason : new Error(String(reason));
});
}
function isClientAlive(state) {
return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
}
function disposeClientConnection(state) {
if (state.connectionDisposed)
return;
state.connectionDisposed = true;
try {
state.connection.dispose();
}
catch {
// ignore
}
}
export async function killProcessTree(proc, pid, options = {}) {
// If our child has already exited, its PID is dead and the OS may have
// RECYCLED it. The Windows `taskkill /F /T` below force-kills the PID's whole
// tree, so on a recycled PID it would kill an unrelated process (in the test
// suite this occasionally nuked a vitest worker fork → "Worker exited
// unexpectedly" with no fatal dump). There is nothing left for us to kill, and
// the handle-based proc.kill() below is moot, so return early.
if ((proc.exitCode != null || proc.signalCode != null) &&
!options.processExiting) {
proc.unref?.();
return;
}
if (process.platform === "win32" && pid > 0) {
// Host process is exiting (loop already closing): never spawn a child here —
// the spawn's uv_async_send on the closing loop-wakeup handle hard-aborts
// (src\win\async.c). Kill the direct child via the handle we already hold
// (TerminateProcess; synchronous, no async handle).
//
// #472 CORRECTION of a prior false claim here ("orphaned grandchildren are
// reaped by the OS as the host exits"): Windows does NOT kill children when
// a parent dies. For shell/.cmd-wrapped servers the direct child is
// cmd.exe, so this path only ever kills the wrapper — the actual server
// (its grandchild) survives by design whenever it doesn't independently
// exit. It relies entirely on best-effort backstops instead: (1) the
// server observing stdin EOF once the wrapper's pipes close, (2) LSP
// `initialize.processId: process.pid` (some servers self-watchdog on that
// pid dying — typescript-language-server does, ast-grep's native binary
// does not, an upstream spec violation), and (3) the #449/#472
// cross-process instance registry's orphan reaper, which is the only
// mechanism that works regardless of why a pipe write-end stayed open
// (e.g. Windows handle-inheritance capture by a long-lived process). This
// is why registering every LSP child at spawn matters uniformly — do NOT
// weaken this direct-child-only kill to try to chase grandchildren here;
// spawning taskkill in this branch is exactly the libuv hazard above.
if (options.processExiting) {
try {
proc.kill();
}
catch {
// best-effort
}
proc.unref?.();
return;
}
try {
// Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
shell: false,
windowsHide: true,
stdio: "ignore",
detached: !!options.fast,
});
if (options.fast) {
killer.unref();
proc.unref?.();
return;
}
await new Promise((resolve) => {
killer.once("close", () => resolve());
killer.once("error", () => resolve());
});
}
catch {
// ignore
}
return;
}
const killPosixProcessGroup = (signal) => {
if (pid <= 0)
return false;
try {
process.kill(-pid, signal);
return true;
}
catch {
return false;
}
};
const killDirectChild = (signal) => {
try {
proc.kill(signal);
}
catch {
// best-effort
}
};
try {
if (!killPosixProcessGroup("SIGTERM")) {
killDirectChild("SIGTERM");
}
if (options.fast) {
const timer = setTimeout(() => {
if (!proc.killed) {
if (!killPosixProcessGroup("SIGKILL")) {
killDirectChild("SIGKILL");
}
}
}, 1500);
timer.unref?.();
proc.unref?.();
return;
}
// SIGTERM → 1.5s → SIGKILL escalation.
// SIGTERM alone can leave zombie processes if the server hangs.
await new Promise((resolve) => setTimeout(resolve, 1500));
if (!proc.killed) {
if (!killPosixProcessGroup("SIGKILL")) {
killDirectChild("SIGKILL");
}
}
}
catch {
// ignore
}
}
export function stripDiagnosticNoiseLines(message) {
const cleaned = message
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
if (/^for further information visit\b/i.test(trimmed))
return false;
if (/^https?:\/\/\S+$/i.test(trimmed))
return false;
return true;
})
.join("\n")
.trim();
return cleaned || message.trim() || message;
}
function normalizeLspDiagnostic(diagnostic) {
const message = stripDiagnosticNoiseLines(diagnostic.message);
return message === diagnostic.message
? diagnostic
: { ...diagnostic, message };
}
function normalizeLspDiagnostics(diagnostics) {
return diagnostics.map(normalizeLspDiagnostic);
}
function mergeDiagnosticLists(push, pull) {
const merged = [];
const seen = new Set();
for (const diagnostic of [...(push ?? []), ...(pull ?? [])]) {
const key = [
diagnostic.range.start.line,
diagnostic.range.start.character,
diagnostic.range.end.line,
diagnostic.range.end.character,
diagnostic.code ?? "",
diagnostic.source ?? "",
diagnostic.message,
].join(":");
if (seen.has(key))
continue;
seen.add(key);
merged.push(diagnostic);
}
return merged;
}
function getMergedDiagnosticsForPath(state, normalizedPath) {
const legacy = state;
return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
}
function clearDiagnosticsForPath(state, normalizedPath) {
const legacy = state;
state.pushDiagnostics?.delete(normalizedPath);
state.pushDiagnosticTimestamps?.delete(normalizedPath);
state.documentPullDiagnostics?.delete(normalizedPath);
state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
state.diagnosticDocVersions?.delete(normalizedPath);
legacy.diagnostics?.delete(normalizedPath);
legacy.diagnosticTimestamps?.delete(normalizedPath);
}
// Methods that can be registered dynamically and map to operationSupport keys
const DYNAMIC_OPERATION_METHOD_MAP = {
"textDocument/definition": "definition",
"textDocument/typeDefinition": "typeDefinition",
"textDocument/declaration": "declaration",
"textDocument/references": "references",
"textDocument/hover": "hover",
"textDocument/signatureHelp": "signatureHelp",
"textDocument/documentSymbol": "documentSymbol",
"workspace/symbol": "workspaceSymbol",
"textDocument/codeAction": "codeAction",
"textDocument/rename": "rename",
"textDocument/implementation": "implementation",
"textDocument/prepareCallHierarchy": "callHierarchy",
};
export function applyDynamicCapabilities(state) {
const registeredMethods = new Set(state.dynamicRegistrations.values());
const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
registeredMethods.has("workspace/diagnostic");
if (hasDynamicPull) {
state.workspaceDiagnosticsSupport = {
advertised: true,
mode: "pull",
workspaceDiagnostics: registeredMethods.has("workspace/diagnostic"),
diagnosticProviderKind: "dynamic",
};
}
else if (state.staticDiagnosticsMode === "push-only" &&
state.workspaceDiagnosticsSupport.diagnosticProviderKind === "dynamic") {
// Was only dynamically registered, now unregistered — revert to push-only
state.workspaceDiagnosticsSupport = {
advertised: false,
mode: "push-only",
workspaceDiagnostics: false,
diagnosticProviderKind: "none",
};
}
for (const [method, key] of Object.entries(DYNAMIC_OPERATION_METHOD_MAP)) {
if (registeredMethods.has(method)) {
state.operationSupport[key] = true;
}
}
}
// Exported (only) so tests can invoke the publishDiagnostics notification
// handler directly against a mock LSPClientState/connection without spawning
// a real language server. Not part of the public client API surface.
export function setupIncomingHandlers(state, initialization) {
state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = uriToPath(params.uri);
const normalizedPath = normalizeMapKey(filePath);
const newDiags = normalizeLspDiagnostics(params.diagnostics || []);
const docVersion = params.version;
if (PUB_DEBUG) {
console.error(`[lsp-pub] server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`);
}
const strategy = getStrategy(state.serverId);
// Record the document version these diagnostics were computed against
// (when the server reports it) so waitForDiagnostics can reject results
// that lag behind the latest didChange instead of serving them as fresh.
const recordDocVersion = () => {
if (docVersion !== undefined) {
state.diagnosticDocVersions.set(normalizedPath, docVersion);
}
};
// Late/superseded-push guard: if the server stamped this push with a
// version and that version already lags the latest didChange we sent,
// this is analysis for an edit that's since been overtaken — caching it
// would let getDiagnostics()/getAllDiagnostics()/pruneDiagnostics() (none
// of which consult isVersionStale — that check only gates the *wait*
// helper below) serve stale results as current until the next genuinely
// fresh push overwrites them. Drop it before it reaches the cache instead.
// Checked at write time (not at notification-receipt time) so a push that
// arrives fresh but whose debounce timer fires after a later didChange is
// still caught. Version-less servers (docVersion undefined) are
// unaffected — that's an intentional, separate tradeoff (see
// isVersionStale below), not something this guard touches.
//
// Known, deliberately out-of-scope gaps: the pull-diagnostics path
// (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
// version stamp to compare against in this codebase's current handling,
// so nothing analogous is applied there. And diagnosticsVersion is a
// single global counter rather than per-path, so an unrelated path's
// fresh push can still satisfy a wait for this path's version bump —
// both are separate, larger changes.
const isSupersededPush = () => {
if (docVersion === undefined)
return false;
const currentVersion = state.documentVersions.get(normalizedPath);
return currentVersion !== undefined && docVersion < currentVersion;
};
// Seed on first push for servers whose first push is known complete.
// Bypasses the debounce timer entirely — resolves waiting promises immediately.
if (strategy.seedFirstPush &&
!state.pushDiagnostics.has(normalizedPath)) {
if (isSupersededPush())
return;
state.pushDiagnostics.set(normalizedPath, newDiags);
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
recordDocVersion();
state.diagnosticsVersion += 1;
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
return;
}
const existingTimer = state.pendingDiagnostics.get(normalizedPath);
if (existingTimer)
clearTimeout(existingTimer);
const timer = setTimeout(() => {
state.pendingDiagnostics.delete(normalizedPath);
if (isSupersededPush())
return;
state.pushDiagnostics.set(normalizedPath, newDiags);
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
recordDocVersion();
state.diagnosticsVersion += 1;
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
}, strategy.debounceMs);
state.pendingDiagnostics.set(normalizedPath, timer);
});
state.connection.onRequest("workspace/workspaceFolders", () => [
{ name: "workspace", uri: pathToFileURL(state.root).href },
]);
state.connection.onRequest("client/registerCapability", async (params) => {
for (const reg of params?.registrations ?? []) {
if (reg.id && reg.method) {
state.dynamicRegistrations.set(reg.id, reg.method);
}
// executeCommand commands can arrive dynamically too — merge them
// into the allowlist so dynamically-registered commands are runnable.
if (reg.method === "workspace/executeCommand" &&
Array.isArray(reg.registerOptions?.commands)) {
for (const cmd of reg.registerOptions.commands) {
if (typeof cmd === "string")
state.advertisedCommands.add(cmd);
}
}
}
applyDynamicCapabilities(state);
});
state.connection.onRequest("client/unregisterCapability", async (params) => {
for (const unreg of params?.unregisterations ?? []) {
if (unreg.id) {
state.dynamicRegistrations.delete(unreg.id);
}
}
applyDynamicCapabilities(state);
});
// Server-initiated edits (the mutation vector for executeCommand). Honored
// ONLY while an explicit executeCommand is in flight (serverEditsAllowed > 0);
// an unsolicited applyEdit outside that window is refused so a server can't
// push edits to disk at will. Applied through the same applyWorkspaceEdit path
// as every other edit.
state.connection.onRequest("workspace/applyEdit", async (params) => {
if (state.serverEditsAllowed <= 0 || !params?.edit) {
return { applied: false, failureReason: "edit not solicited" };
}
try {
await applyWorkspaceEdit(params.edit, state.root);
return { applied: true };
}
catch (err) {
return {
applied: false,
failureReason: err instanceof Error ? err.message : String(err),
};
}
});
state.connection.onRequest("workspace/configuration", async () => [
initialization ?? {},
]);
state.connection.onRequest("window/workDoneProgress/create", async () => { });
}
function setupConnectionLifecycle(state, recentStderr) {
state.connection.onError(([error]) => {
state.lastError = error instanceof Error ? error : new Error(String(error));
state.isConnected = false;
state.isDestroyed = true;
disposeClientConnection(state);
});
state.connection.onClose(() => {
state.isConnected = false;
state.isDestroyed = true;
disposeClientConnection(state);
});
state.lspProcess.process.on("exit", (code, signal) => {
// Gate on shutdownRequested (our own clientShutdown() call), not
// isConnected: a genuine crash's connection.onClose/onError handler above
// can fire and flip isConnected false BEFORE this 'exit' event arrives,
// which used to make the old `wasConnected` check silently swallow every
// crash whose transport died before the process itself reported exiting
// (previously: 5 ast-grep deaths during a dogfooding sweep logged only
// "respawn, uptime=Xms" — no exit code, no signal, no stderr — because
// none of them tripped this log).
const wasIntentional = state.shutdownRequested;
state.isConnected = false;
state.isDestroyed = true;
disposeClientConnection(state);
if (!wasIntentional) {
logLatency({
type: "phase",
phase: "lsp_server_unexpected_exit",
filePath: state.root,
durationMs: 0,
metadata: {
serverId: state.serverId,
pid: state.lspProcess.pid,
exitCode: code ?? null,
exitSignal: signal ?? null,
stderrTail: recentStderr(20),
},
});
}
});
}
async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
if (!isClientAlive(state))
return { status: "unavailable" };
const uri = pathToFileURL(filePath).href;
try {
// withTimeout is the backstop against a hung pull-mode server: without it
// this await never settles unless the stream is destroyed. Bounded by the
// smaller of the absolute ceiling and the caller's remaining wait budget.
// On timeout the caught error yields `unavailable` below (never a false
// `clean`), so it falls through to the push-wait/timeout backstop.
const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
if (!report)
return { status: "unavailable" };
const normalizedPath = normalizeMapKey(filePath);
const primaryItems = normalizeLspDiagnostics(report.items ?? []);
const now = Date.now();
state.documentPullDiagnostics.set(normalizedPath, primaryItems);
state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
state.diagnosticsVersion += 1;
let totalCount = primaryItems.length;
if (report.relatedDocuments) {
for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
const relatedPath = uriToPath(relatedUri);
const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
state.documentPullDiagnostics.set(normalizeMapKey(relatedPath), relatedItems);
state.documentPullDiagnosticTimestamps.set(normalizeMapKey(relatedPath), now);
totalCount += relatedItems.length;
}
}
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
return totalCount > 0
? { status: "found", count: totalCount }
: { status: "clean" };
}
catch {
return { status: "unavailable" };
}
}
/**
* One project-wide `workspace/diagnostic` pull — a single request that returns
* diagnostics for every document the server knows, instead of opening N files.
* Returns per-file reports, or `undefined` on unsupported/dead/timeout/malformed
* (caller falls back to the per-file path). `unchanged`-kind items carry no
* diagnostics and are skipped, so a file absent from the result is "clean".
*/
export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
if (!isClientAlive(state))
return undefined;
if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
return undefined;
try {
const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds: [] }), Math.max(1, budgetMs));
if (!report || !Array.isArray(report.items))
return undefined;
const out = [];
for (const item of report.items) {
// Only "full" reports carry items; "unchanged" means "same as last pull"
// (none, since previousResultIds is empty on this one-shot request).
if (!item?.uri || item.kind !== "full")
continue;
out.push({
filePath: uriToPath(item.uri),
diagnostics: normalizeLspDiagnostics(item.items ?? []),
});
}
return out;
}
catch {
return undefined;
}
}
export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
const normalizedPath = normalizeMapKey(filePath);
const minVersion = options.minVersion;
const hasFreshDiagnostics = () => minVersion === undefined || state.diagnosticsVersion > minVersion;
// Version coherence: a cached push is "stale" only when the server reported
// the document version it computed against AND that version lags the latest
// didChange we sent. This prevents serving diagnostics from a superseded
// version as fresh (e.g. once the redundant double-push is collapsed and the
// dispatch wait runs without a push-counter baseline — #203). Unknown version
// (server omits it) is treated as current so version-less servers are
// unaffected, and the timeout remains the backstop.
const isVersionStale = () => {
const cachedVersion = state.diagnosticDocVersions?.get(normalizedPath);
if (cachedVersion === undefined)
return false;
const currentVersion = state.documentVersions?.get(normalizedPath);
return currentVersion !== undefined && cachedVersion < currentVersion;
};
if (state.workspaceDiagnosticsSupport.mode === "pull") {
// Pull is authoritative. An AFFIRMATIVE outcome — diagnostics `found`, or
// an authoritative empty `clean` report — ends the wait. An `unavailable`
// pull (dead client / no reply / thrown) is NOT clean and must not
// short-circuit: fall through to the push-wait/timeout backstop. This is
// the #240 fix — previously the early-return also fired on
// `hasFreshDiagnostics()`, which is unconditionally true when there is no
// version baseline (`minVersion === undefined`), so a failed pull returned
// 0 and was read as a fresh clean.
let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
if (outcome.status === "found")
return;
let sawClean = outcome.status === "clean";
const strategy = getStrategy(state.serverId);
const retryBudgetMs = strategy.pullRetryBudgetMs > 0
? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
: 0;
const startedAt = Date.now();
// Retry within budget to catch incremental servers whose first pull is
// empty while analysis is still running (rust-analyzer). A `clean` seen at
// any point is a valid affirmative answer for this touch.
while (outcome.status !== "found" && Date.now() - startedAt < retryBudgetMs) {
await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
if (outcome.status === "clean")
sawClean = true;
}
if (outcome.status === "found" || sawClean)
return;
}
if (hasFreshDiagnostics() &&
!isVersionStale() &&
getMergedDiagnosticsForPath(state, normalizedPath).length > 0) {
return;
}
return new Promise((resolve) => {
let debounceTimer;
const onDiagnostics = (fp) => {
if (normalizeMapKey(fp) !== normalizedPath)
return;
if (!hasFreshDiagnostics() || isVersionStale())
return;
if (debounceTimer)
clearTimeout(debounceTimer);
// Adaptive debounce: use time since last push to compute remaining
// wait instead of always waiting the full debounce window.
const strategy = getStrategy(state.serverId);
const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
const timeSincePush = hit ? Date.now() - hit : Infinity;
const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
debounceTimer = setTimeout(() => {
state.diagnosticEmitter.off("diagnostics", onDiagnostics);
clearTimeout(timeout);
resolve();
}, remaining);
};
state.diagnosticEmitter.on("diagnostics", onDiagnostics);
const timeout = setTimeout(() => {
if (debounceTimer)
clearTimeout(debounceTimer);
state.diagnosticEmitter.off("diagnostics", onDiagnostics);
resolve();
}, timeoutMs);
});
}
export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
if (!isClientAlive(state))
return;
const uri = pathToFileURL(filePath).href;
const normalizedPath = normalizeMapKey(filePath);
if (state.openDocuments.has(normalizedPath) ||
state.pendingOpens.has(normalizedPath)) {
const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
state.documentVersions.set(normalizedPath, version);
// preserveDiagnostics: skip cache clear for format-only resyncs so
// waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript
// to re-publish what it already knows (formatting doesn't change semantics).
if (!preserveDiagnostics) {
clearDiagnosticsForPath(state, normalizedPath);
}
// Scanners that only re-scan on a fresh open (opengrep ignores didChange):
// close + reopen so the re-edit actually triggers a re-scan instead of
// silently publishing nothing.
if (getStrategy(state.serverId).reopenOnResync) {
await safeSendNotification(state.connection, "textDocument/didClose", {
textDocument: { uri },
});
state.openDocuments.delete(normalizedPath);
state.documentVersions.set(normalizedPath, 0);
if (!isClientAlive(state))
return;
await safeSendNotification(state.connection, "textDocument/didOpen", {
textDocument: { uri, languageId, version: 0, text: content },
});
state.openDocuments.add(normalizedPath);
return;
}
await safeSendNotification(state.connection, "textDocument/didChange", {
textDocument: { uri, version },
contentChanges: [{ text: content }],
});
return;
}
state.pendingOpens.add(normalizedPath);
state.documentVersions.set(normalizedPath, 0);
clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
// Send workspace notification first (like opencode does).
// Skipped in silent mode — cascade reads a file for diagnostics,
// not reporting a real filesystem change. Avoids N project-wide
// rechecks on push-diagnostics LSPs (TypeScript, Python) per CR-1.
if (!silent) {
// Async existence probe (was a synchronous existsSync on the document-open
// path — a stat that blocks the loop during first-read/warm). The notify
// type is unchanged: 2 (Changed) when the file exists on disk, else 1
// (Created). access() rejects when absent.
let fileExists = true;
try {
await access(filePath);
}
catch {
fileExists = false;
}
// #271: enqueue instead of sending now — the per-client queue coalesces a
// turn's file opens into a single notification, so push-diagnostics servers
// re-analyze the project once per burst rather than once per file. didOpen
// (below) still carries this file's content immediately, so the open
// document is analyzed without waiting on the batched watcher notify.
state.watchQueue.enqueue(uri, fileExists ? 2 : 1);
}
if (!isClientAlive(state))
return;
await safeSendNotification(state.connection, "textDocument/didOpen", {
textDocument: { uri, languageId, version: 0, text: content },
});
state.pendingOpens.delete(normalizedPath);
state.openDocuments.add(normalizedPath);
}
export async function handleNotifyChange(state, filePath, content) {
if (!isClientAlive(state))
return;
const uri = pathToFileURL(filePath).href;
const normalizedPath = normalizeMapKey(filePath);
if (!state.openDocuments.has(normalizedPath)) {
// Safety fallback: keep protocol ordering valid even if caller sends
// didChange before first didOpen for this document.
await safeSendNotification(state.connection, "textDocument/didOpen", {
textDocument: { uri, languageId: "plaintext", version: 0, text: content },
});
state.documentVersions.set(normalizedPath, 0);
state.openDocuments.add(normalizedPath);
return;
}
const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
state.documentVersions.set(normalizedPath, version);
// Clear stale diagnostics before sending new content so waitForDiagnostics
// doesn't return immediately with the previous edit's results.
clearDiagnosticsForPath(state, normalizedPath);
await safeSendNotification(state.connection, "textDocument/didChange", {
textDocument: { uri, version },
contentChanges: [{ text: content }],
});
}
export async function clientShutdown(state, options = {}) {
state.shutdownRequested = true;
state.isConnected = false;
state.isDestroyed = true;
for (const timer of state.pendingDiagnostics.values()) {
clearTimeout(timer);
}
state.pendingDiagnostics.clear();
state.pendingOpens.clear();
state.openDocuments.clear();
// #271: drop any pending watched-files batch + its timer (a dying client's
// queued FS changes are moot, and the timer must not outlive the connection).
state.watchQueue?.cancel();
state.diagnosticEmitter.removeAllListeners();
if (!options.fast) {
try {
await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
}
catch {
/* ignore — proceed to exit/kill so shutdown cannot hang the session */
}
try {
await safeSendNotification(state.connection, "exit", {});
}
catch {
/* ignore */
}
}
disposeClientConnection(state);
const pid = state.lspProcess.pid;
// #449/#472: deregister this LSP child from the instance registry. Fire-
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
// including the `processExiting` path where the event loop is closing
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
// so, we don't await it to keep this teardown path as fast as before).
void removeLspChild(pid).catch(() => {
// best-effort — a stale entry is caught dead-pid by the reaper later
});
// On Windows, killing the direct child first can orphan grandchildren before
// taskkill can traverse the tree. Kill the full tree first and wait briefly.
await killProcessTree(state.lspProcess.process, pid, options);
}
/**
* Translate a caller-supplied (UTF-16) `(line, character)` into the position the
* server expects under its negotiated encoding (#269). UTF-16 is the identity —
* the common case pays nothing (no I/O). For UTF-8/UTF-32 we read the target
* line from disk (pi edits files on disk before navigating, so disk == the
* server's content) and re-measure the character offset; a read failure falls
* back to the raw offset rather than dropping the request.
*/
async function toWirePosition(state, filePath, line, character) {
if (state.positionEncoding === "utf-16")
return { line, character };
try {
const content = await readFile(filePath, "utf8");
return {
line,
character: convertCharacterOffset(state.positionEncoding, lineTextAt(content, line), character),
};
}
catch {
return { line, character };
}
}
// #276: drop a navigation result whose document was edited while the request was
// in flight. Mirrors the diagnostics-path staleness check (isVersionStale) which
// compares the version computed-against to the latest didChange. Default on;
// PI_LENS_LSP_NAV_STALE_DROP=0 disables it if it ever over-drops.
function navStaleDropEnabled() {
return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
}
// Exported for the timeout regression tests (#365). `timeoutMs` overrides the
// per-request ceiling so a test can bound a hung server quickly.
export async function navRequest(state, method, params,
// When provided, the request is dropped if the document's version advances
// (an edit landed) between send and response. Omit for non-single-file
// requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS,
// Cancels the in-flight request (LSP `$/cancelRequest`) when the turn is
// abandoned. Defaults to the ambient abort signal set around dispatch/tool
// handling, so callers get cancellation for free without a signature change
// (#238 Item 1). Pass explicitly in tests.
signal = getAmbientAbortSignal()) {
if (!isClientAlive(state))
return null;
const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
const requestVersion = normalizedPath !== undefined
? state.documentVersions.get(normalizedPath)
: undefined;
const result = (await withTimeout(safeSendRequest(state.connection, method, params, signal), timeoutMs).catch((err) => {
if (err instanceof Error && err.message.startsWith("Timeout after")) {
return undefined;
}
throw err;
}));
// requestVersion === undefined (never opened, or version-less) → unaffected,
// matching the diagnostics path; the request timeout remains the backstop.
if (normalizedPath !== undefined &&
requestVersion !== undefined &&
navStaleDropEnabled()) {
const currentVersion = state.documentVersions.get(normalizedPath);
if (currentVersion !== undefined && currentVersion > requestVersion) {
return undefined;
}
}
return result;
}
// Run an advertised server command via workspace/executeCommand, with the
// generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
// hardening invariants: allowlist-by-advertisement (only commands the server
// declared) and the serverEditsAllowed window that gates server-driven
// applyEdit to the duration of an explicit call. Exported with an overridable
// `timeoutMs` for the #365 regression tests.
export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS) {
if (!isClientAlive(state)) {
return { executed: false, reason: "lsp client not alive" };
}
if (!state.advertisedCommands.has(command)) {
return {
executed: false,
reason: `command "${command}" is not advertised by the ${state.serverId} server`,
};
}
state.serverEditsAllowed += 1;
try {
let result;
try {
result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
command,
arguments: args ?? [],
}), timeoutMs);
}
catch (err) {
// Generous backstop only: a timeout means the server is hung (or the
// command is running longer than the ceiling). Surface it honestly — the
// command may still be applying — instead of hanging the caller. Real
// (non-timeout) errors still propagate.
if (err instanceof Error && err.message.startsWith("Timeout after")) {
return {
executed: false,
reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
};
}
throw err;
}
return { executed: true, result };
}
finally {
state.serverEditsAllowed -= 1;
}
}
async function resolveCodeActionBestEffort(state, action) {
if (!isClientAlive(state) || action.edit)
return action;
try {
const resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
if (!resolved || typeof resolved !== "object")
return action;
return { ...action, ...resolved };
}
catch {
// codeAction/resolve is optional. Keep the original lightweight action when
// the server does not support resolve or fails to populate an edit.
return action;
}
}
// --- Client Factory ---
export async function createLSPClient(options) {
installCrashGuard();
const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
// #449/#472: register this LSP child in the cross-process instance registry
// as soon as we have a live pid — BEFORE `initialize` completes, not after.
// Registering early means a child that dies/hangs during initialize (the
// catch block below kills it) is still deregistered by that same path via
// removeLspChild, and a process that crashes mid-initialize is still
// visible to the orphan reaper rather than silently untracked. Fire-and-
// forget: registry I/O must never block or fail LSP startup.
void recordLspChild({
pid: lspProcess.pid,
serverId,
command: lspProcess.command,
marker: extractSpawnMarker(lspProcess.args),
}).catch(() => {
// best-effort observability — never fail LSP startup over this
});
const startupState = {
exitCode: null,
exitSignal: null,
closeCode: null,
closeSignal: null,
stderr: "",
};
// Persistent stderr ring buffer — captures last ~100 lines for diagnostics.
// Used in error messages to show what the server said before dying.
const stderrRing = [];
const MAX_STDERR_LINES = 100;
const onStderr = (chunk) => {
stderrRing.push(chunk.toString());
if (stderrRing.length > MAX_STDERR_LINES)
stderrRing.shift();
// Also capture startup stderr for the initialized-failed error path
if (startupState.stderr.length < 4096) {
startupState.stderr += chunk.toString();
}
};
const recentStderr = (lines = 10) => stderrRing.slice(-lines).join("").trim();
// Pre-request health check — returns error string if process is dead.
const checkProcessAlive = () => {
const exited = lspProcess.process.exitCode;
if (exited !== null) {
const tail = recentStderr(20);
return `LSP server ${serverId} exited with code ${exited}${tail ? `. stderr: ${tail}` : ""}`;
}
if (lspProcess.process.killed) {
return `LSP server ${serverId} was killed`;
}
return undefined;
};
const onProcessExit = (code, signal) => {
startupState.exitCode = code;
startupState.exitSignal = signal;
};
const onProcessClose = (code, signal) => {
startupState.closeCode = code;
startupState.closeSignal = signal;
};
lspProcess.stderr.on("data", onStderr);
lspProcess.process.on("exit", onProcessExit);
lspProcess.process.on("close", onProcessClose);
// Attach persistent 'error' listeners to all three stdio streams.
//
// Why: when the LSP process exits, Node.js destroys its stdio streams and
// may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them.
// Without a listener that becomes an uncaught exception.
//
// vscode-jsonrpc covers stdin/stdout during the connection lifetime but
// removes its listeners on dispose(). Our permanent listeners cover the gap.
const streamErrorHandler = (_label) => (err) => {
if (err.code === "ERR_STREAM_DESTROYED" ||
err.code === "ERR_STREAM_WRITE_AFTER_END" ||
err.code === "EPIPE" ||
err.code === "ECONNRESET")
return;
};
lspProcess.stdin.on("error", streamErrorHandler("stdin"));
lspProcess.stdout.on("error", streamErrorHandler("stdout"));
lspProcess.stderr.on("error", streamErrorHandler("stderr"));
const connection = createMessageConnection(new StreamMessageReader(lspProcess.stdout), new StreamMessageWriter(lspProcess.stdin));
// Local event emitter — signals waitForDiagnostics when new diagnostics arrive.
// Scoped to this client instance. setMaxListeners guards against Node.js warning
// for concurrent waitForDiagnostics calls.
const diagnosticEmitter = new EventEmitter();
diagnosticEmitter.setMaxListeners(50);
const state = {
isConnected: true,
isDestroyed: false,
shutdownRequested: false,
connectionDisposed: false,
lastError: undefined,
connection,
pushDiagnostics: new Map(),
pushDiagnosticTimestamps: new Map(),
documentPullDiagnostics: new Map(),
documentPullDiagnosticTimestamps: new Map(),
pendingDiagnostics: new Map(),
diagnosticEmitter,
diagnosticsVersion: 0,
documentVersions: new Map(),
diagnosticDocVersions: new Map(),
openDocuments: new Set(),
pendingOpens: new Set(),
// these are filled in after initialize — cast to avoid two-phase init
workspaceDiagnosticsSupport: undefined,
operationSupport: undefined,
staticDiagnosticsMode: "push-only",
positionEncoding: "utf-16",
dynamicRegistrations: new Map(),
advertisedCommands: new Set(),
serverEditsAllowed: 0,
serverId,
launchVariant,
root,
lspProcess,
// two-phase: the flush closure needs `state` (below)
watchQueue: undefined,
};
// #271: batch per-file workspace/didChangeWatchedFiles into one notification
// per debounce window, so an N-file turn re-indexes the server once, not N×.
state.watchQueue = new WatchedFilesQueue((changes) => {
if (!isClientAlive(state))
return;
void safeSendNotification(state.connection, "workspace/didChangeWatchedFiles", { changes });
});
setupIncomingHandlers(state, initialization);
connection.listen();
setupConnectionLifecycle(state, recentStderr);
let initResult;
try {
initResult = await withTimeout(safeSendRequest(connection, "initialize", {
processId: process.pid,
rootUri: pathToFileURL(root).href,
workspaceFolders: [
{ name: "workspace", uri: pathToFileURL(root).href },
],
capabilities: CLIENT_CAPABILITIES,
initializationOptions: initialization,
}), initializeTimeoutMs);
}
catch (err) {
// Hard-kill the hung process so it doesn't become a zombie.
// SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
const pid = lspProcess.pid;
void killProcessTree(lspProcess.process, pid);
// A child registered above (recordLspChild) but never reaching a healthy
// createLSPClient return must still be deregistered here — otherwise the
// registry keeps a stale entry for a process we just killed.
void removeLspChild(pid).catch(() => {
// best-effort — a stale registry entry is harmless (the reaper's
// liveness check will find it dead on the next sweep regardless)
});
setTimeout(() => {
if (!lspProcess.process.killed && process.platform !== "win32") {
lspProcess.process.kill("SIGKILL");
}
}, 2000);
throw err;
}
finally {
lspProcess.stderr.off("data", onStderr);
}
if (initResult === undefined) {
const compactStderr = startupState.stderr
.replace(/\s+/g, " ")
.trim()
.slice(0, 320);
const reinstallHint = serverId === "cpp"
? "Install clangd (LLVM/clang-tools) and ensure clangd.exe is on PATH."
: `Try reinstalling: npm install -g ${serverId}-language-server.`;
const telemetry = [
`pid=${lspProcess.pid}`,
`exitCode=${startupState.exitCode ?? "none"}`,
`exitSignal=${startupState.exitSignal ?? "none"}`,
`closeCode=${startupState.closeCode ?? "none"}`,
`closeSignal=${startupState.closeSignal ?? "none"}`,
`root=${root}`,
compactStderr ? `stderr=${compactStderr}` : "stderr=<empty>",
].join(" ");
throw new Error(`[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` +
`The server binary may be missing or crashed immediately. ${reinstallHint} ` +
`telemetry: ${telemetry}`);
}
state.workspaceDiagnosticsSupport =
detectWorkspaceDiagnosticsSupport(initResult);
state.operationSupport = detectOperationSupport(initResult);
state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
{}).sort((a, b) => a.localeCompare(b));
for (const cmd of detectExecuteCommands(initResult)) {
state.advertisedCommands.add(cmd);
}
state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
await safeSendNotification(connection, "initialized", {});
if (initialization) {
await safeSendNotification(connection, "workspace/didChangeConfiguration", {
settings: initialization,
});
}
return {
serverId,
root,
connection,
isAlive: () => isClientAlive(state),
/** True if the server process has exited or been killed. */
processExited: () => lspProcess.process.exitCode !== null ||
lspProcess.process.killed === true,
/** Last N lines of server stderr for diagnostics. */
recentStderr: (lines) => recentStderr(lines),
/** Pre-request health check — returns error string if dead. */
checkAlive: () => checkProcessAlive(),
notify: {
async open(filePath, content, languageId, preserveDiagnostics, silent) {
return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
},
async change(filePath, content) {
return handleNotifyChange(state, filePath, content);
},
},
getDiagnostics(filePath) {
return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
},
getAllDiagnostics() {
const result = new Map();
const keys = new Set([
...state.pushDiagnostics.keys(),
...state.documentPullDiagnostics.keys(),
]);
for (const key of keys) {
result.set(key, {
diags: getMergedDiagnosticsForPath(state, key),
ts: Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0),
});
}
return result;
},
getTrackedDiagnosticPaths() {
return [
...new Set([
...state.pushDiagnostics.keys(),
...state.documentPullDiagnostics.keys(),
]),
];
},
pruneDiagnostics(predicate) {
let removed = 0;
const keys = new Set([
...state.pushDiagnostics.keys(),
...state.documentPullDiagnostics.keys(),
]);
for (const key of keys) {
const diags = getMergedDiagnosticsForPath(state, key);
const ts = Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0);
if (!predicate(key, ts, diags))
continue;
clearDiagnosticsForPath(state, key);
removed++;
}
return removed;
},
getWorkspaceDiagnosticsSupport() {
return state.workspaceDiagnosticsSupport;
},
requestWorkspaceDiagnostics(budgetMs) {
return clientRequestWorkspaceDiagnostics(state, budgetMs);
},
getOperationSupport() {
return state.operationSupport;
},
getAdvertisedCommands() {
return [...state.advertisedCommands];
},
getRawCapabilityKeys() {
return state.rawCapabilityKeys ?? [];
},
getLaunchVariant() {
return state.launchVariant;
},
async executeCommand(command, args) {
return runServerCommand(state, command, args);
},
get diagnosticsVersion() {
return state.diagnosticsVersion;
},
async waitForDiagnostics(filePath, timeoutMs = DIAGNOSTICS_WAIT_TIMEOUT_MS, options) {
return clientWaitForDiagnostics(state, filePath, timeoutMs, options);
},
async definition(filePath, line, character) {
const result = await navRequest(state, "textDocument/definition", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
if (!result)
return [];
return Array.isArray(result) ? result : [result];
},
async typeDefinition(filePath, line, character) {
const result = await navRequest(state, "textDocument/typeDefinition", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
if (!result)
return [];
return Array.isArray(result) ? result : [result];
},
async declaration(filePath, line, character) {
const result = await navRequest(state, "textDocument/declaration", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
if (!result)
return [];
return Array.isArray(result) ? result : [result];
},
async references(filePath, line, character, includeDeclaration = true) {
const result = await navRequest(state, "textDocument/references", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
context: { includeDeclaration },
}, filePath);
return result ?? [];
},
async hover(filePath, line, character) {
const result = await navRequest(state, "textDocument/hover", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
return result ?? null;
},
async signatureHelp(filePath, line, character) {
const result = await navRequest(state, "textDocument/signatureHelp", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
return result ?? null;
},
async documentSymbol(filePath) {
const result = await navRequest(state, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(filePath).href } }, filePath);
return result ?? [];
},
async workspaceSymbol(query) {
if (!isClientAlive(state))
return [];
// Route through navRequest for the shared withTimeout ceiling — a hung
// server would otherwise await forever (safeSendRequest only settles on
// a reply or a destroyed stream). No staleCheckPath: not single-file.
const result = await navRequest(state, "workspace/symbol", {
query,
});
return result ?? [];
},
async codeAction(filePath, line, character, endLine, endCharacter) {
if (!isClientAlive(state))
return [];
const uri = pathToFileURL(filePath).href;
// navRequest adds the shared withTimeout ceiling + single-file
// stale-drop (matches documentSymbol); a hung server no longer awaits
// forever, and code actions computed against superseded content drop.
const result = await navRequest(state, "textDocument/codeAction", {
textDocument: { uri },
range: {
start: await toWirePosition(state, filePath, line, character),
end: await toWirePosition(state, filePath, endLine, endCharacter),
},
context: {
diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
},
}, filePath);
if (!result || !Array.isArray(result))
return [];
const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
return Promise.all(actions.map((action) => resolveCodeActionBestEffort(state, action)));
},
async rename(filePath, line, character, newName) {
const result = await navRequest(state, "textDocument/rename", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
newName,
}, filePath);
return result ?? null;
},
async willRenameFiles(oldFilePath, newFilePath) {
const result = await navRequest(state, "workspace/willRenameFiles", {
files: [
{
oldUri: pathToFileURL(oldFilePath).href,
newUri: pathToFileURL(newFilePath).href,
},
],
});
return result ?? null;
},
async didRenameFiles(oldFilePath, newFilePath) {
if (!isClientAlive(state))
return;
await safeSendNotification(state.connection, "workspace/didRenameFiles", {
files: [
{
oldUri: pathToFileURL(oldFilePath).href,
newUri: pathToFileURL(newFilePath).href,
},
],
});
},
async implementation(filePath, line, character) {
const result = await navRequest(state, "textDocument/implementation", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
if (!result)
return [];
return Array.isArray(result) ? result : [result];
},
async prepareCallHierarchy(filePath, line, character) {
const result = await navRequest(state, "textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(filePath).href },
position: await toWirePosition(state, filePath, line, character),
}, filePath);
if (!result)
return [];
return Array.isArray(result) ? result : [result];
},
async incomingCalls(item) {
const result = await navRequest(state, "callHierarchy/incomingCalls", { item });
return result ?? [];
},
async outgoingCalls(item) {
const result = await navRequest(state, "callHierarchy/outgoingCalls", { item });
return result ?? [];
},
async shutdown(options) {
return clientShutdown(state, options);
},
};
}
// Helper to safely send notifications - catches stream destruction
async function safeSendNotification(connection, method, params) {
try {
await connection.sendNotification(method, params);
}
catch (err) {
if (isStreamError(err)) {
// Silently ignore - stream was destroyed, connection error handlers will update state
return;
}
throw err;
}
}
// Helper to safely send requests - catches stream destruction
async function safeSendRequest(connection, method, params,
// When provided, aborting the signal cancels the in-flight request via
// vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
// so a server stops computing a result the agent has already abandoned (#238
// Item 1). The rejection that follows is swallowed (treated as `undefined`).
signal) {
// Already abandoned before we even sent — don't bother the server.
if (signal?.aborted)
return undefined;
let tokenSource;
let onAbort;
if (signal) {
tokenSource = new CancellationTokenSource();
onAbort = () => tokenSource?.cancel();
signal.addEventListener("abort", onAbort, { once: true });
}
// Only pass a token when cancellation is wired, so the call shape is unchanged
// for the (many) requests without a signal.
const send = () => tokenSource
? connection.sendRequest(method, params, tokenSource.token)
: connection.sendRequest(method, params);
try {
// One safe retry on ContentModified (-32801): the document changed under
// us, so the server discarded the request. A single retry beats returning
// empty — correctness-under-edit is pi-lens's whole hot path (#238 Item 2).
const MAX_ATTEMPTS = 2;
for (let attempt = 1;; attempt++) {
try {
return (await send());
}
catch (err) {
if (isStreamError(err) || isCancellationError(err)) {
// Stream destroyed, or we cancelled the request on abort — either
// way there is no result to return.
return undefined;
}
if (isContentModifiedError(err)) {
// Retry once (unless we've since been aborted); if it's still
// ContentModified after that, return empty rather than throwing a
// code callers don't understand. RequestFailed (-32803) and other
// codes are permanent and fall through to the rethrow below.
if (attempt < MAX_ATTEMPTS && !signal?.aborted)
continue;
return undefined;
}
throw err;
}
}
}
finally {
if (signal && onAbort)
signal.removeEventListener("abort", onAbort);
tokenSource?.dispose();
}
}
// vscode-jsonrpc rejects a token-cancelled request with a `ResponseError` whose
// code is `RequestCancelled` (-32800) or `ServerCancelled` (-32802). Treat both
// as "no result" rather than a failure. (isStreamError also matches the
// "cancelled" message text; this adds the structured error-code path.)
function isCancellationError(err) {
const code = err?.code;
return code === -32800 || code === -32802;
}
// `ContentModified` (-32801): the document changed while the request was in
// flight, so the server couldn't answer against a consistent state. Retryable —
// the only LSP error code worth a second attempt on the edit hot path (#238).
function isContentModifiedError(err) {
return err?.code === -32801;
}
// Helper to detect stream destruction / connection disposal errors.
// vscode-jsonrpc throws these when the LSP server process exits while
// requests are still in flight:
// "Connection is disposed."
// "Pending response rejected since connection got disposed"
// Neither phrase contains "stream", "destroyed", or "closed", which is
// why we must also match "disposed" and "cancelled" here.
function isStreamError(err) {
if (!(err instanceof Error))
return false;
const msg = err.message.toLowerCase();
return (msg.includes("stream") ||
msg.includes("destroyed") ||
msg.includes("closed") ||
msg.includes("disposed") ||
msg.includes("cancelled") ||
err.code === "ERR_STREAM_DESTROYED" ||
err.code === "ERR_STREAM_WRITE_AFTER_END" ||
err.code === "EPIPE");
}
// Using shared path utilities from path-utils.ts
function positiveIntFromEnv(name, fallback) {
const raw = process.env[name];
if (!raw)
return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0)
return fallback;
return parsed;
}
function detectWorkspaceDiagnosticsSupport(initResult) {
const capabilities = typeof initResult === "object" && initResult !== null
? initResult.capabilities
: undefined;
const diagnosticProvider = capabilities?.diagnosticProvider;
if (!diagnosticProvider) {
return {
advertised: false,
mode: "push-only",
workspaceDiagnostics: false,
diagnosticProviderKind: "none",
};
}
if (typeof diagnosticProvider === "boolean") {
return {
advertised: diagnosticProvider,
mode: diagnosticProvider ? "pull" : "push-only",
// The boolean form of diagnosticProvider only signals document pull.
workspaceDiagnostics: false,
diagnosticProviderKind: "boolean",
};
}
if (typeof diagnosticProvider === "object") {
return {
advertised: true,
mode: "pull",
workspaceDiagnostics: diagnosticProvider
.workspaceDiagnostics === true,
diagnosticProviderKind: "object",
};
}
return {
advertised: false,
mode: "push-only",
workspaceDiagnostics: false,
diagnosticProviderKind: typeof diagnosticProvider,
};
}
function detectExecuteCommands(initResult) {
const capabilities = typeof initResult === "object" && initResult !== null
? initResult.capabilities
: undefined;
const provider = capabilities?.executeCommandProvider;
if (typeof provider !== "object" || provider === null)
return [];
const commands = provider.commands;
if (!Array.isArray(commands))
return [];
return commands.filter((cmd) => typeof cmd === "string");
}
function detectOperationSupport(initResult) {
const capabilities = typeof initResult === "object" && initResult !== null
? initResult.capabilities
: undefined;
const hasProvider = (key) => {
const value = capabilities?.[key];
if (value === undefined || value === null)
return false;
if (typeof value === "boolean")
return value;
return true;
};
return {
definition: hasProvider("definitionProvider"),
typeDefinition: hasProvider("typeDefinitionProvider"),
declaration: hasProvider("declarationProvider"),
references: hasProvider("referencesProvider"),
hover: hasProvider("hoverProvider"),
signatureHelp: hasProvider("signatureHelpProvider"),
documentSymbol: hasProvider("documentSymbolProvider"),
workspaceSymbol: hasProvider("workspaceSymbolProvider"),
codeAction: hasProvider("codeActionProvider"),
rename: hasProvider("renameProvider"),
implementation: hasProvider("implementationProvider"),
callHierarchy: hasProvider("callHierarchyProvider"),
};
}