pi-lens 3.8.70 → 3.8.71
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 +24 -0
- package/README.md +16 -0
- package/dist/clients/bus-events-logger.js +10 -8
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/diagnostic-dispositions.js +298 -0
- package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/integration.js +19 -4
- package/dist/clients/dispatch/runners/lsp.js +5 -32
- package/dist/clients/dispatch/runners/oxlint.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +68 -0
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
- package/dist/clients/disposition-logger.js +39 -0
- package/dist/clients/disposition-publish.js +109 -0
- package/dist/clients/file-utils.js +94 -33
- package/dist/clients/format-events-publish.js +299 -0
- package/dist/clients/git-tracked-ignore.js +208 -0
- package/dist/clients/instance-registry.js +74 -4
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/language-profile.js +4 -0
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/client.js +72 -7
- package/dist/clients/lsp/index.js +486 -14
- package/dist/clients/lsp/server.js +17 -1
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/mcp/session.js +8 -0
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +26 -0
- package/dist/clients/pipeline.js +4 -0
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/review-graph/builder.js +78 -52
- package/dist/clients/review-graph/import-resolvers.js +53 -19
- package/dist/clients/runtime-agent-end.js +26 -0
- package/dist/clients/runtime-coordinator.js +9 -1
- package/dist/clients/runtime-session.js +53 -11
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +15 -2
- package/dist/clients/runtime-turn.js +9 -2
- package/dist/clients/session-state-store.js +6 -9
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +5 -0
- package/dist/clients/startup-scan.js +68 -1
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/widget-state.js +22 -0
- package/dist/clients/zizmor-config.js +2 -10
- package/dist/index.js +21089 -18685
- package/dist/tools/lens-diagnostic-mark.js +158 -0
- package/dist/tools/lens-diagnostics.js +41 -7
- package/dist/tools/lsp-diagnostics.js +99 -119
- package/docs/agent-tools.md +11 -4
- package/docs/dispositions.md +85 -0
- package/docs/environment-variables.md +16 -0
- package/docs/fable.md +10 -9
- package/docs/servercapabilities.md +1 -1
- package/docs/tools.md +3 -0
- package/package.json +6 -1
- package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
- package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/docs/agent-tooling-improvement-plan.md +0 -660
- package/docs/inspiration_gograph.md +0 -601
- package/docs/inspiration_neovim.md +0 -1795
- package/docs/inspiration_neovim2.md +0 -402
- package/docs/inspiration_tree_sitter_rules.md +0 -369
- package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
- package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
- package/docs/lsp-bridge-analysis.md +0 -444
- package/docs/newinspiration.md +0 -338
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-elixir.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-lua.wasm +0 -0
- package/grammars/tree-sitter-ocaml.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-toml.wasm +0 -0
- package/grammars/tree-sitter-vue.wasm +0 -0
- package/grammars/tree-sitter-zig.wasm +0 -0
|
@@ -15,6 +15,7 @@ import { isTestMode } from "../env-utils.js";
|
|
|
15
15
|
import { getGlobalPiLensDir, getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
|
|
16
16
|
import { recordLsp } from "../widget-state.js";
|
|
17
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
|
+
import { detectFileRole } from "../file-role.js";
|
|
18
19
|
import { logLatency } from "../latency-logger.js";
|
|
19
20
|
import { withDeadline } from "../deadline-utils.js";
|
|
20
21
|
import { normalizeMapKey, uriToPath } from "../path-utils.js";
|
|
@@ -25,6 +26,9 @@ import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server
|
|
|
25
26
|
import { getStrategy } from "./server-strategies.js";
|
|
26
27
|
import { raceToCompletion } from "./aggregation.js";
|
|
27
28
|
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
|
|
29
|
+
import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
|
|
30
|
+
import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
|
|
31
|
+
import { classifyCascadeWaitTier } from "./cascade-tier.js";
|
|
28
32
|
// --- Init override helpers ---
|
|
29
33
|
/**
|
|
30
34
|
* Recursively merges `override` onto `base`. Override wins on leaf conflicts
|
|
@@ -96,6 +100,42 @@ function readEnvDiagnosticsWaitMs() {
|
|
|
96
100
|
return undefined;
|
|
97
101
|
return parsed;
|
|
98
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* #707: grace delay before the racing tsserver sync clean-confirm fires on a
|
|
105
|
+
* tier-3 silent primary. Short enough to beat the full push-wait budget by a
|
|
106
|
+
* wide margin (~300ms grace + sync RTT vs ~1000ms budget), long enough to give
|
|
107
|
+
* a genuinely dirty file's push a head start — a push that arrives within the
|
|
108
|
+
* grace costs zero extra requests. Read at call time (not memoized) so tests
|
|
109
|
+
* and users can tune without a rebuild.
|
|
110
|
+
*/
|
|
111
|
+
function readTsserverSyncGraceMs() {
|
|
112
|
+
const raw = process.env.PI_LENS_TSSERVER_SYNC_GRACE_MS;
|
|
113
|
+
if (raw === undefined)
|
|
114
|
+
return 300;
|
|
115
|
+
const parsed = Number.parseInt(raw, 10);
|
|
116
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
117
|
+
return 300;
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
|
|
122
|
+
* load time) so tests can set it per-case. Controls how long auxiliary-role
|
|
123
|
+
* promises (opengrep, ast-grep, zizmor, …) are waited after all primary-role
|
|
124
|
+
* promises have settled in both getDiagnostics (raceToCompletion) and the
|
|
125
|
+
* touchFile push wait. Default 500ms — conservative enough to include
|
|
126
|
+
* auxiliaries that are nearly done while not blocking the primary result.
|
|
127
|
+
* Returns undefined when the var is absent (caller falls back to the
|
|
128
|
+
* raceToCompletion default of 500ms, keeping the two in sync).
|
|
129
|
+
*/
|
|
130
|
+
function readEnvAuxGraceMs() {
|
|
131
|
+
const raw = process.env.PI_LENS_AUX_GRACE_MS;
|
|
132
|
+
if (raw === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
const parsed = Number.parseInt(raw, 10);
|
|
135
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
136
|
+
return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
99
139
|
const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
|
|
100
140
|
const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
|
|
101
141
|
// Once the fastest client has diagnostics, remaining clients get this window before
|
|
@@ -295,6 +335,10 @@ function getMaxWorkspaceDiagnosticFiles() {
|
|
|
295
335
|
async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal) {
|
|
296
336
|
const files = [];
|
|
297
337
|
const ignoreMatcher = getProjectIgnoreMatcher(root);
|
|
338
|
+
// #703: prime the tracked-files set once before the walk so a tracked file
|
|
339
|
+
// matching a `.gitignore`/global pattern still gets its workspace
|
|
340
|
+
// diagnostics pulled. Fail-open on no-git/spawn failure.
|
|
341
|
+
await ignoreMatcher.ensureTrackedIndex();
|
|
298
342
|
async function walk(current) {
|
|
299
343
|
if (signal?.aborted || files.length >= maxFiles)
|
|
300
344
|
return;
|
|
@@ -733,6 +777,19 @@ export class LSPService {
|
|
|
733
777
|
recordLsp(server.id, root, "spawn_start");
|
|
734
778
|
try {
|
|
735
779
|
const spawned = await server.spawn(root, { allowInstall });
|
|
780
|
+
// Guard 1: service was shut down while we were waiting for the spawn.
|
|
781
|
+
// Kill the raw process — no LSPClient exists yet — and bail out without
|
|
782
|
+
// marking the key broken (this is not a server failure).
|
|
783
|
+
if (this.isDestroyed) {
|
|
784
|
+
try {
|
|
785
|
+
spawned?.process?.process?.kill();
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// pi-lens-ignore: missing-error-propagation — best-effort kill on aborted spawn
|
|
789
|
+
}
|
|
790
|
+
logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-spawn)`);
|
|
791
|
+
return undefined;
|
|
792
|
+
}
|
|
736
793
|
if (!spawned) {
|
|
737
794
|
logSessionStart(`lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`);
|
|
738
795
|
recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
|
|
@@ -766,6 +823,13 @@ export class LSPService {
|
|
|
766
823
|
initializeTimeoutMs: server.initializeTimeoutMs,
|
|
767
824
|
launchVariant: spawned.launchVariant,
|
|
768
825
|
});
|
|
826
|
+
// Guard 2: service was shut down while we were completing the initialize
|
|
827
|
+
// handshake. Shut down the live client best-effort and do not register it.
|
|
828
|
+
if (this.isDestroyed) {
|
|
829
|
+
client.shutdown({ fast: true }).catch(() => { });
|
|
830
|
+
logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-initialize)`);
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
769
833
|
const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
|
|
770
834
|
? client.getWorkspaceDiagnosticsSupport()
|
|
771
835
|
: {
|
|
@@ -940,6 +1004,17 @@ export class LSPService {
|
|
|
940
1004
|
}
|
|
941
1005
|
}
|
|
942
1006
|
let diagnosticsTimedOut = false;
|
|
1007
|
+
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1008
|
+
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
1009
|
+
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1010
|
+
let auxCutOffServerIds;
|
|
1011
|
+
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1012
|
+
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1013
|
+
// holds the sync commands' answer when the racing confirm won the wait
|
|
1014
|
+
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1015
|
+
// fallback below may still fill it in on a timed-out empty result).
|
|
1016
|
+
let tsserverSyncEligible = false;
|
|
1017
|
+
let tsserverSyncConfirmed = undefined;
|
|
943
1018
|
if (diagnosticsMode !== "none") {
|
|
944
1019
|
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
945
1020
|
// Otherwise, on the single-server hot path (primary scope), use that
|
|
@@ -1008,6 +1083,13 @@ export class LSPService {
|
|
|
1008
1083
|
Math.min(300, strategyWait);
|
|
1009
1084
|
}
|
|
1010
1085
|
if (callerCap !== undefined) {
|
|
1086
|
+
// #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
|
|
1087
|
+
// to act as a FLOOR (give it at least this much, possibly more
|
|
1088
|
+
// if the strategy already wants more) rather than the normal
|
|
1089
|
+
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1090
|
+
if (options.warmupOverride) {
|
|
1091
|
+
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1092
|
+
}
|
|
1011
1093
|
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1012
1094
|
}
|
|
1013
1095
|
return strategyWait > 0 ? strategyWait : modeFloor;
|
|
@@ -1030,8 +1112,51 @@ export class LSPService {
|
|
|
1030
1112
|
}
|
|
1031
1113
|
// Detection deadline = the slowest individual server's budget.
|
|
1032
1114
|
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1115
|
+
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1116
|
+
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1117
|
+
// primary scope, strategy marked silentOnClean — only classic
|
|
1118
|
+
// typescript-language-server carries that flag today), then the live
|
|
1119
|
+
// capability-snapshot tier classification (`classifyCascadeWaitTier`,
|
|
1120
|
+
// which also excludes native-ts7 via `launchVariant`). Non-typescript
|
|
1121
|
+
// and pull-capable servers fail the synchronous `silentOnClean` gate and
|
|
1122
|
+
// pay ZERO extra work — not even the snapshot read.
|
|
1123
|
+
if (!notifyWriteTimedOut &&
|
|
1124
|
+
options.collectDiagnostics === true &&
|
|
1125
|
+
clientScope === "primary" &&
|
|
1126
|
+
spawned.length === 1 &&
|
|
1127
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1128
|
+
try {
|
|
1129
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1130
|
+
tsserverSyncEligible =
|
|
1131
|
+
classifyCascadeWaitTier(this, filePath, snapshots) ===
|
|
1132
|
+
"tier3-silent";
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
// Fail-safe: ineligible — today's full wait, no sync attempt.
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1033
1138
|
const waitStartedAt = Date.now();
|
|
1034
|
-
|
|
1139
|
+
// R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
|
|
1140
|
+
// slow auxiliary no longer holds the push wait to its own deadline.
|
|
1141
|
+
// Primary waits resolve on their own per-server budget; once ALL primaries
|
|
1142
|
+
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1143
|
+
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1144
|
+
// they fall through to the original Promise.all path below.
|
|
1145
|
+
//
|
|
1146
|
+
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1147
|
+
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1148
|
+
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
1149
|
+
// the rest — but we use info.role rather than position so the logic is
|
|
1150
|
+
// correct even if ordering shifts in the future.
|
|
1151
|
+
//
|
|
1152
|
+
// The #707 tsserver sync race operates exclusively on single-server
|
|
1153
|
+
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
1154
|
+
// spawned.length === 1`), so there is NO interaction with this path.
|
|
1155
|
+
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
1156
|
+
spawned.some((e) => e.info.role === "auxiliary");
|
|
1157
|
+
// Per-server wait promises (each already bounded by its own
|
|
1158
|
+
// perServerTimeout — unchanged from before R8).
|
|
1159
|
+
const perServerWaits = spawned.map((entry) => {
|
|
1035
1160
|
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
1036
1161
|
const baseline = diagnosticBaselines.get(entry.client);
|
|
1037
1162
|
const wait = !notifySkipped && Number.isFinite(baseline)
|
|
@@ -1040,12 +1165,154 @@ export class LSPService {
|
|
|
1040
1165
|
})
|
|
1041
1166
|
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
1042
1167
|
return wait.catch(() => undefined);
|
|
1043
|
-
})
|
|
1168
|
+
});
|
|
1169
|
+
// The push wait — same per-server budget composition as before #707;
|
|
1170
|
+
// only the awaiting changed (assigned so it can be raced below).
|
|
1171
|
+
let pushWaitSettled = false;
|
|
1172
|
+
const pushWait = hasTouchAuxiliaries
|
|
1173
|
+
? (() => {
|
|
1174
|
+
// Primary waits: all non-auxiliary servers.
|
|
1175
|
+
const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
|
|
1176
|
+
// Aux waits: auxiliary servers (advisory).
|
|
1177
|
+
const auxWaits = perServerWaits
|
|
1178
|
+
.map((p, i) => spawned[i].info.role === "auxiliary"
|
|
1179
|
+
? { promise: p, serverId: spawned[i].info.id }
|
|
1180
|
+
: null)
|
|
1181
|
+
.filter((x) => x !== null);
|
|
1182
|
+
const auxGraceMs = readEnvAuxGraceMs() ?? 500;
|
|
1183
|
+
// After all primaries settle, give auxiliaries at most auxGraceMs.
|
|
1184
|
+
// Late aux results are dropped from the wait (advisory only — they
|
|
1185
|
+
// land in the client cache and surface on the next edit); aux servers
|
|
1186
|
+
// that did answer within the grace are included automatically since
|
|
1187
|
+
// their waitForDiagnostics already resolved. The cut-off server ids
|
|
1188
|
+
// are logged in the latency metadata (lsp_touch_file phase, field
|
|
1189
|
+
// `auxCutOffServerIds`).
|
|
1190
|
+
return Promise.all(primaryWaits).then(async () => {
|
|
1191
|
+
if (auxWaits.length === 0)
|
|
1192
|
+
return;
|
|
1193
|
+
const auxTimeout = new Promise((resolve) => {
|
|
1194
|
+
const t = setTimeout(() => resolve("timeout"), auxGraceMs);
|
|
1195
|
+
if (typeof t === "object" && "unref" in t)
|
|
1196
|
+
t.unref?.();
|
|
1197
|
+
});
|
|
1198
|
+
const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
|
|
1199
|
+
const outcome = await Promise.race([auxAll, auxTimeout]);
|
|
1200
|
+
if (outcome === "timeout") {
|
|
1201
|
+
// Record which auxiliaries did NOT finish in time.
|
|
1202
|
+
const unfinished = [];
|
|
1203
|
+
for (const a of auxWaits) {
|
|
1204
|
+
let done = false;
|
|
1205
|
+
// Check synchronously if already resolved by racing against
|
|
1206
|
+
// a resolved promise.
|
|
1207
|
+
await Promise.race([
|
|
1208
|
+
a.promise.then(() => {
|
|
1209
|
+
done = true;
|
|
1210
|
+
}),
|
|
1211
|
+
Promise.resolve(),
|
|
1212
|
+
]);
|
|
1213
|
+
if (!done)
|
|
1214
|
+
unfinished.push(a.serverId);
|
|
1215
|
+
}
|
|
1216
|
+
if (unfinished.length > 0)
|
|
1217
|
+
auxCutOffServerIds = unfinished;
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
})()
|
|
1221
|
+
: Promise.all(perServerWaits).then(() => { });
|
|
1222
|
+
pushWait.then(() => {
|
|
1223
|
+
pushWaitSettled = true;
|
|
1224
|
+
});
|
|
1225
|
+
if (tsserverSyncEligible) {
|
|
1226
|
+
// #707 racing variant: rather than burning the full push-wait budget
|
|
1227
|
+
// on a silent-on-clean server (which by definition never answers on a
|
|
1228
|
+
// clean file), race the push wait against a grace-delayed sync
|
|
1229
|
+
// confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
|
|
1230
|
+
// gives a genuinely dirty file's push a head start: if diagnostics
|
|
1231
|
+
// arrive before the grace elapses, the sync request never goes out —
|
|
1232
|
+
// zero new latency or requests on the push-answers path.
|
|
1233
|
+
//
|
|
1234
|
+
// Race semantics:
|
|
1235
|
+
// - sync answers first → that's the confirmed result (clean OR
|
|
1236
|
+
// dirty — the sync commands return the file's real syntactic +
|
|
1237
|
+
// semantic state, so a dirty-file win is still correct and its
|
|
1238
|
+
// findings are surfaced, never discarded).
|
|
1239
|
+
// - push settles first → push wins; a still-in-flight sync outcome
|
|
1240
|
+
// is discarded (the racer checks `pushWaitSettled` after the
|
|
1241
|
+
// call returns and drops its own result).
|
|
1242
|
+
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
1243
|
+
// promise so the race is decided by the push wait's own budget,
|
|
1244
|
+
// exactly today's behavior (the end-of-wait fallback below still
|
|
1245
|
+
// gets its shot on a timed-out empty result).
|
|
1246
|
+
// The racer never rejects (every failure path is caught), so the
|
|
1247
|
+
// losing promise can never surface as an unhandled rejection.
|
|
1248
|
+
const graceMs = readTsserverSyncGraceMs();
|
|
1249
|
+
const primaryClient = spawned[0].client;
|
|
1250
|
+
// Resolves with the sync commands' diagnostics when the confirm
|
|
1251
|
+
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
1252
|
+
// (push already answered, sync unavailable/failed, push won while
|
|
1253
|
+
// in flight) so the race is then decided by the push wait's own
|
|
1254
|
+
// budget — exactly today's behavior.
|
|
1255
|
+
const syncRacer = (async () => {
|
|
1256
|
+
await new Promise((resolve) => {
|
|
1257
|
+
const timer = setTimeout(resolve, graceMs);
|
|
1258
|
+
timer.unref?.();
|
|
1259
|
+
});
|
|
1260
|
+
// Push already answered (settled, or published diagnostics that
|
|
1261
|
+
// its wait is about to settle on) — nothing to confirm, no sync
|
|
1262
|
+
// request goes out.
|
|
1263
|
+
if (pushWaitSettled ||
|
|
1264
|
+
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
1265
|
+
return new Promise(() => { });
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
const result = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
1269
|
+
if (result === undefined || pushWaitSettled) {
|
|
1270
|
+
// Sync unavailable/failed, or push won while the sync call
|
|
1271
|
+
// was in flight — drop the sync outcome and let the push
|
|
1272
|
+
// wait decide the race.
|
|
1273
|
+
return new Promise(() => { });
|
|
1274
|
+
}
|
|
1275
|
+
return result;
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
return new Promise(() => { });
|
|
1279
|
+
}
|
|
1280
|
+
})();
|
|
1281
|
+
const raceOutcome = await Promise.race([
|
|
1282
|
+
pushWait.then(() => undefined),
|
|
1283
|
+
syncRacer,
|
|
1284
|
+
]);
|
|
1285
|
+
if (raceOutcome !== undefined) {
|
|
1286
|
+
tsserverSyncConfirmed = raceOutcome;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
await pushWait;
|
|
1291
|
+
}
|
|
1044
1292
|
const waitedMs = Date.now() - waitStartedAt;
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1293
|
+
if (tsserverSyncConfirmed !== undefined) {
|
|
1294
|
+
// #707: the racing sync confirm won — a definitive answer well under
|
|
1295
|
+
// the push-wait budget. Not a timeout, not inconclusive.
|
|
1296
|
+
logLatency({
|
|
1297
|
+
type: "phase",
|
|
1298
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
1299
|
+
filePath: normalizedPath,
|
|
1300
|
+
durationMs: waitedMs,
|
|
1301
|
+
metadata: {
|
|
1302
|
+
source,
|
|
1303
|
+
clientScope,
|
|
1304
|
+
diagnosticsMode,
|
|
1305
|
+
mode: "race",
|
|
1306
|
+
confirmedDiagnosticCount: tsserverSyncConfirmed.length,
|
|
1307
|
+
budgetMs: timeoutMs,
|
|
1308
|
+
savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
else if (waitedMs + 20 >= timeoutMs) {
|
|
1313
|
+
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
1314
|
+
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
1315
|
+
// land in the client's cache and surface on the next edit.
|
|
1049
1316
|
diagnosticsTimedOut = true;
|
|
1050
1317
|
logLatency({
|
|
1051
1318
|
type: "phase",
|
|
@@ -1061,9 +1328,63 @@ export class LSPService {
|
|
|
1061
1328
|
});
|
|
1062
1329
|
}
|
|
1063
1330
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1331
|
+
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
1332
|
+
// collected result — the file's real syntactic + semantic state straight
|
|
1333
|
+
// from tsserver (clean = [], dirty = real findings that a silentOnClean
|
|
1334
|
+
// server had computed but never published). Otherwise merge the push
|
|
1335
|
+
// diagnostics from the client cache as always.
|
|
1336
|
+
let collected = options.collectDiagnostics
|
|
1337
|
+
? tsserverSyncConfirmed !== undefined
|
|
1338
|
+
? mergeLspDiagnostics(tsserverSyncConfirmed)
|
|
1339
|
+
: mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
|
|
1066
1340
|
: undefined;
|
|
1341
|
+
// #707 end-of-wait fallback: when the racing confirm did NOT decide the
|
|
1342
|
+
// wait (sync unavailable/failed mid-race, or push resolved as a bare
|
|
1343
|
+
// timeout) and the wait timed out with an empty result on an eligible
|
|
1344
|
+
// touch, give the sync clean-confirm one last shot before reporting
|
|
1345
|
+
// inconclusive. `tsserverSyncEligible` already encodes every gate (notify
|
|
1346
|
+
// succeeded, collecting, primary scope, tier3-silent classic typescript —
|
|
1347
|
+
// native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
|
|
1348
|
+
// answers (even with an empty body, which is a confirmed clean), we use
|
|
1349
|
+
// those diagnostics as the confirmed result and clear the
|
|
1350
|
+
// `diagnosticsTimedOut` flag so the touch is no longer treated as
|
|
1351
|
+
// inconclusive. Sync diagnostics on a dirty file are surfaced, not
|
|
1352
|
+
// discarded. If the sync call fails or is unavailable, we fall through to
|
|
1353
|
+
// today's behavior: `inconclusive` = true, `collected` unchanged. This
|
|
1354
|
+
// turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
|
|
1355
|
+
// even when the race path couldn't answer.
|
|
1356
|
+
if (diagnosticsTimedOut &&
|
|
1357
|
+
tsserverSyncEligible &&
|
|
1358
|
+
collected !== undefined &&
|
|
1359
|
+
collected.length === 0) {
|
|
1360
|
+
try {
|
|
1361
|
+
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
1362
|
+
if (syncResult !== undefined) {
|
|
1363
|
+
// Sync answered — confirmed result (clean or with diagnostics).
|
|
1364
|
+
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
1365
|
+
diagnosticsTimedOut = false;
|
|
1366
|
+
collected = syncResult.length > 0
|
|
1367
|
+
? mergeLspDiagnostics(syncResult)
|
|
1368
|
+
: [];
|
|
1369
|
+
logLatency({
|
|
1370
|
+
type: "phase",
|
|
1371
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
1372
|
+
filePath: normalizedPath,
|
|
1373
|
+
durationMs: Date.now() - startedAt,
|
|
1374
|
+
metadata: {
|
|
1375
|
+
source,
|
|
1376
|
+
clientScope,
|
|
1377
|
+
diagnosticsMode,
|
|
1378
|
+
mode: "end-of-wait",
|
|
1379
|
+
confirmedDiagnosticCount: collected.length,
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
catch {
|
|
1385
|
+
// Any failure here falls through to today's inconclusive behavior.
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1067
1388
|
// A touch is inconclusive when EITHER the notify write or the
|
|
1068
1389
|
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
1069
1390
|
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
@@ -1138,6 +1459,11 @@ export class LSPService {
|
|
|
1138
1459
|
notifyWriteTimedOut,
|
|
1139
1460
|
diagnosticsTimedOut,
|
|
1140
1461
|
inconclusive,
|
|
1462
|
+
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
1463
|
+
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
1464
|
+
// Absent when no aux was cut off. These servers' diagnostics are
|
|
1465
|
+
// advisory-only and will surface on the next edit from their cache.
|
|
1466
|
+
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
1141
1467
|
},
|
|
1142
1468
|
});
|
|
1143
1469
|
return collected ?? [];
|
|
@@ -1245,12 +1571,25 @@ export class LSPService {
|
|
|
1245
1571
|
// Document mode: 0ms grace — return as soon as any client has results.
|
|
1246
1572
|
// Full mode: 400ms grace — wait a bit for other clients to catch up.
|
|
1247
1573
|
const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
|
|
1574
|
+
// R8 (#714): per-promise role descriptors so raceToCompletion can apply
|
|
1575
|
+
// a bounded aux grace once all primary-role promises have settled.
|
|
1576
|
+
// Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …) get at
|
|
1577
|
+
// most PI_LENS_AUX_GRACE_MS (default 500ms) after the primary settles;
|
|
1578
|
+
// late arrivals are dropped (advisory only — they land in the client
|
|
1579
|
+
// cache and surface on the next edit). Primary-only callers have no
|
|
1580
|
+
// auxiliary descriptors, so this path is never entered and there is
|
|
1581
|
+
// zero behavior change for the single-server hot path.
|
|
1582
|
+
const diagDescriptors = spawned.map((entry) => ({
|
|
1583
|
+
role: entry.info.role === "auxiliary" ? "auxiliary" : "primary",
|
|
1584
|
+
}));
|
|
1248
1585
|
// Result-aware racing: trigger early-unblock when any client has results,
|
|
1249
1586
|
// OR when a seedFirstPush server returns (its first push is authoritative
|
|
1250
1587
|
// even when empty — waiting longer yields nothing more).
|
|
1251
1588
|
const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
|
|
1252
1589
|
timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
|
|
1253
1590
|
graceMs,
|
|
1591
|
+
descriptors: diagDescriptors,
|
|
1592
|
+
auxGraceMs: readEnvAuxGraceMs(),
|
|
1254
1593
|
});
|
|
1255
1594
|
// Fill in any slots that timed out before producing results.
|
|
1256
1595
|
const earlyUnblockedCount = spawned.length - perServer.length;
|
|
@@ -1736,6 +2075,12 @@ export class LSPService {
|
|
|
1736
2075
|
source: "lsp_sweep_warmup",
|
|
1737
2076
|
maxClientWaitMs: timeoutMs,
|
|
1738
2077
|
maxDiagnosticsWaitMs: timeoutMs,
|
|
2078
|
+
// #669: the caller's cap here is the warm-up budget for a genuinely
|
|
2079
|
+
// COLD server — it must act as a floor, not the usual ceiling, or
|
|
2080
|
+
// `perServerTimeout` silently shrinks it to the strategy's normal
|
|
2081
|
+
// warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
|
|
2082
|
+
// of the requested 20000ms.
|
|
2083
|
+
warmupOverride: true,
|
|
1739
2084
|
});
|
|
1740
2085
|
await (options.signal
|
|
1741
2086
|
? Promise.race([
|
|
@@ -1793,6 +2138,43 @@ export class LSPService {
|
|
|
1793
2138
|
let completed = 0;
|
|
1794
2139
|
let timedOutFiles = 0;
|
|
1795
2140
|
let lastHeartbeat = Date.now();
|
|
2141
|
+
// #671: reuse the last CONFIRMED per-file result instead of re-touching
|
|
2142
|
+
// every file through the language server(s) again when nothing relevant
|
|
2143
|
+
// changed since the last sweep. `createWorkspaceDiagnosticsCacheContext`
|
|
2144
|
+
// (`workspace-diagnostics-cache.ts`) is shared with `tools/lsp-
|
|
2145
|
+
// diagnostics.ts`'s batch/directory sweep so a file swept by either tool
|
|
2146
|
+
// benefits the other's next sweep under the SAME `scopeKey` — see that
|
|
2147
|
+
// module's doc comment for the invalidation rules (own-mtime +
|
|
2148
|
+
// best-effort cross-file dependency staleness) and why `scopeKey` exists
|
|
2149
|
+
// (this sweep's `excludeServerIds` differs from that tool's).
|
|
2150
|
+
const workspaceDiagnosticsCacheCtx = createWorkspaceDiagnosticsCacheContext(root);
|
|
2151
|
+
const workspaceSweepScopeKey = buildScopeKey("all", [
|
|
2152
|
+
...WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
|
|
2153
|
+
]);
|
|
2154
|
+
const cachedResults = [];
|
|
2155
|
+
const filesToTouch = [];
|
|
2156
|
+
for (const filePath of files) {
|
|
2157
|
+
const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
|
|
2158
|
+
if (cached) {
|
|
2159
|
+
cachedResults.push({
|
|
2160
|
+
filePath,
|
|
2161
|
+
diagnostics: cached.diagnostics,
|
|
2162
|
+
count: cached.count,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
else {
|
|
2166
|
+
filesToTouch.push(filePath);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
completed = cachedResults.length;
|
|
2170
|
+
if (cachedResults.length > 0) {
|
|
2171
|
+
options.onProgress?.(completed, files.length);
|
|
2172
|
+
}
|
|
2173
|
+
// Per-file scan mtime captured as each file completes below, so a
|
|
2174
|
+
// confirmed fresh result can be written back into the cache with the
|
|
2175
|
+
// mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
|
|
2176
|
+
// could race a concurrent edit).
|
|
2177
|
+
const scannedMtimeByFile = new Map();
|
|
1796
2178
|
// Group files by their primary language server (#387, extracted as
|
|
1797
2179
|
// `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
|
|
1798
2180
|
// single-threaded per project: N concurrent touches to ONE server don't
|
|
@@ -1803,7 +2185,9 @@ export class LSPService {
|
|
|
1803
2185
|
// in-flight touch each) and parallelize ACROSS servers — real parallelism
|
|
1804
2186
|
// where it exists (a mixed TS+Python repo runs both), no flooding where it
|
|
1805
2187
|
// doesn't. Capped so a many-language monorepo can't spawn unbounded groups.
|
|
1806
|
-
|
|
2188
|
+
// Only files that failed the cache-freshness check above go through this
|
|
2189
|
+
// (and the touch loop below) at all.
|
|
2190
|
+
const groups = groupFilesByPrimaryServer(filesToTouch);
|
|
1807
2191
|
// #645: shared across every file/group in THIS sweep — lets a
|
|
1808
2192
|
// `workspaceIndexing`-strategy server (marksman) pay its full
|
|
1809
2193
|
// aggregateWaitMs budget only for the first file that touches it,
|
|
@@ -1828,6 +2212,8 @@ export class LSPService {
|
|
|
1828
2212
|
durationMs: 0,
|
|
1829
2213
|
metadata: {
|
|
1830
2214
|
fileCount: files.length,
|
|
2215
|
+
cacheHits: cachedResults.length,
|
|
2216
|
+
filesToTouch: filesToTouch.length,
|
|
1831
2217
|
maxFiles,
|
|
1832
2218
|
perFileMs,
|
|
1833
2219
|
serverGroups: groups.length,
|
|
@@ -1927,6 +2313,23 @@ export class LSPService {
|
|
|
1927
2313
|
try {
|
|
1928
2314
|
const content = contentCache.get(filePath) ??
|
|
1929
2315
|
(await nodeFs.promises.readFile(filePath, "utf-8"));
|
|
2316
|
+
// #671: captured alongside the read, ahead of the (possibly slow)
|
|
2317
|
+
// touchFile wait below, so the cache entry records the mtime this
|
|
2318
|
+
// file actually had AT scan time — not a later re-stat that could
|
|
2319
|
+
// race a concurrent edit and silently mis-date the entry. Deliberately
|
|
2320
|
+
// synchronous (not `nodeFs.promises.stat`): this loop is timing-
|
|
2321
|
+
// sensitive (its opens must land inside `WatchedFilesQueue`'s 100ms
|
|
2322
|
+
// debounce window — see workspace-diagnostics-sweep-batch-open.test.ts
|
|
2323
|
+
// / -preopen-chunk.test.ts), and a blocking `statSync` costs a few
|
|
2324
|
+
// microseconds with no extra event-loop tick, where an awaited
|
|
2325
|
+
// promise would insert one.
|
|
2326
|
+
try {
|
|
2327
|
+
scannedMtimeByFile.set(filePath, nodeFs.statSync(filePath).mtimeMs);
|
|
2328
|
+
}
|
|
2329
|
+
catch {
|
|
2330
|
+
// Best-effort: a failed stat here just means this file won't be
|
|
2331
|
+
// eligible for caching below (no entry gets written for it).
|
|
2332
|
+
}
|
|
1930
2333
|
// onTimeout:"undefined" so a hung file yields no diagnostics and the
|
|
1931
2334
|
// worker moves on; a real touchFile rejection still propagates to the
|
|
1932
2335
|
// catch below and is recorded as an error.
|
|
@@ -1961,8 +2364,17 @@ export class LSPService {
|
|
|
1961
2364
|
// `.inconclusive` flag, already read above) so a `lens_diagnostics
|
|
1962
2365
|
// mode=full` sweep suppresses the same findings the per-edit dispatch
|
|
1963
2366
|
// runner does, instead of only the latter honoring it.
|
|
2367
|
+
// #692: also honor a profile's `skipTestFiles` gate (e.g. ast-grep,
|
|
2368
|
+
// #687/#688) — those PRs added the gate only to the per-edit merge
|
|
2369
|
+
// loop (`clients/dispatch/runners/lsp.ts`), so a `mode=full` sweep
|
|
2370
|
+
// re-surfaced every ast-grep finding on `*.test.ts` files wholesale,
|
|
2371
|
+
// duplicating what the per-edit path already suppresses. `content`
|
|
2372
|
+
// was already read above for this file, so `detectFileRole` gets the
|
|
2373
|
+
// higher-accuracy content-aware classification at no extra cost.
|
|
1964
2374
|
const filteredDiagnostics = diagnostics
|
|
1965
|
-
? applyAuxiliarySuppressions(diagnostics, content
|
|
2375
|
+
? applyAuxiliarySuppressions(diagnostics, content, {
|
|
2376
|
+
fileRole: detectFileRole(filePath, content),
|
|
2377
|
+
})
|
|
1966
2378
|
: diagnostics;
|
|
1967
2379
|
results.push({
|
|
1968
2380
|
filePath,
|
|
@@ -2017,8 +2429,19 @@ export class LSPService {
|
|
|
2017
2429
|
if (workspacePullEnabled && !group.multiServer) {
|
|
2018
2430
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2019
2431
|
if (pulled) {
|
|
2020
|
-
for (const result of pulled)
|
|
2432
|
+
for (const result of pulled) {
|
|
2021
2433
|
results.push(result);
|
|
2434
|
+
// #671: a pull result is always confirmed (see
|
|
2435
|
+
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
2436
|
+
// too — best-effort stat since the pull already resolved the
|
|
2437
|
+
// diagnostics for this file some time ago.
|
|
2438
|
+
try {
|
|
2439
|
+
scannedMtimeByFile.set(result.filePath, nodeFs.statSync(result.filePath).mtimeMs);
|
|
2440
|
+
}
|
|
2441
|
+
catch {
|
|
2442
|
+
// Not cache-eligible without a confirmed mtime.
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2022
2445
|
completed += group.files.length;
|
|
2023
2446
|
options.onProgress?.(completed, files.length);
|
|
2024
2447
|
return;
|
|
@@ -2067,6 +2490,7 @@ export class LSPService {
|
|
|
2067
2490
|
durationMs: Date.now() - startedAt,
|
|
2068
2491
|
metadata: {
|
|
2069
2492
|
filesChecked: files.length,
|
|
2493
|
+
cacheHits: cachedResults.length,
|
|
2070
2494
|
diagnosticCount: results.reduce((sum, result) => sum + (result?.count ?? 0), 0),
|
|
2071
2495
|
serverGroups: groups.length,
|
|
2072
2496
|
concurrency: groupWorkers,
|
|
@@ -2075,7 +2499,25 @@ export class LSPService {
|
|
|
2075
2499
|
aborted: signal?.aborted ?? false,
|
|
2076
2500
|
},
|
|
2077
2501
|
});
|
|
2078
|
-
|
|
2502
|
+
// #671: record every CONFIRMED fresh result (`!timedOut && !error`) back
|
|
2503
|
+
// into the cache, keyed by the mtime it was actually scanned at
|
|
2504
|
+
// (`scannedMtimeByFile`, captured per-file above), then persist once.
|
|
2505
|
+
// Deliberately survives an aborted/partial sweep — whatever completed
|
|
2506
|
+
// before the abort is still real, confirmed work and shouldn't be thrown
|
|
2507
|
+
// away; files that never got a confirmed result (including ones an abort
|
|
2508
|
+
// cut off before `processFile` ran) are simply absent from
|
|
2509
|
+
// `scannedMtimeByFile` and are skipped, leaving any pre-existing cache
|
|
2510
|
+
// entry for them exactly as `createWorkspaceDiagnosticsCacheContext`
|
|
2511
|
+
// loaded it (already-stale entries stay unreachable via `lookup`'s own
|
|
2512
|
+
// freshness check; nothing here needs to explicitly evict them).
|
|
2513
|
+
for (const result of results) {
|
|
2514
|
+
const scannedAt = scannedMtimeByFile.get(result.filePath);
|
|
2515
|
+
if (result.error || result.timedOut || scannedAt === undefined)
|
|
2516
|
+
continue;
|
|
2517
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt);
|
|
2518
|
+
}
|
|
2519
|
+
workspaceDiagnosticsCacheCtx.persist();
|
|
2520
|
+
return [...cachedResults, ...results].filter(Boolean);
|
|
2079
2521
|
}
|
|
2080
2522
|
/**
|
|
2081
2523
|
* #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
|
|
@@ -2184,8 +2626,38 @@ export class LSPService {
|
|
|
2184
2626
|
if (this.checkDestroyed())
|
|
2185
2627
|
return;
|
|
2186
2628
|
this.isDestroyed = true;
|
|
2187
|
-
//
|
|
2188
|
-
|
|
2629
|
+
// Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
|
|
2630
|
+
// spawnClient can observe isDestroyed and clean up. Skip on the
|
|
2631
|
+
// process-exiting path — the event loop is closing and we must not block.
|
|
2632
|
+
if (!options.processExiting && this.state.inFlight.size > 0) {
|
|
2633
|
+
const pending = Array.from(this.state.inFlight.values());
|
|
2634
|
+
this.state.inFlight.clear();
|
|
2635
|
+
const settled = await Promise.allSettled(pending);
|
|
2636
|
+
for (const result of settled) {
|
|
2637
|
+
if (result.status === "fulfilled" && result.value?.client) {
|
|
2638
|
+
result.value.client.shutdown({ fast: true }).catch(() => { });
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
else {
|
|
2643
|
+
this.state.inFlight.clear();
|
|
2644
|
+
}
|
|
2645
|
+
// Count alive clients BEFORE tearing them down — gives a meaningful
|
|
2646
|
+
// snapshot of what was released by this reset (post-teardown the count
|
|
2647
|
+
// would always be zero, which is useless for root-cause analysis).
|
|
2648
|
+
const aliveClients = this.getAliveClientCount();
|
|
2649
|
+
logLatency({
|
|
2650
|
+
type: "phase",
|
|
2651
|
+
phase: "lsp_service_reset",
|
|
2652
|
+
filePath: "",
|
|
2653
|
+
durationMs: 0,
|
|
2654
|
+
metadata: {
|
|
2655
|
+
reason: options.reason ?? null,
|
|
2656
|
+
aliveClients,
|
|
2657
|
+
fast: !!options.fast,
|
|
2658
|
+
processExiting: !!options.processExiting,
|
|
2659
|
+
},
|
|
2660
|
+
});
|
|
2189
2661
|
for (const [_key, client] of this.state.clients) {
|
|
2190
2662
|
try {
|
|
2191
2663
|
await client.shutdown(options);
|