pi-lens 3.8.71 → 3.8.73
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 +152 -1
- package/README.md +31 -22
- 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/deps/js-yaml.js +1 -1
- package/dist/clients/diagnostic-dispositions.js +64 -2
- package/dist/clients/dispatch/integration.js +2 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
- 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 +32 -21
- 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 +707 -109
- 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 +15205 -11330
- 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 +143 -10
- 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 +8 -9
- package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
- package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
- 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,9 +429,27 @@ 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
|
+
/**
|
|
444
|
+
* #850: teardown completion for every singleton generation retired before
|
|
445
|
+
* this service was published. Only replacement services receive one; direct
|
|
446
|
+
* `new LSPService()` callers and the first singleton generation stay hot-path
|
|
447
|
+
* identical. Cleared after the first completed wait so warm reuse never pays
|
|
448
|
+
* a permanent promise/microtask tax.
|
|
449
|
+
*/
|
|
450
|
+
generationHandoff;
|
|
451
|
+
constructor(generationHandoff) {
|
|
452
|
+
this.generationHandoff = generationHandoff;
|
|
409
453
|
this.state = {
|
|
410
454
|
clients: new Map(),
|
|
411
455
|
servers: new Map(),
|
|
@@ -413,6 +457,7 @@ export class LSPService {
|
|
|
413
457
|
inFlight: new Map(),
|
|
414
458
|
clientSpawnedAt: new Map(),
|
|
415
459
|
demonstratedReady: new Set(),
|
|
460
|
+
demonstratedCold: new Set(),
|
|
416
461
|
};
|
|
417
462
|
}
|
|
418
463
|
/** Guard: return true if service is shutting down or shut down */
|
|
@@ -498,6 +543,46 @@ export class LSPService {
|
|
|
498
543
|
}
|
|
499
544
|
markDemonstratedReadyKey(key) {
|
|
500
545
|
this.state.demonstratedReady.add(key);
|
|
546
|
+
// #799: readiness through ANY path supersedes an earlier cold verdict —
|
|
547
|
+
// a server that recovers later in the session must not stay stuck in
|
|
548
|
+
// the negative cache.
|
|
549
|
+
this.state.demonstratedCold.delete(key);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* #743: record one notify-write timeout for a server and, once it has stalled
|
|
553
|
+
* {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
|
|
554
|
+
* the EXISTING broken-cooldown map so subsequent sweeps stop re-paying its
|
|
555
|
+
* notify budget on every file. The wedged client is also evicted: an alive
|
|
556
|
+
* client is reused before the broken check in {@link ensureClientForServer},
|
|
557
|
+
* so the cooldown only bites once the stale client is gone. `key` is
|
|
558
|
+
* "serverId:normalizedRoot" (the broken-map identity); undefined when the
|
|
559
|
+
* server's root could not be resolved, in which case there is nothing to key.
|
|
560
|
+
*/
|
|
561
|
+
recordNotifyWriteBackpressure(key, entry, filePath) {
|
|
562
|
+
if (!key)
|
|
563
|
+
return;
|
|
564
|
+
const streak = (this.notifyWriteBackpressureStreak.get(key) ?? 0) + 1;
|
|
565
|
+
if (streak < NOTIFY_BACKPRESSURE_BROKEN_AFTER) {
|
|
566
|
+
this.notifyWriteBackpressureStreak.set(key, streak);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.notifyWriteBackpressureStreak.delete(key);
|
|
570
|
+
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
571
|
+
void entry.client.shutdown().catch(() => { });
|
|
572
|
+
this.state.clients.delete(key);
|
|
573
|
+
this.state.clientSpawnedAt.delete(key);
|
|
574
|
+
this.state.demonstratedReady.delete(key);
|
|
575
|
+
logLatency({
|
|
576
|
+
type: "phase",
|
|
577
|
+
phase: "lsp_notify_backpressure_broken",
|
|
578
|
+
filePath: normalizeMapKey(filePath),
|
|
579
|
+
durationMs: 0,
|
|
580
|
+
metadata: {
|
|
581
|
+
serverId: entry.info.id,
|
|
582
|
+
cooldownMs: BROKEN_BASE_COOLDOWN_MS,
|
|
583
|
+
consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
|
|
584
|
+
},
|
|
585
|
+
});
|
|
501
586
|
}
|
|
502
587
|
activeClientsForCwd(cwd, priorityServerIds = []) {
|
|
503
588
|
const normalizedCwd = normalizeMapKey(cwd);
|
|
@@ -662,9 +747,38 @@ export class LSPService {
|
|
|
662
747
|
}
|
|
663
748
|
return undefined;
|
|
664
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* Read-only liveness check for one server/file pair. Unlike
|
|
752
|
+
* `getClientForFile`, this never creates or warms a client; it only resolves
|
|
753
|
+
* the server's root and checks the already-connected client map.
|
|
754
|
+
*/
|
|
755
|
+
async isServerAliveForFile(serverId, filePath) {
|
|
756
|
+
if (this.checkDestroyed())
|
|
757
|
+
return false;
|
|
758
|
+
for (const server of getServersForFileWithConfig(filePath)) {
|
|
759
|
+
if (server.id !== serverId)
|
|
760
|
+
continue;
|
|
761
|
+
const root = await server.root(filePath);
|
|
762
|
+
if (!root)
|
|
763
|
+
continue;
|
|
764
|
+
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
765
|
+
if (this.state.clients.get(key)?.isAlive())
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
665
770
|
async ensureClientForServer(filePath, server) {
|
|
771
|
+
const handoff = this.generationHandoff;
|
|
772
|
+
if (handoff) {
|
|
773
|
+
await handoff;
|
|
774
|
+
if (this.generationHandoff === handoff) {
|
|
775
|
+
this.generationHandoff = undefined;
|
|
776
|
+
}
|
|
777
|
+
if (this.checkDestroyed())
|
|
778
|
+
return undefined;
|
|
779
|
+
}
|
|
666
780
|
const root = await server.root(filePath);
|
|
667
|
-
if (!root)
|
|
781
|
+
if (!root || this.checkDestroyed())
|
|
668
782
|
return undefined;
|
|
669
783
|
const allowInstall = this.shouldAllowInstall(filePath, root);
|
|
670
784
|
const normalizedRoot = normalizeMapKey(root);
|
|
@@ -755,6 +869,11 @@ export class LSPService {
|
|
|
755
869
|
if (inFlight) {
|
|
756
870
|
return inFlight;
|
|
757
871
|
}
|
|
872
|
+
// `server.root()` and a dead client's shutdown above are both async. A
|
|
873
|
+
// reset during either gap may have completed without seeing this request in
|
|
874
|
+
// state.inFlight; never let that retired generation start a late spawn.
|
|
875
|
+
if (this.checkDestroyed())
|
|
876
|
+
return undefined;
|
|
758
877
|
const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
759
878
|
this.state.inFlight.set(key, spawnPromise);
|
|
760
879
|
try {
|
|
@@ -986,23 +1105,70 @@ export class LSPService {
|
|
|
986
1105
|
// runner touch sequence expensive on slow TS projects.
|
|
987
1106
|
const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
|
|
988
1107
|
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
989
|
-
|
|
1108
|
+
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1109
|
+
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1110
|
+
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1111
|
+
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1112
|
+
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1113
|
+
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1114
|
+
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1115
|
+
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1116
|
+
//
|
|
1117
|
+
// Holds the serverId of every server whose write did NOT land in time.
|
|
1118
|
+
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1119
|
+
// server timed out"; this list carries the per-server detail the
|
|
1120
|
+
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1121
|
+
const notifyWriteTimedOutServerIds = [];
|
|
990
1122
|
if (!notifySkipped) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1123
|
+
const budget = notifyWriteBudgetMs();
|
|
1124
|
+
await Promise.all(spawned.map(async (entry) => {
|
|
1125
|
+
// Same identity as the broken/demonstratedReady maps.
|
|
1126
|
+
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1127
|
+
let wrote;
|
|
1128
|
+
let rejected = false;
|
|
1129
|
+
try {
|
|
1130
|
+
wrote = await withDeadline(entry.client.notify
|
|
1131
|
+
.open(filePath, content, languageId, undefined, silent)
|
|
1132
|
+
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
// The write itself rejected (not backpressure): the content did
|
|
1136
|
+
// not land, so this server is inconclusive for the touch, but a
|
|
1137
|
+
// rejection is not a stdin-backpressure signal and must not count
|
|
1138
|
+
// toward the backpressure demotion streak.
|
|
1139
|
+
rejected = true;
|
|
1140
|
+
}
|
|
1141
|
+
if (wrote === true) {
|
|
1142
|
+
// A clean write clears any accrued backpressure streak (#743).
|
|
1143
|
+
if (clientKey)
|
|
1144
|
+
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1148
|
+
if (!rejected) {
|
|
1149
|
+
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return true;
|
|
1153
|
+
}));
|
|
1154
|
+
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
997
1155
|
logLatency({
|
|
998
1156
|
type: "phase",
|
|
999
1157
|
phase: "lsp_notify_timeout",
|
|
1000
1158
|
filePath: normalizedPath,
|
|
1001
1159
|
durationMs: Date.now() - startedAt,
|
|
1002
|
-
metadata: {
|
|
1160
|
+
metadata: {
|
|
1161
|
+
source,
|
|
1162
|
+
clientScope,
|
|
1163
|
+
serverCount: spawned.length,
|
|
1164
|
+
timedOutServerIds: notifyWriteTimedOutServerIds,
|
|
1165
|
+
},
|
|
1003
1166
|
});
|
|
1004
1167
|
}
|
|
1005
1168
|
}
|
|
1169
|
+
// File-level flag: at least one server's write timed out (kept for the
|
|
1170
|
+
// conservative touch-wide `inconclusive` merge semantics — see below).
|
|
1171
|
+
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
1006
1172
|
let diagnosticsTimedOut = false;
|
|
1007
1173
|
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1008
1174
|
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
@@ -1014,7 +1180,7 @@ export class LSPService {
|
|
|
1014
1180
|
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1015
1181
|
// fallback below may still fill it in on a timed-out empty result).
|
|
1016
1182
|
let tsserverSyncEligible = false;
|
|
1017
|
-
let tsserverSyncConfirmed
|
|
1183
|
+
let tsserverSyncConfirmed;
|
|
1018
1184
|
if (diagnosticsMode !== "none") {
|
|
1019
1185
|
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1020
1186
|
// Otherwise, on the single-server hot path (primary scope), use that
|
|
@@ -1061,6 +1227,44 @@ export class LSPService {
|
|
|
1061
1227
|
}
|
|
1062
1228
|
}
|
|
1063
1229
|
}
|
|
1230
|
+
// #832: workspace-indexing servers that are classified as silent on
|
|
1231
|
+
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1232
|
+
// configured strategy already gives the first sweep touch a bounded
|
|
1233
|
+
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1234
|
+
// classification proves that a clean push has no affirmative signal to
|
|
1235
|
+
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1236
|
+
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1237
|
+
// load still needs the longer 20s floor.
|
|
1238
|
+
//
|
|
1239
|
+
// Build this from the live spawned client's capabilities rather than
|
|
1240
|
+
// server id alone. Missing/throwing capability data fails closed, so a
|
|
1241
|
+
// new or ambiguous server keeps the existing generous warm-up budget.
|
|
1242
|
+
const silentCleanWarmupServers = new Set();
|
|
1243
|
+
if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
|
|
1244
|
+
for (const entry of spawned) {
|
|
1245
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1246
|
+
if (strategy.workspaceIndexing !== true)
|
|
1247
|
+
continue;
|
|
1248
|
+
try {
|
|
1249
|
+
const snapshot = {
|
|
1250
|
+
serverId: entry.client.serverId,
|
|
1251
|
+
root: entry.client.root,
|
|
1252
|
+
operationSupport: entry.client.getOperationSupport(),
|
|
1253
|
+
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1254
|
+
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1255
|
+
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1256
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1257
|
+
};
|
|
1258
|
+
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1259
|
+
"tier3-silent") {
|
|
1260
|
+
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// Fail closed: capability uncertainty must retain the cold floor.
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1064
1268
|
// Each server gets its OWN deadline, bounded by the caller cap as a
|
|
1065
1269
|
// CEILING (never a floor) — so a clean push-silent primary (typescript
|
|
1066
1270
|
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
|
|
@@ -1088,6 +1292,18 @@ export class LSPService {
|
|
|
1088
1292
|
// if the strategy already wants more) rather than the normal
|
|
1089
1293
|
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1090
1294
|
if (options.warmupOverride) {
|
|
1295
|
+
// #832: a workspace-indexing server already classified as
|
|
1296
|
+
// silent-on-clean uses its strategy's bounded wait on the first
|
|
1297
|
+
// attempt; the generic cold floor is for servers whose cold work
|
|
1298
|
+
// can eventually produce a push answer (notably TypeScript).
|
|
1299
|
+
if (silentCleanWarmupServers.has(serverId)) {
|
|
1300
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1301
|
+
}
|
|
1302
|
+
// #799: only the FIRST warm-up attempt for a cold server gets the
|
|
1303
|
+
// floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
1304
|
+
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1305
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1306
|
+
}
|
|
1091
1307
|
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1092
1308
|
}
|
|
1093
1309
|
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
@@ -1114,16 +1330,21 @@ export class LSPService {
|
|
|
1114
1330
|
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1115
1331
|
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1116
1332
|
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1117
|
-
// primary scope,
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1333
|
+
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
1334
|
+
// races are tsserver-specific protocol extensions, not a generic
|
|
1335
|
+
// push-only capability, so #799 giving other servers (marksman) the
|
|
1336
|
+
// SAME `silentOnClean` marker must not route them into a sync attempt
|
|
1337
|
+
// that can never succeed for them), then the live capability-snapshot
|
|
1338
|
+
// tier classification (`classifyCascadeWaitTier`, which also excludes
|
|
1339
|
+
// native-ts7 via `launchVariant`). Every other server fails this
|
|
1340
|
+
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
1341
|
+
// read; a non-typescript `silentOnClean` server instead gets the
|
|
1342
|
+
// generic (non-racing) clean-confirm fallback further below.
|
|
1123
1343
|
if (!notifyWriteTimedOut &&
|
|
1124
1344
|
options.collectDiagnostics === true &&
|
|
1125
1345
|
clientScope === "primary" &&
|
|
1126
1346
|
spawned.length === 1 &&
|
|
1347
|
+
spawned[0].client.serverId === "typescript" &&
|
|
1127
1348
|
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1128
1349
|
try {
|
|
1129
1350
|
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
@@ -1156,14 +1377,29 @@ export class LSPService {
|
|
|
1156
1377
|
spawned.some((e) => e.info.role === "auxiliary");
|
|
1157
1378
|
// Per-server wait promises (each already bounded by its own
|
|
1158
1379
|
// perServerTimeout — unchanged from before R8).
|
|
1380
|
+
let pressureSnapshots = [];
|
|
1381
|
+
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1382
|
+
try {
|
|
1383
|
+
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
// Fail-open: missing capability state keeps today's push fallback.
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1159
1389
|
const perServerWaits = spawned.map((entry) => {
|
|
1160
1390
|
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
1161
1391
|
const baseline = diagnosticBaselines.get(entry.client);
|
|
1392
|
+
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
1162
1393
|
const wait = !notifySkipped && Number.isFinite(baseline)
|
|
1163
1394
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1164
1395
|
minVersion: baseline,
|
|
1396
|
+
...(pullOnly && { pullOnly: true }),
|
|
1165
1397
|
})
|
|
1166
|
-
:
|
|
1398
|
+
: pullOnly
|
|
1399
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1400
|
+
pullOnly: true,
|
|
1401
|
+
})
|
|
1402
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
1167
1403
|
return wait.catch(() => undefined);
|
|
1168
1404
|
});
|
|
1169
1405
|
// The push wait — same per-server budget composition as before #707;
|
|
@@ -1327,6 +1563,69 @@ export class LSPService {
|
|
|
1327
1563
|
},
|
|
1328
1564
|
});
|
|
1329
1565
|
}
|
|
1566
|
+
// #814: capability-aware AGGREGATE wait — generalize #799's
|
|
1567
|
+
// single-server (`clientScope === "primary" && spawned.length === 1`)
|
|
1568
|
+
// silent-clean confirm to multi-server `clientScope: "all"` touches
|
|
1569
|
+
// (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
|
|
1570
|
+
// `serverScope: "all"`). #799's gate never fires here (it's scoped to
|
|
1571
|
+
// the primary hot path), so a scope-"all" touch where every OTHER
|
|
1572
|
+
// spawned server already answered but one push-only `silentOnClean`
|
|
1573
|
+
// server (marksman on a clean markdown file) never publishes still
|
|
1574
|
+
// reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
|
|
1575
|
+
// though the "silence" is exactly what that server's own known
|
|
1576
|
+
// clean-behavior predicts — not an unresolved question.
|
|
1577
|
+
//
|
|
1578
|
+
// A spawned server counts as "still outstanding" when nothing landed
|
|
1579
|
+
// in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
|
|
1580
|
+
// is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
|
|
1581
|
+
// that file's entry as part of the didOpen/didChange this touch just
|
|
1582
|
+
// sent, so a present entry can only be a FRESH answer (found or a real
|
|
1583
|
+
// confirmed-empty push/pull), never a stale one bleeding through from
|
|
1584
|
+
// an earlier touch. This is the same "did anything publish for this
|
|
1585
|
+
// file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
|
|
1586
|
+
// already trusts (#240 doctrine) — reused here, not reinvented.
|
|
1587
|
+
//
|
|
1588
|
+
// The touch stays inconclusive unless EVERY still-outstanding server
|
|
1589
|
+
// is classified `tier3-silent` (push-only + `silentOnClean`, the same
|
|
1590
|
+
// `classifyServerWaitTier` rule the single-server gate below and the
|
|
1591
|
+
// cascade lane use) — one ordinary push-only straggler (still
|
|
1592
|
+
// genuinely analyzing) or a pull-capable server that never answered
|
|
1593
|
+
// keeps the touch cautious, matching #799's "err toward caution"
|
|
1594
|
+
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
1595
|
+
// plus the per-server re-check below are the same "the notify write
|
|
1596
|
+
// must have actually landed" conservatism #799 established — a
|
|
1597
|
+
// server's silence is only evidence of "clean" when we know it saw
|
|
1598
|
+
// the new content.
|
|
1599
|
+
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
1600
|
+
try {
|
|
1601
|
+
const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
1602
|
+
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
1603
|
+
if (outstanding.length > 0) {
|
|
1604
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1605
|
+
const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
|
|
1606
|
+
if (allSilent) {
|
|
1607
|
+
diagnosticsTimedOut = false;
|
|
1608
|
+
logLatency({
|
|
1609
|
+
type: "phase",
|
|
1610
|
+
phase: "lsp_silent_clean_confirm",
|
|
1611
|
+
filePath: normalizedPath,
|
|
1612
|
+
durationMs: Date.now() - startedAt,
|
|
1613
|
+
metadata: {
|
|
1614
|
+
source,
|
|
1615
|
+
clientScope,
|
|
1616
|
+
diagnosticsMode,
|
|
1617
|
+
aggregate: true,
|
|
1618
|
+
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
1619
|
+
},
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
1626
|
+
// inconclusive behavior, exactly like the single-server gate.
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1330
1629
|
}
|
|
1331
1630
|
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
1332
1631
|
// collected result — the file's real syntactic + semantic state straight
|
|
@@ -1385,6 +1684,71 @@ export class LSPService {
|
|
|
1385
1684
|
// Any failure here falls through to today's inconclusive behavior.
|
|
1386
1685
|
}
|
|
1387
1686
|
}
|
|
1687
|
+
// #799: generalize the "silent-clean push-only" confirm beyond
|
|
1688
|
+
// typescript's active sync-command race above. That mechanism is
|
|
1689
|
+
// TS-specific (`attemptTsserverSyncDiagnostics` races an actual
|
|
1690
|
+
// `typescript.tsserverRequest` — no equivalent protocol exists for
|
|
1691
|
+
// e.g. marksman) and is now scoped to `serverId === "typescript"`
|
|
1692
|
+
// only (see the gate above), so it never fires for another
|
|
1693
|
+
// `silentOnClean` server. This is the generic fallback for those
|
|
1694
|
+
// servers: if the wait ran its full budget with a successful notify
|
|
1695
|
+
// write and nothing published, and the live capability snapshot
|
|
1696
|
+
// classifies this touch as tier3-silent (push-only + `silentOnClean`,
|
|
1697
|
+
// #458's `classifyCascadeWaitTier`), that is not "still working" —
|
|
1698
|
+
// `silentOnClean` means by definition this server publishes NOTHING
|
|
1699
|
+
// on a clean transition, so a timeout under those conditions IS the
|
|
1700
|
+
// confirmed-clean answer. `!tsserverSyncEligible` keeps this from
|
|
1701
|
+
// ever double-deciding typescript's own touches — when the sync race
|
|
1702
|
+
// was attempted and failed/was unavailable, typescript's existing
|
|
1703
|
+
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
1704
|
+
// preserved exactly; typescript touches that never enter that gate
|
|
1705
|
+
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
1706
|
+
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
1707
|
+
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
1708
|
+
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
1709
|
+
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
1710
|
+
// with-auxiliary/all touch — where a partial timeout must stay
|
|
1711
|
+
// cautious per the doc below — is never affected.
|
|
1712
|
+
//
|
|
1713
|
+
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
1714
|
+
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
1715
|
+
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
1716
|
+
// server is tier3-silent" collapses to exactly this single-server check.
|
|
1717
|
+
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
1718
|
+
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
1719
|
+
// into the general one once both have soaked, but that's a separate,
|
|
1720
|
+
// lower-risk follow-up, not bundled into this fix.
|
|
1721
|
+
if (diagnosticsTimedOut &&
|
|
1722
|
+
!notifyWriteTimedOut &&
|
|
1723
|
+
!tsserverSyncEligible &&
|
|
1724
|
+
clientScope === "primary" &&
|
|
1725
|
+
spawned.length === 1 &&
|
|
1726
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1727
|
+
try {
|
|
1728
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1729
|
+
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
1730
|
+
diagnosticsTimedOut = false;
|
|
1731
|
+
if (collected !== undefined)
|
|
1732
|
+
collected = mergeLspDiagnostics([]);
|
|
1733
|
+
logLatency({
|
|
1734
|
+
type: "phase",
|
|
1735
|
+
phase: "lsp_silent_clean_confirm",
|
|
1736
|
+
filePath: normalizedPath,
|
|
1737
|
+
durationMs: Date.now() - startedAt,
|
|
1738
|
+
metadata: {
|
|
1739
|
+
source,
|
|
1740
|
+
clientScope,
|
|
1741
|
+
diagnosticsMode,
|
|
1742
|
+
serverId: spawned[0].client.serverId,
|
|
1743
|
+
},
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
|
|
1749
|
+
// behavior.
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1388
1752
|
// A touch is inconclusive when EITHER the notify write or the
|
|
1389
1753
|
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
1390
1754
|
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
@@ -1399,8 +1763,17 @@ export class LSPService {
|
|
|
1399
1763
|
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
1400
1764
|
// spawned server so a later sweep in this session sees the check as a
|
|
1401
1765
|
// no-op instead of paying the warm-up round trip again.
|
|
1402
|
-
|
|
1766
|
+
//
|
|
1767
|
+
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
1768
|
+
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
1769
|
+
// write stalled must still be eligible, so only servers whose OWN write
|
|
1770
|
+
// timed out are skipped here (rather than gating the whole loop on the
|
|
1771
|
+
// file-level `inconclusive`).
|
|
1772
|
+
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
1773
|
+
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
1403
1774
|
for (const entry of spawned) {
|
|
1775
|
+
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
1776
|
+
continue;
|
|
1404
1777
|
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1405
1778
|
if (key)
|
|
1406
1779
|
this.markDemonstratedReadyKey(key);
|
|
@@ -1457,6 +1830,12 @@ export class LSPService {
|
|
|
1457
1830
|
collectedDiagnostics: collected?.length,
|
|
1458
1831
|
notifySkipped,
|
|
1459
1832
|
notifyWriteTimedOut,
|
|
1833
|
+
// #743: per-server detail — which servers' writes actually timed out.
|
|
1834
|
+
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
1835
|
+
// least one" summary.
|
|
1836
|
+
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
1837
|
+
notifyWriteTimedOutServerIds,
|
|
1838
|
+
}),
|
|
1460
1839
|
diagnosticsTimedOut,
|
|
1461
1840
|
inconclusive,
|
|
1462
1841
|
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
@@ -2030,14 +2409,45 @@ export class LSPService {
|
|
|
2030
2409
|
* Returns `performedWarmup: true` only when the round trip actually ran
|
|
2031
2410
|
* (false = already warm, no-op) — tests assert on this to guard against
|
|
2032
2411
|
* the warm-up becoming a mandatory extra round trip on every sweep.
|
|
2412
|
+
*
|
|
2413
|
+
* #744: a warm-up that TIMES OUT is no longer left as a silent dead end.
|
|
2414
|
+
* The old one-shot behavior left a wedged server (observed live: marksman,
|
|
2415
|
+
* a `workspaceIndexing` server, burned the full 20s and stayed cold) with
|
|
2416
|
+
* no re-warm and no skip — so every subsequent per-file touch in the sweep
|
|
2417
|
+
* re-paid a full per-file budget against it and timed out again, dragging
|
|
2418
|
+
* the whole sweep. Now: on a failed warm-up this retries exactly once (after
|
|
2419
|
+
* a short `warmupRetryBackoffMs` breather — the state where warm-up fails is
|
|
2420
|
+
* usually a server mid-relaunch/index that just needs a moment), and if the
|
|
2421
|
+
* retry ALSO leaves the server cold it is reported in `failedServerIds`. The
|
|
2422
|
+
* caller (the sweep loop) skips that server's files up front and marks them
|
|
2423
|
+
* unconfirmed, so per-file touches stop paying its timeout. This is a
|
|
2424
|
+
* sweep-scoped skip (the caller discards `failedServerIds` when the sweep
|
|
2425
|
+
* ends), deliberately NOT the global `broken` cooldown map: a server that is
|
|
2426
|
+
* merely still indexing is not broken and must not be cooldown-banned across
|
|
2427
|
+
* the whole session — it just wasn't ready for THIS sweep.
|
|
2428
|
+
*
|
|
2429
|
+
* "Failed warm-up" is measured per non-auxiliary server via the SAME
|
|
2430
|
+
* `demonstratedReady` signal `touchFile` marks on a confirmed round trip: a
|
|
2431
|
+
* server whose key is still absent from `demonstratedReady` after both
|
|
2432
|
+
* attempts never proved it can answer diagnostics. Warm-up stays
|
|
2433
|
+
* `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
|
|
2434
|
+
* would additionally spawn the sweep-EXCLUDED auxiliaries
|
|
2435
|
+
* (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
|
|
2436
|
+
* `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
|
|
2437
|
+
* suppress the `demonstratedReady` marking for a perfectly healthy primary —
|
|
2438
|
+
* falsely condemning it. Recording per-primary-server outcomes and skipping
|
|
2439
|
+
* on those is the correct, non-regressing way to cover the servers the sweep
|
|
2440
|
+
* actually gates on (the sweep groups by primary server, so the group this
|
|
2441
|
+
* warms IS the one whose per-file touches would drag).
|
|
2033
2442
|
*/
|
|
2034
2443
|
async ensureWarmForSweep(representativeFile, options = {}) {
|
|
2035
2444
|
if (this.checkDestroyed() || options.signal?.aborted) {
|
|
2036
|
-
return { performedWarmup: false };
|
|
2445
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2037
2446
|
}
|
|
2038
2447
|
const servers = getServersForFileWithConfig(representativeFile).filter((s) => s.role !== "auxiliary");
|
|
2039
|
-
if (servers.length === 0)
|
|
2040
|
-
return { performedWarmup: false };
|
|
2448
|
+
if (servers.length === 0) {
|
|
2449
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2450
|
+
}
|
|
2041
2451
|
// A server with no resolvable root never spawns a client for this file
|
|
2042
2452
|
// either way, so it can't block "already warm" — only servers that WILL
|
|
2043
2453
|
// actually be used count toward the readiness check. Same key
|
|
@@ -2047,7 +2457,43 @@ export class LSPService {
|
|
|
2047
2457
|
const keys = await Promise.all(servers.map((server) => this.demonstratedReadyKeyFor(server, representativeFile)));
|
|
2048
2458
|
const alreadyWarm = keys.every((key) => key === undefined || this.state.demonstratedReady.has(key));
|
|
2049
2459
|
if (alreadyWarm)
|
|
2050
|
-
return { performedWarmup: false };
|
|
2460
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2461
|
+
// #799: negative cache. Every server that still needs warming (not
|
|
2462
|
+
// already `demonstratedReady`) was ALSO left cold by a warm-up earlier
|
|
2463
|
+
// this session (`demonstratedCold`, populated below when a warm-up's
|
|
2464
|
+
// initial attempt + retry both fail) — skip straight to the group-skip
|
|
2465
|
+
// accounting the caller already has for `failedServerIds`, instead of
|
|
2466
|
+
// re-paying the initial-attempt + retry round trip all over again. A
|
|
2467
|
+
// MIXED group (one server cached cold, another never tried) still runs
|
|
2468
|
+
// the real warm-up — the never-tried server deserves its fair shot, and
|
|
2469
|
+
// `touchFile`'s multi-server spawn already covers both in one call.
|
|
2470
|
+
const cachedColdServerIds = [];
|
|
2471
|
+
let allNonWarmCached = true;
|
|
2472
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2473
|
+
const key = keys[i];
|
|
2474
|
+
if (key === undefined || this.state.demonstratedReady.has(key))
|
|
2475
|
+
continue;
|
|
2476
|
+
if (this.state.demonstratedCold.has(key)) {
|
|
2477
|
+
cachedColdServerIds.push(servers[i].id);
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
allNonWarmCached = false;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
if (allNonWarmCached && cachedColdServerIds.length > 0) {
|
|
2484
|
+
logLatency({
|
|
2485
|
+
type: "phase",
|
|
2486
|
+
phase: "lsp_sweep_warmup_cached_cold",
|
|
2487
|
+
filePath: representativeFile,
|
|
2488
|
+
durationMs: 0,
|
|
2489
|
+
metadata: { serverIds: cachedColdServerIds },
|
|
2490
|
+
});
|
|
2491
|
+
return {
|
|
2492
|
+
performedWarmup: false,
|
|
2493
|
+
failedServerIds: cachedColdServerIds,
|
|
2494
|
+
skippedFromCache: true,
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2051
2497
|
let content;
|
|
2052
2498
|
try {
|
|
2053
2499
|
content = await nodeFs.promises.readFile(representativeFile, "utf-8");
|
|
@@ -2055,55 +2501,129 @@ export class LSPService {
|
|
|
2055
2501
|
catch {
|
|
2056
2502
|
// Nothing to warm up with — the real sweep's own read will surface
|
|
2057
2503
|
// the file error.
|
|
2058
|
-
return { performedWarmup: false };
|
|
2504
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2505
|
+
}
|
|
2506
|
+
if (options.signal?.aborted) {
|
|
2507
|
+
return { performedWarmup: false, failedServerIds: [] };
|
|
2059
2508
|
}
|
|
2060
|
-
if (options.signal?.aborted)
|
|
2061
|
-
return { performedWarmup: false };
|
|
2062
2509
|
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
|
-
|
|
2510
|
+
// A non-auxiliary server that WILL spawn for this file (resolvable key)
|
|
2511
|
+
// but whose key is still absent from `demonstratedReady` after an attempt
|
|
2512
|
+
// never proved it can answer diagnostics — that's a failed warm-up. A
|
|
2513
|
+
// server with an unresolvable key never spawns here, so it can't fail this
|
|
2514
|
+
// way and is excluded (never skipped by the caller for this file).
|
|
2515
|
+
const stillColdServerIds = () => {
|
|
2516
|
+
const cold = [];
|
|
2517
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2518
|
+
const key = keys[i];
|
|
2519
|
+
if (key !== undefined && !this.state.demonstratedReady.has(key)) {
|
|
2520
|
+
cold.push(servers[i].id);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return cold;
|
|
2524
|
+
};
|
|
2525
|
+
const runWarmupTouch = async (attempt) => {
|
|
2526
|
+
if (options.signal?.aborted)
|
|
2527
|
+
return;
|
|
2528
|
+
const startedAt = Date.now();
|
|
2529
|
+
logLatency({
|
|
2530
|
+
type: "phase",
|
|
2531
|
+
phase: "lsp_sweep_warmup_start",
|
|
2532
|
+
filePath: representativeFile,
|
|
2533
|
+
durationMs: 0,
|
|
2534
|
+
metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
|
|
2535
|
+
});
|
|
2536
|
+
const warmupAttempt = this.touchFile(representativeFile, content, {
|
|
2537
|
+
diagnostics: "document",
|
|
2538
|
+
collectDiagnostics: false,
|
|
2539
|
+
clientScope: "primary",
|
|
2540
|
+
source: "lsp_sweep_warmup",
|
|
2541
|
+
maxClientWaitMs: timeoutMs,
|
|
2542
|
+
maxDiagnosticsWaitMs: timeoutMs,
|
|
2543
|
+
// #669: the caller's cap here is the warm-up budget for a genuinely
|
|
2544
|
+
// COLD server — it must act as a floor, not the usual ceiling, or
|
|
2545
|
+
// `perServerTimeout` silently shrinks it to the strategy's normal
|
|
2546
|
+
// warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
|
|
2547
|
+
// of the requested 20000ms.
|
|
2548
|
+
warmupOverride: true,
|
|
2549
|
+
// #799: only attempt 1 gets the cold-start floor — see the
|
|
2550
|
+
// `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
2551
|
+
warmupAttempt: attempt,
|
|
2552
|
+
});
|
|
2553
|
+
await (options.signal
|
|
2554
|
+
? Promise.race([
|
|
2555
|
+
withDeadline(warmupAttempt, {
|
|
2556
|
+
ms: timeoutMs,
|
|
2557
|
+
onTimeout: "undefined",
|
|
2558
|
+
}),
|
|
2559
|
+
new Promise((resolve) => {
|
|
2560
|
+
if (options.signal.aborted) {
|
|
2561
|
+
resolve();
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
2565
|
+
once: true,
|
|
2566
|
+
});
|
|
2567
|
+
}),
|
|
2568
|
+
])
|
|
2569
|
+
: withDeadline(warmupAttempt, {
|
|
2570
|
+
ms: timeoutMs,
|
|
2571
|
+
onTimeout: "undefined",
|
|
2572
|
+
}));
|
|
2573
|
+
logLatency({
|
|
2574
|
+
type: "phase",
|
|
2575
|
+
phase: "lsp_sweep_warmup_done",
|
|
2576
|
+
filePath: representativeFile,
|
|
2577
|
+
durationMs: Date.now() - startedAt,
|
|
2578
|
+
metadata: {
|
|
2579
|
+
serverIds: servers.map((s) => s.id),
|
|
2580
|
+
timeoutMs,
|
|
2581
|
+
attempt,
|
|
2582
|
+
coldServerIds: stillColdServerIds(),
|
|
2583
|
+
},
|
|
2584
|
+
});
|
|
2585
|
+
};
|
|
2586
|
+
await runWarmupTouch(1);
|
|
2587
|
+
let failedServerIds = stillColdServerIds();
|
|
2588
|
+
// One retry, and only when the first attempt actually left a server cold —
|
|
2589
|
+
// a short backoff first so a server mid-relaunch/index gets a breather
|
|
2590
|
+
// rather than an immediate second hammer.
|
|
2591
|
+
if (failedServerIds.length > 0 && !options.signal?.aborted) {
|
|
2592
|
+
const backoffMs = warmupRetryBackoffMs();
|
|
2593
|
+
if (backoffMs > 0) {
|
|
2594
|
+
await new Promise((resolve) => {
|
|
2595
|
+
const timer = setTimeout(resolve, backoffMs);
|
|
2596
|
+
timer.unref?.();
|
|
2597
|
+
options.signal?.addEventListener("abort", () => resolve(), {
|
|
2094
2598
|
once: true,
|
|
2095
2599
|
});
|
|
2096
|
-
})
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
if (!options.signal?.aborted) {
|
|
2603
|
+
await runWarmupTouch(2);
|
|
2604
|
+
failedServerIds = stillColdServerIds();
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (failedServerIds.length > 0) {
|
|
2608
|
+
logLatency({
|
|
2609
|
+
type: "phase",
|
|
2610
|
+
phase: "lsp_sweep_warmup_failed",
|
|
2611
|
+
filePath: representativeFile,
|
|
2612
|
+
durationMs: 0,
|
|
2613
|
+
metadata: { failedServerIds, timeoutMs },
|
|
2614
|
+
});
|
|
2615
|
+
// #799: record the negative cache so a LATER sweep this session
|
|
2616
|
+
// skips straight past re-paying this warm-up (initial + retry).
|
|
2617
|
+
// Cleared automatically the moment the server demonstrates
|
|
2618
|
+
// readiness through any path (`markDemonstratedReadyKey`).
|
|
2619
|
+
for (let i = 0; i < servers.length; i++) {
|
|
2620
|
+
const key = keys[i];
|
|
2621
|
+
if (key !== undefined && failedServerIds.includes(servers[i].id)) {
|
|
2622
|
+
this.state.demonstratedCold.add(key);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return { performedWarmup: true, failedServerIds };
|
|
2107
2627
|
}
|
|
2108
2628
|
/**
|
|
2109
2629
|
* Actively scan every LSP-supported source file under a project root.
|
|
@@ -2252,6 +2772,8 @@ export class LSPService {
|
|
|
2252
2772
|
// existing group-worker pool.
|
|
2253
2773
|
const contentCache = new Map();
|
|
2254
2774
|
const preOpenGroupFiles = async (groupFiles) => {
|
|
2775
|
+
if (isWarmAttached())
|
|
2776
|
+
return;
|
|
2255
2777
|
for (const filePath of groupFiles) {
|
|
2256
2778
|
if (signal?.aborted)
|
|
2257
2779
|
return;
|
|
@@ -2333,20 +2855,28 @@ export class LSPService {
|
|
|
2333
2855
|
// onTimeout:"undefined" so a hung file yields no diagnostics and the
|
|
2334
2856
|
// worker moves on; a real touchFile rejection still propagates to the
|
|
2335
2857
|
// catch below and is recorded as an error.
|
|
2336
|
-
const
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2858
|
+
const attached = isWarmAttached()
|
|
2859
|
+
? await tryWarmAttachedDiagnostics(filePath, content, perFileMs, "sweep")
|
|
2860
|
+
: undefined;
|
|
2861
|
+
if (attached && !attached.available) {
|
|
2862
|
+
await this.ensureWarmForSweep(filePath, { signal });
|
|
2863
|
+
}
|
|
2864
|
+
const diagnostics = attached?.available
|
|
2865
|
+
? attached.response.diagnostics
|
|
2866
|
+
: await withDeadline(this.touchFile(filePath, content, {
|
|
2867
|
+
diagnostics: "document",
|
|
2868
|
+
collectDiagnostics: true,
|
|
2869
|
+
clientScope: "all",
|
|
2870
|
+
source: "lens_diagnostics_full",
|
|
2871
|
+
// #584: opengrep's findings for a full sweep come from the
|
|
2872
|
+
// `opengrep-client.ts` CLI extractor (one project-wide scan,
|
|
2873
|
+
// cached, read via extractors.ts) instead — see the
|
|
2874
|
+
// `excludeServerIds` doc on `LSPTouchFileOptions`.
|
|
2875
|
+
excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
|
|
2876
|
+
// #645: lets a workspaceIndexing server (marksman) pay its
|
|
2877
|
+
// full wait budget only once across this whole sweep.
|
|
2878
|
+
sweepIndexGate,
|
|
2879
|
+
}), { ms: perFileMs, onTimeout: "undefined" });
|
|
2350
2880
|
// #571: prefer #570's real per-touch inconclusive signal
|
|
2351
2881
|
// (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
|
|
2352
2882
|
// notify write or the diagnostics wait itself timed out) over this
|
|
@@ -2426,7 +2956,9 @@ export class LSPService {
|
|
|
2426
2956
|
if (signal?.aborted)
|
|
2427
2957
|
return;
|
|
2428
2958
|
// Fast path: one project-wide pull for the whole group (opt-in).
|
|
2429
|
-
if (
|
|
2959
|
+
if (!isWarmAttached() &&
|
|
2960
|
+
workspacePullEnabled &&
|
|
2961
|
+
!group.multiServer) {
|
|
2430
2962
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2431
2963
|
if (pulled) {
|
|
2432
2964
|
for (const result of pulled) {
|
|
@@ -2457,10 +2989,48 @@ export class LSPService {
|
|
|
2457
2989
|
// budget in one shot — the per-file "first N files eat individual
|
|
2458
2990
|
// timeouts" failure mode this fixes doesn't apply there.
|
|
2459
2991
|
const first = group.files[0];
|
|
2460
|
-
if (first) {
|
|
2461
|
-
await this.ensureWarmForSweep(first, { signal });
|
|
2992
|
+
if (first && !isWarmAttached()) {
|
|
2993
|
+
const warmup = await this.ensureWarmForSweep(first, { signal });
|
|
2462
2994
|
if (signal?.aborted)
|
|
2463
2995
|
return;
|
|
2996
|
+
// #744: the group's primary server failed warm-up (initial round
|
|
2997
|
+
// trip + one retry both left it cold). Every per-file touch to it
|
|
2998
|
+
// would re-pay its full timeout and time out again, dragging the
|
|
2999
|
+
// whole sweep — the exact wedged-marksman failure mode this closes.
|
|
3000
|
+
// So skip this group's files and record each as UNCONFIRMED
|
|
3001
|
+
// (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
|
|
3002
|
+
// the group is keyed by its primary server, so a non-empty
|
|
3003
|
+
// `failedServerIds` means that primary is the one that couldn't warm.
|
|
3004
|
+
if (warmup.failedServerIds.length > 0) {
|
|
3005
|
+
logLatency({
|
|
3006
|
+
type: "phase",
|
|
3007
|
+
phase: "lsp_sweep_group_skipped_warmup",
|
|
3008
|
+
filePath: first,
|
|
3009
|
+
durationMs: 0,
|
|
3010
|
+
metadata: {
|
|
3011
|
+
failedServerIds: warmup.failedServerIds,
|
|
3012
|
+
// #799: distinguishes a fresh warm-up failure from a
|
|
3013
|
+
// negative-cache hit (this sweep never re-attempted warm-up
|
|
3014
|
+
// at all — it was already known cold from earlier this
|
|
3015
|
+
// session).
|
|
3016
|
+
skippedFromCache: warmup.skippedFromCache ?? false,
|
|
3017
|
+
skippedFiles: group.files.length,
|
|
3018
|
+
},
|
|
3019
|
+
});
|
|
3020
|
+
for (const filePath of group.files) {
|
|
3021
|
+
results.push({
|
|
3022
|
+
filePath,
|
|
3023
|
+
diagnostics: [],
|
|
3024
|
+
count: 0,
|
|
3025
|
+
timedOut: true,
|
|
3026
|
+
skippedWarmupFailure: true,
|
|
3027
|
+
});
|
|
3028
|
+
timedOutFiles += 1;
|
|
3029
|
+
completed += 1;
|
|
3030
|
+
}
|
|
3031
|
+
options.onProgress?.(completed, files.length);
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
2464
3034
|
}
|
|
2465
3035
|
// #608/#621: batch-open a CHUNK of this group's files before
|
|
2466
3036
|
// waiting on diagnostics for any of them individually — see
|
|
@@ -2575,6 +3145,7 @@ export class LSPService {
|
|
|
2575
3145
|
catch {
|
|
2576
3146
|
/* missing → will be pruned */
|
|
2577
3147
|
}
|
|
3148
|
+
return true;
|
|
2578
3149
|
}));
|
|
2579
3150
|
client.pruneDiagnostics((filePath, ts) => !existingPaths.has(filePath) ||
|
|
2580
3151
|
now - ts > CASCADE_DIAGNOSTICS_TTL_MS);
|
|
@@ -2658,14 +3229,13 @@ export class LSPService {
|
|
|
2658
3229
|
processExiting: !!options.processExiting,
|
|
2659
3230
|
},
|
|
2660
3231
|
});
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
}
|
|
3232
|
+
// Start every client teardown before awaiting any of them. A non-fast
|
|
3233
|
+
// process-tree kill can spend its grace period per client, so awaiting in
|
|
3234
|
+
// map order makes the reset tail O(clientCount * grace) instead of the
|
|
3235
|
+
// maximum individual teardown. allSettled preserves the per-client
|
|
3236
|
+
// best-effort contract: one failure must not prevent other clients from
|
|
3237
|
+
// finishing, and the caller still waits for every teardown to settle.
|
|
3238
|
+
await Promise.allSettled(Array.from(this.state.clients.values(), (client) => Promise.resolve().then(() => client.shutdown(options))));
|
|
2669
3239
|
this.state.clients.clear();
|
|
2670
3240
|
this.state.broken.clear();
|
|
2671
3241
|
this.workspaceProbeLogged.clear();
|
|
@@ -2717,25 +3287,53 @@ export class LSPService {
|
|
|
2717
3287
|
}
|
|
2718
3288
|
// --- Singleton Instance ---
|
|
2719
3289
|
let globalLSPService = null;
|
|
3290
|
+
/**
|
|
3291
|
+
* #850: all singleton generations whose teardown is still pending. A new
|
|
3292
|
+
* generation may be allocated synchronously, but its first spawn waits on this
|
|
3293
|
+
* handoff so two generations can never own the same server/root concurrently.
|
|
3294
|
+
*/
|
|
3295
|
+
let globalLSPGenerationHandoff;
|
|
2720
3296
|
export function getLSPService() {
|
|
2721
3297
|
if (!globalLSPService) {
|
|
2722
|
-
globalLSPService = new LSPService();
|
|
3298
|
+
globalLSPService = new LSPService(globalLSPGenerationHandoff);
|
|
2723
3299
|
}
|
|
2724
3300
|
return globalLSPService;
|
|
2725
3301
|
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Cross-layer liveness seam for dispatch-side auxiliary gates. This is a
|
|
3304
|
+
* liveness read only: it does not spawn, wait for initialization, or probe a
|
|
3305
|
+
* binary.
|
|
3306
|
+
*/
|
|
3307
|
+
export async function isAuxiliaryLspAlive(serverId, filePath) {
|
|
3308
|
+
return getLSPService().isServerAliveForFile(serverId, filePath);
|
|
3309
|
+
}
|
|
2726
3310
|
export function resetLSPService(options = {}) {
|
|
2727
|
-
|
|
2728
|
-
globalLSPService.shutdown(options).catch(() => { });
|
|
2729
|
-
}
|
|
3311
|
+
const retiringService = globalLSPService;
|
|
2730
3312
|
globalLSPService = null;
|
|
3313
|
+
if (!retiringService)
|
|
3314
|
+
return;
|
|
3315
|
+
// shutdown() marks the service destroyed synchronously before its first
|
|
3316
|
+
// await. Include both that teardown and every earlier pending generation:
|
|
3317
|
+
// repeated resets may retire a replacement that is itself still waiting on
|
|
3318
|
+
// its predecessor. allSettled keeps teardown best-effort without ever
|
|
3319
|
+
// rejecting (and therefore permanently poisoning) the next generation.
|
|
3320
|
+
const teardown = retiringService.shutdown(options);
|
|
3321
|
+
const pending = globalLSPGenerationHandoff
|
|
3322
|
+
? [globalLSPGenerationHandoff, teardown]
|
|
3323
|
+
: [teardown];
|
|
3324
|
+
const handoff = Promise.allSettled(pending).then(() => undefined);
|
|
3325
|
+
globalLSPGenerationHandoff = handoff;
|
|
3326
|
+
void handoff.then(() => {
|
|
3327
|
+
if (globalLSPGenerationHandoff === handoff) {
|
|
3328
|
+
globalLSPGenerationHandoff = undefined;
|
|
3329
|
+
}
|
|
3330
|
+
});
|
|
2731
3331
|
}
|
|
2732
3332
|
/**
|
|
2733
3333
|
* Test-only: exposes the async workspace-diagnostics file walk so its
|
|
2734
3334
|
* event-loop occupancy can be guarded (see workspace-diagnostics-occupancy
|
|
2735
3335
|
* test). Not part of the public API.
|
|
2736
3336
|
*/
|
|
2737
|
-
export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal) {
|
|
2738
|
-
return maxFiles
|
|
2739
|
-
? collectWorkspaceDiagnosticFiles(path.resolve(root), undefined, signal)
|
|
2740
|
-
: collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles, signal);
|
|
3337
|
+
export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal, homeDir) {
|
|
3338
|
+
return collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles ?? getMaxWorkspaceDiagnosticFiles(), signal, homeDir);
|
|
2741
3339
|
}
|