pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
2,270 lines • 108 kB
JavaScript
/**
* LSP Service Layer for pi-lens
*
* Manages multiple LSP clients per workspace with:
* - Auto-spawning based on file type
* - Effect-TS service composition
* - Bus event integration
* - Resource cleanup
*/
import { createHash } from "node:crypto";
import * as nodeFs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { isTestMode } from "../env-utils.js";
import { getGlobalPiLensDir, getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
import { recordLsp } from "../widget-state.js";
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
import { logLatency } from "../latency-logger.js";
import { withDeadline } from "../deadline-utils.js";
import { normalizeMapKey, uriToPath } from "../path-utils.js";
import { createLSPClient } from "./client.js";
import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
import { getLanguageId } from "./language.js";
import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
import { getStrategy } from "./server-strategies.js";
import { raceToCompletion } from "./aggregation.js";
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
// --- Init override helpers ---
/**
* Recursively merges `override` onto `base`. Override wins on leaf conflicts
* at every nesting level; arrays and non-plain-object values are replaced, not
* merged (consistent with standard LSP settings merge semantics).
*/
function deepMergeObjects(base, override) {
const result = { ...base };
for (const [key, val] of Object.entries(override)) {
if (val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
result[key] !== null &&
typeof result[key] === "object" &&
!Array.isArray(result[key])) {
result[key] = deepMergeObjects(result[key], val);
}
else {
result[key] = val;
}
}
return result;
}
/**
* Merges user-supplied initializationOptions onto a server's built-in defaults.
* - If neither side is defined → undefined (no options sent).
* - If only one side is defined → that side is returned directly.
* - Both defined → deep merge, user wins on conflicts.
*/
export function mergeInitializationOptions(base, override) {
if (!override)
return base;
if (!base)
return override;
return deepMergeObjects(base, override);
}
const BROKEN_BASE_COOLDOWN_MS = 15_000;
const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
const OPTIONAL_LSP_RETRY_COOLDOWN_MS = 5 * 60_000;
const OPTIONAL_LSP_SERVER_IDS = new Set();
const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_NAV_CLIENT_WAIT_MS ?? "1500", 10) ||
1500);
const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
1500);
// #667: the sweep warm-up round trip's OWN generous, one-time budget —
// deliberately larger than any single per-file sweep budget (`perFileMs` in
// `runWorkspaceDiagnostics`, or the batch tool's per-file wait) because this
// pays for whatever a cold tsserver-style server needs to finish its internal
// project load/index before it can usefully answer ANY diagnostics request,
// not just one file's worth of work. Env-tunable like every other wait budget
// in this file.
function warmupTimeoutMs() {
const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_TIMEOUT_MS ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
}
/**
* Read the `PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS` env override at call time
* (process.env mutations in tests stay live). Returns undefined when unset,
* non-numeric, or negative — callers fall back through the explicit option
* chain in {@link LSPService.touchFile}.
*/
function readEnvDiagnosticsWaitMs() {
const raw = process.env.PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS;
if (raw === undefined)
return undefined;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0)
return undefined;
return parsed;
}
const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
// Once the fastest client has diagnostics, remaining clients get this window before
// we proceed with whatever results are ready. 0 disables early-unblock.
const EARLY_UNBLOCK_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400", 10) || 400);
const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
const SESSIONSTART_LOG_DIR = getGlobalPiLensDir();
const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
function logSessionStart(msg) {
if (isTestMode()) {
return;
}
const line = `[${new Date().toISOString()}] ${msg}\n`;
void fs
.mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
.then(() => fs.appendFile(SESSIONSTART_LOG, line))
.catch(() => {
// best-effort logging
});
}
function mergeLspDiagnostics(diagnostics) {
const merged = [];
const seen = new Set();
for (const diagnostic of diagnostics) {
const key = [
diagnostic.range.start.line,
diagnostic.range.start.character,
diagnostic.message,
].join(":");
if (seen.has(key))
continue;
seen.add(key);
merged.push(diagnostic);
}
return merged;
}
/**
* Group files by their primary language server id (#387/#631 — extracted
* from `runWorkspaceDiagnostics`'s inline grouping so other callers, e.g.
* `lsp_diagnostics`' batch/directory scan in tools/lsp-diagnostics.ts, can
* share the exact same server-affinity key instead of hand-copying it).
* `multiServer` flags a group containing at least one file with more than
* one attached server (primary + auxiliary) — callers that care about that
* distinction (the workspace-pull fast path below) can act on it; callers
* that don't (a plain per-file touch) can ignore it.
*/
export function groupFilesByPrimaryServer(files) {
const byServer = new Map();
for (const filePath of files) {
const servers = getServersForFileWithConfig(filePath);
const primary = servers[0]?.id ?? "none";
const group = byServer.get(primary);
if (group) {
group.files.push(filePath);
if (servers.length > 1)
group.multiServer = true;
}
else {
byServer.set(primary, {
files: [filePath],
multiServer: servers.length > 1,
});
}
}
return [...byServer.values()];
}
/** Create a fresh, empty {@link SweepIndexGate} for one `runWorkspaceDiagnostics` call. */
export function createSweepIndexGate() {
const seen = new Set();
return {
consumeFirstTouch(serverId) {
if (seen.has(serverId))
return false;
seen.add(serverId);
return true;
},
};
}
/**
* Run one worker per server group (#387/#631): at most one in-flight
* `processGroup` call per group at a time — each group's own callback is
* responsible for iterating its files serially, this scheduler never starts
* a second concurrent call into the same group — parallelized ACROSS
* distinct groups up to `concurrency` workers. This is the exact scheduling
* shape `runWorkspaceDiagnostics` (the engine behind `lens_diagnostics
* mode=full`) has used since #387 to avoid flooding a single-threaded LSP
* server with concurrent touches that only queue server-side instead of
* parallelizing (observed: 51/123 files "timed out" purely from queue
* position in a flat pool) — extracted here so `lsp_diagnostics`' batch/
* directory scan (tools/lsp-diagnostics.ts, #631) can share the identical
* property instead of running a flat, server-oblivious bounded pool.
*
* `concurrency` caps how many DISTINCT groups run at once, not how many
* files run at once — a single-language batch (one group, the common case)
* becomes effectively serial for that group regardless of `concurrency`.
* That is the intended #387 behavior, not something to work around.
*
* `processGroup` receives the whole group (not just `.files`) so a caller
* that cares about `multiServer` (e.g. the workspace-pull fast path below,
* which only applies to a single-server group) can still act on it; a
* caller that doesn't can just destructure `.files`.
*/
export async function runPerServerGroups(groups, concurrency, processGroup, signal) {
let nextGroup = 0;
const workers = Math.min(Math.max(1, concurrency), groups.length);
await Promise.all(Array.from({ length: workers }, async () => {
while (true) {
if (signal?.aborted)
return;
const gi = nextGroup;
nextGroup += 1;
if (gi >= groups.length)
return;
await processGroup(groups[gi]);
}
}));
}
const WORKSPACE_DIAGNOSTICS_CONCURRENCY = 8;
// #621: a single-server group (the common case — one language, one server)
// used to pre-open its ENTIRE file list in one uninterrupted burst (#608)
// before the per-file diagnostics-wait loop even started. That coalesces
// watched-files notifications into one flush (the #608 fix's intent), but at
// real project scale (~150 files) it also dumps the whole group on the
// server's single-threaded request queue essentially at once, forcing it to
// ingest/typecheck the full burst before any per-file diagnostics request
// even gets a turn — observed to collapse to near-100% per-file timeouts on
// a ~150-file TS project (pi-drykiss dogfooding). `lsp_diagnostics`'
// bounded-concurrency batch/directory mode (tools/lsp-diagnostics.ts, default
// 8) never has this problem: it only ever has ~8 files in flight at once.
// Chunking the pre-open+process cycle to the same width gets both properties:
// each chunk's opens still land inside `WatchedFilesQueue`'s 100ms debounce
// window and coalesce into one flush (bounded burst, not per-file — the
// original #608 bug pre-opened lazily one file at a time with a full
// diagnostics wait in between, which is what defeated the debounce), while no
// single burst ever exceeds this width regardless of total group size.
const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
const raw = Number(process.env.PI_LENS_LSP_WORKSPACE_PREOPEN_CHUNK);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
})();
// #584: opengrep has no `workspace/diagnostic` pull support (push-only,
// docs/servercapabilities.md) and `reopenOnResync: true` (server-strategies.ts)
// means every per-file LSP touch already forces a full re-scan anyway — there's
// no incremental win from routing it through the sweep's per-file loop. On a
// full workspace sweep it instead dominates the per-file wait (its own
// wait-tier budget is the slowest of any spawned server) and serializes with
// everything else in its server group (#387). Its findings for a BULK/
// full-workspace scan come from `opengrep-client.ts` — a dedicated CLI
// extractor that scans the whole tree once and is read via
// `project-diagnostics/extractors.ts`, same architecture as knip/jscpd/
// gitleaks. The per-edit real-time LSP path (clientScope "primary"/
// "with-auxiliary") is untouched by this — opengrep still attaches there.
const WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS = new Set([
"opengrep",
]);
// The notify write (didOpen/didChange) is normally instant, but it awaits a
// JSON-RPC send that BACKPRESSURES when the server's stdin isn't being drained
// (a wedged/CPU-bound server, e.g. TypeScript mid-recheck). Unbounded, that
// write parks every touchFile caller: the pre-dispatch sync, the dispatch LSP
// runner (which then rides to its 30s dispatcher timeout — the observed ~31s
// edits), and the workspace sweep. Bounding it here degrades a wedged server to
// "no fresh diagnostics" instead of hanging the edit, for ALL callers.
function notifyWriteBudgetMs() {
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 2000;
}
// Budget for one project-wide `workspace/diagnostic` pull (#387 Item 2). Larger
// than a per-file wait — it's a single request but scans the whole program —
// yet bounded so a hung server still falls back to the per-file path.
function workspacePullBudgetMs() {
const raw = Number(process.env.PI_LENS_LSP_WORKSPACE_PULL_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
// Hard cap on the workspace-diagnostics walk. Even though this is an explicit,
// user-invoked project-wide tool, the walk must be bounded so a misrooted run
// (e.g. cwd that resolves to $HOME) can't enumerate an entire home tree (#250).
// Generous — real projects are well under this; override for monorepos.
const DEFAULT_MAX_WORKSPACE_DIAGNOSTIC_FILES = 5000;
function getMaxWorkspaceDiagnosticFiles() {
const override = Number.parseInt(process.env.PI_LENS_LSP_WORKSPACE_MAX_FILES ?? "", 10);
return Number.isFinite(override) && override > 0
? override
: DEFAULT_MAX_WORKSPACE_DIAGNOSTIC_FILES;
}
/**
* Async, event-loop-yielding walk of the workspace to find LSP-supported source
* files. Uses `fs.promises.readdir` so each directory read hands control back to
* the loop — a synchronous `readdirSync` recursion blocks the loop for the whole
* O(N) enumeration (~44ms at 1.4k files, scaling linearly on monorepos).
*
* Directory/file exclusion goes through the SAME ignore matcher every other scan
* surface uses: `isExcludedDirName` for default dependency/build dirs plus the
* project's `.pi-lens.json` / `.gitignore` patterns via `getProjectIgnoreMatcher`.
* Previously this walk used its own hardcoded skip-dir set, which silently
* dropped user `"ignore": [...]` patterns and diverged from the canonical list
* (#243). The walk is also hard-capped (#250).
*/
async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal) {
const files = [];
const ignoreMatcher = getProjectIgnoreMatcher(root);
async function walk(current) {
if (signal?.aborted || files.length >= maxFiles)
return;
let entries;
try {
entries = await nodeFs.promises.readdir(current, {
withFileTypes: true,
});
}
catch {
return;
}
for (const entry of entries) {
if (signal?.aborted || files.length >= maxFiles)
return;
if (entry.isSymbolicLink())
continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (isExcludedDirName(entry.name))
continue;
if (ignoreMatcher.isIgnored(full, true))
continue;
await walk(full);
}
else if (entry.isFile() &&
!ignoreMatcher.isIgnored(full, false) &&
getServersForFileWithConfig(full).length > 0) {
files.push(full);
}
}
}
await walk(root);
return files;
}
// --- Service ---
export class LSPService {
state;
workspaceProbeLogged = new Set();
warmStartLogged = new Set();
optionalFailureLogged = new Set();
optionalDisabled = new Set();
/** Consecutive failure counts for exponential backoff circuit breaker */
failureCounts = new Map();
/** Server/root keys disabled for the rest of this session after repeated failures. */
permanentlyBroken = new Set();
/**
* Last non-empty diagnostic result per normalized file path.
* Returned as a fallback when no live LSP clients are available so the
* widget keeps showing the last known issues rather than going blank.
*/
lastKnownDiagnostics = new Map();
/**
* SHA-256 of the file content that produced the matching {@link
* lastKnownDiagnostics} entry, when that content is known (set by
* {@link touchFile}). Lets a hot-path consumer verify a cached entry is for
* the *current* bytes before trusting it as fresh — see
* {@link getLastKnownDiagnostics}. Absent for entries written without content
* (the service-level {@link getDiagnostics} merge), so a hash-guarded read of
* those falls through to a fresh check rather than serving a stale result.
*/
lastKnownContentHash = new Map();
lastDiagnosticsHealth = new Map();
recentTouches = new Map();
/** True after shutdown() has been called; blocks new operations */
isDestroyed = false;
constructor() {
this.state = {
clients: new Map(),
servers: new Map(),
broken: new Map(),
inFlight: new Map(),
clientSpawnedAt: new Map(),
demonstratedReady: new Set(),
};
}
/** Guard: return true if service is shutting down or shut down */
checkDestroyed() {
return this.isDestroyed;
}
fingerprintContent(content) {
if (content.length <= 96) {
return `${content.length}:${content}`;
}
return `${content.length}:${content.slice(0, 48)}:${content.slice(-48)}`;
}
/**
* Should the whole touchFile call short-circuit? Only when the caller does
* NOT need diagnostics — those callers still need to wait for the LSP to
* publish, even if the notify itself is a no-op.
*/
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
if (waitForDiagnostics)
return false;
return this.shouldSkipNotify(filePath, content, clientScope);
}
/**
* Should the didOpen/didChange notify be skipped while keeping the
* waitForDiagnostics step? True when the same content was already pushed
* recently. Skipping the notify avoids the diagnostic-cache clear that
* notify.open does, so the LSP doesn't restart computation it already
* finished for the first push.
*
* Concretely: the post-write tool_result fires touchFile with
* diagnosticsMode="none" first; the dispatch-lsp-runner fires it again
* with diagnosticsMode="document" moments later. Without this check the
* second call's notify clears in-progress diagnostics and the LSP has to
* start over — observed as multi-second waits on slow TS projects.
*/
shouldSkipNotify(filePath, content, clientScope) {
if (TOUCH_DEBOUNCE_MS <= 0)
return false;
const key = `${normalizeMapKey(filePath)}:${clientScope}`;
const previous = this.recentTouches.get(key);
if (!previous)
return false;
const now = Date.now();
if (now - previous.touchedAt > TOUCH_DEBOUNCE_MS)
return false;
return previous.fingerprint === this.fingerprintContent(content);
}
markTouched(filePath, content, clientScope) {
const key = `${normalizeMapKey(filePath)}:${clientScope}`;
const now = Date.now();
this.recentTouches.set(key, {
fingerprint: this.fingerprintContent(content),
touchedAt: now,
clientScope,
});
// Trim entries that are already past the debounce window — shouldSkipTouch
// ignores them anyway, so they serve no purpose. Only sweep when the map
// exceeds the threshold to avoid iterating on every call.
if (this.recentTouches.size > 200) {
for (const [k, v] of this.recentTouches) {
if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
this.recentTouches.delete(k);
}
}
}
}
/**
* Key `demonstratedReady`/`clientSpawnedAt`/`state.clients` all share:
* "serverId:normalizedRoot" — the same identity `ensureClientForServer`
* uses to store/look up a client. Deliberately resolved from the
* `LSPServerInfo.root()` config resolver (NOT `LSPClientInfo.root`):
* `touchFile`'s spawned entries carry both `{ client, info }`, and
* resolving from `info.root()` guarantees this lines up with
* `ensureWarmForSweep`'s own key derivation (also via `server.root()`)
* even for a not-fully-real client (test fixture, or any future client
* implementation that doesn't independently stamp `.root`).
*/
async demonstratedReadyKeyFor(server, filePath) {
const root = await server.root(filePath);
if (!root)
return undefined;
return `${server.id}:${normalizeMapKey(root)}`;
}
markDemonstratedReadyKey(key) {
this.state.demonstratedReady.add(key);
}
activeClientsForCwd(cwd, priorityServerIds = []) {
const normalizedCwd = normalizeMapKey(cwd);
const priority = new Map(priorityServerIds.map((serverId, index) => [serverId, index]));
const entries = [];
for (const [key, client] of this.state.clients) {
if (!client.isAlive())
continue;
const separator = key.indexOf(":");
const serverId = separator >= 0 ? key.slice(0, separator) : key;
const root = normalizeMapKey(client.root);
const sameOrNested = root === normalizedCwd ||
root.startsWith(`${normalizedCwd}/`) ||
normalizedCwd.startsWith(`${root}/`);
if (!sameOrNested)
continue;
entries.push({ serverId, client });
}
return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
(priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
}
/**
* Get or create LSP client for a file
* Prevents duplicate client creation via in-flight promise tracking
*/
async getClientForFile(filePath, maxWaitMs, hardCapMs) {
if (this.checkDestroyed())
return undefined;
// Primary selection considers language servers only — auxiliary servers
// (opengrep, …) attach alongside the primary and are never chosen as it.
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
const serverWaitOverrideMs = servers.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
// hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
// prevents tool_result from blocking the TUI for the full LSP cold-start
// window. When no server config sets a wait (serverWaitOverrideMs = 0),
// hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
// take the no-timeout branch and block indefinitely (e.g. pyright, which
// has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
const serverBaseMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
const effectiveMaxWaitMs = hardCapMs !== undefined
? serverBaseMs > 0
? Math.min(serverBaseMs, hardCapMs)
: hardCapMs
: serverBaseMs;
const withBudget = async () => {
if (servers.length === 0)
return undefined;
// Try each matching server
for (const server of servers) {
const spawned = await this.ensureClientForServer(filePath, server);
if (spawned) {
logLatency({
type: "phase",
phase: "lsp_client_selected",
filePath,
durationMs: 0,
metadata: {
serverId: server.id,
candidateCount: servers.length,
},
});
return spawned;
}
}
logLatency({
type: "phase",
phase: "lsp_client_unavailable",
filePath,
durationMs: 0,
metadata: {
candidateCount: servers.length,
servers: servers.map((server) => server.id),
},
});
return undefined;
};
if (!effectiveMaxWaitMs || effectiveMaxWaitMs <= 0) {
return withBudget();
}
const timeoutSentinel = Symbol("lsp-client-wait-timeout");
const waitResult = await Promise.race([
withBudget(),
new Promise((resolve) => setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs)),
]);
if (waitResult === timeoutSentinel) {
// Snapshot known client health — scan by serverId prefix (no root needed)
const knownHealth = [...this.state.clients.entries()]
.filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
.map(([k, c]) => ({
serverId: k.split(":")[0],
alive: c.isAlive(),
spawnedAt: this.state.clientSpawnedAt.get(k) ?? null,
}));
logLatency({
type: "phase",
phase: "lsp_client_wait_timeout",
filePath,
durationMs: effectiveMaxWaitMs,
metadata: {
maxWaitMs: effectiveMaxWaitMs,
serverIds: servers.map((s) => s.id),
// servers absent from knownHealth were never spawned or are still spawning
knownClientHealth: knownHealth,
},
});
return undefined;
}
return waitResult;
}
/**
* Get or create ALL LSP clients that can serve a file.
* Used for diagnostics aggregation across complementary servers.
*/
async getClientsForFile(filePath, excludeServerIds) {
const allServers = getServersForFileWithConfig(filePath);
const servers = excludeServerIds && excludeServerIds.size > 0
? allServers.filter((s) => !excludeServerIds.has(s.id))
: allServers;
if (servers.length === 0)
return { clients: [], serverCountAttempted: 0 };
// Count servers with a valid root as "attempted" — extension-only matches
// that fail the root check are not real spawn attempts.
const roots = await Promise.all(servers.map((s) => s.root(filePath)));
const serverCountAttempted = roots.filter(Boolean).length;
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
return {
clients: spawned.filter((entry) => Boolean(entry)),
serverCountAttempted,
};
}
/**
* Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
* the enabled set. These attach alongside the primary on the with-auxiliary
* diagnostics path (cross-cutting scanners like opengrep).
*/
async getAuxiliaryClientsForFile(filePath, enabledIds) {
if (this.checkDestroyed() || enabledIds.size === 0)
return [];
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
if (servers.length === 0)
return [];
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
return spawned.filter((entry) => Boolean(entry));
}
/**
* Get a warm LSP client for a file without spawning.
* Returns undefined if no matching client is already connected and alive.
*/
async getWarmClientForFile(filePath) {
if (this.checkDestroyed())
return undefined;
const servers = getServersForFileWithConfig(filePath);
for (const server of servers) {
const root = await server.root(filePath);
if (!root)
continue;
const key = `${server.id}:${normalizeMapKey(root)}`;
const existing = this.state.clients.get(key);
if (existing?.isAlive()) {
return { client: existing, info: server };
}
}
return undefined;
}
async ensureClientForServer(filePath, server) {
const root = await server.root(filePath);
if (!root)
return undefined;
const allowInstall = this.shouldAllowInstall(filePath, root);
const normalizedRoot = normalizeMapKey(root);
const key = `${server.id}:${normalizedRoot}`;
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
if (server.availabilityKey &&
isDirectLspCommandTemporarilyUnavailable(server.availabilityKey)) {
logLatency({
type: "phase",
phase: "lsp_client_skipped_unavailable_command",
filePath,
durationMs: 0,
metadata: {
serverId: server.id,
command: server.availabilityKey,
},
});
return undefined;
}
if (isOptionalServer && this.optionalDisabled.has(key)) {
return undefined;
}
if (this.permanentlyBroken.has(key)) {
logLatency({
type: "phase",
phase: "lsp_client_skipped_broken",
filePath,
durationMs: 0,
metadata: {
serverId: server.id,
permanent: true,
},
});
return undefined;
}
const existing = this.state.clients.get(key);
if (existing) {
if (existing.isAlive()) {
if (!this.warmStartLogged.has(key)) {
logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
this.warmStartLogged.add(key);
}
return { client: existing, info: server };
}
// Dead client — was previously alive, now needs respawn
const spawnedAt = this.state.clientSpawnedAt.get(key);
logLatency({
type: "phase",
phase: "lsp_server_respawn",
filePath,
durationMs: 0,
metadata: {
serverId: server.id,
root,
uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
},
});
try {
await existing.shutdown();
}
catch {
/* ignore dead client shutdown errors */
}
this.state.clients.delete(key);
this.state.clientSpawnedAt.delete(key);
this.state.broken.delete(key);
}
const brokenUntil = this.state.broken.get(key);
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
logLatency({
type: "phase",
phase: "lsp_client_skipped_broken",
filePath,
durationMs: 0,
metadata: {
serverId: server.id,
retryInMs: Math.max(0, brokenUntil - Date.now()),
},
});
return undefined;
}
if (typeof brokenUntil === "number" && brokenUntil <= Date.now()) {
this.state.broken.delete(key);
if (isOptionalServer)
this.optionalDisabled.delete(key);
}
const inFlight = this.state.inFlight.get(key);
if (inFlight) {
return inFlight;
}
const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
this.state.inFlight.set(key, spawnPromise);
try {
return await spawnPromise;
}
finally {
this.state.inFlight.delete(key);
}
}
shouldAllowInstall(_filePath, _root) {
return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
}
/**
* Internal: spawn a client for a server/root combination
*/
async spawnClient(server, root, key, filePath, allowInstall) {
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
const startedAt = Date.now();
logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
recordLsp(server.id, root, "spawn_start");
try {
const spawned = await server.spawn(root, { allowInstall });
if (!spawned) {
logSessionStart(`lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`);
recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
// When installs are disabled, an unavailable binary is an expected
// policy outcome, not proof the server/root is broken. Cool down briefly
// to avoid hot-looping PATH probes, but do not count toward permanent
// disablement: a user may install or expose the binary on PATH during the
// same session and should not need a full LSP reset.
if (!allowInstall) {
logSessionStart(`lsp spawn ${server.id}: unavailable with install disabled; temporary cooldown only`);
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
return undefined;
}
const uCount = (this.failureCounts.get(key) ?? 0) + 1;
this.failureCounts.set(key, uCount);
const uCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (uCount - 1), BROKEN_MAX_COOLDOWN_MS);
this.state.broken.set(key, Date.now() + uCooldown);
if (uCount >= BROKEN_PERMANENT_AFTER) {
this.permanentlyBroken.add(key);
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
}
return undefined;
}
const override = getServerInitOverride(server.id, filePath);
const mergedInit = mergeInitializationOptions(spawned.initialization, override?.initializationOptions);
const client = await createLSPClient({
serverId: server.id,
process: spawned.process,
root,
initialization: mergedInit,
initializeTimeoutMs: server.initializeTimeoutMs,
launchVariant: spawned.launchVariant,
});
const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
? client.getWorkspaceDiagnosticsSupport()
: {
advertised: false,
mode: "push-only",
diagnosticProviderKind: "unavailable",
};
this.state.clients.set(key, client);
this.state.clientSpawnedAt.set(key, Date.now());
this.failureCounts.delete(key);
if (isOptionalServer) {
this.optionalDisabled.delete(key);
this.optionalFailureLogged.delete(key);
}
logSessionStart(`lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`);
recordLsp(server.id, root, "spawn_success", Date.now() - startedAt);
if (!this.workspaceProbeLogged.has(key)) {
logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
this.workspaceProbeLogged.add(key);
}
return { client, info: server };
}
catch (err) {
recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
if (!isOptionalServer || !this.optionalFailureLogged.has(key)) {
logSessionStart(`lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`);
if (isOptionalServer) {
this.optionalFailureLogged.add(key);
}
}
const eCount = (this.failureCounts.get(key) ?? 0) + 1;
this.failureCounts.set(key, eCount);
const eCooldown = isOptionalServer
? OPTIONAL_LSP_RETRY_COOLDOWN_MS
: Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (eCount - 1), BROKEN_MAX_COOLDOWN_MS);
this.state.broken.set(key, Date.now() + eCooldown);
if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
this.permanentlyBroken.add(key);
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
}
if (isOptionalServer) {
this.optionalDisabled.add(key);
}
return undefined;
}
}
/**
* Open a file in LSP (sends textDocument/didOpen)
*/
async openFile(filePath, content, options) {
if (this.checkDestroyed())
return;
const spawned = await this.getClientForFile(filePath, undefined, options?.spawnBudgetMs);
if (!spawned)
return;
const languageId = getLanguageId(filePath) ?? "plaintext";
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
}
/**
* Update file content (sends textDocument/didChange)
*/
async updateFile(filePath, content) {
if (this.checkDestroyed())
return;
const spawned = await this.getClientForFile(filePath);
if (!spawned)
return;
await spawned.client.notify.change(filePath, content);
}
/**
* Touch a file like OpenCode's LSP flow: ensure document is open/synced,
* and optionally collect diagnostics with explicit scope.
*/
async touchFile(filePath, content, options = {}) {
if (this.checkDestroyed())
return;
const startedAt = Date.now();
const normalizedPath = normalizeMapKey(filePath);
const diagnosticsMode = options.collectDiagnostics
? (options.diagnostics ?? "document")
: (options.diagnostics ?? "none");
const source = options.source ?? "unknown";
const clientScope = options.clientScope ?? (diagnosticsMode === "full" ? "all" : "primary");
const useAllClients = clientScope === "all";
let spawned;
let serverCountAttempted;
if (useAllClients) {
const result = await this.getClientsForFile(filePath, options.excludeServerIds);
spawned = result.clients;
serverCountAttempted = result.serverCountAttempted;
}
else if (clientScope === "with-auxiliary") {
// Primary language server + the enabled cross-cutting auxiliaries
// (opengrep, …). The aggregation layer merges/dedups their diagnostics.
const [entry, aux] = await Promise.all([
this.getClientForFile(filePath, options.maxClientWaitMs),
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
]);
spawned = entry ? [entry, ...aux] : aux;
serverCountAttempted = spawned.length;
}
else {
const entry = await this.getClientForFile(filePath, options.maxClientWaitMs);
spawned = entry ? [entry] : [];
serverCountAttempted =
spawned.length > 0
? 1
: getServersForFileWithConfig(filePath).length > 0
? 1
: 0;
}
if (spawned.length === 0) {
logLatency({
type: "phase",
phase: "lsp_touch_file",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: {
serverCountAttempted,
serverCountReady: 0,
clientScope,
diagnosticsMode,
source,
maxClientWaitMs: options.maxClientWaitMs,
failureKind: "no_clients",
},
});
return;
}
if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none")) {
logLatency({
type: "phase",
phase: "lsp_touch_file",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: {
serverCountReady: spawned.length,
clientScope,
diagnosticsMode,
source,
failureKind: "success",
skipped: true,
reason: "debounced_unchanged_content",
},
});
return [];
}
const languageId = getLanguageId(filePath) ?? "plaintext";
const silent = options.silent ?? false;
// When the same content was already pushed to the LSP within the touch
// debounce window, skip the notify — pushing again clears the LSP's
// diagnostic cache (via notify.open) and forces it to restart work it
// already did. This is what makes the post-write touch + dispatch-lsp-
// runner touch sequence expensive on slow TS projects.
const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
let notifyWriteTimedOut = false;
if (!notifySkipped) {
// Bounded so a backpressured write can't hang the caller (see
// notifyWriteBudgetMs). On timeout we proceed: the diagnostics wait below
// is separately bounded and simply returns no fresh diagnostics.
const wrote = await withDeadline(Promise.all(spawned.map((entry) => entry.client.notify.open(filePath, content, languageId, undefined, silent))), { ms: notifyWriteBudgetMs(), onTimeout: "undefined", onReject: "undefined" });
if (wrote === undefined) {
notifyWriteTimedOut = true;
logLatency({
type: "phase",
phase: "lsp_notify_timeout",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: { source, clientScope, serverCount: spawned.length },
});
}
}
let diagnosticsTimedOut = false;
if (diagnosticsMode !== "none") {
// Resolution: env wins so users can tune the cap without rebuilding.
// Otherwise, on the single-server hot path (primary scope), use that
// server's own strategy budget (server-strategies.ts) so a fast server
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
// ceiling that exists to protect the per-edit pipeline budget (#203).
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
// gets the same per-server treatment as "with-auxiliary" — each spawned
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
// budget instead of one flat number shared by every server. This was
// never a deliberate "all means wait for the group ceiling" semantic:
// #203 introduced perServerTimeout only for the single-server primary
// path and left "full"/"all" on the pre-existing flat resolution
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
// without revisiting "all". The one property "all" genuinely needs —
// the touch's overall detection deadline is the SLOWEST spawned
// server's budget, not the fastest — is unaffected: `timeoutMs` below
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
// timeoutFor is selected, so a slow auxiliary still gets to run to its
// own budget before the touch is logged as timed out. What changes is
// only that a fast server's *individual* `waitForDiagnostics` call
// (further below) now resolves/times out against its own budget
// instead of blocking to the flat multi-server number.
const envWait = readEnvDiagnosticsWaitMs();
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
// #645: resolve each spawned server's "is this the first same-sweep
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
// (it marks the server seen), and `perServerTimeout` below is invoked
// twice per server in this call (once to compute the overall
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
// the gate directly from inside `perServerTimeout` would consume the
// "first touch" slot on the first of those two calls and read as
// already-warm on the second, silently shortchanging the very touch
// that was supposed to get the full budget.
const sweepFirstTouch = new Map();
if (options.sweepIndexGate) {
for (const entry of spawned) {
const strategy = getStrategy(entry.client.serverId);
if (strategy.workspaceIndexing) {
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
}
}
}
// Each server gets its OWN deadline, bounded by the caller cap as a
// CEILING (never a floor) — so a clean push-silent primary (typescript
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
// slow aux (opengrep) can't override the per-edit cap. Resolves as soon
// as a server publishes; this is just its individual deadline. (#242)
const perServerTimeout = (serverId) => {
const strategy = getStrategy(serverId);
let strategyWait = strategy.aggregateWaitMs;
// #645: a `workspaceIndexing` server (marksman) only needs the
// full budget for the FIRST same-sweep touch to it — every
// subsequent touch in this sweep uses the much shorter warm-wait
// instead, since the one-time index build only needs to finish
// once. `sweepFirstTouch` only has entries when a sweep gate was
// passed in AND the strategy is marked, so a per-edit touch
// (no gate) or an unmarked server is completely unaffected.
const isFirstTouch = sweepFirstTouch.get(serverId);
if (isFirstTouch === false && strategy.workspaceIndexing) {
strategyWait =
strategy.workspaceIndexingWarmWaitMs ??
Math.min(300, strategyWait);
}
if (callerCap !== undefined) {
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
}
return strategyWait > 0 ? strategyWait : modeFloor;
};
let timeoutFor;
if (envWait !== undefined) {
// Env override is a single flat cap so users can tune without rebuilding.
timeoutFor = () => envWait;
}
else if ((!useAllClients && spawned.length === 1) ||
clientScope === "with-auxiliary" ||
clientScope === "all") {
timeoutFor = perServerTimeout;
}
else {
// Fail-safe for any future clientScope this branch hasn't been
// taught about yet — keep the old flat resolution rather than
// silently mis-budgeting an unrecognized scope.
timeoutFor = () => callerCap ?? modeFloor;
}
// Detection deadline = the slowest individual server's budget.
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
const waitStartedAt = Date.now();
await Promise.all(spawned.map((entry) => {
const serverTimeout = timeoutFor(entry.client.serverId);
const baseline = diagnosticBaselines.get(entry.client);
const wait = !notifySkipped && Number.isFinite(baseline)
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
minVersion: baseline,
})
: entry.client.waitForDiagnostics(filePath, serverTimeout);
return wait.catch(() => undefined);
}));
const waitedMs = Date.now() - waitStartedAt;
// Within ~20 ms of the configured budget we treat it as a timeout;
// the LSP didn't beat the cap. Diagnostics that arrive late still
// land in the client's cache and surface on the next edit.
if (waitedMs + 20 >= timeoutMs) {
diagnosticsTimedOut = true;
logLatency({
type: "phase",
phase: "lsp_diagnostics_timeout",
filePath: normalizedPath,
durationMs: waitedMs,
metadata: {
source,
clientScope,
diagnosticsMode,
timeoutMs,
},
});
}
}
const collected = options.collectDiagnostics
? mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
: undefined;
// A touch is inconclusive when EITHER the notify write or the
// diagnostics wait hit their deadline for ANY of the spawned servers
// (these flags are touch-wide, covering the whole `Promise.all` over
// `spawned` — see the field doc on the return type). We deliberately
// err toward caution here: `collected` merges diagnostics across every
// spawned server, so even a partial timeout (e.g. a slow auxiliary
// while the primary answered) means the merged result may be missing
// findings that just hadn't arrived yet — it must not be trusted as a
// confirmed answer.
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
// spawned server so a later sweep in this session sees the check as a
// no-op instead of paying the warm-up round trip again.
if (diagnosticsMode !== "none" && !inconclusive) {
for (const entry of spawned) {
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
if (key)
this.markDemonstratedReadyKey(key);
}
}
// Prime the last-known cache WITH the hash of the content we just synced,
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
// cached diagnostics are for the current bytes before reusing them instead
// of paying for a second open+wait. Only when we actually collected — a
// non-collecting touch (didChange-only) leaves the prior entry intact.
// Skip this entirely when the touch was inconclusive: an unconfirmed
// empty `collected` must never erase a previously-confirmed non-empty
// record (that's the #570 bug — a timeout silently reporting as clean
// and wiping out known-good diagnostic state).
if (collected !== undefined && !inconclusive) {
const normalizedKey = normalizeMapKey(filePath);
if (collected.length > 0) {
this.lastKnownDiagnostics.set(normalizedKey, collected);
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
}
else {
this.lastKnownDiagnostics.delete(normalizedKey);
this.lastKnownContentHash.delete(normalizedKey);
}
}
if (collected !== undefined && inconclusive) {
// Non-enumerable so JSON.stringify / spread / logging of the
// diagnostics array is unaffected — this is a query-only bonus
// field, not a shape change existing array consumers need to know
// about.
Object.defineProperty(collected, "inconclusive", {
value: true,
enumerable: false,
configurable: true,
});
}
// Only refresh the recent-touches entry when we actually pushed. Skipping
// here keeps the original push timestamp intact so the debounce window
// expires naturally instead of being extended by every reuse.
if (!notifySkipped) {
this.markTouched(filePath, content, clientScope);
}
logLatency({
type: "phase",
phase: "lsp_touch_file",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: {
serverCountReady: spawned.length,
clientScope,
diagnosticsMode,
source,
failureKind: "success",
collectedDiagnostics: collected?.length,
notifySkipped,
notifyWriteTimedOut,
diagnosticsTimedOut,
inconclusive,
},
});
return collected ?? [];
}
/**
* Get diagnostics for a file
*/
getDiagnosticsHealth(filePath) {
return this.lastDiagnosticsHealth.get(normalizeMapKey(filePath));
}
/**
* Return whatever LSP diagnostics were last cached for this file without
* triggering a fresh open / wait / merge. Returns `undefined` when nothing
* was ever cached; callers should treat that as distinct from "cached but
* empty" (`[]`), which means LSP confirmed no diagnostics last time.
*
* Intended for hot-path consumers (e.g. actionable-warnings at turn_end)
* that already paid for a `touchFile` during dispatch and just want to
* read the result without a second LSP round trip.
*
* Pass `expectedContentHash` (sha256 of the current file bytes) to guard
* against staleness: the entry is returned only when it was primed by a
* `touchFile` for the *same* content. On mismatch — or for an entry written
* without content (the service-level merge) — this returns `undefined` so the
* caller does a fresh check instead of serving a previous turn's diagnostics.
* Omit it for display consumers (the widget) that accept last-known.
*/
getLastKnownDiagnostics(filePath, expectedContentHash) {
const normalizedKey = normalizeMapKey(filePath);
if (expectedContentHash !== undefined) {
const knownHash = this.lastKnownContentHash.get(normalizedKey);
if (knownHash === undefined || knownHash !== expectedContentHash) {
return undefined;
}
}
return this.lastKnownDiagnostics.get(normalizedKey);
}
async getDiagnostics(filePath, diagnosticsMode = "full") {
const normalizedPath = normalizeMapKey(filePath);
if (this.checkDestroyed()) {
this.lastDiagnosticsHealth.set(normalizedPath, {
health: "destroyed",
failureKind: "destroyed",
serverCountAttempted: 0,
serverCountReady: 0,
candidateServerIds: getServersForFileWithConfig(filePath).map((s) => s.id),
mergedCount: 0,
dedupDroppedCount: 0,
checkedAt: new Date().toISOString(),
});
return [];
}
const startedAt = Date.now();
const candidateServerIds = getServersForFileWithConfig(filePath).map((s) => s.id);
const { clients: spawned, serverCountAttempted } = await this.getClientsForFile(filePath);
if (spawned.length === 0) {
const stale = this.lastKnownDiagnostics.get(normalizedPath);
const failureKind = stale?.length ? "no_clients_stale" : "no_clients";
this.lastDiagnosticsHealth.set(normalizedPath, {
health: failureKind,
failureKind,
serverCountAttempted,
serverCountReady: 0,
candidateServerIds,
mergedCount: stale?.length ?? 0,
dedupDroppedCount: 0,
checkedAt: new Date().toISOString(),
});
logLatency({
type: "phase",
phase: "lsp_diagnostics_aggregate",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: {
serverCountAttempted,
serverCountReady: 0,
mergedCount: stale?.length ?? 0,
dedupDroppedCount: 0,
failureKind,
health: failureKind,
servers: [],
},
});
return stale ?? [];
}
const clientWaits = spawned.map(async (entry) => {
const waitStart = Date.now();
const strategy = getStrategy(entry.info.id);
await entry.client.waitForDiagnostics(filePath, strategy.aggregateWaitMs);
let diagnostics = entry.client.getDiagnostics(filePath);
const firstWaitMs = Date.now() - waitStart;
if (strategy.expectSemanticSecondPush &&
diagnostics.length === 0 &&
firstWaitMs < DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS) {
await entry.client.waitForDiagnostics(filePath, DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS);
diagnostics = entry.client.getDiagnostics(filePath);
}
return {
serverId: entry.info.id,
waitMs: Date.now() - waitStart,
diagnosticCount: diagnostics.length,
diagnostics,
};
});
// Document mode: 0ms grace — return as soon as any client has results.
// Full mode: 400ms grace — wait a bit for other clients to catch up.
const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
// Result-aware racing: trigger early-unblock when any client has results,
// OR when a seedFirstPush server returns (its first push is authoritative
// even when empty — waiting longer yields nothing more).
const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
graceMs,
});
// Fill in any slots that timed out before producing results.
const earlyUnblockedCount = spawned.length - perServer.length;
const perServerFull = spawned.map((entry) => {
const found = perServer.find((r) => r.serverId === entry.info.id);
return (found ?? {
serverId: entry.info.id,
waitMs: getStrategy(entry.info.id).aggregateWaitMs,
diagnosticCount: 0,
diagnostics: [],
});
});
// Deduplicate across servers (same diagnostic reported by multiple tools).
const merged = [];
const seen = new Set();
for (const entry of perServerFull) {
for (const diagnostic of entry.diagnostics) {
const key = [
diagnostic.range.start.line,
diagnostic.range.start.character,
diagnostic.message,
].join(":");
if (seen.has(key))
continue;
seen.add(key);
merged.push(diagnostic);
}
}
const rawCount = perServerFull.reduce((sum, entry) => sum + entry.diagnosticCount, 0);
const serversWithDiagnostics = perServerFull.filter((entry) => entry.diagnosticCount > 0).length;
const failureKind = merged.length === 0 ? "ok_empty" : "success";
this.lastDiagnosticsHealth.set(normalizedPath, {
health: failureKind === "success" ? "ok" : "ok_empty",
failureKind,
serverCountAttempted,
serverCountReady: perServerFull.length,
candidateServerIds,
mergedCount: merged.length,
dedupDroppedCount: rawCount - merged.length,
checkedAt: new Date().toISOString(),
});
logLatency({
type: "phase",
phase: "lsp_diagnostics_aggregate",
filePath: normalizedPath,
durationMs: Date.now() - startedAt,
metadata: {
serverCountAttempted,
serverCountReady: perServerFull.length,
serverCountWithDiagnostics: serversWithDiagnostics,
mergedCount: merged.length,
dedupDroppedCount: rawCount - merged.length,
earlyUnblockedCount,
diagnosticsMode,
failureKind,
health: failureKind === "success" ? "ok" : "ok_empty",
servers: perServerFull.map((entry) => ({
id: entry.serverId,
waitMs: entry.waitMs,
diagnosticCount: entry.diagnosticCount,
})),
},
});
// Keep last known so the widget can show stale diagnostics if LSP dies.
// Live clients returning [] means genuinely no errors — clear the stale
// entry so the widget doesn't show resolved issues. This path has no
// content in hand, so drop any content hash: a hash-guarded read won't
// trust this entry as current (it falls through to a fresh check), while
// the unguarded widget read still gets last-known for display.
if (merged.length > 0) {
this.lastKnownDiagnostics.set(normalizedPath, merged);
}
else {
this.lastKnownDiagnostics.delete(normalizedPath);
}
this.lastKnownContentHash.delete(normalizedPath);
return merged;
}
hashContent(content) {
return createHash("sha256").update(content).digest("hex");
}
/**
* Navigation: go to definition
*/
async definition(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.definition(filePath, line, character);
}
/**
* Navigation: go to the type definition of the symbol at a position
*/
async typeDefinition(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.typeDefinition(filePath, line, character);
}
/**
* Navigation: go to the declaration of the symbol at a position
*/
async declaration(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.declaration(filePath, line, character);
}
/**
* Navigation: find all references
*/
async references(filePath, line, character, includeDeclaration = true) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.references(filePath, line, character, includeDeclaration);
}
/**
* Navigation: hover info
*/
async hover(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return null;
return spawned.client.hover(filePath, line, character);
}
/**
* Navigation: signature help at cursor position
*/
async signatureHelp(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return null;
return spawned.client.signatureHelp(filePath, line, character);
}
/**
* Navigation: symbols in document
*/
async documentSymbol(filePath) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.documentSymbol(filePath);
}
/**
* Navigation: workspace-wide symbol search
*/
async workspaceSymbol(query, filePath) {
if (filePath) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.workspaceSymbol(query);
}
// Use the first active client for workspace-level queries
const clients = Array.from(this.state.clients.values());
if (clients.length === 0)
return [];
return clients[0].workspaceSymbol(query);
}
/**
* Commands advertised for workspace/executeCommand. If filePath is given,
* the server for that file; otherwise the first active client.
*/
async getAdvertisedCommands(filePath) {
if (filePath) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.getAdvertisedCommands();
}
const first = this.state.clients.values().next().value;
return first ? first.getAdvertisedCommands() : [];
}
/**
* Run a server command via workspace/executeCommand (hardened: allowlisted by
* advertisement in the client). If filePath is given, target that file's
* server; otherwise the first active client.
*/
async executeCommand(filePath, command, args) {
if (filePath) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned) {
return { executed: false, reason: "no LSP server for file" };
}
return spawned.client.executeCommand(command, args);
}
const first = this.state.clients.values().next().value;
if (!first)
return { executed: false, reason: "no active LSP server" };
return first.executeCommand(command, args);
}
/**
* Capability snapshot for LSP operations.
* If filePath is provided, probes that server; otherwise uses first active client.
*/
async getOperationSupport(filePath) {
if (filePath) {
const spawned = await this.getClientForFile(filePath);
if (!spawned)
return null;
const getter = spawned.client.getOperationSupport;
if (typeof getter !== "function")
return null;
return getter();
}
const first = this.state.clients.values().next().value;
if (!first)
return null;
const getter = first.getOperationSupport;
if (typeof getter !== "function")
return null;
return getter();
}
/**
* Capability snapshot for workspace diagnostics support.
* If filePath is provided, probes that server; otherwise uses first active client.
*/
async getCapabilitySnapshots(filePath) {
if (this.checkDestroyed())
return [];
const snapshots = [];
if (filePath) {
const servers = getServersForFileWithConfig(filePath);
for (const server of servers) {
const root = await server.root(filePath);
if (!root)
continue;
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
if (!client?.isAlive())
continue;
snapshots.push({
serverId: server.id,
root,
operationSupport: client.getOperationSupport(),
workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
advertisedCommands: client.getAdvertisedCommands(),
rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
launchVariant: client.getLaunchVariant?.(),
});
}
return snapshots;
}
for (const [key, client] of this.state.clients) {
if (!client.isAlive())
continue;
const separator = key.indexOf(":");
const serverId = separator >= 0 ? key.slice(0, separator) : key;
snapshots.push({
serverId,
root: client.root,
operationSupport: client.getOperationSupport(),
workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
advertisedCommands: client.getAdvertisedCommands(),
rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
launchVariant: client.getLaunchVariant?.(),
});
}
return snapshots;
}
async getWorkspaceDiagnosticsSupport(filePath) {
if (filePath) {
const spawned = await this.getClientForFile(filePath);
if (!spawned)
return null;
const getter = spawned.client.getWorkspaceDiagnosticsSupport;
if (typeof getter !== "function")
return null;
return getter();
}
const first = this.state.clients.values().next().value;
if (!first)
return null;
const getter = first.getWorkspaceDiagnosticsSupport;
if (typeof getter !== "function")
return null;
return getter();
}
/**
* Navigation: available code actions at position/range
*/
async codeAction(filePath, line, character, endLine, endCharacter) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.codeAction(filePath, line, character, endLine, endCharacter);
}
/**
* Navigation: rename symbol at position
*/
async rename(filePath, line, character, newName) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return null;
return spawned.client.rename(filePath, line, character, newName);
}
async renameFile(oldFilePath, newFilePath, options) {
const cwd = options.cwd;
const apply = options.apply ?? false;
const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
const willRenameFailures = [];
const didRenameFailures = [];
const willResults = await Promise.all(activeClients.map(async ({ serverId, client }) => {
try {
return {
serverId,
edit: await client.willRenameFiles(oldFilePath, newFilePath),
};
}
catch (err) {
willRenameFailures.push({
serverId,
error: err instanceof Error ? err.message : String(err),
});
return { serverId, edit: null };
}
}));
const successfulWillResults = willResults.filter((result) => !willRenameFailures.some((failure) => failure.serverId === result.serverId));
if (activeClients.length > 0 && successfulWillResults.length === 0) {
throw new Error(`workspace/willRenameFiles failed for all active LSP servers: ${willRenameFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
}
const merged = mergeWorkspaceTextEditsByPriority(successfulWillResults);
const summary = summarizeWorkspaceEdit(merged.edit, cwd);
if (!apply) {
return {
applied: false,
serverIds: activeClients.map((entry) => entry.serverId),
willRenameFailures,
didRenameFailures,
droppedConflicts: merged.droppedConflicts,
inputEditCount: merged.inputEditCount,
summary,
};
}
const applied = await applyWorkspaceEdit(merged.edit, cwd);
await fs.mkdir(path.dirname(newFilePath), { recursive: true });
await fs.rename(oldFilePath, newFilePath);
const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
path.basename(oldFilePath);
const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
path.basename(newFilePath);
const renameDescription = `Renamed ${relOld} → ${relNew}`;
await Promise.all(activeClients.map(async ({ serverId, client }) => {
try {
await client.didRenameFiles(oldFilePath, newFilePath);
}
catch (err) {
didRenameFailures.push({
serverId,
error: err instanceof Error ? err.message : String(err),
});
}
}));
return {
applied: true,
serverIds: activeClients.map((entry) => entry.serverId),
willRenameFailures,
didRenameFailures,
droppedConflicts: merged.droppedConflicts,
inputEditCount: merged.inputEditCount,
summary,
descriptions: [...applied.descriptions, renameDescription],
files: [...new Set([...applied.files, oldFilePath, newFilePath])],
};
}
/**
* Navigation: go to implementation
*/
async implementation(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.implementation(filePath, line, character);
}
/**
* Navigation: prepare call hierarchy at position
*/
async prepareCallHierarchy(filePath, line, character) {
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.prepareCallHierarchy(filePath, line, character);
}
/**
* Navigation: find incoming calls (callers)
*/
async incomingCalls(item) {
const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.incomingCalls(item);
}
/**
* Navigation: find outgoing calls (callees)
*/
async outgoingCalls(item) {
const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
if (!spawned)
return [];
return spawned.client.outgoingCalls(item);
}
/**
* #667: shared warm-check/ensure-warm step for BOTH `lsp_diagnostics`
* (`tools/lsp-diagnostics.ts`'s batch/directory sweep) and
* `lens_diagnostics mode=full` (`runWorkspaceDiagnostics` below) — one
* implementation instead of two hand-copied ones, since both already
* share `groupFilesByPrimaryServer`/`runPerServerGroups` (#631).
*
* Root cause this closes (#667): `serverCountReady:1` only proves the
* server process spawned and passed the LSP `initialize` handshake — it
* does NOT prove the server can usefully answer a diagnostics request
* yet. tsserver-style servers can still be loading/indexing the project
* internally for seconds after `initialize` resolves, and without this
* check whichever file(s) land first in a sweep pay that cost as
* individual per-file timeouts (observed: the first 5 files of a
* 100-file sweep all hit the exact per-file ceiling with
* `serverCountReady:1`, file 6 onward clean and fast).
*
* `representativeFile` should be one file from the group/batch about to
* be swept — used only to resolve which server(s) serve it and, if
* needed, to perform the warm-up touch itself.
*
* Cheap/no-op when every non-auxiliary server for `representativeFile`
* has already answered a confirmed diagnostics touch earlier in THIS
* session (`isDemonstratedReady` — set by `touchFile` above): resolves
* the server list and root(s) (no spawn, no I/O beyond that) and
* returns immediately. Only when at least one candidate server hasn't
* demonstrated readiness does this perform one deliberate warm-up
* `touchFile` round trip against `representativeFile`, bounded by its
* OWN generous budget (`warmupTimeoutMs`/`PI_LENS_LSP_WARMUP_TIMEOUT_MS`
* — distinct from the per-file sweep budget), and waits for it to
* settle (success, timeout, or abort) before returning. This does NOT
* change the per-file wait budgets or confirmed/unconfirmed contract
* (#242/#611/#634) the sweep itself uses — it only runs once, before
* the sweep's own loop starts.
*
* Returns `performedWarmup: true` only when the round trip actually ran
* (false = already warm, no-op) — tests assert on this to guard against
* the warm-up becoming a mandatory extra round trip on every sweep.
*/
async ensureWarmForSweep(representativeFile, options = {}) {
if (this.checkDestroyed() || options.signal?.aborted) {
return { performedWarmup: false };
}
const servers = getServersForFileWithConfig(representativeFile).filter((s) => s.role !== "auxiliary");
if (servers.length === 0)
return { performedWarmup: false };
// A server with no resolvable root never spawns a client for this file
// either way, so it can't block "already warm" — only servers that WILL
// actually be used count toward the readiness check. Same key
// derivation `touchFile` uses to mark readiness (`demonstratedReadyKeyFor`)
// so this lines up exactly regardless of what a client instance itself
// reports as its `.root`.
const keys = await Promise.all(servers.map((server) => this.demonstratedReadyKeyFor(server, representativeFile)));
const alreadyWarm = keys.every((key) => key === undefined || this.state.demonstratedReady.has(key));
if (alreadyWarm)
return { performedWarmup: false };
let content;
try {
content = await nodeFs.promises.readFile(representativeFile, "utf-8");
}
catch {
// Nothing to warm up with — the real sweep's own read will surface
// the file error.
return { performedWarmup: false };
}
if (options.signal?.aborted)
return { performedWarmup: false };
const timeoutMs = options.timeoutMs ?? warmupTimeoutMs();
const startedAt = Date.now();
logLatency({
type: "phase",
phase: "lsp_sweep_warmup_start",
filePath: representativeFile,
durationMs: 0,
metadata: { serverIds: servers.map((s) => s.id), timeoutMs },
});
const warmupAttempt = this.touchFile(representativeFile, content, {
diagnostics: "document",
collectDiagnostics: false,
clientScope: "primary",
source: "lsp_sweep_warmup",
maxClientWaitMs: timeoutMs,
maxDiagnosticsWaitMs: timeoutMs,
});
await (options.signal
? Promise.race([
withDeadline(warmupAttempt, { ms: timeoutMs, onTimeout: "undefined" }),
new Promise((resolve) => {
if (options.signal.aborted) {
resolve();
return;
}
options.signal.addEventListener("abort", () => resolve(), {
once: true,
});
}),
])
: withDeadline(warmupAttempt, { ms: timeoutMs, onTimeout: "undefined" }));
logLatency({
type: "phase",
phase: "lsp_sweep_warmup_done",
filePath: representativeFile,
durationMs: Date.now() - startedAt,
metadata: { serverIds: servers.map((s) => s.id), timeoutMs },
});
return { performedWarmup: true };
}
/**
* Actively scan every LSP-supported source file under a project root.
* This is intentionally expensive and used only by explicit project-wide tools.
*/
async runWorkspaceDiagnostics(cwd, options = {}) {
const startedAt = Date.now();
const root = path.resolve(cwd);
const { signal } = options;
// Cap the per-file LSP sweep: a Next.js-scale project can route thousands
// of files through the language server at concurrency 8, and without a
// caller cap that grinds for tens of minutes (#341). `maxFiles` lets
// lens_diagnostics' `maxLspFiles` bound it; falls back to the env/default.
const maxFiles = typeof options.maxFiles === "number" &&
Number.isFinite(options.maxFiles) &&
options.maxFiles > 0
? Math.floor(options.maxFiles)
: getMaxWorkspaceDiagnosticFiles();
const files = options.files
? options.files.slice(0, maxFiles)
: await collectWorkspaceDiagnosticFiles(root, maxFiles, signal);
// Per-file wall-clock: a language server that hangs during spawn/initialize
// would otherwise park a worker on `touchFile` FOREVER (the per-edit
// diagnostic wait is bounded, but client acquisition here is not) — the root
// of an observed multi-hour hang. Budget each file so the worker always
// returns to the loop (and its abort check). Env-tunable.
const perFileMs = (() => {
const raw = Number(process.env.PI_LENS_LSP_WORKSPACE_PER_FILE_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 15_000;
})();
const results = [];
let completed = 0;
let timedOutFiles = 0;
let lastHeartbeat = Date.now();
// Group files by their primary language server (#387, extracted as
// `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
// single-threaded per project: N concurrent touches to ONE server don't
// parallelize, they queue. That inflates the working set (each didOpen can
// force a project recheck) and cascades per-file-budget timeouts by queue
// position (observed: 51/123 files "timed out" purely from being behind
// others in an 8-wide flat pool). So serialize WITHIN a server (one
// in-flight touch each) and parallelize ACROSS servers — real parallelism
// where it exists (a mixed TS+Python repo runs both), no flooding where it
// doesn't. Capped so a many-language monorepo can't spawn unbounded groups.
const groups = groupFilesByPrimaryServer(files);
// #645: shared across every file/group in THIS sweep — lets a
// `workspaceIndexing`-strategy server (marksman) pay its full
// aggregateWaitMs budget only for the first file that touches it,
// instead of every markdown file independently racing the same cold
// workspace-index build. Scoped to this one call (never stored on the
// service), so it can't leak into a later sweep or a per-edit touch.
const sweepIndexGate = createSweepIndexGate();
// Opt-in project-wide pull: one `workspace/diagnostic` per server instead of
// N per-file opens (#387 Item 2). Gated off by default — a cold server can
// answer with an empty/partial report that reads as a false "all clean", and
// the pull covers only the primary server (files with auxiliary scanners
// would lose those). Enabled per group only when the server advertises it and
// no file in the group has an auxiliary; any miss falls back to per-file.
const workspacePullEnabled = process.env.PI_LENS_LSP_WORKSPACE_PULL === "1";
// Start marker: without this a hang leaves no trace that the sweep even
// began (the completion log below never fires). Per-file `lsp_touch_file`
// phases + these heartbeats let a hang be bracketed to a file/time.
logLatency({
type: "phase",
phase: "lsp_workspace_diagnostics_start",
filePath: root,
durationMs: 0,
metadata: {
fileCount: files.length,
maxFiles,
perFileMs,
serverGroups: groups.length,
},
});
// #608: pre-open every swept file's document, across whichever server(s)
// it belongs to, in ONE fast pass BEFORE a group's serial per-file
// diagnostics-wait loop starts (see the per-group worker below).
// `handleNotifyOpen`'s workspace/didChangeWatchedFiles enqueue (#271)
// only coalesces opens that land within its 100ms debounce window
// (`WatchedFilesQueue`, watch-queue.ts) — it arms the flush timer on
// the FIRST enqueue and just accumulates on every call after that
// until the timer fires. The per-file loop waits up to several
// seconds per file for diagnostics before moving to the next one, so
// consecutive first-opens during a sweep land far outside that 100ms
// window: every previously-unopened file used to fire its OWN
// project-wide recheck notification instead of one for the whole
// sweep, and later files timed out purely from queueing behind those
// rechecks (#608). Firing every file's open notification here,
// back-to-back with no diagnostics wait between them, keeps them
// inside the debounce window so `WatchedFilesQueue` coalesces them
// into (at most a small handful of) flushes per server the same way
// a per-edit dispatch burst already does. By the time `processFile`
// below calls `touchFile`, each document is already in
// `openDocuments`, so `handleNotifyOpen` takes the cheap already-open
// `didChange` branch and enqueues nothing further. Content read here
// is cached so `processFile` doesn't re-read the same file from disk.
//
// Only used on the per-file fallback path — a group whose
// `workspace/diagnostic` pull (#387 Item 2) succeeds never opens
// per-file documents at all, so pre-opening ahead of a pull attempt
// would be pure waste (and would break that path's "no per-file
// opens" guarantee). Called from inside each group's own serial loop
// (below), so it inherits the SAME #387 shape: one in-flight open at
// a time per server, parallel across distinct servers via the
// existing group-worker pool.
const contentCache = new Map();
const preOpenGroupFiles = async (groupFiles) => {
for (const filePath of groupFiles) {
if (signal?.aborted)
return;
let content;
try {
content = await nodeFs.promises.readFile(filePath, "utf-8");
}
catch {
continue; // processFile's own read will surface the real error.
}
contentCache.set(filePath, content);
const languageId = getLanguageId(filePath) ?? "plaintext";
// #615: this pre-open pass had NO bound at all — unlike every other
// per-file step in this sweep (`processFile`'s `touchFile` call
// below is `withDeadline`-wrapped). `getClientsForFile` can wait on
// a server spawn/initialize handshake, and `notify.open` can wait
// on a stuck notification write; either hanging left the WHOLE
// sweep stuck with no heartbeat and no escape (a real dogfooding
// incident: `lsp_workspace_diagnostics_start` logged, then total
// silence — and pressing Escape didn't help either, since the
// per-iteration `signal?.aborted` check above never gets a turn
// while stuck inside a single file's await). Two bounds, not one:
// `withDeadline` catches a hang with no abort press at all; racing
// the abort signal directly means an explicit Escape unblocks
// immediately too, instead of waiting out the rest of `perFileMs`.
// `onTimeout:"undefined"` mirrors the existing catch-based "best
// effort" intent below: a timed-out/aborted pre-open just means
// `processFile`'s own touchFile call pays for the open instead,
// exactly like a thrown error already did.
const preOpenAttempt = withDeadline((async () => {
const { clients } = await this.getClientsForFile(filePath, WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS);
for (const entry of clients) {
try {
await entry.client.notify.open(filePath, content, languageId);
}
catch {
// Best-effort: a failed pre-open just means processFile's own
// touchFile call below pays for the open instead.
}
}
})(), { ms: perFileMs, onTimeout: "undefined" });
await (signal
? Promise.race([
preOpenAttempt,
new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), {
once: true,
});
}),
])
: preOpenAttempt);
}
};
const processFile = async (filePath) => {
try {
const content = contentCache.get(filePath) ??
(await nodeFs.promises.readFile(filePath, "utf-8"));
// onTimeout:"undefined" so a hung file yields no diagnostics and the
// worker moves on; a real touchFile rejection still propagates to the
// catch below and is recorded as an error.
const diagnostics = await withDeadline(this.touchFile(filePath, content, {
diagnostics: "document",
collectDiagnostics: true,
clientScope: "all",
source: "lens_diagnostics_full",
// #584: opengrep's findings for a full sweep come from the
// `opengrep-client.ts` CLI extractor (one project-wide scan,
// cached, read via extractors.ts) instead — see the
// `excludeServerIds` doc on `LSPTouchFileOptions`.
excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
// #645: lets a workspaceIndexing server (marksman) pay its
// full wait budget only once across this whole sweep.
sweepIndexGate,
}), { ms: perFileMs, onTimeout: "undefined" });
// #571: prefer #570's real per-touch inconclusive signal
// (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
// notify write or the diagnostics wait itself timed out) over this
// sweep's own OUTER `perFileMs` deadline, which only catches a touch
// that never returned at all within budget. Either one means the
// result wasn't confirmed.
const inconclusive = diagnostics
?.inconclusive === true;
const timedOut = diagnostics === undefined || inconclusive;
if (timedOut)
timedOutFiles += 1;
// #586: honor each auxiliary profile's native inline-suppression
// comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
// raw `diagnostics` (before this drops its non-enumerable
// `.inconclusive` flag, already read above) so a `lens_diagnostics
// mode=full` sweep suppresses the same findings the per-edit dispatch
// runner does, instead of only the latter honoring it.
const filteredDiagnostics = diagnostics
? applyAuxiliarySuppressions(diagnostics, content)
: diagnostics;
results.push({
filePath,
diagnostics: filteredDiagnostics ?? [],
count: filteredDiagnostics?.length ?? 0,
timedOut,
});
}
catch (err) {
results.push({
filePath,
diagnostics: [],
count: 0,
error: err instanceof Error ? err.message : String(err),
// An errored check is exactly as inconclusive as a timed-out one —
// no confirmed result was obtained, so reconciliation (#571) must
// skip it the same way.
timedOut: true,
});
}
completed += 1;
// User-facing progress (streamed to the tool's onUpdate). Per-file so the
// bar moves; the tool throttles the actual UI writes.
options.onProgress?.(completed, files.length);
// Time-based heartbeat (every ~10s): a hang shows the last heartbeat
// then silence, so latency.log pinpoints how far it got.
if (Date.now() - lastHeartbeat >= 10_000) {
lastHeartbeat = Date.now();
logLatency({
type: "phase",
phase: "lsp_workspace_diagnostics_progress",
filePath: root,
durationMs: Date.now() - startedAt,
metadata: {
completed,
total: files.length,
timedOutFiles,
aborted: signal?.aborted ?? false,
},
});
}
};
// One worker per server group (serial within a server), up to the
// concurrency cap across distinct servers — `runPerServerGroups` (#631)
// is the same primitive `tools/lsp-diagnostics.ts`'s batch/directory scan
// now uses for its own file list.
const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
await runPerServerGroups(groups, groupWorkers, async (group) => {
if (signal?.aborted)
return;
// Fast path: one project-wide pull for the whole group (opt-in).
if (workspacePullEnabled && !group.multiServer) {
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
if (pulled) {
for (const result of pulled)
results.push(result);
completed += group.files.length;
options.onProgress?.(completed, files.length);
return;
}
}
// #667: warm-check before this group's own per-file loop starts —
// cheap/no-op when the group's primary server already demonstrated
// readiness (from an earlier sweep, or an earlier group sharing the
// same server root, this session); pays one deliberate warm-up round
// trip against the group's first file only when genuinely cold. Not
// needed above the pull fast path: a `workspace/diagnostic` pull
// already covers the WHOLE group with its own generous per-server
// budget in one shot — the per-file "first N files eat individual
// timeouts" failure mode this fixes doesn't apply there.
const first = group.files[0];
if (first) {
await this.ensureWarmForSweep(first, { signal });
if (signal?.aborted)
return;
}
// #608/#621: batch-open a CHUNK of this group's files before
// waiting on diagnostics for any of them individually — see
// `preOpenGroupFiles` above. Chunking (rather than the whole
// group at once) bounds how much a single burst can dump on
// the server's request queue at real project scale, while each
// chunk's opens still land inside the debounce window and
// coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
if (signal?.aborted)
return;
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
await preOpenGroupFiles(chunk);
for (const filePath of chunk) {
// Honor cancellation between files (#341); already-collected
// results are returned as a partial.
if (signal?.aborted)
return;
await processFile(filePath);
}
}
}, signal);
logLatency({
type: "phase",
phase: "lsp_workspace_diagnostics",
filePath: root,
durationMs: Date.now() - startedAt,
metadata: {
filesChecked: files.length,
diagnosticCount: results.reduce((sum, result) => sum + (result?.count ?? 0), 0),
serverGroups: groups.length,
concurrency: groupWorkers,
maxFiles,
timedOutFiles,
aborted: signal?.aborted ?? false,
},
});
return results.filter(Boolean);
}
/**
* #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
* instead of N per-file opens. Returns per-file results (files absent from the
* report are reported clean), or `undefined` when the server doesn't advertise
* workspace pull / the pull fails — the caller then falls back to per-file.
*/
async tryWorkspacePull(groupFiles, perFileMs) {
try {
const first = groupFiles[0];
if (!first)
return undefined;
const spawned = await this.getClientForFile(first, perFileMs);
if (!spawned)
return undefined;
if (!spawned.client.getWorkspaceDiagnosticsSupport().workspaceDiagnostics) {
return undefined;
}
const report = await spawned.client.requestWorkspaceDiagnostics(Math.max(perFileMs, workspacePullBudgetMs()));
if (!report)
return undefined;
const byPath = new Map();
for (const entry of report) {
byPath.set(normalizeMapKey(entry.filePath), entry.diagnostics);
}
return groupFiles.map((filePath) => {
const diagnostics = byPath.get(normalizeMapKey(filePath)) ?? [];
// A pull that got here returned a real workspace/diagnostic report
// (see the `!report` guard above) — always confirmed, unlike a
// per-file touchFile default-empty on timeout.
return { filePath, diagnostics, count: diagnostics.length, timedOut: false };
});
}
catch {
return undefined;
}
}
/**
* Get all diagnostics across all tracked files (for cascade checking)
*/
async getAllDiagnostics() {
const all = new Map();
const now = Date.now();
for (const [_key, client] of this.state.clients) {
// Resolve existence asynchronously (was a blocking existsSync per tracked
// file inside the prune predicate) so this cascade-checking path doesn't
// hold the event loop; then prune with a synchronous, in-memory predicate.
const trackedPaths = client.getTrackedDiagnosticPaths();
const existingPaths = new Set();
await Promise.all(trackedPaths.map(async (filePath) => {
try {
await nodeFs.promises.access(filePath);
existingPaths.add(filePath);
}
catch {
/* missing → will be pruned */
}
}));
client.pruneDiagnostics((filePath, ts) => !existingPaths.has(filePath) ||
now - ts > CASCADE_DIAGNOSTICS_TTL_MS);
const clientDiags = client.getAllDiagnostics();
for (const [filePath, entry] of clientDiags) {
const existing = all.get(filePath);
if (existing) {
existing.diags = mergeLspDiagnostics([
...existing.diags,
...entry.diags,
]);
existing.ts = Math.max(existing.ts, entry.ts);
}
else {
all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
}
}
}
return all;
}
/**
* Check whether a file type/root has any configured LSP support.
* Pure capability check — does not spawn or wait for clients.
*/
supportsLSP(filePath) {
return getServersForFileWithConfig(filePath).length > 0;
}
/**
* Check whether an LSP client is already alive for a file.
* Lightweight — does not spawn or wait for a client.
*/
async hasWarmLSP(filePath) {
const spawned = await this.getWarmClientForFile(filePath);
return Boolean(spawned);
}
/**
* Check if LSP is available for a file.
* May spawn a client; prefer supportsLSP()/hasWarmLSP() when you only need
* a capability or warm-state check.
*/
async hasLSP(filePath) {
const spawned = await this.getClientForFile(filePath);
return Boolean(spawned);
}
/**
* Shutdown all LSP clients
*/
async shutdown(options = {}) {
if (this.checkDestroyed())
return;
this.isDestroyed = true;
// Cancel any in-flight spawns
this.state.inFlight.clear();
for (const [_key, client] of this.state.clients) {
try {
await client.shutdown(options);
}
catch {
// pi-lens-ignore: missing-error-propagation — per-client shutdown failure, must not abort remaining shutdowns
}
}
this.state.clients.clear();
this.state.broken.clear();
this.workspaceProbeLogged.clear();
this.warmStartLogged.clear();
}
/**
* Get status of all active clients
*/
getStatus() {
return Array.from(this.state.clients.entries()).map(([key, client]) => {
const [serverId, root] = key.split(":");
return { serverId, root, connected: client.isAlive() };
});
}
/**
* Count clients that are currently alive (connected and initialized).
* Lightweight — does not spawn or wait for anything.
*/
getAliveClientCount() {
let count = 0;
for (const client of this.state.clients.values()) {
if (client.isAlive())
count++;
}
return count;
}
/**
* Distinct serverIds of currently-alive clients, ordered primary-first then
* auxiliary (cross-cutting scanners like opengrep/ast-grep), stable within
* each group. Deduped across roots — one warm server serving two roots
* collapses to a single id. Lightweight: does not spawn or wait. (#267)
*/
getAliveServerIds() {
const primary = [];
const aux = [];
const seen = new Set();
for (const client of this.state.clients.values()) {
if (!client.isAlive())
continue;
const id = client.serverId;
if (seen.has(id))
continue;
seen.add(id);
const role = LSP_SERVERS.find((s) => s.id === id)?.role;
(role === "auxiliary" ? aux : primary).push(id);
}
return [...primary, ...aux];
}
}
// --- Singleton Instance ---
let globalLSPService = null;
export function getLSPService() {
if (!globalLSPService) {
globalLSPService = new LSPService();
}
return globalLSPService;
}
export function resetLSPService(options = {}) {
if (globalLSPService) {
globalLSPService.shutdown(options).catch(() => { });
}
globalLSPService = null;
}
/**
* Test-only: exposes the async workspace-diagnostics file walk so its
* event-loop occupancy can be guarded (see workspace-diagnostics-occupancy
* test). Not part of the public API.
*/
export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal) {
return maxFiles === undefined
? collectWorkspaceDiagnosticFiles(path.resolve(root), undefined, signal)
: collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles, signal);
}