@mrclrchtr/supi-lsp 11.2.0 → 11.3.0
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/README.md +39 -2
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
- package/package.json +3 -3
- package/src/client/client-diagnostic-collection.ts +11 -4
- package/src/client/client-diagnostics.ts +77 -6
- package/src/client/client-input-admission.ts +149 -0
- package/src/client/client-typescript-project.ts +40 -0
- package/src/client/client.ts +151 -44
- package/src/diagnostics/evidence.ts +2 -2
- package/src/manager/manager-workspace-symbol.ts +157 -23
- package/src/manager/manager.ts +254 -56
- package/src/session/runtime-registry.ts +19 -2
- package/src/session/runtime-rename.ts +1 -0
- package/src/session/workspace-lsp-runtime.ts +5 -1
package/src/manager/manager.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
withTimeout,
|
|
20
20
|
} from "../client/client.ts";
|
|
21
21
|
import type { ClientDiagnosticSnapshot } from "../client/client-document-state.ts";
|
|
22
|
+
import type { ProjectMembership } from "../client/client-typescript-project.ts";
|
|
22
23
|
|
|
23
24
|
export { RECOVERY_CLIENT_STARTUP_BOUND_MS } from "../client/client.ts";
|
|
24
25
|
|
|
@@ -135,8 +136,10 @@ import {
|
|
|
135
136
|
collectWorkspaceSymbols,
|
|
136
137
|
findWorkspaceSymbolWarmTargets,
|
|
137
138
|
getWorkspaceSymbolWarmPosition,
|
|
139
|
+
isWithinAnyScope,
|
|
138
140
|
type WorkspaceSymbolCollection,
|
|
139
141
|
workspaceSymbolCollectionResult,
|
|
142
|
+
workspaceSymbolCoverageGap,
|
|
140
143
|
} from "./manager-workspace-symbol.ts";
|
|
141
144
|
|
|
142
145
|
type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
|
|
@@ -251,6 +254,8 @@ export interface ManagerLifecycleTransition {
|
|
|
251
254
|
type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
|
|
252
255
|
|
|
253
256
|
type FileClientRequestOptions = {
|
|
257
|
+
/** Reject known input changes while this call's first open waits to dispatch original parameters. */
|
|
258
|
+
protectInputs?: boolean;
|
|
254
259
|
/** Allow this request to start the route's one process-crash recovery. */
|
|
255
260
|
recoverProcessCrash?: boolean;
|
|
256
261
|
/** Control only this caller's wait for a shared replacement. */
|
|
@@ -397,8 +402,12 @@ export class LspManager {
|
|
|
397
402
|
private invalidationEpoch = 0;
|
|
398
403
|
/** Invalidation generation in which each route last received a recovery restart. */
|
|
399
404
|
private recoveryRestartEpochs = new Map<string, number>();
|
|
400
|
-
/**
|
|
401
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Files already probed for workspace-symbol warm-up, with the project
|
|
407
|
+
* membership the server reported. `inferred` probes stay recorded so they
|
|
408
|
+
* are not reopened, but they do not prove project coverage.
|
|
409
|
+
*/
|
|
410
|
+
private warmedWorkspaceSymbolFiles = new Map<string, ProjectMembership>();
|
|
402
411
|
/** Project roots whose semantic state was warmed with a readiness probe. */
|
|
403
412
|
private warmedSemanticProjects = new Set<string>();
|
|
404
413
|
/** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
|
|
@@ -434,6 +443,19 @@ export class LspManager {
|
|
|
434
443
|
detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
|
|
435
444
|
);
|
|
436
445
|
}
|
|
446
|
+
/** Check configured routing only, independently of executable availability or startup state. */
|
|
447
|
+
hasConfiguredServerForFile(filePath: string): boolean {
|
|
448
|
+
return getServerForFile(this.config, resolveSessionPath(this.cwd, filePath)) !== null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Observe a retained initial-start failure without retrying or conflating process-crash recovery. */
|
|
452
|
+
hasFailedStartupForFile(filePath: string): boolean {
|
|
453
|
+
const route = this.resolveFileRoute(filePath);
|
|
454
|
+
if (!route || this.processCrashRecoveries.has(route.key)) return false;
|
|
455
|
+
const reason = this.unavailable.get(route.key);
|
|
456
|
+
return reason === "start-failed" || reason === "missing-command";
|
|
457
|
+
}
|
|
458
|
+
|
|
437
459
|
/** Check whether a file path has an available LSP server for explicit semantic operations. */
|
|
438
460
|
canServeFile(filePath: string): boolean {
|
|
439
461
|
const route = this.resolveFileRoute(filePath);
|
|
@@ -1314,7 +1336,13 @@ export class LspManager {
|
|
|
1314
1336
|
recovery.statusReason = undefined;
|
|
1315
1337
|
recovery.failureMessage = undefined;
|
|
1316
1338
|
this.publishLifecycle("recovery");
|
|
1317
|
-
this.restoreTrackedDocuments(started, recovery.files);
|
|
1339
|
+
await this.restoreTrackedDocuments(started, recovery.files);
|
|
1340
|
+
if (
|
|
1341
|
+
this.isProcessCrashRecoveryCancelled(recovery, key) ||
|
|
1342
|
+
this.clients.get(key) !== started ||
|
|
1343
|
+
started.status !== "running"
|
|
1344
|
+
)
|
|
1345
|
+
return null;
|
|
1318
1346
|
this.recordProcessCrashRecoveryEvent(
|
|
1319
1347
|
route.serverName,
|
|
1320
1348
|
route.root,
|
|
@@ -1399,20 +1427,16 @@ export class LspManager {
|
|
|
1399
1427
|
}
|
|
1400
1428
|
|
|
1401
1429
|
/** Restore route documents from disk and retain failed-document evidence. */
|
|
1402
|
-
private restoreTrackedDocuments(
|
|
1403
|
-
|
|
1430
|
+
private async restoreTrackedDocuments(
|
|
1431
|
+
client: LspClient,
|
|
1432
|
+
files: readonly string[],
|
|
1433
|
+
): Promise<void> {
|
|
1434
|
+
const key = clientKey(client.name, client.root);
|
|
1435
|
+
const isCurrent = () => !this.shuttingDown && this.clients.get(key) === client;
|
|
1404
1436
|
for (const filePath of files) {
|
|
1405
|
-
if (!
|
|
1406
|
-
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
try {
|
|
1410
|
-
client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
|
|
1411
|
-
} catch {
|
|
1412
|
-
failedFiles.push(filePath);
|
|
1413
|
-
}
|
|
1437
|
+
if (!isCurrent()) return;
|
|
1438
|
+
await client.restoreDocument(filePath, () => fs.readFileSync(filePath, "utf-8"), isCurrent);
|
|
1414
1439
|
}
|
|
1415
|
-
for (const filePath of failedFiles) client.markFailedFile(filePath);
|
|
1416
1440
|
}
|
|
1417
1441
|
|
|
1418
1442
|
private markProcessCrashRecoveryFailed(
|
|
@@ -1656,7 +1680,15 @@ export class LspManager {
|
|
|
1656
1680
|
void replacement.forceKill().catch(() => {});
|
|
1657
1681
|
return { files, restarted: false };
|
|
1658
1682
|
}
|
|
1659
|
-
this.restoreTrackedDocuments(replacement, files);
|
|
1683
|
+
await this.restoreTrackedDocuments(replacement, files);
|
|
1684
|
+
if (
|
|
1685
|
+
this.clients.get(key) !== replacement ||
|
|
1686
|
+
this.shuttingDown ||
|
|
1687
|
+
replacement.status !== "running"
|
|
1688
|
+
) {
|
|
1689
|
+
void replacement.forceKill().catch(() => {});
|
|
1690
|
+
return { files, restarted: false };
|
|
1691
|
+
}
|
|
1660
1692
|
this.clearUnconsumedProcessCrashState(key, client);
|
|
1661
1693
|
return { files, restarted: true };
|
|
1662
1694
|
} catch {
|
|
@@ -1766,7 +1798,9 @@ export class LspManager {
|
|
|
1766
1798
|
if (options.retryFailedRoute) {
|
|
1767
1799
|
this.retireMissingProjectRoots();
|
|
1768
1800
|
const route = this.resolveFileRoute(resolvedPath);
|
|
1769
|
-
|
|
1801
|
+
// Exact-file demand, including refresh, bypasses automatic discovery exclusions.
|
|
1802
|
+
// Keep the original route identity for document synchronization and bounded retries.
|
|
1803
|
+
if (!route) {
|
|
1770
1804
|
return {
|
|
1771
1805
|
client: null,
|
|
1772
1806
|
processCrashRecovery: emptyProcessCrashRecoveryReport(),
|
|
@@ -2050,13 +2084,19 @@ export class LspManager {
|
|
|
2050
2084
|
|
|
2051
2085
|
const serverConfig = this.config.servers[firstReady.name];
|
|
2052
2086
|
if (serverConfig) {
|
|
2053
|
-
|
|
2087
|
+
// Candidates for the best-ranked marker root; later ones replace a probe
|
|
2088
|
+
// that lands in an inferred project outside the configured program.
|
|
2089
|
+
const targets = findWorkspaceSymbolWarmTargets(
|
|
2054
2090
|
firstReady.root,
|
|
2055
2091
|
serverConfig.rootMarkers,
|
|
2056
2092
|
serverConfig.fileTypes,
|
|
2057
2093
|
{ policy: this.automaticPathPolicy },
|
|
2058
|
-
)
|
|
2059
|
-
|
|
2094
|
+
);
|
|
2095
|
+
const primaryRoot = targets[0]?.projectRoot;
|
|
2096
|
+
const files = targets
|
|
2097
|
+
.filter((target) => target.projectRoot === primaryRoot)
|
|
2098
|
+
.map((target) => target.file);
|
|
2099
|
+
if (files.length > 0) await this.warmSemanticProject(firstReady, files, false, control);
|
|
2060
2100
|
// The warm-up request can start server indexing after the initial
|
|
2061
2101
|
// readiness wait resolves. Observe the resulting progress before
|
|
2062
2102
|
// reporting the workspace as ready.
|
|
@@ -2899,7 +2939,20 @@ export class LspManager {
|
|
|
2899
2939
|
return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
|
|
2900
2940
|
};
|
|
2901
2941
|
const initial = await collect(this.clients.values(), query);
|
|
2902
|
-
|
|
2942
|
+
// Allowed out-of-scope rows do not show that the server knows the scope yet.
|
|
2943
|
+
const hasScopedResult = (collection: WorkspaceSymbolCollection) =>
|
|
2944
|
+
collection.hasSupport &&
|
|
2945
|
+
collection.results.some(
|
|
2946
|
+
(result) =>
|
|
2947
|
+
!resolvedScopes?.length ||
|
|
2948
|
+
isWithinAnyScope(uriToFile(result.location.uri), resolvedScopes),
|
|
2949
|
+
);
|
|
2950
|
+
// Rows from a route whose warm-up reached only inferred projects may come
|
|
2951
|
+
// from those files alone, so they cannot end the search early.
|
|
2952
|
+
if (
|
|
2953
|
+
!initial.hasSupport ||
|
|
2954
|
+
(hasScopedResult(initial) && !this.hasInferredOnlyWarmRoute(routeMatches))
|
|
2955
|
+
) {
|
|
2903
2956
|
return workspaceSymbolCollectionResult(initial);
|
|
2904
2957
|
}
|
|
2905
2958
|
|
|
@@ -2911,11 +2964,32 @@ export class LspManager {
|
|
|
2911
2964
|
}),
|
|
2912
2965
|
getWarmPosition: getWorkspaceSymbolWarmPosition,
|
|
2913
2966
|
collect,
|
|
2967
|
+
hasResult: hasScopedResult,
|
|
2914
2968
|
routeMatches,
|
|
2915
2969
|
query,
|
|
2916
2970
|
control,
|
|
2917
2971
|
});
|
|
2918
|
-
|
|
2972
|
+
const collection = warmed.collection ?? initial;
|
|
2973
|
+
return workspaceSymbolCollectionResult(
|
|
2974
|
+
warmed.coverageConfirmed
|
|
2975
|
+
? collection
|
|
2976
|
+
: workspaceSymbolCoverageGap(collection, warmed.inferredProbeCount),
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/** True when a matching route has inferred warm-up probes and no other probe. */
|
|
2981
|
+
private hasInferredOnlyWarmRoute(routeMatches: (client: LspClient) => boolean): boolean {
|
|
2982
|
+
return Array.from(this.clients.values()).some((client) => {
|
|
2983
|
+
if (!routeMatches(client)) return false;
|
|
2984
|
+
const prefix = `${client.name}:${path.resolve(client.root)}`;
|
|
2985
|
+
let inferred = false;
|
|
2986
|
+
for (const [key, membership] of this.warmedWorkspaceSymbolFiles) {
|
|
2987
|
+
if (key !== prefix && !key.startsWith(`${prefix}${path.sep}`)) continue;
|
|
2988
|
+
if (membership !== "inferred") return false;
|
|
2989
|
+
inferred = true;
|
|
2990
|
+
}
|
|
2991
|
+
return inferred;
|
|
2992
|
+
});
|
|
2919
2993
|
}
|
|
2920
2994
|
|
|
2921
2995
|
private addWorkspaceSymbolDemandEvidence(
|
|
@@ -2938,6 +3012,7 @@ export class LspManager {
|
|
|
2938
3012
|
const results = collection.results.filter((result) => {
|
|
2939
3013
|
const file = uriToFile(result.location.uri);
|
|
2940
3014
|
if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
|
|
3015
|
+
if (!this.isWorkspaceSymbolExclusionRelevant(file, scopes)) return false;
|
|
2941
3016
|
excludedCount++;
|
|
2942
3017
|
const example = displayRelativeFilePath(file, this.cwd);
|
|
2943
3018
|
if (excludedExamples.size < 5) excludedExamples.add(example);
|
|
@@ -2951,6 +3026,26 @@ export class LspManager {
|
|
|
2951
3026
|
};
|
|
2952
3027
|
}
|
|
2953
3028
|
|
|
3029
|
+
/** Only proven-disjoint exclusions can be omitted from scoped completeness. */
|
|
3030
|
+
private isWorkspaceSymbolExclusionRelevant(
|
|
3031
|
+
filePath: string,
|
|
3032
|
+
scopes: readonly string[] | undefined,
|
|
3033
|
+
): boolean {
|
|
3034
|
+
if (!scopes?.length || !path.isAbsolute(filePath)) return true;
|
|
3035
|
+
if (scopes.some((scope) => projectRoots.isWithinOrEqual(scope, filePath))) return true;
|
|
3036
|
+
try {
|
|
3037
|
+
// Observed spellings and physical identities both matter: an excluded
|
|
3038
|
+
// alias may point into scope even when its spelling is outside it.
|
|
3039
|
+
const physicalFile = fs.realpathSync(filePath);
|
|
3040
|
+
return scopes.some((scope) =>
|
|
3041
|
+
projectRoots.isWithinOrEqual(fs.realpathSync(scope), physicalFile),
|
|
3042
|
+
);
|
|
3043
|
+
} catch {
|
|
3044
|
+
// Missing, unreadable or otherwise unresolved paths cannot prove absence.
|
|
3045
|
+
return true;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
2954
3049
|
/** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
|
|
2955
3050
|
private isWorkspaceSymbolResultAllowed(
|
|
2956
3051
|
filePath: string,
|
|
@@ -3045,15 +3140,22 @@ export class LspManager {
|
|
|
3045
3140
|
const client = await this.getClientForFile(resolvedPath, options);
|
|
3046
3141
|
if (!client) return null;
|
|
3047
3142
|
try {
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3143
|
+
await client.openDocument(
|
|
3144
|
+
resolvedPath,
|
|
3145
|
+
() => {
|
|
3146
|
+
try {
|
|
3147
|
+
return fs.readFileSync(resolvedPath, "utf-8");
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
this.closeFile(resolvedPath);
|
|
3150
|
+
throw error;
|
|
3151
|
+
}
|
|
3152
|
+
},
|
|
3153
|
+
options.control,
|
|
3154
|
+
options.protectInputs,
|
|
3155
|
+
);
|
|
3053
3156
|
return client;
|
|
3054
3157
|
} catch (error) {
|
|
3055
3158
|
if (isCodeRequestInterruption(error, options.control)) throw error;
|
|
3056
|
-
this.closeFile(resolvedPath);
|
|
3057
3159
|
return null;
|
|
3058
3160
|
}
|
|
3059
3161
|
}
|
|
@@ -3069,13 +3171,29 @@ export class LspManager {
|
|
|
3069
3171
|
symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
|
|
3070
3172
|
) => import("../config/types.ts").Position | null;
|
|
3071
3173
|
collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
|
|
3174
|
+
/** Stop warming once a collection contains a row that answers the request. */
|
|
3175
|
+
hasResult: (collection: WorkspaceSymbolCollection) => boolean;
|
|
3072
3176
|
routeMatches?: (client: LspClient) => boolean;
|
|
3073
3177
|
query: string;
|
|
3074
3178
|
control?: CodeRequestControl;
|
|
3075
|
-
}): Promise<{
|
|
3076
|
-
|
|
3077
|
-
|
|
3179
|
+
}): Promise<{
|
|
3180
|
+
collection: WorkspaceSymbolCollection | null;
|
|
3181
|
+
/** False only when every probe the server classified landed in an inferred project. */
|
|
3182
|
+
coverageConfirmed: boolean;
|
|
3183
|
+
inferredProbeCount: number;
|
|
3184
|
+
}> {
|
|
3185
|
+
const { findWarmTargets, getWarmPosition, collect, hasResult, routeMatches, query, control } =
|
|
3186
|
+
options;
|
|
3078
3187
|
let latestCollection: WorkspaceSymbolCollection | null = null;
|
|
3188
|
+
// A route with no inferred probe keeps today's evidence: non-TypeScript
|
|
3189
|
+
// servers index the workspace, and `unknown` cannot prove a gap.
|
|
3190
|
+
const routeCoverage: boolean[] = [];
|
|
3191
|
+
let inferredProbeCount = 0;
|
|
3192
|
+
const outcome = () => ({
|
|
3193
|
+
collection: latestCollection,
|
|
3194
|
+
coverageConfirmed: routeCoverage.every(Boolean),
|
|
3195
|
+
inferredProbeCount,
|
|
3196
|
+
});
|
|
3079
3197
|
|
|
3080
3198
|
for (const client of Array.from(this.clients.values())) {
|
|
3081
3199
|
if (routeMatches && !routeMatches(client)) continue;
|
|
@@ -3091,13 +3209,29 @@ export class LspManager {
|
|
|
3091
3209
|
serverConfig.fileTypes,
|
|
3092
3210
|
).slice(0, 24);
|
|
3093
3211
|
|
|
3212
|
+
// Marker roots whose program an earlier candidate already loaded.
|
|
3213
|
+
const coveredRoots = new Set<string>();
|
|
3214
|
+
let routeInferred = false;
|
|
3094
3215
|
for (const target of warmTargets) {
|
|
3216
|
+
if (coveredRoots.has(target.projectRoot)) continue;
|
|
3095
3217
|
// A file probe does not load every program under the route root.
|
|
3096
3218
|
const fileKey = `${client.name}:${path.resolve(target.file)}`;
|
|
3097
|
-
|
|
3219
|
+
const previous = this.warmedWorkspaceSymbolFiles.get(fileKey);
|
|
3220
|
+
if (previous === "inferred") {
|
|
3221
|
+
routeInferred = true;
|
|
3222
|
+
inferredProbeCount++;
|
|
3223
|
+
continue;
|
|
3224
|
+
}
|
|
3225
|
+
if (previous !== undefined) {
|
|
3226
|
+
coveredRoots.add(target.projectRoot);
|
|
3227
|
+
continue;
|
|
3228
|
+
}
|
|
3098
3229
|
|
|
3099
|
-
const openedClient = await this.ensureFileOpen(target.file);
|
|
3100
|
-
if (!openedClient)
|
|
3230
|
+
const openedClient = await this.ensureFileOpen(target.file, { control });
|
|
3231
|
+
if (!openedClient) {
|
|
3232
|
+
this.recordWarmProbeEvent(client.name, target.file, "open-failed", control);
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
3101
3235
|
|
|
3102
3236
|
// A cancelled pass must not mark projects warmed on its way out.
|
|
3103
3237
|
throwIfCodeRequestInterrupted(control);
|
|
@@ -3107,32 +3241,84 @@ export class LspManager {
|
|
|
3107
3241
|
getWarmPosition,
|
|
3108
3242
|
control,
|
|
3109
3243
|
);
|
|
3110
|
-
if (available) {
|
|
3111
|
-
this.
|
|
3112
|
-
|
|
3244
|
+
if (!available) {
|
|
3245
|
+
this.recordWarmProbeEvent(client.name, target.file, "probe-unavailable", control);
|
|
3246
|
+
continue;
|
|
3247
|
+
}
|
|
3248
|
+
const membership = await openedClient.projectMembership(target.file, control);
|
|
3249
|
+
throwIfCodeRequestInterrupted(control);
|
|
3250
|
+
this.warmedWorkspaceSymbolFiles.set(fileKey, membership);
|
|
3251
|
+
this.recordWarmProbeEvent(client.name, target.file, membership, control);
|
|
3252
|
+
if (membership === "inferred") {
|
|
3253
|
+
// The file loaded only its own inferred project; workspace/symbol
|
|
3254
|
+
// still cannot see the configured program, so try the next file
|
|
3255
|
+
// without collecting: a hit here could come from that inferred
|
|
3256
|
+
// project alone and would hide the configured program's rows.
|
|
3257
|
+
routeInferred = true;
|
|
3258
|
+
inferredProbeCount++;
|
|
3259
|
+
continue;
|
|
3113
3260
|
}
|
|
3114
3261
|
|
|
3262
|
+
coveredRoots.add(target.projectRoot);
|
|
3115
3263
|
latestCollection = await collect(this.clients.values(), query);
|
|
3116
|
-
if (latestCollection
|
|
3117
|
-
return {
|
|
3264
|
+
if (hasResult(latestCollection)) {
|
|
3265
|
+
return { ...outcome(), coverageConfirmed: true };
|
|
3118
3266
|
}
|
|
3119
3267
|
}
|
|
3268
|
+
routeCoverage.push(coveredRoots.size > 0 || !routeInferred);
|
|
3120
3269
|
}
|
|
3121
3270
|
|
|
3271
|
+
if (inferredProbeCount > 0 && latestCollection === null) {
|
|
3272
|
+
latestCollection = await collect(this.clients.values(), query);
|
|
3273
|
+
}
|
|
3122
3274
|
// A completed-empty retry replaces an earlier failure too.
|
|
3123
|
-
return
|
|
3275
|
+
return outcome();
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
/**
|
|
3279
|
+
* Record one workspace-symbol warm-up probe: which file was opened and
|
|
3280
|
+
* whether the server placed it in a configured project. `unknown` means the
|
|
3281
|
+
* route cannot report membership (non-TypeScript or native TS7).
|
|
3282
|
+
*/
|
|
3283
|
+
private recordWarmProbeEvent(
|
|
3284
|
+
serverName: string,
|
|
3285
|
+
file: string,
|
|
3286
|
+
outcome: ProjectMembership | "open-failed" | "probe-unavailable",
|
|
3287
|
+
control?: CodeRequestControl,
|
|
3288
|
+
): void {
|
|
3289
|
+
try {
|
|
3290
|
+
recordDebugEvent({
|
|
3291
|
+
operationId: control?.operationId,
|
|
3292
|
+
source: "lsp",
|
|
3293
|
+
level: "debug",
|
|
3294
|
+
category: "workspace-symbol.warm-probe",
|
|
3295
|
+
message: `Workspace-symbol warm-up probe: ${outcome}`,
|
|
3296
|
+
cwd: boundCwd(this.cwd),
|
|
3297
|
+
data: {
|
|
3298
|
+
server: truncateIdentity(serverName),
|
|
3299
|
+
file: truncateIdentity(displayRelativeFilePath(file, this.cwd)),
|
|
3300
|
+
outcome,
|
|
3301
|
+
},
|
|
3302
|
+
});
|
|
3303
|
+
} catch {
|
|
3304
|
+
// Warm-up telemetry must never change query behavior.
|
|
3305
|
+
}
|
|
3124
3306
|
}
|
|
3125
3307
|
|
|
3126
3308
|
private async warmSemanticProject(
|
|
3127
3309
|
client: LspClient,
|
|
3128
|
-
filePath: string,
|
|
3310
|
+
filePath: string | readonly string[],
|
|
3129
3311
|
preferExactFile: boolean = false,
|
|
3130
3312
|
control?: CodeRequestControl,
|
|
3131
3313
|
): Promise<void> {
|
|
3132
3314
|
const serverConfig = this.config.servers[client.name];
|
|
3133
3315
|
if (!serverConfig) return;
|
|
3134
3316
|
|
|
3135
|
-
const
|
|
3317
|
+
const candidates = (typeof filePath === "string" ? [filePath] : filePath).map((file) =>
|
|
3318
|
+
resolveSessionPath(this.cwd, file),
|
|
3319
|
+
);
|
|
3320
|
+
const resolvedFile = candidates[0];
|
|
3321
|
+
if (!resolvedFile) return;
|
|
3136
3322
|
const projectRoot = preferExactFile
|
|
3137
3323
|
? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
|
|
3138
3324
|
knownRoots: this.knownRoots,
|
|
@@ -3150,7 +3336,7 @@ export class LspManager {
|
|
|
3150
3336
|
return;
|
|
3151
3337
|
}
|
|
3152
3338
|
|
|
3153
|
-
const probe = this.performWarmProbe(client,
|
|
3339
|
+
const probe = this.performWarmProbe(client, candidates, projectKey, control);
|
|
3154
3340
|
this.pendingWarmProbes.set(projectKey, probe);
|
|
3155
3341
|
try {
|
|
3156
3342
|
await raceRequestControl(probe, control);
|
|
@@ -3161,28 +3347,40 @@ export class LspManager {
|
|
|
3161
3347
|
}
|
|
3162
3348
|
}
|
|
3163
3349
|
|
|
3350
|
+
/**
|
|
3351
|
+
* Probe candidates in order until one loads a non-inferred project.
|
|
3352
|
+
* The project is marked warmed after any successful probe so files that
|
|
3353
|
+
* really sit in inferred projects are not reprobed on every request.
|
|
3354
|
+
*/
|
|
3164
3355
|
private async performWarmProbe(
|
|
3165
3356
|
client: LspClient,
|
|
3166
|
-
|
|
3357
|
+
candidates: readonly string[],
|
|
3167
3358
|
projectKey: string,
|
|
3168
3359
|
control?: CodeRequestControl,
|
|
3169
3360
|
): Promise<void> {
|
|
3170
3361
|
const key = clientKey(client.name, client.root);
|
|
3171
|
-
if (this.clients.get(key) !== client) return;
|
|
3172
|
-
const openedClient = await this.ensureFileOpen(resolvedFile);
|
|
3173
|
-
if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
|
|
3174
|
-
|
|
3175
3362
|
// The probe is shared state for concurrent callers. One caller's
|
|
3176
3363
|
// cancellation must not cancel the shared in-flight queries, so only the
|
|
3177
3364
|
// Debug Operation ID is forwarded; each caller races its own wait above.
|
|
3178
3365
|
const sharedControl = { operationId: control?.operationId };
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3366
|
+
let warmed = false;
|
|
3367
|
+
for (const [index, resolvedFile] of candidates.entries()) {
|
|
3368
|
+
if (this.clients.get(key) !== client) return;
|
|
3369
|
+
const openedClient = await this.ensureFileOpen(resolvedFile);
|
|
3370
|
+
if (!openedClient || openedClient !== client || this.clients.get(key) !== client) continue;
|
|
3371
|
+
const available = await this.probeDocumentSymbols(
|
|
3372
|
+
openedClient,
|
|
3373
|
+
resolvedFile,
|
|
3374
|
+
getWorkspaceSymbolWarmPosition,
|
|
3375
|
+
sharedControl,
|
|
3376
|
+
);
|
|
3377
|
+
if (!available) continue;
|
|
3378
|
+
warmed = true;
|
|
3379
|
+
if (index === candidates.length - 1) break;
|
|
3380
|
+
const membership = await openedClient.projectMembership(resolvedFile, sharedControl);
|
|
3381
|
+
if (membership !== "inferred") break;
|
|
3382
|
+
}
|
|
3383
|
+
if (warmed && this.clients.get(key) === client) {
|
|
3186
3384
|
this.warmedSemanticProjects.add(projectKey);
|
|
3187
3385
|
}
|
|
3188
3386
|
}
|
|
@@ -3213,7 +3411,7 @@ export class LspManager {
|
|
|
3213
3411
|
|
|
3214
3412
|
private clearWarmedWorkspaceSymbolFiles(serverName: string, root: string): void {
|
|
3215
3413
|
const prefix = `${serverName}:${path.resolve(root)}`;
|
|
3216
|
-
for (const key of Array.from(this.warmedWorkspaceSymbolFiles)) {
|
|
3414
|
+
for (const key of Array.from(this.warmedWorkspaceSymbolFiles.keys())) {
|
|
3217
3415
|
if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
|
|
3218
3416
|
this.warmedWorkspaceSymbolFiles.delete(key);
|
|
3219
3417
|
}
|
|
@@ -139,6 +139,8 @@ function recordScopeDecisionEvent(manager: LspManager): void {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
const FILE_STARTUP_FAILURE_REASON = "The routed LSP client could not be started for this file";
|
|
143
|
+
|
|
142
144
|
class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
143
145
|
constructor(private readonly manager: LspManager) {}
|
|
144
146
|
|
|
@@ -157,6 +159,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
157
159
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
158
160
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
159
161
|
recoverProcessCrash: true,
|
|
162
|
+
protectInputs: true,
|
|
160
163
|
control,
|
|
161
164
|
});
|
|
162
165
|
if (!client) return unavailableFileQuery("hover", resolvedPath);
|
|
@@ -171,6 +174,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
171
174
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
172
175
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
173
176
|
recoverProcessCrash: true,
|
|
177
|
+
protectInputs: true,
|
|
174
178
|
control,
|
|
175
179
|
});
|
|
176
180
|
if (!client) return unavailableFileQuery("definition", resolvedPath);
|
|
@@ -185,6 +189,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
185
189
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
186
190
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
187
191
|
recoverProcessCrash: true,
|
|
192
|
+
protectInputs: true,
|
|
188
193
|
control,
|
|
189
194
|
});
|
|
190
195
|
if (!client) return unavailableFileQuery("references", resolvedPath);
|
|
@@ -202,6 +207,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
202
207
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
203
208
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
204
209
|
recoverProcessCrash: true,
|
|
210
|
+
protectInputs: true,
|
|
205
211
|
control,
|
|
206
212
|
});
|
|
207
213
|
if (!client) return unavailableFileQuery("implementation", resolvedPath);
|
|
@@ -215,6 +221,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
215
221
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
216
222
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
217
223
|
recoverProcessCrash: true,
|
|
224
|
+
protectInputs: true,
|
|
218
225
|
control,
|
|
219
226
|
});
|
|
220
227
|
if (!client) return unavailableFileQuery("document symbols", resolvedPath);
|
|
@@ -260,6 +267,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
260
267
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
261
268
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
262
269
|
recoverProcessCrash: true,
|
|
270
|
+
protectInputs: true,
|
|
263
271
|
control,
|
|
264
272
|
});
|
|
265
273
|
if (!client) return null;
|
|
@@ -309,7 +317,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
309
317
|
const processCrashRecovery = this.manager.getProcessCrashRecoveryReportForFile(resolvedPath);
|
|
310
318
|
return {
|
|
311
319
|
kind: "unavailable",
|
|
312
|
-
reason: "No LSP client can serve this file",
|
|
320
|
+
reason: this.fileUnavailableReason(resolvedPath, "No LSP client can serve this file"),
|
|
313
321
|
...(processCrashRecovery ? { processCrashRecovery } : {}),
|
|
314
322
|
};
|
|
315
323
|
}
|
|
@@ -347,7 +355,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
347
355
|
if (!client) {
|
|
348
356
|
return {
|
|
349
357
|
kind: "unavailable",
|
|
350
|
-
reason:
|
|
358
|
+
reason: this.fileUnavailableReason(resolvedPath, FILE_STARTUP_FAILURE_REASON),
|
|
351
359
|
...startup,
|
|
352
360
|
...recovery,
|
|
353
361
|
};
|
|
@@ -355,6 +363,15 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
355
363
|
return { kind: "ready", ...startup, ...recovery };
|
|
356
364
|
}
|
|
357
365
|
|
|
366
|
+
private fileUnavailableReason(filePath: string, configuredRouteReason: string): string {
|
|
367
|
+
if (!this.manager.hasConfiguredServerForFile(filePath)) {
|
|
368
|
+
return "No compatible LSP server is configured for this file";
|
|
369
|
+
}
|
|
370
|
+
return this.manager.hasFailedStartupForFile(filePath)
|
|
371
|
+
? FILE_STARTUP_FAILURE_REASON
|
|
372
|
+
: configuredRouteReason;
|
|
373
|
+
}
|
|
374
|
+
|
|
358
375
|
/**
|
|
359
376
|
* Wait until all started LSP clients are ready for semantic queries.
|
|
360
377
|
* Performs one representative warm-up probe per client/root.
|
|
@@ -21,6 +21,7 @@ export async function runRoutedRename(options: {
|
|
|
21
21
|
const { manager, file, position, newName, control } = options;
|
|
22
22
|
const client = await manager.ensureFileOpen(file, {
|
|
23
23
|
recoverProcessCrash: true,
|
|
24
|
+
protectInputs: true,
|
|
24
25
|
control,
|
|
25
26
|
});
|
|
26
27
|
if (!client) return null;
|
|
@@ -143,7 +143,11 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
|
|
|
143
143
|
filePath: string,
|
|
144
144
|
control?: CodeRequestControl,
|
|
145
145
|
): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[]>>;
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Collect symbols from contributing routes; completeness is relative to scopes.
|
|
148
|
+
* Data may include out-of-scope rows, which callers must filter themselves.
|
|
149
|
+
* Only policy exclusions proven outside every scope are irrelevant to completeness.
|
|
150
|
+
*/
|
|
147
151
|
workspaceSymbol(
|
|
148
152
|
query: string,
|
|
149
153
|
control?: CodeRequestControl,
|