@mrclrchtr/supi-code-intelligence 4.10.0 → 5.0.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 +2 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
- package/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
- package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
- package/node_modules/@mrclrchtr/supi-lsp/README.md +80 -5
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
- package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
- package/node_modules/@mrclrchtr/supi-lsp/package.json +7 -6
- package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +10 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +206 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +78 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +114 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +372 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +14 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-pull.ts +62 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +491 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-request.ts +9 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +71 -11
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-waiters.ts +206 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +365 -297
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +52 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +185 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client-file-state.ts +25 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +467 -115
- package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +161 -33
- package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +10 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/config/defaults.json +1 -1
- package/node_modules/@mrclrchtr/supi-lsp/src/debug-telemetry.ts +45 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +38 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-client-state.ts +79 -9
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +13 -4
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +300 -24
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +5 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +464 -96
- package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-refactor-provider.ts +130 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-semantic-provider.ts +45 -329
- package/node_modules/@mrclrchtr/supi-lsp/src/provider/refactor-planning.ts +57 -120
- package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-edit-normalizer.ts +320 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/provider/semantic-symbol-mapper.ts +207 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/session/readiness.ts +72 -2
- package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-controller.ts +151 -26
- package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +40 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +50 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +136 -120
- package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-transition-debug.ts +49 -0
- package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +102 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +17 -7
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +9 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +53 -13
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/request-control.ts +47 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/LICENSE +21 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/README.md +258 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/dist/babel.cjs +257 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/dist/jiti.cjs +1 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-cli.mjs +34 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-hooks.mjs +124 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-native.mjs +121 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-register.d.mts +1 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-register.mjs +4 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti-static.mjs +23 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.cjs +30 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.d.cts +8 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.d.mts +8 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/jiti.mjs +29 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/lib/types.d.ts +420 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/jiti/package.json +146 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +6 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/api.ts +0 -9
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +0 -8
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +16 -12
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime-controller.ts +65 -87
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/session.ts +47 -95
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-timing.ts +147 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client-lifecycle.ts +39 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-client.ts +561 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-message-size.ts +24 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/structural-worker-protocol.ts +452 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/call-sites.ts +4 -2
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +12 -4
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/exports.ts +4 -2
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/imports.ts +4 -2
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/node-at.ts +5 -3
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +33 -12
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/blocking-test-bootstrap.mjs +34 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/bootstrap.mjs +8 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store-helpers.ts +23 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/parsed-file-store.ts +400 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/request-control.ts +48 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime-parser-helpers.ts +24 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime-query-helpers.ts +43 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/runtime.ts +421 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/service.ts +116 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/worker/worker-main.ts +201 -0
- package/package.json +5 -5
- package/src/analysis/health/diagnostics.ts +236 -24
- package/src/analysis/health/recovery.ts +6 -2
- package/src/analysis/provider.ts +58 -0
- package/src/analysis/readiness.ts +30 -9
- package/src/analysis/refactor/apply.ts +45 -1
- package/src/analysis/refactor/mutation-authority.ts +143 -0
- package/src/analysis/relations/callees.ts +9 -1
- package/src/analysis/relations/types.ts +5 -2
- package/src/analysis/search/ast-scan-timing.ts +9 -2
- package/src/analysis/search/ast-scan.ts +1 -1
- package/src/analysis/search/deadline.ts +2 -2
- package/src/analysis/search/pattern-analysis.ts +46 -32
- package/src/analysis/search/pattern.ts +12 -2
- package/src/analysis/target/anchored.ts +20 -11
- package/src/analysis/target/file.ts +18 -11
- package/src/analysis/target/identity.ts +19 -3
- package/src/analysis/target/symbol.ts +36 -15
- package/src/api.ts +4 -0
- package/src/diagnostics/evidence.ts +51 -0
- package/src/session/capability-adapter.ts +12 -3
- package/src/session/find-workflow.ts +17 -7
- package/src/session/graph/collect.ts +18 -2
- package/src/session/graph-workflow.ts +19 -12
- package/src/session/health-refresh.ts +148 -0
- package/src/session/health-types.ts +37 -15
- package/src/session/health-workflow.ts +37 -39
- package/src/session/inspect/collect.ts +20 -3
- package/src/session/inspect-workflow.ts +10 -5
- package/src/session/orientation/collect.ts +10 -1
- package/src/session/orientation/context-facts.ts +3 -1
- package/src/session/orientation/context-sections.ts +20 -7
- package/src/session/orientation/gather.ts +17 -11
- package/src/session/orientation-types.ts +2 -1
- package/src/session/orientation-workflow.ts +15 -8
- package/src/session/refactor-plans.ts +2 -0
- package/src/session/refactor-workflow.ts +59 -14
- package/src/session/session.ts +4 -1
- package/src/session/target-workflow.ts +54 -23
- package/src/session/workflow-control.ts +7 -3
- package/src/substrate/lsp/lifecycle.ts +26 -46
- package/src/substrate/lsp/maintenance.ts +199 -25
- package/src/substrate/lsp/state.ts +2 -2
- package/src/substrate/workspace-provider-host.ts +25 -3
- package/src/tool/find/execute.ts +7 -1
- package/src/tool/find/render.ts +20 -3
- package/src/tool/find/tui.ts +23 -21
- package/src/tool/graph/execute.ts +23 -3
- package/src/tool/graph/tui.ts +59 -46
- package/src/tool/health/execute.ts +7 -2
- package/src/tool/health/markdown.ts +56 -14
- package/src/tool/health/refresh-status.ts +105 -0
- package/src/tool/health/tui.ts +95 -98
- package/src/tool/infra/workflow-control.ts +8 -0
- package/src/tool/inspect/execute.ts +7 -1
- package/src/tool/inspect/tui.ts +26 -18
- package/src/tool/orientation/execute.ts +21 -3
- package/src/tool/orientation/tui.ts +51 -35
- package/src/tool/refactor-apply/execute.ts +3 -0
- package/src/tool/refactor-apply/tui.ts +43 -25
- package/src/tool/refactor-plan/execute.ts +4 -0
- package/src/tool/refactor-plan/tui.ts +33 -20
- package/src/tool/register.ts +127 -22
- package/src/tool/resolve/execute.ts +18 -1
- package/src/tool/resolve/tui.ts +42 -24
- package/src/tool/result/display.ts +107 -0
- package/src/tool/result/errors.ts +20 -17
- package/src/tool/result/find.ts +52 -8
- package/src/tool/result/graph.ts +44 -1
- package/src/tool/result/health.ts +53 -1
- package/src/tool/result/inspect.ts +113 -1
- package/src/tool/result/orientation.ts +66 -14
- package/src/tool/result/refactor.ts +36 -4
- package/src/tool/result/resolve.ts +44 -7
- package/src/tool/result/types.ts +20 -0
- package/src/types/execution.ts +18 -5
- package/src/types/index.ts +8 -1
- package/src/ui/status-command.ts +5 -3
- package/src/ui/status-overlay.ts +4 -0
- package/src/ui/tui/common.ts +108 -10
- package/src/ui/tui/display.ts +48 -0
- package/node_modules/@mrclrchtr/supi-tree-sitter/src/session/runtime.ts +0 -351
|
@@ -6,7 +6,10 @@ import { type ChildProcess, execSync, spawn } from "node:child_process";
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import {
|
|
8
8
|
type CodeQueryResult,
|
|
9
|
+
type CodeRequestControl,
|
|
9
10
|
completedCodeQuery,
|
|
11
|
+
isCodeRequestInterruption,
|
|
12
|
+
throwIfCodeRequestInterrupted,
|
|
10
13
|
unavailableCodeQuery,
|
|
11
14
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
12
15
|
import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
|
|
@@ -25,7 +28,6 @@ import type {
|
|
|
25
28
|
Location,
|
|
26
29
|
LocationLink,
|
|
27
30
|
Position,
|
|
28
|
-
PublishDiagnosticsParams,
|
|
29
31
|
Range,
|
|
30
32
|
ServerCapabilities,
|
|
31
33
|
ServerConfig,
|
|
@@ -33,14 +35,35 @@ import type {
|
|
|
33
35
|
WorkspaceEdit,
|
|
34
36
|
WorkspaceSymbol,
|
|
35
37
|
} from "../config/types.ts";
|
|
38
|
+
import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
|
|
39
|
+
import type { DiagnosticEvidenceSummary } from "../diagnostics/evidence.ts";
|
|
40
|
+
import { raceRequestControl } from "../session/readiness.ts";
|
|
36
41
|
import { fileToUri } from "../utils.ts";
|
|
37
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
ClientDynamicRegistrations,
|
|
44
|
+
DOCUMENT_DIAGNOSTIC_METHOD,
|
|
45
|
+
isValidDiagnosticOptions,
|
|
46
|
+
} from "./client-diagnostic-capabilities.ts";
|
|
47
|
+
import { ClientDiagnostics } from "./client-diagnostics.ts";
|
|
48
|
+
import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
|
|
38
49
|
import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
|
|
39
50
|
|
|
40
51
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
41
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Fixed bound after which a running client that never became ready is
|
|
55
|
+
* considered readiness-stalled and eligible for a recovery restart.
|
|
56
|
+
*/
|
|
57
|
+
export const RECOVERY_CLIENT_STARTUP_BOUND_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
/** Repeated protocol-stall failures that justify a recovery restart. */
|
|
60
|
+
export const RECOVERY_PROTOCOL_FAILURE_THRESHOLD = 3;
|
|
61
|
+
|
|
62
|
+
/** Stall signals that justify replacing a client's server process. */
|
|
63
|
+
export type RecoveryRestartReason = "readiness-stall" | "protocol-errors";
|
|
64
|
+
|
|
42
65
|
/** Race an operation against a timeout without retaining the timer after settlement. */
|
|
43
|
-
async function withTimeout<T>(
|
|
66
|
+
export async function withTimeout<T>(
|
|
44
67
|
operation: Promise<T>,
|
|
45
68
|
timeoutMs: number,
|
|
46
69
|
message: string,
|
|
@@ -58,6 +81,41 @@ async function withTimeout<T>(
|
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read and validate the `registrations` array of a `client/registerCapability`
|
|
90
|
+
* request. Each entry must be a record with string `id` and `method`; the
|
|
91
|
+
* optional `registerOptions` stay unvalidated here (method-specific checks
|
|
92
|
+
* happen in the handler). Malformed values reject the request.
|
|
93
|
+
*/
|
|
94
|
+
function readRegistrations(
|
|
95
|
+
params: unknown,
|
|
96
|
+
requestName: string,
|
|
97
|
+
): Array<{ id: string; method: string; registerOptions?: unknown }> {
|
|
98
|
+
if (!isRecord(params) || !Array.isArray(params.registrations)) {
|
|
99
|
+
throw new JsonRpcRequestError(-32602, `Malformed ${requestName} params.`);
|
|
100
|
+
}
|
|
101
|
+
const registrations: Array<{ id: string; method: string; registerOptions?: unknown }> = [];
|
|
102
|
+
for (const registration of params.registrations) {
|
|
103
|
+
if (
|
|
104
|
+
!isRecord(registration) ||
|
|
105
|
+
typeof registration.id !== "string" ||
|
|
106
|
+
typeof registration.method !== "string"
|
|
107
|
+
) {
|
|
108
|
+
throw new JsonRpcRequestError(-32602, `Malformed ${requestName} registration.`);
|
|
109
|
+
}
|
|
110
|
+
registrations.push({
|
|
111
|
+
id: registration.id,
|
|
112
|
+
method: registration.method,
|
|
113
|
+
registerOptions: registration.registerOptions,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return registrations;
|
|
117
|
+
}
|
|
118
|
+
|
|
61
119
|
// ── Process-tree cleanup ──────────────────────────────────────────────
|
|
62
120
|
|
|
63
121
|
/**
|
|
@@ -86,6 +144,17 @@ function killProcessTree(pid: number): void {
|
|
|
86
144
|
// ── Types ─────────────────────────────────────────────────────────────
|
|
87
145
|
export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
|
|
88
146
|
|
|
147
|
+
/** Package-internal facts that the manager projects into workspace lifecycle transitions. */
|
|
148
|
+
export type LspClientLifecycleTransitionKind =
|
|
149
|
+
| "startup"
|
|
150
|
+
| "readiness"
|
|
151
|
+
| "crash"
|
|
152
|
+
| "shutdown"
|
|
153
|
+
| "tracked-files";
|
|
154
|
+
|
|
155
|
+
/** Observer for one concrete client's lifecycle facts. */
|
|
156
|
+
export type LspClientLifecycleListener = (kind: LspClientLifecycleTransitionKind) => void;
|
|
157
|
+
|
|
89
158
|
// ── LspClient ─────────────────────────────────────────────────────────
|
|
90
159
|
export class LspClient {
|
|
91
160
|
readonly name: string;
|
|
@@ -96,37 +165,61 @@ export class LspClient {
|
|
|
96
165
|
private _status: ClientStatus = "initializing";
|
|
97
166
|
private capabilities: ServerCapabilities | null = null;
|
|
98
167
|
private readonly diagnostics: ClientDiagnostics;
|
|
168
|
+
/** Dynamic capability registrations for this client instance only. */
|
|
169
|
+
private readonly dynamicRegistrations = new ClientDynamicRegistrations();
|
|
99
170
|
|
|
100
171
|
// ── Readiness (work-done-progress) ──────────────────────────────────
|
|
101
|
-
private trackedTokens = new Map<ProgressToken, "
|
|
172
|
+
private trackedTokens = new Map<ProgressToken, "created" | "active" | "ended">();
|
|
173
|
+
private tokenCreatedAt = new Map<ProgressToken, number>();
|
|
102
174
|
private _readyPromise: Promise<void> | null = null;
|
|
103
175
|
private _readyResolve: (() => void) | undefined;
|
|
104
176
|
private _readyReject: ((err: Error) => void) | undefined;
|
|
105
177
|
private _isReady = false;
|
|
178
|
+
/** Whether this client generation ever reached concrete readiness. */
|
|
179
|
+
private everReady = false;
|
|
106
180
|
private noProgressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
107
181
|
private tokenTimeouts = new Map<ProgressToken, ReturnType<typeof setTimeout>>();
|
|
182
|
+
/** Wall-clock start of the current client generation, for stall detection. */
|
|
183
|
+
private startedAt = 0;
|
|
108
184
|
|
|
185
|
+
// biome-ignore lint/complexity/useMaxParams: internal constructor keeps positional identity for test call sites
|
|
109
186
|
constructor(
|
|
110
187
|
name: string,
|
|
111
188
|
private readonly config: ServerConfig,
|
|
112
189
|
root: string,
|
|
190
|
+
private readonly onLifecycleTransition?: LspClientLifecycleListener,
|
|
191
|
+
/** Absolute workspace root for debug-telemetry identity. */
|
|
192
|
+
readonly cwd?: string,
|
|
113
193
|
) {
|
|
114
194
|
this.name = name;
|
|
115
195
|
this.root = root;
|
|
116
196
|
this.diagnostics = new ClientDiagnostics({
|
|
197
|
+
server: name,
|
|
198
|
+
cwd: cwd,
|
|
117
199
|
isOperational: () => this.rpc !== null && this._status === "running",
|
|
118
200
|
supportsPullDiagnostics: () => this.hasDiagnosticProvider,
|
|
119
201
|
sendNotification: (method, params) => {
|
|
120
202
|
if (this.rpc) void this.rpc.sendNotification(method, params);
|
|
121
203
|
},
|
|
122
|
-
pullDocumentDiagnostics: async (
|
|
204
|
+
pullDocumentDiagnostics: async (request) => {
|
|
123
205
|
const rpc = this.rpc;
|
|
124
206
|
if (!rpc || this._status !== "running") throw new Error("client not running");
|
|
125
|
-
await this.getReady(
|
|
207
|
+
await this.getReady({
|
|
208
|
+
signal: request.signal,
|
|
209
|
+
deadline: request.deadline,
|
|
210
|
+
});
|
|
126
211
|
return rpc.sendRequest(
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
|
|
212
|
+
DOCUMENT_DIAGNOSTIC_METHOD,
|
|
213
|
+
{
|
|
214
|
+
textDocument: { uri: request.uri },
|
|
215
|
+
previousResultId: request.previousResultId,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
timeoutMs: request.timeoutMs,
|
|
219
|
+
signal: request.signal,
|
|
220
|
+
deadline: request.deadline,
|
|
221
|
+
operationId: request.operationId,
|
|
222
|
+
},
|
|
130
223
|
) as Promise<DocumentDiagnosticReport>;
|
|
131
224
|
},
|
|
132
225
|
});
|
|
@@ -149,11 +242,21 @@ export class LspClient {
|
|
|
149
242
|
return this._isReady;
|
|
150
243
|
}
|
|
151
244
|
|
|
245
|
+
/** Publish one client fact without letting an observer disrupt the client. */
|
|
246
|
+
private publishLifecycle(kind: LspClientLifecycleTransitionKind): void {
|
|
247
|
+
try {
|
|
248
|
+
this.onLifecycleTransition?.(kind);
|
|
249
|
+
} catch {
|
|
250
|
+
// Lifecycle observers must not alter protocol behavior.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
152
254
|
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
153
255
|
/** Spawn the server process and perform the initialize handshake. */
|
|
154
256
|
async start(): Promise<void> {
|
|
155
257
|
const cmd = this.config.command;
|
|
156
258
|
const args = this.config.args ?? [];
|
|
259
|
+
this.startedAt = Date.now();
|
|
157
260
|
|
|
158
261
|
try {
|
|
159
262
|
this.process = spawn(cmd, args, {
|
|
@@ -166,22 +269,27 @@ export class LspClient {
|
|
|
166
269
|
detached: true,
|
|
167
270
|
});
|
|
168
271
|
} catch (err) {
|
|
169
|
-
|
|
170
|
-
|
|
272
|
+
const failure = new Error(`Failed to spawn ${cmd}: ${err}`, { cause: err });
|
|
273
|
+
this.handleProcessFailure(failure);
|
|
274
|
+
throw failure;
|
|
171
275
|
}
|
|
172
276
|
|
|
173
277
|
if (!this.process.stdin || !this.process.stdout) {
|
|
174
|
-
|
|
278
|
+
const failure = new Error(`${cmd}: missing stdin/stdout`);
|
|
279
|
+
this.handleProcessFailure(failure);
|
|
175
280
|
this.process.kill();
|
|
176
|
-
throw
|
|
281
|
+
throw failure;
|
|
177
282
|
}
|
|
178
283
|
|
|
179
|
-
this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin
|
|
284
|
+
this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin, {
|
|
285
|
+
server: this.name,
|
|
286
|
+
cwd: this.cwd,
|
|
287
|
+
});
|
|
180
288
|
|
|
181
289
|
// Handle notifications
|
|
182
290
|
this.rpc.onNotification((method, params) => {
|
|
183
291
|
if (method === "textDocument/publishDiagnostics") {
|
|
184
|
-
this.handlePublishDiagnostics(params
|
|
292
|
+
this.handlePublishDiagnostics(params);
|
|
185
293
|
} else if (method === "$/progress") {
|
|
186
294
|
this.handleProgress(params as { token: ProgressToken; value: { kind: string } });
|
|
187
295
|
}
|
|
@@ -190,19 +298,11 @@ export class LspClient {
|
|
|
190
298
|
|
|
191
299
|
// Handle crashes
|
|
192
300
|
this.process.on("exit", (_code) => {
|
|
193
|
-
|
|
194
|
-
this._status = "error";
|
|
195
|
-
this.cancelNoProgressTimer();
|
|
196
|
-
this.rejectReady(new Error("Client crashed"));
|
|
197
|
-
}
|
|
198
|
-
this.rpc?.dispose();
|
|
301
|
+
this.handleProcessFailure(new Error("Client crashed"));
|
|
199
302
|
});
|
|
200
303
|
|
|
201
304
|
this.process.on("error", (_err) => {
|
|
202
|
-
|
|
203
|
-
this._status = "error";
|
|
204
|
-
this.rejectReady(new Error("Client process error"));
|
|
205
|
-
}
|
|
305
|
+
this.handleProcessFailure(new Error("Client process error"));
|
|
206
306
|
});
|
|
207
307
|
|
|
208
308
|
// Suppress stderr to avoid noise in the agent
|
|
@@ -217,15 +317,21 @@ export class LspClient {
|
|
|
217
317
|
initializationOptions: this.config.initializationOptions,
|
|
218
318
|
})) as InitializeResult;
|
|
219
319
|
|
|
320
|
+
const positionEncoding = result.capabilities.positionEncoding ?? "utf-16";
|
|
321
|
+
if (positionEncoding !== "utf-16") {
|
|
322
|
+
throw new Error(`Server selected unsupported position encoding "${positionEncoding}".`);
|
|
323
|
+
}
|
|
220
324
|
this.capabilities = result.capabilities;
|
|
221
325
|
void this.rpc.sendNotification("initialized", {});
|
|
222
326
|
this._status = "running";
|
|
327
|
+
this.publishLifecycle("startup");
|
|
223
328
|
|
|
224
329
|
this.armNoProgressTimer();
|
|
225
330
|
} catch (err) {
|
|
226
|
-
this.
|
|
331
|
+
const failure = new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
|
|
332
|
+
this.handleProcessFailure(failure);
|
|
227
333
|
this.process.kill();
|
|
228
|
-
throw
|
|
334
|
+
throw failure;
|
|
229
335
|
}
|
|
230
336
|
}
|
|
231
337
|
|
|
@@ -233,6 +339,11 @@ export class LspClient {
|
|
|
233
339
|
async shutdown(): Promise<void> {
|
|
234
340
|
if (this._status === "shutdown") return;
|
|
235
341
|
this._status = "shutdown";
|
|
342
|
+
this.diagnostics.clear();
|
|
343
|
+
this.dynamicRegistrations.clear();
|
|
344
|
+
this.cancelNoProgressTimer();
|
|
345
|
+
this.rejectReady(new Error("Client shutdown"));
|
|
346
|
+
this.publishLifecycle("shutdown");
|
|
236
347
|
|
|
237
348
|
if (!this.rpc || !this.process) return;
|
|
238
349
|
|
|
@@ -271,38 +382,123 @@ export class LspClient {
|
|
|
271
382
|
});
|
|
272
383
|
}
|
|
273
384
|
}
|
|
385
|
+
}
|
|
274
386
|
|
|
275
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Terminate the process tree without a protocol handshake.
|
|
389
|
+
*
|
|
390
|
+
* Used when a replacement startup exceeds its recovery bound, so the
|
|
391
|
+
* orphaned server process cannot outlive the failed restart.
|
|
392
|
+
*/
|
|
393
|
+
async forceKill(): Promise<void> {
|
|
394
|
+
const pid = this.process?.pid;
|
|
395
|
+
this.rpc?.dispose();
|
|
396
|
+
if (pid && this.process?.exitCode === null) {
|
|
397
|
+
killProcessTree(pid);
|
|
398
|
+
if (process.platform !== "win32") {
|
|
399
|
+
// Escalate to SIGKILL after a brief grace period on Unix, mirroring
|
|
400
|
+
// the graceful shutdown path for servers that ignore SIGTERM.
|
|
401
|
+
await new Promise<void>((resolve) => {
|
|
402
|
+
setTimeout(() => {
|
|
403
|
+
try {
|
|
404
|
+
process.kill(-pid, "SIGKILL");
|
|
405
|
+
} catch {
|
|
406
|
+
// Already dead — ignore.
|
|
407
|
+
}
|
|
408
|
+
resolve();
|
|
409
|
+
}, 500);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
this.handleProcessFailure(new Error("Client start bound exceeded"));
|
|
414
|
+
}
|
|
276
415
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
this.
|
|
416
|
+
private handleProcessFailure(reason: Error): void {
|
|
417
|
+
const didCrash = this._status !== "shutdown" && this._status !== "error";
|
|
418
|
+
if (didCrash) {
|
|
419
|
+
this._status = "error";
|
|
420
|
+
this.cancelNoProgressTimer();
|
|
421
|
+
this.rejectReady(reason);
|
|
281
422
|
}
|
|
282
|
-
|
|
283
|
-
this.
|
|
284
|
-
this.
|
|
423
|
+
this.diagnostics.clear({ preserveFailedDocuments: didCrash || this._status === "error" });
|
|
424
|
+
this.dynamicRegistrations.clear();
|
|
425
|
+
this.rpc?.dispose();
|
|
426
|
+
if (didCrash) this.publishLifecycle("crash");
|
|
285
427
|
}
|
|
286
428
|
|
|
287
429
|
// ── Document Synchronization and Diagnostics ────────────────────────
|
|
288
430
|
/** Open a document, or update it when it is already open. */
|
|
289
431
|
didOpen(filePath: string, content: string): void {
|
|
432
|
+
const trackedCount = this.openFiles.length;
|
|
290
433
|
this.diagnostics.didOpen(filePath, content);
|
|
434
|
+
this.publishTrackedFileChange(trackedCount);
|
|
291
435
|
}
|
|
292
436
|
|
|
293
437
|
/** Update a document, or open it when it is not tracked yet. */
|
|
294
438
|
didChange(filePath: string, content: string): void {
|
|
439
|
+
const trackedCount = this.openFiles.length;
|
|
295
440
|
this.diagnostics.didChange(filePath, content);
|
|
441
|
+
this.publishTrackedFileChange(trackedCount);
|
|
296
442
|
}
|
|
297
443
|
|
|
298
444
|
/** Close a document and remove its cached diagnostic state. */
|
|
299
445
|
didClose(filePath: string): void {
|
|
446
|
+
const trackedCount = this.openFiles.length;
|
|
300
447
|
this.diagnostics.didClose(filePath);
|
|
448
|
+
this.publishTrackedFileChange(trackedCount);
|
|
301
449
|
}
|
|
302
450
|
|
|
303
451
|
/** Remove missing document and diagnostic state, and return the removed paths. */
|
|
304
452
|
pruneMissingFiles(): string[] {
|
|
305
|
-
|
|
453
|
+
const trackedCount = this.openFiles.length;
|
|
454
|
+
const removed = this.diagnostics.pruneMissingFiles();
|
|
455
|
+
this.publishTrackedFileChange(trackedCount);
|
|
456
|
+
return removed;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private publishTrackedFileChange(previousCount: number): void {
|
|
460
|
+
if (this.openFiles.length !== previousCount) this.publishLifecycle("tracked-files");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Retain a failed document outcome when a replacement cannot reopen it. */
|
|
464
|
+
markFailedFile(filePath: string): void {
|
|
465
|
+
this.diagnostics.markFailedFile(filePath);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Return the stall signal that justifies replacing this client's process,
|
|
470
|
+
* or null when the client is healthy. Recovery restarts clients only on
|
|
471
|
+
* these signals, never on unconfirmed evidence alone (ADR 0020).
|
|
472
|
+
*/
|
|
473
|
+
getRecoveryStallSignal(): RecoveryRestartReason | null {
|
|
474
|
+
if (this._status !== "running") return null;
|
|
475
|
+
// The startup bound applies only before the client ever became ready: a
|
|
476
|
+
// later readiness loss (a normal progress begin during indexing) is not
|
|
477
|
+
// a startup stall.
|
|
478
|
+
const pastStartupBound =
|
|
479
|
+
this.startedAt > 0 && Date.now() - this.startedAt >= RECOVERY_CLIENT_STARTUP_BOUND_MS;
|
|
480
|
+
if (!this.everReady && pastStartupBound) return "readiness-stall";
|
|
481
|
+
if (this.hasUnbegunCreatedToken()) return "readiness-stall";
|
|
482
|
+
if ((this.rpc?.getProtocolFailureCount() ?? 0) >= RECOVERY_PROTOCOL_FAILURE_THRESHOLD) {
|
|
483
|
+
return "protocol-errors";
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Test whether a created progress token never began within its per-token bound. */
|
|
489
|
+
private hasUnbegunCreatedToken(): boolean {
|
|
490
|
+
const boundMs = this.config.readinessTimeoutMs ?? 10_000;
|
|
491
|
+
for (const [token, createdAt] of this.tokenCreatedAt) {
|
|
492
|
+
if (this.trackedTokens.get(token) === "created" && Date.now() - createdAt >= boundMs) {
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Return the current client version, or null when the document is not open. */
|
|
500
|
+
getOpenDocumentVersion(filePath: string): number | null {
|
|
501
|
+
return this.diagnostics.getOpenDocumentVersion(filePath);
|
|
306
502
|
}
|
|
307
503
|
|
|
308
504
|
/** Return stored diagnostics for one file. */
|
|
@@ -311,8 +507,12 @@ export class LspClient {
|
|
|
311
507
|
}
|
|
312
508
|
|
|
313
509
|
/** Return non-empty diagnostics for files that still exist. */
|
|
510
|
+
getDiagnosticSnapshot(): ClientDiagnosticSnapshot {
|
|
511
|
+
return this.diagnostics.getDiagnosticSnapshot();
|
|
512
|
+
}
|
|
513
|
+
|
|
314
514
|
getAllDiagnostics(): DiagnosticEntry[] {
|
|
315
|
-
return this.
|
|
515
|
+
return this.getDiagnosticSnapshot().entries;
|
|
316
516
|
}
|
|
317
517
|
|
|
318
518
|
/** Force the next pull refresh to request complete diagnostic reports. */
|
|
@@ -322,12 +522,20 @@ export class LspClient {
|
|
|
322
522
|
|
|
323
523
|
/** Check if server supports pull diagnostics. */
|
|
324
524
|
get hasDiagnosticProvider(): boolean {
|
|
325
|
-
|
|
525
|
+
// Static state: a valid `diagnosticProvider` in the initialize result.
|
|
526
|
+
// Dynamic state: an active registration for the diagnostic method. A
|
|
527
|
+
// malformed static shape or an empty dynamic set fails closed, so an
|
|
528
|
+
// unsupported server never gets pull requests.
|
|
529
|
+
return (
|
|
530
|
+
isValidDiagnosticOptions(this.capabilities?.diagnosticProvider) ||
|
|
531
|
+
this.dynamicRegistrations.has(DOCUMENT_DIAGNOSTIC_METHOD)
|
|
532
|
+
);
|
|
326
533
|
}
|
|
327
534
|
|
|
328
535
|
/** Notify the server that watched workspace files changed. */
|
|
329
536
|
notifyWorkspaceFileChanges(changes: FileEvent[]): void {
|
|
330
537
|
if (!this.rpc || this._status !== "running" || changes.length === 0) return;
|
|
538
|
+
this.diagnostics.invalidateCachedEvidence();
|
|
331
539
|
void this.rpc.sendNotification("workspace/didChangeWatchedFiles", {
|
|
332
540
|
changes,
|
|
333
541
|
} satisfies DidChangeWatchedFilesParams);
|
|
@@ -335,118 +543,157 @@ export class LspClient {
|
|
|
335
543
|
|
|
336
544
|
/** Re-read open documents, then collect pull diagnostics or wait for push diagnostics. */
|
|
337
545
|
async refreshOpenDiagnostics(
|
|
338
|
-
options: { maxWaitMs?: number; quietMs?: number } = {},
|
|
339
|
-
): Promise<
|
|
546
|
+
options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
|
|
547
|
+
): Promise<DiagnosticEvidenceSummary> {
|
|
340
548
|
return this.diagnostics.refreshOpenDiagnostics(options);
|
|
341
549
|
}
|
|
342
550
|
|
|
343
|
-
/** Sync one file and return
|
|
344
|
-
async syncAndWaitForDiagnostics(
|
|
345
|
-
|
|
551
|
+
/** Sync one file and return diagnostics with explicit evidence availability. */
|
|
552
|
+
async syncAndWaitForDiagnostics(
|
|
553
|
+
filePath: string,
|
|
554
|
+
content: string,
|
|
555
|
+
control?: CodeRequestControl,
|
|
556
|
+
): Promise<CodeQueryResult<Diagnostic[]>> {
|
|
557
|
+
return this.diagnostics.syncAndWaitForDiagnostics(filePath, content, control);
|
|
346
558
|
}
|
|
347
559
|
|
|
348
560
|
// ── LSP Requests ───────────────────────────────────────────────────
|
|
349
|
-
async hover(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
561
|
+
async hover(
|
|
562
|
+
filePath: string,
|
|
563
|
+
position: Position,
|
|
564
|
+
control?: CodeRequestControl,
|
|
565
|
+
): Promise<CodeQueryResult<Hover | null>> {
|
|
566
|
+
return this.query(
|
|
567
|
+
"textDocument/hover",
|
|
568
|
+
{ textDocument: { uri: fileToUri(filePath) }, position },
|
|
569
|
+
control,
|
|
570
|
+
);
|
|
354
571
|
}
|
|
355
572
|
|
|
356
573
|
async definition(
|
|
357
574
|
filePath: string,
|
|
358
575
|
position: Position,
|
|
576
|
+
control?: CodeRequestControl,
|
|
359
577
|
): Promise<CodeQueryResult<Location | Location[] | LocationLink[] | null>> {
|
|
360
|
-
return this.query(
|
|
361
|
-
textDocument
|
|
362
|
-
position,
|
|
363
|
-
|
|
578
|
+
return this.query(
|
|
579
|
+
"textDocument/definition",
|
|
580
|
+
{ textDocument: { uri: fileToUri(filePath) }, position },
|
|
581
|
+
control,
|
|
582
|
+
);
|
|
364
583
|
}
|
|
365
584
|
|
|
366
585
|
async references(
|
|
367
586
|
filePath: string,
|
|
368
587
|
position: Position,
|
|
588
|
+
control?: CodeRequestControl,
|
|
369
589
|
): Promise<CodeQueryResult<Location[] | null>> {
|
|
370
|
-
return this.query(
|
|
371
|
-
textDocument
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
590
|
+
return this.query(
|
|
591
|
+
"textDocument/references",
|
|
592
|
+
{
|
|
593
|
+
textDocument: { uri: fileToUri(filePath) },
|
|
594
|
+
position,
|
|
595
|
+
context: { includeDeclaration: true },
|
|
596
|
+
},
|
|
597
|
+
control,
|
|
598
|
+
);
|
|
375
599
|
}
|
|
376
600
|
|
|
377
601
|
async documentSymbols(
|
|
378
602
|
filePath: string,
|
|
603
|
+
control?: CodeRequestControl,
|
|
379
604
|
): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[] | null>> {
|
|
380
|
-
return this.query(
|
|
381
|
-
textDocument
|
|
382
|
-
|
|
605
|
+
return this.query(
|
|
606
|
+
"textDocument/documentSymbol",
|
|
607
|
+
{ textDocument: { uri: fileToUri(filePath) } },
|
|
608
|
+
control,
|
|
609
|
+
);
|
|
383
610
|
}
|
|
384
611
|
|
|
385
612
|
async workspaceSymbol(
|
|
386
613
|
query: string,
|
|
614
|
+
control?: CodeRequestControl,
|
|
387
615
|
): Promise<CodeQueryResult<SymbolInformation[] | WorkspaceSymbol[] | null>> {
|
|
388
616
|
if (!this.capabilities?.workspaceSymbolProvider) {
|
|
389
617
|
return unavailableCodeQuery("Workspace-symbol requests are not supported by this server.");
|
|
390
618
|
}
|
|
391
|
-
return this.query("workspace/symbol", { query });
|
|
619
|
+
return this.query("workspace/symbol", { query }, control);
|
|
392
620
|
}
|
|
393
621
|
|
|
394
622
|
async rename(
|
|
395
623
|
filePath: string,
|
|
396
624
|
position: Position,
|
|
397
625
|
newName: string,
|
|
626
|
+
control?: CodeRequestControl,
|
|
398
627
|
): Promise<WorkspaceEdit | null> {
|
|
399
|
-
return this.request(
|
|
400
|
-
textDocument
|
|
401
|
-
position,
|
|
402
|
-
|
|
403
|
-
|
|
628
|
+
return this.request(
|
|
629
|
+
"textDocument/rename",
|
|
630
|
+
{ textDocument: { uri: fileToUri(filePath) }, position, newName },
|
|
631
|
+
control,
|
|
632
|
+
);
|
|
404
633
|
}
|
|
405
634
|
|
|
406
635
|
async codeActions(
|
|
407
636
|
filePath: string,
|
|
408
637
|
range: Range,
|
|
409
638
|
context: CodeActionContext,
|
|
639
|
+
control?: CodeRequestControl,
|
|
410
640
|
): Promise<CodeAction[] | null> {
|
|
411
|
-
return this.request(
|
|
412
|
-
textDocument
|
|
413
|
-
range,
|
|
414
|
-
|
|
415
|
-
|
|
641
|
+
return this.request(
|
|
642
|
+
"textDocument/codeAction",
|
|
643
|
+
{ textDocument: { uri: fileToUri(filePath) }, range, context },
|
|
644
|
+
control,
|
|
645
|
+
);
|
|
416
646
|
}
|
|
417
647
|
|
|
418
648
|
async implementation(
|
|
419
649
|
filePath: string,
|
|
420
650
|
position: Position,
|
|
651
|
+
control?: CodeRequestControl,
|
|
421
652
|
): Promise<CodeQueryResult<Location | Location[] | LocationLink[] | null>> {
|
|
422
653
|
if (!this.capabilities?.implementationProvider) {
|
|
423
654
|
return unavailableCodeQuery("Implementation requests are not supported by this server.");
|
|
424
655
|
}
|
|
425
|
-
return this.query(
|
|
426
|
-
textDocument
|
|
427
|
-
position,
|
|
428
|
-
|
|
656
|
+
return this.query(
|
|
657
|
+
"textDocument/implementation",
|
|
658
|
+
{ textDocument: { uri: fileToUri(filePath) }, position },
|
|
659
|
+
control,
|
|
660
|
+
);
|
|
429
661
|
}
|
|
430
662
|
|
|
431
663
|
// ── Private ─────────────────────────────────────────────────────────
|
|
432
|
-
private async query<T>(
|
|
664
|
+
private async query<T>(
|
|
665
|
+
method: string,
|
|
666
|
+
params: unknown,
|
|
667
|
+
control?: CodeRequestControl,
|
|
668
|
+
): Promise<CodeQueryResult<T | null>> {
|
|
669
|
+
// An already-cancelled caller gets the interruption, not an unavailable
|
|
670
|
+
// outcome that could mask the cancellation.
|
|
671
|
+
throwIfCodeRequestInterrupted(control);
|
|
433
672
|
if (!this.rpc || this._status !== "running") {
|
|
434
673
|
return unavailableCodeQuery(
|
|
435
674
|
`LSP request ${method} is unavailable because the client is not running.`,
|
|
436
675
|
);
|
|
437
676
|
}
|
|
438
677
|
try {
|
|
439
|
-
await this.getReady();
|
|
440
|
-
const data = (await this.rpc.sendRequest(method, params)) as T | null | undefined;
|
|
678
|
+
await this.getReady(control);
|
|
679
|
+
const data = (await this.rpc.sendRequest(method, params, control)) as T | null | undefined;
|
|
441
680
|
return completedCodeQuery(data ?? null);
|
|
442
681
|
} catch (error) {
|
|
682
|
+
// Cancellation and absolute-deadline expiry propagate as interruptions:
|
|
683
|
+
// the caller no longer awaits a result, so no unavailable outcome may
|
|
684
|
+
// mask the cancellation.
|
|
685
|
+
if (isCodeRequestInterruption(error, control)) throw error;
|
|
443
686
|
const detail = error instanceof Error ? error.message : String(error);
|
|
444
687
|
return unavailableCodeQuery(`LSP request ${method} failed: ${detail}`);
|
|
445
688
|
}
|
|
446
689
|
}
|
|
447
690
|
|
|
448
|
-
private async request<T>(
|
|
449
|
-
|
|
691
|
+
private async request<T>(
|
|
692
|
+
method: string,
|
|
693
|
+
params: unknown,
|
|
694
|
+
control?: CodeRequestControl,
|
|
695
|
+
): Promise<T | null> {
|
|
696
|
+
const result = await this.query<T>(method, params, control);
|
|
450
697
|
return result.kind === "unavailable" ? null : result.data;
|
|
451
698
|
}
|
|
452
699
|
|
|
@@ -457,23 +704,22 @@ export class LspClient {
|
|
|
457
704
|
case "workspace/workspaceFolders":
|
|
458
705
|
return [{ uri: fileToUri(this.root), name: path.basename(this.root) || this.root }];
|
|
459
706
|
case "client/registerCapability":
|
|
707
|
+
return this.handleRegisterCapability(params);
|
|
460
708
|
case "client/unregisterCapability":
|
|
709
|
+
return this.handleUnregisterCapability(params);
|
|
710
|
+
case "workspace/diagnostic/refresh":
|
|
711
|
+
// The client does not advertise refresh support, but pyright sends
|
|
712
|
+
// this request anyway when a document opens in pull mode. Answering
|
|
713
|
+
// with an error crashes pyright (exit 1); answering null keeps the
|
|
714
|
+
// server alive and pull-on-demand still serves fresh diagnostics.
|
|
461
715
|
return null;
|
|
462
716
|
case "window/workDoneProgress/create": {
|
|
717
|
+
// A create reserves a token; it does not prove active work. The
|
|
718
|
+
// token stays pending and readiness is untouched until a begin
|
|
719
|
+
// arrives, so an unused token never causes false readiness loss.
|
|
463
720
|
const token = (params as { token: ProgressToken }).token;
|
|
464
|
-
this.trackedTokens.set(token, "
|
|
465
|
-
this.
|
|
466
|
-
this._isReady = false;
|
|
467
|
-
if (!this._readyPromise) {
|
|
468
|
-
this._readyPromise = new Promise<void>((resolve, reject) => {
|
|
469
|
-
this._readyResolve = resolve;
|
|
470
|
-
this._readyReject = reject;
|
|
471
|
-
});
|
|
472
|
-
// Prevent unhandled rejection when rejectReady fires before any
|
|
473
|
-
// consumer is actively awaiting this promise (e.g. during shutdown).
|
|
474
|
-
this._readyPromise.catch(() => {});
|
|
475
|
-
}
|
|
476
|
-
this.startTokenTimeout(token);
|
|
721
|
+
this.trackedTokens.set(token, "created");
|
|
722
|
+
this.tokenCreatedAt.set(token, Date.now());
|
|
477
723
|
return null;
|
|
478
724
|
}
|
|
479
725
|
default:
|
|
@@ -488,8 +734,52 @@ export class LspClient {
|
|
|
488
734
|
return items.map(() => null);
|
|
489
735
|
}
|
|
490
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Apply a dynamic registration for `textDocument/diagnostic`.
|
|
739
|
+
*
|
|
740
|
+
* Registrations for other methods are ignored (status quo). Malformed
|
|
741
|
+
* params or malformed diagnostic registration options reject the request
|
|
742
|
+
* without enabling pull, so a server never gets pull requests it did not
|
|
743
|
+
* validly register for.
|
|
744
|
+
*/
|
|
745
|
+
private handleRegisterCapability(params: unknown): null {
|
|
746
|
+
const registrations = readRegistrations(params, "client/registerCapability");
|
|
747
|
+
for (const registration of registrations) {
|
|
748
|
+
if (registration.method !== DOCUMENT_DIAGNOSTIC_METHOD) continue;
|
|
749
|
+
if (!isValidDiagnosticOptions(registration.registerOptions)) {
|
|
750
|
+
throw new JsonRpcRequestError(
|
|
751
|
+
-32602,
|
|
752
|
+
"Malformed textDocument/diagnostic registration options.",
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
this.dynamicRegistrations.register(registration.method, registration.id);
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Remove dynamic registrations for `textDocument/diagnostic`.
|
|
762
|
+
*
|
|
763
|
+
* The params key is the LSP specification's documented compatibility typo
|
|
764
|
+
* `unregisterations` (renamed to `unregistrations` only in a future 4.x).
|
|
765
|
+
* Capability loss disables pull as soon as the last id is removed.
|
|
766
|
+
*/
|
|
767
|
+
private handleUnregisterCapability(params: unknown): null {
|
|
768
|
+
if (!isRecord(params) || !Array.isArray(params.unregisterations)) {
|
|
769
|
+
throw new JsonRpcRequestError(-32602, "Malformed client/unregisterCapability params.");
|
|
770
|
+
}
|
|
771
|
+
for (const entry of params.unregisterations) {
|
|
772
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || typeof entry.method !== "string") {
|
|
773
|
+
throw new JsonRpcRequestError(-32602, "Malformed client/unregisterCapability entry.");
|
|
774
|
+
}
|
|
775
|
+
if (entry.method !== DOCUMENT_DIAGNOSTIC_METHOD) continue;
|
|
776
|
+
this.dynamicRegistrations.unregister(entry.method, entry.id);
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
491
781
|
/** Apply a diagnostic publication received from the LSP transport. */
|
|
492
|
-
handlePublishDiagnostics(params:
|
|
782
|
+
handlePublishDiagnostics(params: unknown): void {
|
|
493
783
|
this.diagnostics.handlePublishDiagnostics(params);
|
|
494
784
|
}
|
|
495
785
|
|
|
@@ -499,9 +789,17 @@ export class LspClient {
|
|
|
499
789
|
* Wait for the server to be ready to serve queries.
|
|
500
790
|
* Returns immediately if already ready; returns the ongoing promise
|
|
501
791
|
* if one is pending; creates and returns a new one otherwise.
|
|
792
|
+
* With request control, the caller's wait stops promptly on abort or
|
|
793
|
+
* deadline while the shared readiness state keeps its own lifecycle.
|
|
502
794
|
*/
|
|
503
|
-
async getReady(): Promise<void> {
|
|
504
|
-
|
|
795
|
+
async getReady(control?: CodeRequestControl): Promise<void> {
|
|
796
|
+
const pending = this.pendingReady();
|
|
797
|
+
if (!control) return pending;
|
|
798
|
+
return raceRequestControl(pending, control);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private pendingReady(): Promise<void> {
|
|
802
|
+
if (this._isReady) return Promise.resolve();
|
|
505
803
|
if (this._readyPromise !== null) return this._readyPromise;
|
|
506
804
|
// If no progress timer was ever armed and no tokens are tracked,
|
|
507
805
|
// the server was either never started with a real process (test scenario)
|
|
@@ -509,8 +807,11 @@ export class LspClient {
|
|
|
509
807
|
// only when the client is still running — a crash or shutdown clears
|
|
510
808
|
// both fields but must not report the client as ready.
|
|
511
809
|
if (this.noProgressTimer === null && this.trackedTokens.size === 0) {
|
|
512
|
-
if (this._status === "running")
|
|
513
|
-
|
|
810
|
+
if (this._status === "running") {
|
|
811
|
+
this._isReady = true;
|
|
812
|
+
this.everReady = true;
|
|
813
|
+
}
|
|
814
|
+
return Promise.resolve();
|
|
514
815
|
}
|
|
515
816
|
this._readyPromise = new Promise<void>((resolve, reject) => {
|
|
516
817
|
this._readyResolve = resolve;
|
|
@@ -533,14 +834,20 @@ export class LspClient {
|
|
|
533
834
|
source: "lsp",
|
|
534
835
|
level: "debug",
|
|
535
836
|
category: "readiness.progress-begin",
|
|
536
|
-
message: `Readiness progress begin for
|
|
537
|
-
|
|
837
|
+
message: `Readiness progress begin for ${this.name}`,
|
|
838
|
+
cwd: boundCwd(this.cwd),
|
|
839
|
+
data: { server: this.name, root: truncateIdentity(this.root) },
|
|
538
840
|
});
|
|
539
|
-
//
|
|
540
|
-
//
|
|
841
|
+
// begin is the only transition that proves active work: it cancels
|
|
842
|
+
// the no-progress grace timer, blocks readiness, and arms the
|
|
843
|
+
// bounded per-token timeout. A server that sends begin without a
|
|
844
|
+
// prior create is spec-deviant but valid.
|
|
541
845
|
this.cancelNoProgressTimer();
|
|
542
|
-
this.trackedTokens.set(token, "
|
|
846
|
+
this.trackedTokens.set(token, "active");
|
|
847
|
+
this.tokenCreatedAt.delete(token);
|
|
848
|
+
const wasReady = this._isReady;
|
|
543
849
|
this._isReady = false;
|
|
850
|
+
if (wasReady) this.publishLifecycle("readiness");
|
|
544
851
|
// Re-arm readiness promise if not already pending
|
|
545
852
|
if (!this._readyPromise) {
|
|
546
853
|
this._readyPromise = new Promise<void>((resolve, reject) => {
|
|
@@ -557,22 +864,36 @@ export class LspClient {
|
|
|
557
864
|
source: "lsp",
|
|
558
865
|
level: "debug",
|
|
559
866
|
category: "readiness.progress-end",
|
|
560
|
-
message: `Readiness progress end for
|
|
561
|
-
|
|
867
|
+
message: `Readiness progress end for ${this.name}`,
|
|
868
|
+
cwd: boundCwd(this.cwd),
|
|
869
|
+
data: { server: this.name, root: truncateIdentity(this.root) },
|
|
562
870
|
});
|
|
871
|
+
const state = this.trackedTokens.get(token);
|
|
872
|
+
if (state === undefined) return; // Unknown token: ignore fail-closed.
|
|
873
|
+
this.tokenCreatedAt.delete(token);
|
|
874
|
+
if (state === "created") {
|
|
875
|
+
// A pending token never blocked readiness; its end removes it.
|
|
876
|
+
this.trackedTokens.delete(token);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
563
879
|
this.trackedTokens.set(token, "ended");
|
|
564
880
|
this.clearTokenTimeout(token);
|
|
565
881
|
this.checkAllTokensEnded();
|
|
566
882
|
}
|
|
567
|
-
// kind: "report" — intentionally no-op
|
|
883
|
+
// kind: "report" — intentionally no-op; active state is retained.
|
|
568
884
|
}
|
|
569
885
|
|
|
570
|
-
/**
|
|
571
|
-
private
|
|
572
|
-
if (this.trackedTokens.size === 0) return;
|
|
886
|
+
/** Test whether any token has active (begun) work. */
|
|
887
|
+
private hasActiveTokens(): boolean {
|
|
573
888
|
for (const state of this.trackedTokens.values()) {
|
|
574
|
-
if (state
|
|
889
|
+
if (state === "active") return true;
|
|
575
890
|
}
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Resolve readiness when no token is active; pending tokens do not block. */
|
|
895
|
+
private checkAllTokensEnded(): void {
|
|
896
|
+
if (this.hasActiveTokens()) return;
|
|
576
897
|
this.trackedTokens.clear();
|
|
577
898
|
this.resolveReady();
|
|
578
899
|
}
|
|
@@ -585,15 +906,35 @@ export class LspClient {
|
|
|
585
906
|
this._readyResolve = undefined;
|
|
586
907
|
this._readyReject = undefined;
|
|
587
908
|
}
|
|
909
|
+
// A rejected or disposed client must never be marked ready again,
|
|
910
|
+
// even if a stray progress end arrives after the rejection.
|
|
911
|
+
if (this._status !== "running") return;
|
|
912
|
+
const becameReady = !this._isReady;
|
|
588
913
|
this._isReady = true;
|
|
914
|
+
if (becameReady) this.everReady = true;
|
|
915
|
+
if (becameReady) this.publishLifecycle("readiness");
|
|
589
916
|
recordDebugEvent({
|
|
590
917
|
source: "lsp",
|
|
591
918
|
level: "info",
|
|
592
919
|
category: "readiness.resolved",
|
|
593
|
-
message: `LSP client ${this.name} is ready
|
|
920
|
+
message: `LSP client ${this.name} is ready`,
|
|
921
|
+
cwd: boundCwd(this.cwd),
|
|
922
|
+
data: { server: this.name, root: truncateIdentity(this.root) },
|
|
594
923
|
});
|
|
595
924
|
}
|
|
596
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Tear down readiness state without a protocol shutdown.
|
|
928
|
+
*
|
|
929
|
+
* Clears pending and active progress tokens and rejects any pending
|
|
930
|
+
* readiness. Called when a client is discarded or its observer is
|
|
931
|
+
* disposed so token state cannot outlive the client.
|
|
932
|
+
*/
|
|
933
|
+
dispose(): void {
|
|
934
|
+
this.cancelNoProgressTimer();
|
|
935
|
+
this.dynamicRegistrations.clear();
|
|
936
|
+
this.rejectReady(new Error("Client disposed"));
|
|
937
|
+
}
|
|
597
938
|
/**
|
|
598
939
|
* Reject the current readiness promise (if any) and mark the client
|
|
599
940
|
* not ready. Called on shutdown, crash, or restart.
|
|
@@ -607,6 +948,7 @@ export class LspClient {
|
|
|
607
948
|
}
|
|
608
949
|
this._isReady = false;
|
|
609
950
|
this.trackedTokens.clear();
|
|
951
|
+
this.tokenCreatedAt.clear();
|
|
610
952
|
for (const timer of this.tokenTimeouts.values()) clearTimeout(timer);
|
|
611
953
|
this.tokenTimeouts.clear();
|
|
612
954
|
recordDebugEvent({
|
|
@@ -614,7 +956,12 @@ export class LspClient {
|
|
|
614
956
|
level: this._status === "shutdown" ? "debug" : "warning",
|
|
615
957
|
category: "readiness.rejected",
|
|
616
958
|
message: `LSP client ${this.name} readiness rejected: ${reason.message}`,
|
|
617
|
-
|
|
959
|
+
cwd: boundCwd(this.cwd),
|
|
960
|
+
data: {
|
|
961
|
+
server: this.name,
|
|
962
|
+
root: truncateIdentity(this.root),
|
|
963
|
+
status: this._status,
|
|
964
|
+
},
|
|
618
965
|
});
|
|
619
966
|
}
|
|
620
967
|
|
|
@@ -634,8 +981,9 @@ export class LspClient {
|
|
|
634
981
|
source: "lsp",
|
|
635
982
|
level: "debug",
|
|
636
983
|
category: "readiness.token-timeout",
|
|
637
|
-
message: `Readiness per-token timeout fired for
|
|
638
|
-
|
|
984
|
+
message: `Readiness per-token timeout fired for ${this.name} after ${timeoutMs}ms`,
|
|
985
|
+
cwd: boundCwd(this.cwd),
|
|
986
|
+
data: { server: this.name, root: truncateIdentity(this.root), timeoutMs },
|
|
639
987
|
});
|
|
640
988
|
this.checkAllTokensEnded();
|
|
641
989
|
}, timeoutMs);
|
|
@@ -661,6 +1009,8 @@ export class LspClient {
|
|
|
661
1009
|
level: "debug",
|
|
662
1010
|
category: "readiness.no-progress-cancelled",
|
|
663
1011
|
message: `No-progress grace timer cancelled for ${this.name}`,
|
|
1012
|
+
cwd: boundCwd(this.cwd),
|
|
1013
|
+
data: { server: this.name, root: truncateIdentity(this.root) },
|
|
664
1014
|
});
|
|
665
1015
|
}
|
|
666
1016
|
}
|
|
@@ -676,12 +1026,14 @@ export class LspClient {
|
|
|
676
1026
|
*/
|
|
677
1027
|
private armNoProgressTimer(): void {
|
|
678
1028
|
this.noProgressTimer = setTimeout(() => {
|
|
679
|
-
if (this.
|
|
1029
|
+
if (!this.hasActiveTokens() && this._status === "running") {
|
|
680
1030
|
recordDebugEvent({
|
|
681
1031
|
source: "lsp",
|
|
682
1032
|
level: "debug",
|
|
683
1033
|
category: "readiness.no-progress-resolved",
|
|
684
1034
|
message: `No-progress grace timer resolved for ${this.name}`,
|
|
1035
|
+
cwd: boundCwd(this.cwd),
|
|
1036
|
+
data: { server: this.name, root: truncateIdentity(this.root) },
|
|
685
1037
|
});
|
|
686
1038
|
this.resolveReady();
|
|
687
1039
|
}
|