pi-lens 3.8.71 → 3.8.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -1
- package/README.md +8 -0
- package/config/markdownlint/core.json +6 -0
- package/dist/clients/atomic-write.js +83 -0
- package/dist/clients/config-enabled-shape.js +39 -0
- package/dist/clients/diagnostic-dispositions.js +64 -2
- package/dist/clients/dispatch/integration.js +2 -1
- package/dist/clients/dispatch/runners/lsp.js +54 -25
- package/dist/clients/dispatch/runners/markdownlint.js +7 -4
- package/dist/clients/dispatch/runners/pyright.js +1 -0
- package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
- package/dist/clients/env-utils.js +38 -0
- package/dist/clients/file-utils.js +21 -3
- package/dist/clients/instance-registry.js +20 -20
- package/dist/clients/jscpd-client.js +52 -39
- package/dist/clients/language-profile.js +12 -19
- package/dist/clients/lens-config.js +106 -27
- package/dist/clients/lens-engine.js +129 -3
- package/dist/clients/lsp/cascade-tier.js +1 -38
- package/dist/clients/lsp/client.js +3 -1
- package/dist/clients/lsp/index.js +621 -95
- package/dist/clients/lsp/server-strategies.js +3 -224
- package/dist/clients/lsp/server.js +1 -1
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
- package/dist/clients/lsp/wait-policy/classification.js +63 -0
- package/dist/clients/lsp/wait-policy/index.js +10 -0
- package/dist/clients/lsp/wait-policy/strategies.js +244 -0
- package/dist/clients/lsp-budget.js +54 -16
- package/dist/clients/mcp/analyze.js +1 -1
- package/dist/clients/mcp/host-shim.js +12 -8
- package/dist/clients/mcp/ipc.js +118 -0
- package/dist/clients/mcp/session.js +2 -2
- package/dist/clients/pipeline.js +11 -6
- package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
- package/dist/clients/project-diagnostics/scanner.js +37 -6
- package/dist/clients/project-lens-config.js +167 -3
- package/dist/clients/project-report.js +627 -0
- package/dist/clients/project-scale.js +239 -0
- package/dist/clients/project-scan-policy.js +11 -1
- package/dist/clients/recent-touches.js +6 -11
- package/dist/clients/review-graph/builder.js +170 -13
- package/dist/clients/review-graph/import-resolvers.js +149 -13
- package/dist/clients/review-graph/tsconfig-paths.js +242 -0
- package/dist/clients/review-graph/workspace-modules.js +21 -9
- package/dist/clients/rules-scanner.js +15 -2
- package/dist/clients/runtime-agent-end.js +104 -17
- package/dist/clients/runtime-config.js +4 -0
- package/dist/clients/runtime-coordinator.js +50 -1
- package/dist/clients/runtime-session.js +41 -3
- package/dist/clients/runtime-tool-result.js +4 -3
- package/dist/clients/runtime-turn.js +4 -1
- package/dist/clients/safe-spawn.js +264 -40
- package/dist/clients/session-lifecycle.js +36 -0
- package/dist/clients/session-state-store.js +8 -3
- package/dist/clients/source-filter.js +136 -61
- package/dist/clients/source-walker.js +89 -0
- package/dist/clients/startup-scan.js +189 -111
- package/dist/clients/subagent-mode.js +14 -3
- package/dist/clients/todo-scanner.js +5 -1
- package/dist/clients/tool-policy.js +26 -4
- package/dist/clients/warm-attach.js +204 -0
- package/dist/clients/word-index.js +36 -5
- package/dist/clients/workspace-topology.js +266 -0
- package/dist/index.js +13826 -10251
- package/dist/mcp/server.js +125 -10
- package/dist/tools/ast-grep-search.js +21 -0
- package/dist/tools/lens-diagnostic-mark.js +198 -7
- package/dist/tools/lens-diagnostics.js +119 -21
- package/dist/tools/lsp-diagnostics.js +17 -1
- package/dist/tools/lsp-navigation.js +11 -2
- package/dist/tools/project-report.js +94 -0
- package/dist/tools/symbol-search.js +32 -8
- package/docs/environment-variables.md +10 -0
- package/docs/features.md +7 -1
- package/docs/globalconfig.md +70 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/usage.md +13 -2
- package/package.json +1 -1
- package/scripts/download-grammars.js +1 -0
|
@@ -17,18 +17,19 @@ import { recordLsp } from "../widget-state.js";
|
|
|
17
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
18
|
import { detectFileRole } from "../file-role.js";
|
|
19
19
|
import { logLatency } from "../latency-logger.js";
|
|
20
|
+
import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
20
21
|
import { withDeadline } from "../deadline-utils.js";
|
|
21
|
-
import { normalizeMapKey, uriToPath } from "../path-utils.js";
|
|
22
|
+
import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
22
23
|
import { createLSPClient } from "./client.js";
|
|
23
24
|
import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
|
|
24
25
|
import { getLanguageId } from "./language.js";
|
|
25
26
|
import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
26
|
-
import { getStrategy } from "./
|
|
27
|
+
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
27
28
|
import { raceToCompletion } from "./aggregation.js";
|
|
28
29
|
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
|
|
29
30
|
import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
|
|
30
31
|
import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
|
|
31
|
-
import {
|
|
32
|
+
import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
|
|
32
33
|
// --- Init override helpers ---
|
|
33
34
|
/**
|
|
34
35
|
* Recursively merges `override` onto `base`. Override wins on leaf conflicts
|
|
@@ -68,6 +69,12 @@ export function mergeInitializationOptions(base, override) {
|
|
|
68
69
|
const BROKEN_BASE_COOLDOWN_MS = 15_000;
|
|
69
70
|
const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
|
|
70
71
|
const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
|
|
72
|
+
// #743: a server whose per-server notify write (didOpen/didChange) times out
|
|
73
|
+
// this many times in a row is a persistently backpressured server; it is demoted
|
|
74
|
+
// into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
|
|
75
|
+
// stop re-paying its notify budget on every file. A single successful write
|
|
76
|
+
// resets the streak.
|
|
77
|
+
const NOTIFY_BACKPRESSURE_BROKEN_AFTER = 3;
|
|
71
78
|
const OPTIONAL_LSP_RETRY_COOLDOWN_MS = 5 * 60_000;
|
|
72
79
|
const OPTIONAL_LSP_SERVER_IDS = new Set();
|
|
73
80
|
const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_NAV_CLIENT_WAIT_MS ?? "1500", 10) ||
|
|
@@ -85,6 +92,17 @@ function warmupTimeoutMs() {
|
|
|
85
92
|
const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_TIMEOUT_MS ?? "", 10);
|
|
86
93
|
return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
|
|
87
94
|
}
|
|
95
|
+
// #744: short pause between the first warm-up round trip and the single retry
|
|
96
|
+
// `ensureWarmForSweep` gives a server that didn't warm on its first attempt.
|
|
97
|
+
// Deliberately small — it's a breather for a server mid-relaunch/index (the
|
|
98
|
+
// state where warmup failure is most likely, e.g. a sweep starting seconds
|
|
99
|
+
// after an `lsp_service_reset`), not a second full budget. Read at call time so
|
|
100
|
+
// tests can drive the retry without waiting out a real backoff. 0 disables the
|
|
101
|
+
// pause entirely (retry fires immediately).
|
|
102
|
+
function warmupRetryBackoffMs() {
|
|
103
|
+
const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_RETRY_BACKOFF_MS ?? "", 10);
|
|
104
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 500;
|
|
105
|
+
}
|
|
88
106
|
/**
|
|
89
107
|
* Read the `PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS` env override at call time
|
|
90
108
|
* (process.env mutations in tests stay live). Returns undefined when unset,
|
|
@@ -242,15 +260,14 @@ export async function runPerServerGroups(groups, concurrency, processGroup, sign
|
|
|
242
260
|
let nextGroup = 0;
|
|
243
261
|
const workers = Math.min(Math.max(1, concurrency), groups.length);
|
|
244
262
|
await Promise.all(Array.from({ length: workers }, async () => {
|
|
245
|
-
while (
|
|
246
|
-
if (signal?.aborted)
|
|
247
|
-
return;
|
|
263
|
+
while (!signal?.aborted) {
|
|
248
264
|
const gi = nextGroup;
|
|
249
265
|
nextGroup += 1;
|
|
250
266
|
if (gi >= groups.length)
|
|
251
|
-
|
|
267
|
+
break;
|
|
252
268
|
await processGroup(groups[gi]);
|
|
253
269
|
}
|
|
270
|
+
return true;
|
|
254
271
|
}));
|
|
255
272
|
}
|
|
256
273
|
const WORKSPACE_DIAGNOSTICS_CONCURRENCY = 8;
|
|
@@ -332,8 +349,17 @@ function getMaxWorkspaceDiagnosticFiles() {
|
|
|
332
349
|
* dropped user `"ignore": [...]` patterns and diverged from the canonical list
|
|
333
350
|
* (#243). The walk is also hard-capped (#250).
|
|
334
351
|
*/
|
|
335
|
-
async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal) {
|
|
352
|
+
async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal, homeDir) {
|
|
336
353
|
const files = [];
|
|
354
|
+
// #747/#250: the 5000-file cap alone bounds total work, but from a cwd at or
|
|
355
|
+
// above $HOME the walk still traverses (and pulls diagnostics for) 5000 files
|
|
356
|
+
// spread across every unrelated repo under home. Refuse outright — walking
|
|
357
|
+
// nothing is the honest result; the caller (runWorkspaceDiagnostics →
|
|
358
|
+
// tools/lens-diagnostics.ts) renders "unsafe root" so an empty sweep never
|
|
359
|
+
// reads as a clean project. Same ceiling as fresh-fetch.ts / the cheap-tier
|
|
360
|
+
// scanner.
|
|
361
|
+
if (isAtOrAboveHomeDir(root, homeDir))
|
|
362
|
+
return files;
|
|
337
363
|
const ignoreMatcher = getProjectIgnoreMatcher(root);
|
|
338
364
|
// #703: prime the tracked-files set once before the walk so a tracked file
|
|
339
365
|
// matching a `.gitignore`/global pattern still gets its workspace
|
|
@@ -403,6 +429,15 @@ export class LSPService {
|
|
|
403
429
|
lastKnownContentHash = new Map();
|
|
404
430
|
lastDiagnosticsHealth = new Map();
|
|
405
431
|
recentTouches = new Map();
|
|
432
|
+
/**
|
|
433
|
+
* #743: consecutive per-server notify-write timeout count, keyed by
|
|
434
|
+
* "serverId:normalizedRoot" — the SAME identity as {@link LSPState.broken}
|
|
435
|
+
* and {@link demonstratedReadyKeyFor}. A server that backpressures its stdin
|
|
436
|
+
* write {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row is demoted
|
|
437
|
+
* into the broken cooldown (see {@link recordNotifyWriteBackpressure}); any
|
|
438
|
+
* successful write clears its entry.
|
|
439
|
+
*/
|
|
440
|
+
notifyWriteBackpressureStreak = new Map();
|
|
406
441
|
/** True after shutdown() has been called; blocks new operations */
|
|
407
442
|
isDestroyed = false;
|
|
408
443
|
constructor() {
|
|
@@ -413,6 +448,7 @@ export class LSPService {
|
|
|
413
448
|
inFlight: new Map(),
|
|
414
449
|
clientSpawnedAt: new Map(),
|
|
415
450
|
demonstratedReady: new Set(),
|
|
451
|
+
demonstratedCold: new Set(),
|
|
416
452
|
};
|
|
417
453
|
}
|
|
418
454
|
/** Guard: return true if service is shutting down or shut down */
|
|
@@ -498,6 +534,46 @@ export class LSPService {
|
|
|
498
534
|
}
|
|
499
535
|
markDemonstratedReadyKey(key) {
|
|
500
536
|
this.state.demonstratedReady.add(key);
|
|
537
|
+
// #799: readiness through ANY path supersedes an earlier cold verdict —
|
|
538
|
+
// a server that recovers later in the session must not stay stuck in
|
|
539
|
+
// the negative cache.
|
|
540
|
+
this.state.demonstratedCold.delete(key);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* #743: record one notify-write timeout for a server and, once it has stalled
|
|
544
|
+
* {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
|
|
545
|
+
* the EXISTING broken-cooldown map so subsequent sweeps stop re-paying its
|
|
546
|
+
* notify budget on every file. The wedged client is also evicted: an alive
|
|
547
|
+
* client is reused before the broken check in {@link ensureClientForServer},
|
|
548
|
+
* so the cooldown only bites once the stale client is gone. `key` is
|
|
549
|
+
* "serverId:normalizedRoot" (the broken-map identity); undefined when the
|
|
550
|
+
* server's root could not be resolved, in which case there is nothing to key.
|
|
551
|
+
*/
|
|
552
|
+
recordNotifyWriteBackpressure(key, entry, filePath) {
|
|
553
|
+
if (!key)
|
|
554
|
+
return;
|
|
555
|
+
const streak = (this.notifyWriteBackpressureStreak.get(key) ?? 0) + 1;
|
|
556
|
+
if (streak < NOTIFY_BACKPRESSURE_BROKEN_AFTER) {
|
|
557
|
+
this.notifyWriteBackpressureStreak.set(key, streak);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.notifyWriteBackpressureStreak.delete(key);
|
|
561
|
+
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
562
|
+
void entry.client.shutdown().catch(() => { });
|
|
563
|
+
this.state.clients.delete(key);
|
|
564
|
+
this.state.clientSpawnedAt.delete(key);
|
|
565
|
+
this.state.demonstratedReady.delete(key);
|
|
566
|
+
logLatency({
|
|
567
|
+
type: "phase",
|
|
568
|
+
phase: "lsp_notify_backpressure_broken",
|
|
569
|
+
filePath: normalizeMapKey(filePath),
|
|
570
|
+
durationMs: 0,
|
|
571
|
+
metadata: {
|
|
572
|
+
serverId: entry.info.id,
|
|
573
|
+
cooldownMs: BROKEN_BASE_COOLDOWN_MS,
|
|
574
|
+
consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
501
577
|
}
|
|
502
578
|
activeClientsForCwd(cwd, priorityServerIds = []) {
|
|
503
579
|
const normalizedCwd = normalizeMapKey(cwd);
|
|
@@ -986,23 +1062,70 @@ export class LSPService {
|
|
|
986
1062
|
// runner touch sequence expensive on slow TS projects.
|
|
987
1063
|
const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
|
|
988
1064
|
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
989
|
-
|
|
1065
|
+
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1066
|
+
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1067
|
+
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1068
|
+
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1069
|
+
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1070
|
+
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1071
|
+
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1072
|
+
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1073
|
+
//
|
|
1074
|
+
// Holds the serverId of every server whose write did NOT land in time.
|
|
1075
|
+
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1076
|
+
// server timed out"; this list carries the per-server detail the
|
|
1077
|
+
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1078
|
+
const notifyWriteTimedOutServerIds = [];
|
|
990
1079
|
if (!notifySkipped) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1080
|
+
const budget = notifyWriteBudgetMs();
|
|
1081
|
+
await Promise.all(spawned.map(async (entry) => {
|
|
1082
|
+
// Same identity as the broken/demonstratedReady maps.
|
|
1083
|
+
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1084
|
+
let wrote;
|
|
1085
|
+
let rejected = false;
|
|
1086
|
+
try {
|
|
1087
|
+
wrote = await withDeadline(entry.client.notify
|
|
1088
|
+
.open(filePath, content, languageId, undefined, silent)
|
|
1089
|
+
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
// The write itself rejected (not backpressure): the content did
|
|
1093
|
+
// not land, so this server is inconclusive for the touch, but a
|
|
1094
|
+
// rejection is not a stdin-backpressure signal and must not count
|
|
1095
|
+
// toward the backpressure demotion streak.
|
|
1096
|
+
rejected = true;
|
|
1097
|
+
}
|
|
1098
|
+
if (wrote === true) {
|
|
1099
|
+
// A clean write clears any accrued backpressure streak (#743).
|
|
1100
|
+
if (clientKey)
|
|
1101
|
+
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1105
|
+
if (!rejected) {
|
|
1106
|
+
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return true;
|
|
1110
|
+
}));
|
|
1111
|
+
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
997
1112
|
logLatency({
|
|
998
1113
|
type: "phase",
|
|
999
1114
|
phase: "lsp_notify_timeout",
|
|
1000
1115
|
filePath: normalizedPath,
|
|
1001
1116
|
durationMs: Date.now() - startedAt,
|
|
1002
|
-
metadata: {
|
|
1117
|
+
metadata: {
|
|
1118
|
+
source,
|
|
1119
|
+
clientScope,
|
|
1120
|
+
serverCount: spawned.length,
|
|
1121
|
+
timedOutServerIds: notifyWriteTimedOutServerIds,
|
|
1122
|
+
},
|
|
1003
1123
|
});
|
|
1004
1124
|
}
|
|
1005
1125
|
}
|
|
1126
|
+
// File-level flag: at least one server's write timed out (kept for the
|
|
1127
|
+
// conservative touch-wide `inconclusive` merge semantics — see below).
|
|
1128
|
+
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
1006
1129
|
let diagnosticsTimedOut = false;
|
|
1007
1130
|
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1008
1131
|
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
@@ -1014,7 +1137,7 @@ export class LSPService {
|
|
|
1014
1137
|
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1015
1138
|
// fallback below may still fill it in on a timed-out empty result).
|
|
1016
1139
|
let tsserverSyncEligible = false;
|
|
1017
|
-
let tsserverSyncConfirmed
|
|
1140
|
+
let tsserverSyncConfirmed;
|
|
1018
1141
|
if (diagnosticsMode !== "none") {
|
|
1019
1142
|
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1020
1143
|
// Otherwise, on the single-server hot path (primary scope), use that
|
|
@@ -1061,6 +1184,44 @@ export class LSPService {
|
|
|
1061
1184
|
}
|
|
1062
1185
|
}
|
|
1063
1186
|
}
|
|
1187
|
+
// #832: workspace-indexing servers that are classified as silent on
|
|
1188
|
+
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1189
|
+
// configured strategy already gives the first sweep touch a bounded
|
|
1190
|
+
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1191
|
+
// classification proves that a clean push has no affirmative signal to
|
|
1192
|
+
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1193
|
+
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1194
|
+
// load still needs the longer 20s floor.
|
|
1195
|
+
//
|
|
1196
|
+
// Build this from the live spawned client's capabilities rather than
|
|
1197
|
+
// server id alone. Missing/throwing capability data fails closed, so a
|
|
1198
|
+
// new or ambiguous server keeps the existing generous warm-up budget.
|
|
1199
|
+
const silentCleanWarmupServers = new Set();
|
|
1200
|
+
if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
|
|
1201
|
+
for (const entry of spawned) {
|
|
1202
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1203
|
+
if (strategy.workspaceIndexing !== true)
|
|
1204
|
+
continue;
|
|
1205
|
+
try {
|
|
1206
|
+
const snapshot = {
|
|
1207
|
+
serverId: entry.client.serverId,
|
|
1208
|
+
root: entry.client.root,
|
|
1209
|
+
operationSupport: entry.client.getOperationSupport(),
|
|
1210
|
+
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1211
|
+
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1212
|
+
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1213
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1214
|
+
};
|
|
1215
|
+
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1216
|
+
"tier3-silent") {
|
|
1217
|
+
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
// Fail closed: capability uncertainty must retain the cold floor.
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1064
1225
|
// Each server gets its OWN deadline, bounded by the caller cap as a
|
|
1065
1226
|
// CEILING (never a floor) — so a clean push-silent primary (typescript
|
|
1066
1227
|
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
|
|
@@ -1088,6 +1249,18 @@ export class LSPService {
|
|
|
1088
1249
|
// if the strategy already wants more) rather than the normal
|
|
1089
1250
|
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1090
1251
|
if (options.warmupOverride) {
|
|
1252
|
+
// #832: a workspace-indexing server already classified as
|
|
1253
|
+
// silent-on-clean uses its strategy's bounded wait on the first
|
|
1254
|
+
// attempt; the generic cold floor is for servers whose cold work
|
|
1255
|
+
// can eventually produce a push answer (notably TypeScript).
|
|
1256
|
+
if (silentCleanWarmupServers.has(serverId)) {
|
|
1257
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1258
|
+
}
|
|
1259
|
+
// #799: only the FIRST warm-up attempt for a cold server gets the
|
|
1260
|
+
// floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
1261
|
+
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1262
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1263
|
+
}
|
|
1091
1264
|
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1092
1265
|
}
|
|
1093
1266
|
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
@@ -1114,16 +1287,21 @@ export class LSPService {
|
|
|
1114
1287
|
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1115
1288
|
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1116
1289
|
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1117
|
-
// primary scope,
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1290
|
+
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
1291
|
+
// races are tsserver-specific protocol extensions, not a generic
|
|
1292
|
+
// push-only capability, so #799 giving other servers (marksman) the
|
|
1293
|
+
// SAME `silentOnClean` marker must not route them into a sync attempt
|
|
1294
|
+
// that can never succeed for them), then the live capability-snapshot
|
|
1295
|
+
// tier classification (`classifyCascadeWaitTier`, which also excludes
|
|
1296
|
+
// native-ts7 via `launchVariant`). Every other server fails this
|
|
1297
|
+
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
1298
|
+
// read; a non-typescript `silentOnClean` server instead gets the
|
|
1299
|
+
// generic (non-racing) clean-confirm fallback further below.
|
|
1123
1300
|
if (!notifyWriteTimedOut &&
|
|
1124
1301
|
options.collectDiagnostics === true &&
|
|
1125
1302
|
clientScope === "primary" &&
|
|
1126
1303
|
spawned.length === 1 &&
|
|
1304
|
+
spawned[0].client.serverId === "typescript" &&
|
|
1127
1305
|
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1128
1306
|
try {
|
|
1129
1307
|
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
@@ -1156,14 +1334,29 @@ export class LSPService {
|
|
|
1156
1334
|
spawned.some((e) => e.info.role === "auxiliary");
|
|
1157
1335
|
// Per-server wait promises (each already bounded by its own
|
|
1158
1336
|
// perServerTimeout — unchanged from before R8).
|
|
1337
|
+
let pressureSnapshots = [];
|
|
1338
|
+
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1339
|
+
try {
|
|
1340
|
+
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1341
|
+
}
|
|
1342
|
+
catch {
|
|
1343
|
+
// Fail-open: missing capability state keeps today's push fallback.
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1159
1346
|
const perServerWaits = spawned.map((entry) => {
|
|
1160
1347
|
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
1161
1348
|
const baseline = diagnosticBaselines.get(entry.client);
|
|
1349
|
+
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
1162
1350
|
const wait = !notifySkipped && Number.isFinite(baseline)
|
|
1163
1351
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1164
1352
|
minVersion: baseline,
|
|
1353
|
+
...(pullOnly && { pullOnly: true }),
|
|
1165
1354
|
})
|
|
1166
|
-
:
|
|
1355
|
+
: pullOnly
|
|
1356
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1357
|
+
pullOnly: true,
|
|
1358
|
+
})
|
|
1359
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
1167
1360
|
return wait.catch(() => undefined);
|
|
1168
1361
|
});
|
|
1169
1362
|
// The push wait — same per-server budget composition as before #707;
|
|
@@ -1327,6 +1520,69 @@ export class LSPService {
|
|
|
1327
1520
|
},
|
|
1328
1521
|
});
|
|
1329
1522
|
}
|
|
1523
|
+
// #814: capability-aware AGGREGATE wait — generalize #799's
|
|
1524
|
+
// single-server (`clientScope === "primary" && spawned.length === 1`)
|
|
1525
|
+
// silent-clean confirm to multi-server `clientScope: "all"` touches
|
|
1526
|
+
// (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
|
|
1527
|
+
// `serverScope: "all"`). #799's gate never fires here (it's scoped to
|
|
1528
|
+
// the primary hot path), so a scope-"all" touch where every OTHER
|
|
1529
|
+
// spawned server already answered but one push-only `silentOnClean`
|
|
1530
|
+
// server (marksman on a clean markdown file) never publishes still
|
|
1531
|
+
// reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
|
|
1532
|
+
// though the "silence" is exactly what that server's own known
|
|
1533
|
+
// clean-behavior predicts — not an unresolved question.
|
|
1534
|
+
//
|
|
1535
|
+
// A spawned server counts as "still outstanding" when nothing landed
|
|
1536
|
+
// in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
|
|
1537
|
+
// is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
|
|
1538
|
+
// that file's entry as part of the didOpen/didChange this touch just
|
|
1539
|
+
// sent, so a present entry can only be a FRESH answer (found or a real
|
|
1540
|
+
// confirmed-empty push/pull), never a stale one bleeding through from
|
|
1541
|
+
// an earlier touch. This is the same "did anything publish for this
|
|
1542
|
+
// file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
|
|
1543
|
+
// already trusts (#240 doctrine) — reused here, not reinvented.
|
|
1544
|
+
//
|
|
1545
|
+
// The touch stays inconclusive unless EVERY still-outstanding server
|
|
1546
|
+
// is classified `tier3-silent` (push-only + `silentOnClean`, the same
|
|
1547
|
+
// `classifyServerWaitTier` rule the single-server gate below and the
|
|
1548
|
+
// cascade lane use) — one ordinary push-only straggler (still
|
|
1549
|
+
// genuinely analyzing) or a pull-capable server that never answered
|
|
1550
|
+
// keeps the touch cautious, matching #799's "err toward caution"
|
|
1551
|
+
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
1552
|
+
// plus the per-server re-check below are the same "the notify write
|
|
1553
|
+
// must have actually landed" conservatism #799 established — a
|
|
1554
|
+
// server's silence is only evidence of "clean" when we know it saw
|
|
1555
|
+
// the new content.
|
|
1556
|
+
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
1557
|
+
try {
|
|
1558
|
+
const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
1559
|
+
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
1560
|
+
if (outstanding.length > 0) {
|
|
1561
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1562
|
+
const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
|
|
1563
|
+
if (allSilent) {
|
|
1564
|
+
diagnosticsTimedOut = false;
|
|
1565
|
+
logLatency({
|
|
1566
|
+
type: "phase",
|
|
1567
|
+
phase: "lsp_silent_clean_confirm",
|
|
1568
|
+
filePath: normalizedPath,
|
|
1569
|
+
durationMs: Date.now() - startedAt,
|
|
1570
|
+
metadata: {
|
|
1571
|
+
source,
|
|
1572
|
+
clientScope,
|
|
1573
|
+
diagnosticsMode,
|
|
1574
|
+
aggregate: true,
|
|
1575
|
+
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
1583
|
+
// inconclusive behavior, exactly like the single-server gate.
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1330
1586
|
}
|
|
1331
1587
|
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
1332
1588
|
// collected result — the file's real syntactic + semantic state straight
|
|
@@ -1385,6 +1641,71 @@ export class LSPService {
|
|
|
1385
1641
|
// Any failure here falls through to today's inconclusive behavior.
|
|
1386
1642
|
}
|
|
1387
1643
|
}
|
|
1644
|
+
// #799: generalize the "silent-clean push-only" confirm beyond
|
|
1645
|
+
// typescript's active sync-command race above. That mechanism is
|
|
1646
|
+
// TS-specific (`attemptTsserverSyncDiagnostics` races an actual
|
|
1647
|
+
// `typescript.tsserverRequest` — no equivalent protocol exists for
|
|
1648
|
+
// e.g. marksman) and is now scoped to `serverId === "typescript"`
|
|
1649
|
+
// only (see the gate above), so it never fires for another
|
|
1650
|
+
// `silentOnClean` server. This is the generic fallback for those
|
|
1651
|
+
// servers: if the wait ran its full budget with a successful notify
|
|
1652
|
+
// write and nothing published, and the live capability snapshot
|
|
1653
|
+
// classifies this touch as tier3-silent (push-only + `silentOnClean`,
|
|
1654
|
+
// #458's `classifyCascadeWaitTier`), that is not "still working" —
|
|
1655
|
+
// `silentOnClean` means by definition this server publishes NOTHING
|
|
1656
|
+
// on a clean transition, so a timeout under those conditions IS the
|
|
1657
|
+
// confirmed-clean answer. `!tsserverSyncEligible` keeps this from
|
|
1658
|
+
// ever double-deciding typescript's own touches — when the sync race
|
|
1659
|
+
// was attempted and failed/was unavailable, typescript's existing
|
|
1660
|
+
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
1661
|
+
// preserved exactly; typescript touches that never enter that gate
|
|
1662
|
+
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
1663
|
+
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
1664
|
+
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
1665
|
+
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
1666
|
+
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
1667
|
+
// with-auxiliary/all touch — where a partial timeout must stay
|
|
1668
|
+
// cautious per the doc below — is never affected.
|
|
1669
|
+
//
|
|
1670
|
+
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
1671
|
+
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
1672
|
+
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
1673
|
+
// server is tier3-silent" collapses to exactly this single-server check.
|
|
1674
|
+
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
1675
|
+
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
1676
|
+
// into the general one once both have soaked, but that's a separate,
|
|
1677
|
+
// lower-risk follow-up, not bundled into this fix.
|
|
1678
|
+
if (diagnosticsTimedOut &&
|
|
1679
|
+
!notifyWriteTimedOut &&
|
|
1680
|
+
!tsserverSyncEligible &&
|
|
1681
|
+
clientScope === "primary" &&
|
|
1682
|
+
spawned.length === 1 &&
|
|
1683
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1684
|
+
try {
|
|
1685
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1686
|
+
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
1687
|
+
diagnosticsTimedOut = false;
|
|
1688
|
+
if (collected !== undefined)
|
|
1689
|
+
collected = mergeLspDiagnostics([]);
|
|
1690
|
+
logLatency({
|
|
1691
|
+
type: "phase",
|
|
1692
|
+
phase: "lsp_silent_clean_confirm",
|
|
1693
|
+
filePath: normalizedPath,
|
|
1694
|
+
durationMs: Date.now() - startedAt,
|
|
1695
|
+
metadata: {
|
|
1696
|
+
source,
|
|
1697
|
+
clientScope,
|
|
1698
|
+
diagnosticsMode,
|
|
1699
|
+
serverId: spawned[0].client.serverId,
|
|
1700
|
+
},
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
|
|
1706
|
+
// behavior.
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1388
1709
|
// A touch is inconclusive when EITHER the notify write or the
|
|
1389
1710
|
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
1390
1711
|
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
@@ -1399,8 +1720,17 @@ export class LSPService {
|
|
|
1399
1720
|
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
1400
1721
|
// spawned server so a later sweep in this session sees the check as a
|
|
1401
1722
|
// no-op instead of paying the warm-up round trip again.
|
|
1402
|
-
|
|
1723
|
+
//
|
|
1724
|
+
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
1725
|
+
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
1726
|
+
// write stalled must still be eligible, so only servers whose OWN write
|
|
1727
|
+
// timed out are skipped here (rather than gating the whole loop on the
|
|
1728
|
+
// file-level `inconclusive`).
|
|
1729
|
+
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
1730
|
+
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
1403
1731
|
for (const entry of spawned) {
|
|
1732
|
+
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
1733
|
+
continue;
|
|
1404
1734
|
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1405
1735
|
if (key)
|
|
1406
1736
|
this.markDemonstratedReadyKey(key);
|
|
@@ -1457,6 +1787,12 @@ export class LSPService {
|
|
|
1457
1787
|
collectedDiagnostics: collected?.length,
|
|
1458
1788
|
notifySkipped,
|
|
1459
1789
|
notifyWriteTimedOut,
|
|
1790
|
+
// #743: per-server detail — which servers' writes actually timed out.
|
|
1791
|
+
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
1792
|
+
// least one" summary.
|
|
1793
|
+
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
1794
|
+
notifyWriteTimedOutServerIds,
|
|
1795
|
+
}),
|
|
1460
1796
|
diagnosticsTimedOut,
|
|
1461
1797
|
inconclusive,
|
|
1462
1798
|
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
@@ -2030,14 +2366,45 @@ export class LSPService {
|
|
|
2030
2366
|
* Returns `performedWarmup: true` only when the round trip actually ran
|
|
2031
2367
|
* (false = already warm, no-op) — tests assert on this to guard against
|
|
2032
2368
|
* the warm-up becoming a mandatory extra round trip on every sweep.
|
|
2369
|
+
*
|
|
2370
|
+
* #744: a warm-up that TIMES OUT is no longer left as a silent dead end.
|
|
2371
|
+
* The old one-shot behavior left a wedged server (observed live: marksman,
|
|
2372
|
+
* a `workspaceIndexing` server, burned the full 20s and stayed cold) with
|
|
2373
|
+
* no re-warm and no skip — so every subsequent per-file touch in the sweep
|
|
2374
|
+
* re-paid a full per-file budget against it and timed out again, dragging
|
|
2375
|
+
* the whole sweep. Now: on a failed warm-up this retries exactly once (after
|
|
2376
|
+
* a short `warmupRetryBackoffMs` breather — the state where warm-up fails is
|
|
2377
|
+
* usually a server mid-relaunch/index that just needs a moment), and if the
|
|
2378
|
+
* retry ALSO leaves the server cold it is reported in `failedServerIds`. The
|
|
2379
|
+
* caller (the sweep loop) skips that server's files up front and marks them
|
|
2380
|
+
* unconfirmed, so per-file touches stop paying its timeout. This is a
|
|
2381
|
+
* sweep-scoped skip (the caller discards `failedServerIds` when the sweep
|
|
2382
|
+
* ends), deliberately NOT the global `broken` cooldown map: a server that is
|
|
2383
|
+
* merely still indexing is not broken and must not be cooldown-banned across
|
|
2384
|
+
* the whole session — it just wasn't ready for THIS sweep.
|
|
2385
|
+
*
|
|
2386
|
+
* "Failed warm-up" is measured per non-auxiliary server via the SAME
|
|
2387
|
+
* `demonstratedReady` signal `touchFile` marks on a confirmed round trip: a
|
|
2388
|
+
* server whose key is still absent from `demonstratedReady` after both
|
|
2389
|
+
* attempts never proved it can answer diagnostics. Warm-up stays
|
|
2390
|
+
* `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
|
|
2391
|
+
* would additionally spawn the sweep-EXCLUDED auxiliaries
|
|
2392
|
+
* (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
|
|
2393
|
+
* `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
|
|
2394
|
+
* suppress the `demonstratedReady` marking for a perfectly healthy primary —
|
|
2395
|
+
* falsely condemning it. Recording per-primary-server outcomes and skipping
|
|
2396
|
+
* on those is the correct, non-regressing way to cover the servers the sweep
|
|
2397
|
+
* actually gates on (the sweep groups by primary server, so the group this
|
|
2398
|
+
* warms IS the one whose per-file touches would drag).
|
|
2033
2399
|
*/
|
|
2034
2400
|
async ensureWarmForSweep(representativeFile, options = {}) {
|
|
2035
2401
|
if (this.checkDestroyed() || options.signal?.aborted) {
|
|
2036
|
-
return { performedWarmup: false };
|
|
2402
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2037
2403
|
}
|
|
2038
2404
|
const servers = getServersForFileWithConfig(representativeFile).filter((s) => s.role !== "auxiliary");
|
|
2039
|
-
if (servers.length === 0)
|
|
2040
|
-
return { performedWarmup: false };
|
|
2405
|
+
if (servers.length === 0) {
|
|
2406
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2407
|
+
}
|
|
2041
2408
|
// A server with no resolvable root never spawns a client for this file
|
|
2042
2409
|
// either way, so it can't block "already warm" — only servers that WILL
|
|
2043
2410
|
// actually be used count toward the readiness check. Same key
|
|
@@ -2047,7 +2414,43 @@ export class LSPService {
|
|
|
2047
2414
|
const keys = await Promise.all(servers.map((server) => this.demonstratedReadyKeyFor(server, representativeFile)));
|
|
2048
2415
|
const alreadyWarm = keys.every((key) => key === undefined || this.state.demonstratedReady.has(key));
|
|
2049
2416
|
if (alreadyWarm)
|
|
2050
|
-
return { performedWarmup: false };
|
|
2417
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2418
|
+
// #799: negative cache. Every server that still needs warming (not
|
|
2419
|
+
// already `demonstratedReady`) was ALSO left cold by a warm-up earlier
|
|
2420
|
+
// this session (`demonstratedCold`, populated below when a warm-up's
|
|
2421
|
+
// initial attempt + retry both fail) — skip straight to the group-skip
|
|
2422
|
+
// accounting the caller already has for `failedServerIds`, instead of
|
|
2423
|
+
// re-paying the initial-attempt + retry round trip all over again. A
|
|
2424
|
+
// MIXED group (one server cached cold, another never tried) still runs
|
|
2425
|
+
// the real warm-up — the never-tried server deserves its fair shot, and
|
|
2426
|
+
// `touchFile`'s multi-server spawn already covers both in one call.
|
|
2427
|
+
const cachedColdServerIds = [];
|
|
2428
|
+
let allNonWarmCached = true;
|
|
2429
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2430
|
+
const key = keys[i];
|
|
2431
|
+
if (key === undefined || this.state.demonstratedReady.has(key))
|
|
2432
|
+
continue;
|
|
2433
|
+
if (this.state.demonstratedCold.has(key)) {
|
|
2434
|
+
cachedColdServerIds.push(servers[i].id);
|
|
2435
|
+
}
|
|
2436
|
+
else {
|
|
2437
|
+
allNonWarmCached = false;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
if (allNonWarmCached && cachedColdServerIds.length > 0) {
|
|
2441
|
+
logLatency({
|
|
2442
|
+
type: "phase",
|
|
2443
|
+
phase: "lsp_sweep_warmup_cached_cold",
|
|
2444
|
+
filePath: representativeFile,
|
|
2445
|
+
durationMs: 0,
|
|
2446
|
+
metadata: { serverIds: cachedColdServerIds },
|
|
2447
|
+
});
|
|
2448
|
+
return {
|
|
2449
|
+
performedWarmup: false,
|
|
2450
|
+
failedServerIds: cachedColdServerIds,
|
|
2451
|
+
skippedFromCache: true,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2051
2454
|
let content;
|
|
2052
2455
|
try {
|
|
2053
2456
|
content = await nodeFs.promises.readFile(representativeFile, "utf-8");
|
|
@@ -2055,55 +2458,129 @@ export class LSPService {
|
|
|
2055
2458
|
catch {
|
|
2056
2459
|
// Nothing to warm up with — the real sweep's own read will surface
|
|
2057
2460
|
// the file error.
|
|
2058
|
-
return { performedWarmup: false };
|
|
2461
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2462
|
+
}
|
|
2463
|
+
if (options.signal?.aborted) {
|
|
2464
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2059
2465
|
}
|
|
2060
|
-
if (options.signal?.aborted)
|
|
2061
|
-
return { performedWarmup: false };
|
|
2062
2466
|
const timeoutMs = options.timeoutMs ?? warmupTimeoutMs();
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2467
|
+
// A non-auxiliary server that WILL spawn for this file (resolvable key)
|
|
2468
|
+
// but whose key is still absent from `demonstratedReady` after an attempt
|
|
2469
|
+
// never proved it can answer diagnostics — that's a failed warm-up. A
|
|
2470
|
+
// server with an unresolvable key never spawns here, so it can't fail this
|
|
2471
|
+
// way and is excluded (never skipped by the caller for this file).
|
|
2472
|
+
const stillColdServerIds = () => {
|
|
2473
|
+
const cold = [];
|
|
2474
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2475
|
+
const key = keys[i];
|
|
2476
|
+
if (key !== undefined && !this.state.demonstratedReady.has(key)) {
|
|
2477
|
+
cold.push(servers[i].id);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return cold;
|
|
2481
|
+
};
|
|
2482
|
+
const runWarmupTouch = async (attempt) => {
|
|
2483
|
+
if (options.signal?.aborted)
|
|
2484
|
+
return;
|
|
2485
|
+
const startedAt = Date.now();
|
|
2486
|
+
logLatency({
|
|
2487
|
+
type: "phase",
|
|
2488
|
+
phase: "lsp_sweep_warmup_start",
|
|
2489
|
+
filePath: representativeFile,
|
|
2490
|
+
durationMs: 0,
|
|
2491
|
+
metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
|
|
2492
|
+
});
|
|
2493
|
+
const warmupAttempt = this.touchFile(representativeFile, content, {
|
|
2494
|
+
diagnostics: "document",
|
|
2495
|
+
collectDiagnostics: false,
|
|
2496
|
+
clientScope: "primary",
|
|
2497
|
+
source: "lsp_sweep_warmup",
|
|
2498
|
+
maxClientWaitMs: timeoutMs,
|
|
2499
|
+
maxDiagnosticsWaitMs: timeoutMs,
|
|
2500
|
+
// #669: the caller's cap here is the warm-up budget for a genuinely
|
|
2501
|
+
// COLD server — it must act as a floor, not the usual ceiling, or
|
|
2502
|
+
// `perServerTimeout` silently shrinks it to the strategy's normal
|
|
2503
|
+
// warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
|
|
2504
|
+
// of the requested 20000ms.
|
|
2505
|
+
warmupOverride: true,
|
|
2506
|
+
// #799: only attempt 1 gets the cold-start floor — see the
|
|
2507
|
+
// `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
2508
|
+
warmupAttempt: attempt,
|
|
2509
|
+
});
|
|
2510
|
+
await (options.signal
|
|
2511
|
+
? Promise.race([
|
|
2512
|
+
withDeadline(warmupAttempt, {
|
|
2513
|
+
ms: timeoutMs,
|
|
2514
|
+
onTimeout: "undefined",
|
|
2515
|
+
}),
|
|
2516
|
+
new Promise((resolve) => {
|
|
2517
|
+
if (options.signal.aborted) {
|
|
2518
|
+
resolve();
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
2522
|
+
once: true,
|
|
2523
|
+
});
|
|
2524
|
+
}),
|
|
2525
|
+
])
|
|
2526
|
+
: withDeadline(warmupAttempt, {
|
|
2527
|
+
ms: timeoutMs,
|
|
2528
|
+
onTimeout: "undefined",
|
|
2529
|
+
}));
|
|
2530
|
+
logLatency({
|
|
2531
|
+
type: "phase",
|
|
2532
|
+
phase: "lsp_sweep_warmup_done",
|
|
2533
|
+
filePath: representativeFile,
|
|
2534
|
+
durationMs: Date.now() - startedAt,
|
|
2535
|
+
metadata: {
|
|
2536
|
+
serverIds: servers.map((s) => s.id),
|
|
2537
|
+
timeoutMs,
|
|
2538
|
+
attempt,
|
|
2539
|
+
coldServerIds: stillColdServerIds(),
|
|
2540
|
+
},
|
|
2541
|
+
});
|
|
2542
|
+
};
|
|
2543
|
+
await runWarmupTouch(1);
|
|
2544
|
+
let failedServerIds = stillColdServerIds();
|
|
2545
|
+
// One retry, and only when the first attempt actually left a server cold —
|
|
2546
|
+
// a short backoff first so a server mid-relaunch/index gets a breather
|
|
2547
|
+
// rather than an immediate second hammer.
|
|
2548
|
+
if (failedServerIds.length > 0 && !options.signal?.aborted) {
|
|
2549
|
+
const backoffMs = warmupRetryBackoffMs();
|
|
2550
|
+
if (backoffMs > 0) {
|
|
2551
|
+
await new Promise((resolve) => {
|
|
2552
|
+
const timer = setTimeout(resolve, backoffMs);
|
|
2553
|
+
timer.unref?.();
|
|
2554
|
+
options.signal?.addEventListener("abort", () => resolve(), {
|
|
2094
2555
|
once: true,
|
|
2095
2556
|
});
|
|
2096
|
-
})
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
if (!options.signal?.aborted) {
|
|
2560
|
+
await runWarmupTouch(2);
|
|
2561
|
+
failedServerIds = stillColdServerIds();
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
if (failedServerIds.length > 0) {
|
|
2565
|
+
logLatency({
|
|
2566
|
+
type: "phase",
|
|
2567
|
+
phase: "lsp_sweep_warmup_failed",
|
|
2568
|
+
filePath: representativeFile,
|
|
2569
|
+
durationMs: 0,
|
|
2570
|
+
metadata: { failedServerIds, timeoutMs },
|
|
2571
|
+
});
|
|
2572
|
+
// #799: record the negative cache so a LATER sweep this session
|
|
2573
|
+
// skips straight past re-paying this warm-up (initial + retry).
|
|
2574
|
+
// Cleared automatically the moment the server demonstrates
|
|
2575
|
+
// readiness through any path (`markDemonstratedReadyKey`).
|
|
2576
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2577
|
+
const key = keys[i];
|
|
2578
|
+
if (key !== undefined && failedServerIds.includes(servers[i].id)) {
|
|
2579
|
+
this.state.demonstratedCold.add(key);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return { performedWarmup: true, failedServerIds };
|
|
2107
2584
|
}
|
|
2108
2585
|
/**
|
|
2109
2586
|
* Actively scan every LSP-supported source file under a project root.
|
|
@@ -2252,6 +2729,8 @@ export class LSPService {
|
|
|
2252
2729
|
// existing group-worker pool.
|
|
2253
2730
|
const contentCache = new Map();
|
|
2254
2731
|
const preOpenGroupFiles = async (groupFiles) => {
|
|
2732
|
+
if (isWarmAttached())
|
|
2733
|
+
return;
|
|
2255
2734
|
for (const filePath of groupFiles) {
|
|
2256
2735
|
if (signal?.aborted)
|
|
2257
2736
|
return;
|
|
@@ -2333,20 +2812,28 @@ export class LSPService {
|
|
|
2333
2812
|
// onTimeout:"undefined" so a hung file yields no diagnostics and the
|
|
2334
2813
|
// worker moves on; a real touchFile rejection still propagates to the
|
|
2335
2814
|
// catch below and is recorded as an error.
|
|
2336
|
-
const
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2815
|
+
const attached = isWarmAttached()
|
|
2816
|
+
? await tryWarmAttachedDiagnostics(filePath, content, perFileMs, "sweep")
|
|
2817
|
+
: undefined;
|
|
2818
|
+
if (attached && !attached.available) {
|
|
2819
|
+
await this.ensureWarmForSweep(filePath, { signal });
|
|
2820
|
+
}
|
|
2821
|
+
const diagnostics = attached?.available
|
|
2822
|
+
? attached.response.diagnostics
|
|
2823
|
+
: await withDeadline(this.touchFile(filePath, content, {
|
|
2824
|
+
diagnostics: "document",
|
|
2825
|
+
collectDiagnostics: true,
|
|
2826
|
+
clientScope: "all",
|
|
2827
|
+
source: "lens_diagnostics_full",
|
|
2828
|
+
// #584: opengrep's findings for a full sweep come from the
|
|
2829
|
+
// `opengrep-client.ts` CLI extractor (one project-wide scan,
|
|
2830
|
+
// cached, read via extractors.ts) instead — see the
|
|
2831
|
+
// `excludeServerIds` doc on `LSPTouchFileOptions`.
|
|
2832
|
+
excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
|
|
2833
|
+
// #645: lets a workspaceIndexing server (marksman) pay its
|
|
2834
|
+
// full wait budget only once across this whole sweep.
|
|
2835
|
+
sweepIndexGate,
|
|
2836
|
+
}), { ms: perFileMs, onTimeout: "undefined" });
|
|
2350
2837
|
// #571: prefer #570's real per-touch inconclusive signal
|
|
2351
2838
|
// (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
|
|
2352
2839
|
// notify write or the diagnostics wait itself timed out) over this
|
|
@@ -2426,7 +2913,9 @@ export class LSPService {
|
|
|
2426
2913
|
if (signal?.aborted)
|
|
2427
2914
|
return;
|
|
2428
2915
|
// Fast path: one project-wide pull for the whole group (opt-in).
|
|
2429
|
-
if (
|
|
2916
|
+
if (!isWarmAttached() &&
|
|
2917
|
+
workspacePullEnabled &&
|
|
2918
|
+
!group.multiServer) {
|
|
2430
2919
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2431
2920
|
if (pulled) {
|
|
2432
2921
|
for (const result of pulled) {
|
|
@@ -2457,10 +2946,48 @@ export class LSPService {
|
|
|
2457
2946
|
// budget in one shot — the per-file "first N files eat individual
|
|
2458
2947
|
// timeouts" failure mode this fixes doesn't apply there.
|
|
2459
2948
|
const first = group.files[0];
|
|
2460
|
-
if (first) {
|
|
2461
|
-
await this.ensureWarmForSweep(first, { signal });
|
|
2949
|
+
if (first && !isWarmAttached()) {
|
|
2950
|
+
const warmup = await this.ensureWarmForSweep(first, { signal });
|
|
2462
2951
|
if (signal?.aborted)
|
|
2463
2952
|
return;
|
|
2953
|
+
// #744: the group's primary server failed warm-up (initial round
|
|
2954
|
+
// trip + one retry both left it cold). Every per-file touch to it
|
|
2955
|
+
// would re-pay its full timeout and time out again, dragging the
|
|
2956
|
+
// whole sweep — the exact wedged-marksman failure mode this closes.
|
|
2957
|
+
// So skip this group's files and record each as UNCONFIRMED
|
|
2958
|
+
// (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
|
|
2959
|
+
// the group is keyed by its primary server, so a non-empty
|
|
2960
|
+
// `failedServerIds` means that primary is the one that couldn't warm.
|
|
2961
|
+
if (warmup.failedServerIds.length > 0) {
|
|
2962
|
+
logLatency({
|
|
2963
|
+
type: "phase",
|
|
2964
|
+
phase: "lsp_sweep_group_skipped_warmup",
|
|
2965
|
+
filePath: first,
|
|
2966
|
+
durationMs: 0,
|
|
2967
|
+
metadata: {
|
|
2968
|
+
failedServerIds: warmup.failedServerIds,
|
|
2969
|
+
// #799: distinguishes a fresh warm-up failure from a
|
|
2970
|
+
// negative-cache hit (this sweep never re-attempted warm-up
|
|
2971
|
+
// at all — it was already known cold from earlier this
|
|
2972
|
+
// session).
|
|
2973
|
+
skippedFromCache: warmup.skippedFromCache ?? false,
|
|
2974
|
+
skippedFiles: group.files.length,
|
|
2975
|
+
},
|
|
2976
|
+
});
|
|
2977
|
+
for (const filePath of group.files) {
|
|
2978
|
+
results.push({
|
|
2979
|
+
filePath,
|
|
2980
|
+
diagnostics: [],
|
|
2981
|
+
count: 0,
|
|
2982
|
+
timedOut: true,
|
|
2983
|
+
skippedWarmupFailure: true,
|
|
2984
|
+
});
|
|
2985
|
+
timedOutFiles += 1;
|
|
2986
|
+
completed += 1;
|
|
2987
|
+
}
|
|
2988
|
+
options.onProgress?.(completed, files.length);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2464
2991
|
}
|
|
2465
2992
|
// #608/#621: batch-open a CHUNK of this group's files before
|
|
2466
2993
|
// waiting on diagnostics for any of them individually — see
|
|
@@ -2575,6 +3102,7 @@ export class LSPService {
|
|
|
2575
3102
|
catch {
|
|
2576
3103
|
/* missing → will be pruned */
|
|
2577
3104
|
}
|
|
3105
|
+
return true;
|
|
2578
3106
|
}));
|
|
2579
3107
|
client.pruneDiagnostics((filePath, ts) => !existingPaths.has(filePath) ||
|
|
2580
3108
|
now - ts > CASCADE_DIAGNOSTICS_TTL_MS);
|
|
@@ -2734,8 +3262,6 @@ export function resetLSPService(options = {}) {
|
|
|
2734
3262
|
* event-loop occupancy can be guarded (see workspace-diagnostics-occupancy
|
|
2735
3263
|
* test). Not part of the public API.
|
|
2736
3264
|
*/
|
|
2737
|
-
export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal) {
|
|
2738
|
-
return maxFiles
|
|
2739
|
-
? collectWorkspaceDiagnosticFiles(path.resolve(root), undefined, signal)
|
|
2740
|
-
: collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles, signal);
|
|
3265
|
+
export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal, homeDir) {
|
|
3266
|
+
return collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles ?? getMaxWorkspaceDiagnosticFiles(), signal, homeDir);
|
|
2741
3267
|
}
|