@mrclrchtr/supi-lsp 4.8.0 → 4.10.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/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/README.md +3 -1
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
- package/package.json +3 -3
- package/src/client/client-diagnostic-timing.ts +155 -0
- package/src/client/client-diagnostics.ts +70 -34
- package/src/client/transport.ts +104 -5
|
@@ -48,7 +48,7 @@ Config file locations:
|
|
|
48
48
|
### Shared registries
|
|
49
49
|
|
|
50
50
|
- context-provider registry for `/supi-context`
|
|
51
|
-
- debug-event registry for producers that want shared debug capture
|
|
51
|
+
- debug-event registry and monotonic phase timers for producers that want shared debug capture
|
|
52
52
|
- settings registry used by `/supi-settings`
|
|
53
53
|
|
|
54
54
|
### Project and session helpers
|
|
@@ -108,5 +108,7 @@ export default function myExtension(pi: ExtensionAPI) {
|
|
|
108
108
|
|
|
109
109
|
- `src/api.ts` — exported library surface
|
|
110
110
|
- `src/config.ts` — shared config loading and writing
|
|
111
|
+
- `src/debug-registry.ts` — Debug domain surface, event state, retention, redaction, listeners, and queries
|
|
112
|
+
- `src/debug-timing.ts` — monotonic total and phase timers for Debug Event Producers
|
|
111
113
|
- `src/settings/` — settings registry, schema, scope resolution, and persistence
|
|
112
114
|
- `src/report.ts` — shared text/report rendering helpers
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// supi-debug extension owns policy/configuration and exposes events through a
|
|
5
5
|
// command/tool while this module stays dependency-free for producers.
|
|
6
6
|
|
|
7
|
+
// biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
|
|
8
|
+
export * from "./debug-timing.ts";
|
|
9
|
+
|
|
7
10
|
export type DebugLevel = "debug" | "info" | "warning" | "error";
|
|
8
11
|
export type DebugAgentAccess = "off" | "sanitized" | "raw";
|
|
9
12
|
export interface DebugRegistryConfig {
|
|
@@ -180,6 +183,11 @@ export function getDebugRegistryConfig(): DebugRegistryConfig {
|
|
|
180
183
|
return cloneConfig(getState().config);
|
|
181
184
|
}
|
|
182
185
|
|
|
186
|
+
/** Return whether the Debug Registry currently retains producer events. */
|
|
187
|
+
export function isDebugRegistryEnabled(): boolean {
|
|
188
|
+
return getState().config.enabled;
|
|
189
|
+
}
|
|
190
|
+
|
|
183
191
|
/** Best-effort redaction helper for data exposed through sanitized debug views. */
|
|
184
192
|
export function redactDebugData<T>(value: T): T {
|
|
185
193
|
return redactValue(value, 8) as T;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import {
|
|
3
|
+
type DebugEvent,
|
|
4
|
+
type DebugEventInput,
|
|
5
|
+
isDebugRegistryEnabled,
|
|
6
|
+
recordDebugEvent,
|
|
7
|
+
} from "./debug-registry.ts";
|
|
8
|
+
|
|
9
|
+
/** Monotonic duration data added to a timed debug event. */
|
|
10
|
+
export interface DebugTiming {
|
|
11
|
+
readonly durationMs: number;
|
|
12
|
+
readonly phasesMs: Readonly<Record<string, number>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Debug event input whose data can receive the reserved `timing` field. */
|
|
16
|
+
export interface TimedDebugEventInput extends Omit<DebugEventInput, "data"> {
|
|
17
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Test seam for the monotonic clock used by a debug timer. */
|
|
21
|
+
export interface DebugTimerOptions {
|
|
22
|
+
readonly now?: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Lazy timed-event input that is not evaluated when Debug is disabled. */
|
|
26
|
+
export type TimedDebugEventFactory = () => TimedDebugEventInput;
|
|
27
|
+
|
|
28
|
+
/** One-shot debug timer with optional sequential phase measurements. */
|
|
29
|
+
export interface DebugTimer {
|
|
30
|
+
/** Whether this timer sampled Debug as enabled when it started. */
|
|
31
|
+
readonly enabled: boolean;
|
|
32
|
+
/** Finish the current phase and start the next unnamed interval. */
|
|
33
|
+
mark(phase: string): void;
|
|
34
|
+
/** Record one event. A second call returns `null` without recording another event. */
|
|
35
|
+
finish(
|
|
36
|
+
input: TimedDebugEventInput | TimedDebugEventFactory,
|
|
37
|
+
finalPhase?: string,
|
|
38
|
+
): DebugEvent | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start a monotonic timer for one debug event.
|
|
43
|
+
*
|
|
44
|
+
* Each `mark(name)` stores the interval since the previous mark. `finish()`
|
|
45
|
+
* stores the total duration and can name the final interval. Repeated phase
|
|
46
|
+
* names are accumulated. Event data reserves the `timing` field. When Debug is
|
|
47
|
+
* disabled at start, this returns a no-op timer and does not read the clock.
|
|
48
|
+
* Pass a factory to `finish()` to avoid event-data construction when disabled.
|
|
49
|
+
* Clock, event-construction, and registry failures are isolated from the
|
|
50
|
+
* measured operation and make the timer a no-op.
|
|
51
|
+
*/
|
|
52
|
+
export function startDebugTimer(options: DebugTimerOptions = {}): DebugTimer {
|
|
53
|
+
if (!isDebugRegistryEnabled()) return DISABLED_DEBUG_TIMER;
|
|
54
|
+
const now = options.now ?? performance.now.bind(performance);
|
|
55
|
+
let startedAt: number;
|
|
56
|
+
try {
|
|
57
|
+
startedAt = now();
|
|
58
|
+
} catch {
|
|
59
|
+
return DISABLED_DEBUG_TIMER;
|
|
60
|
+
}
|
|
61
|
+
let previousAt = startedAt;
|
|
62
|
+
let finished = false;
|
|
63
|
+
let failed = false;
|
|
64
|
+
const phases = new Map<string, number>();
|
|
65
|
+
|
|
66
|
+
const markAt = (phase: string, current: number): void => {
|
|
67
|
+
const name = phase.trim();
|
|
68
|
+
if (!name) return;
|
|
69
|
+
phases.set(name, (phases.get(name) ?? 0) + Math.max(0, current - previousAt));
|
|
70
|
+
previousAt = current;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
enabled: true,
|
|
75
|
+
mark(phase) {
|
|
76
|
+
if (finished || failed) return;
|
|
77
|
+
try {
|
|
78
|
+
markAt(phase, now());
|
|
79
|
+
} catch {
|
|
80
|
+
failed = true;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
finish(input, finalPhase) {
|
|
84
|
+
if (finished || failed) return null;
|
|
85
|
+
finished = true;
|
|
86
|
+
try {
|
|
87
|
+
if (!isDebugRegistryEnabled()) return null;
|
|
88
|
+
const completedAt = now();
|
|
89
|
+
if (finalPhase) markAt(finalPhase, completedAt);
|
|
90
|
+
const phasesMs = Object.fromEntries(
|
|
91
|
+
[...phases.entries()].map(([name, value]) => [name, duration(value)]),
|
|
92
|
+
);
|
|
93
|
+
const timing: DebugTiming = {
|
|
94
|
+
durationMs: duration(completedAt - startedAt),
|
|
95
|
+
phasesMs,
|
|
96
|
+
};
|
|
97
|
+
const eventInput = typeof input === "function" ? input() : input;
|
|
98
|
+
return recordDebugEvent({
|
|
99
|
+
...eventInput,
|
|
100
|
+
data: { ...eventInput.data, timing },
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const DISABLED_DEBUG_TIMER: DebugTimer = Object.freeze({
|
|
110
|
+
enabled: false,
|
|
111
|
+
mark() {},
|
|
112
|
+
finish() {
|
|
113
|
+
return null;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function duration(value: number): number {
|
|
118
|
+
if (!Number.isFinite(value)) return 0;
|
|
119
|
+
return Math.round(Math.max(0, value) * 10) / 10;
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-lsp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
4
4
|
"description": "Language Server Protocol runtime for SuPi code intelligence",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"vscode-jsonrpc": "^9.0.0",
|
|
38
38
|
"vscode-languageserver-protocol": "^3.17.5",
|
|
39
39
|
"vscode-languageserver-types": "^3.17.5",
|
|
40
|
-
"@mrclrchtr/supi-code-runtime": "4.
|
|
41
|
-
"@mrclrchtr/supi-core": "4.
|
|
40
|
+
"@mrclrchtr/supi-code-runtime": "4.10.0",
|
|
41
|
+
"@mrclrchtr/supi-core": "4.10.0"
|
|
42
42
|
},
|
|
43
43
|
"bundledDependencies": [
|
|
44
44
|
"@mrclrchtr/supi-code-runtime",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { startDebugTimer } from "@mrclrchtr/supi-core/debug";
|
|
2
|
+
|
|
3
|
+
type DiagnosticCollection = "fallback" | "none" | "pull" | "push";
|
|
4
|
+
type DiagnosticFreshness = "not-observed" | "observed";
|
|
5
|
+
type DiagnosticOutcome = "completed" | "skipped" | "timed-out";
|
|
6
|
+
type DiagnosticPullOutcome = "completed" | "failed" | "not-supported" | "not-used" | "timed-out";
|
|
7
|
+
type DiagnosticPushOutcome = "not-used" | "published" | "released" | "settled" | "timed-out";
|
|
8
|
+
type DiagnosticSettleOutcome = "not-used" | "published" | "quiet" | "released" | "timed-out";
|
|
9
|
+
type DiagnosticTimingOperation = "refresh-open" | "sync-file";
|
|
10
|
+
|
|
11
|
+
/** Result of waiting for a quiet push-diagnostic window. */
|
|
12
|
+
export interface DiagnosticSettleResult {
|
|
13
|
+
readonly outcome: "quiet" | "timed-out";
|
|
14
|
+
readonly freshness: DiagnosticFreshness;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Result of waiting for one file's push diagnostics. */
|
|
18
|
+
export type DiagnosticPushWaitOutcome = "published" | "released" | "timed-out";
|
|
19
|
+
|
|
20
|
+
interface DiagnosticTimingData {
|
|
21
|
+
readonly collection: DiagnosticCollection;
|
|
22
|
+
readonly documentCount: number;
|
|
23
|
+
readonly fallback: boolean;
|
|
24
|
+
readonly freshness: DiagnosticFreshness;
|
|
25
|
+
readonly outcome: DiagnosticOutcome;
|
|
26
|
+
readonly pull: DiagnosticPullOutcome;
|
|
27
|
+
readonly push: DiagnosticPushOutcome;
|
|
28
|
+
readonly settle: DiagnosticSettleOutcome;
|
|
29
|
+
readonly timedOut: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Internal pull failure that retains only whether a timeout occurred. */
|
|
33
|
+
export class DiagnosticPullError extends Error {
|
|
34
|
+
constructor(readonly timedOut: boolean) {
|
|
35
|
+
super("pull diagnostics incomplete");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Record one diagnostic operation without document identifiers or diagnostic text.
|
|
41
|
+
*
|
|
42
|
+
* The observer owns result classification so diagnostic control flow does not
|
|
43
|
+
* duplicate the event shape.
|
|
44
|
+
*/
|
|
45
|
+
export class DiagnosticObserver {
|
|
46
|
+
readonly #timer = startDebugTimer();
|
|
47
|
+
#pull: "failed" | "not-supported" | "timed-out";
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
readonly operation: DiagnosticTimingOperation,
|
|
51
|
+
readonly supportsPull: boolean,
|
|
52
|
+
) {
|
|
53
|
+
this.#pull = supportsPull ? "failed" : "not-supported";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
synchronized(): void {
|
|
57
|
+
this.#timer.mark("synchronize");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
skipped(documentCount: number): void {
|
|
61
|
+
this.#finish({
|
|
62
|
+
collection: "none",
|
|
63
|
+
documentCount,
|
|
64
|
+
fallback: false,
|
|
65
|
+
freshness: "not-observed",
|
|
66
|
+
outcome: "skipped",
|
|
67
|
+
pull: "not-used",
|
|
68
|
+
push: "not-used",
|
|
69
|
+
settle: "not-used",
|
|
70
|
+
timedOut: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pullCompleted(documentCount: number): void {
|
|
75
|
+
this.#finish(
|
|
76
|
+
{
|
|
77
|
+
collection: "pull",
|
|
78
|
+
documentCount,
|
|
79
|
+
fallback: false,
|
|
80
|
+
freshness: "observed",
|
|
81
|
+
outcome: "completed",
|
|
82
|
+
pull: "completed",
|
|
83
|
+
push: "not-used",
|
|
84
|
+
settle: "not-used",
|
|
85
|
+
timedOut: false,
|
|
86
|
+
},
|
|
87
|
+
"pull",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pullFailed(error: unknown): void {
|
|
92
|
+
this.#pull = isDiagnosticTimeout(error) ? "timed-out" : "failed";
|
|
93
|
+
this.#timer.mark("pull");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pullTimedOut(): void {
|
|
97
|
+
this.#pull = "timed-out";
|
|
98
|
+
this.#timer.mark("pull");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pushSettled(documentCount: number, settle: DiagnosticSettleResult): void {
|
|
102
|
+
const timedOut = settle.outcome === "timed-out";
|
|
103
|
+
this.#finish(
|
|
104
|
+
{
|
|
105
|
+
collection: this.supportsPull ? "fallback" : "push",
|
|
106
|
+
documentCount,
|
|
107
|
+
fallback: this.supportsPull,
|
|
108
|
+
freshness: settle.freshness,
|
|
109
|
+
outcome: timedOut ? "timed-out" : "completed",
|
|
110
|
+
pull: this.#pull,
|
|
111
|
+
push: timedOut ? "timed-out" : "settled",
|
|
112
|
+
settle: settle.outcome,
|
|
113
|
+
timedOut: timedOut || this.#pull === "timed-out",
|
|
114
|
+
},
|
|
115
|
+
"push-settle",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pushWaitCompleted(documentCount: number, push: DiagnosticPushWaitOutcome): void {
|
|
120
|
+
const timedOut = push === "timed-out";
|
|
121
|
+
this.#finish(
|
|
122
|
+
{
|
|
123
|
+
collection: this.supportsPull ? "fallback" : "push",
|
|
124
|
+
documentCount,
|
|
125
|
+
fallback: this.supportsPull,
|
|
126
|
+
freshness: push === "published" ? "observed" : "not-observed",
|
|
127
|
+
outcome: timedOut ? "timed-out" : "completed",
|
|
128
|
+
pull: this.#pull,
|
|
129
|
+
push,
|
|
130
|
+
settle: push,
|
|
131
|
+
timedOut: timedOut || this.#pull === "timed-out",
|
|
132
|
+
},
|
|
133
|
+
"push-settle",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#finish(data: DiagnosticTimingData, finalPhase?: "pull" | "push-settle" | "synchronize"): void {
|
|
138
|
+
this.#timer.finish(
|
|
139
|
+
() => ({
|
|
140
|
+
source: "lsp",
|
|
141
|
+
level: "debug",
|
|
142
|
+
category: "diagnostics.timing",
|
|
143
|
+
message: `LSP diagnostic ${this.operation} ${data.outcome}`,
|
|
144
|
+
data: { operation: this.operation, ...data },
|
|
145
|
+
}),
|
|
146
|
+
finalPhase,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Return whether a diagnostic failure represents a timeout without retaining its message. */
|
|
152
|
+
export function isDiagnosticTimeout(error: unknown): boolean {
|
|
153
|
+
if (error instanceof DiagnosticPullError) return error.timedOut;
|
|
154
|
+
return error instanceof Error && /\btimed? ?out\b|\btimeout\b/i.test(error.message);
|
|
155
|
+
}
|
|
@@ -10,10 +10,18 @@ import type {
|
|
|
10
10
|
VersionedTextDocumentIdentifier,
|
|
11
11
|
} from "../config/types.ts";
|
|
12
12
|
import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
|
|
13
|
+
import {
|
|
14
|
+
DiagnosticObserver,
|
|
15
|
+
DiagnosticPullError,
|
|
16
|
+
type DiagnosticPushWaitOutcome,
|
|
17
|
+
type DiagnosticSettleResult,
|
|
18
|
+
isDiagnosticTimeout,
|
|
19
|
+
} from "./client-diagnostic-timing.ts";
|
|
13
20
|
|
|
14
21
|
const DIAGNOSTIC_WAIT_MS = 3_000;
|
|
15
22
|
|
|
16
23
|
type OpenDocument = { version: number };
|
|
24
|
+
type DiagnosticWaiter = (outcome: "published" | "released") => void;
|
|
17
25
|
|
|
18
26
|
type DiagnosticCacheEntry = {
|
|
19
27
|
diagnostics: Diagnostic[];
|
|
@@ -48,7 +56,7 @@ interface ClientDiagnosticsHost {
|
|
|
48
56
|
export class ClientDiagnostics {
|
|
49
57
|
readonly #openDocs = new Map<string, OpenDocument>();
|
|
50
58
|
readonly #diagnosticStore = new Map<string, DiagnosticCacheEntry>();
|
|
51
|
-
readonly #diagnosticWaiters = new Map<string,
|
|
59
|
+
readonly #diagnosticWaiters = new Map<string, DiagnosticWaiter[]>();
|
|
52
60
|
|
|
53
61
|
constructor(private readonly host: ClientDiagnosticsHost) {}
|
|
54
62
|
|
|
@@ -160,53 +168,72 @@ export class ClientDiagnostics {
|
|
|
160
168
|
receivedAt: Date.now(),
|
|
161
169
|
version: params.version ?? undefined,
|
|
162
170
|
});
|
|
163
|
-
this.#releaseDiagnosticWaiters(params.uri);
|
|
171
|
+
this.#releaseDiagnosticWaiters(params.uri, "published");
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
/** Re-read open documents, then collect pull diagnostics or wait for push diagnostics. */
|
|
167
175
|
async refreshOpenDiagnostics(
|
|
168
176
|
options: { maxWaitMs?: number; quietMs?: number } = {},
|
|
169
177
|
): Promise<void> {
|
|
170
|
-
|
|
178
|
+
const supportsPull = this.host.supportsPullDiagnostics();
|
|
179
|
+
const observer = new DiagnosticObserver("refresh-open", supportsPull);
|
|
180
|
+
if (!this.host.isOperational()) {
|
|
181
|
+
observer.skipped(0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
171
184
|
|
|
172
185
|
const maxWaitMs = options.maxWaitMs ?? 3_000;
|
|
173
186
|
const quietMs = options.quietMs ?? 200;
|
|
174
187
|
const syncStart = Date.now();
|
|
175
|
-
|
|
176
188
|
this.#resyncOpenDocuments();
|
|
177
|
-
|
|
189
|
+
observer.synchronized();
|
|
190
|
+
const documentCount = this.#openDocs.size;
|
|
191
|
+
if (documentCount === 0) {
|
|
192
|
+
observer.skipped(0);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
178
195
|
|
|
179
|
-
if (
|
|
196
|
+
if (supportsPull) {
|
|
180
197
|
try {
|
|
181
198
|
await this.#pullDiagnosticsForOpenDocuments(syncStart, maxWaitMs);
|
|
199
|
+
observer.pullCompleted(documentCount);
|
|
182
200
|
return;
|
|
183
|
-
} catch {
|
|
184
|
-
|
|
201
|
+
} catch (error) {
|
|
202
|
+
observer.pullFailed(error);
|
|
185
203
|
}
|
|
186
204
|
}
|
|
187
205
|
|
|
188
|
-
await this.#waitForDiagnosticSettle(syncStart, maxWaitMs, quietMs);
|
|
206
|
+
const settle = await this.#waitForDiagnosticSettle(syncStart, maxWaitMs, quietMs);
|
|
207
|
+
observer.pushSettled(documentCount, settle);
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
/** Sync one file and return its diagnostics after pull or push collection. */
|
|
192
211
|
async syncAndWaitForDiagnostics(filePath: string, content: string): Promise<Diagnostic[]> {
|
|
212
|
+
const supportsPull = this.host.supportsPullDiagnostics();
|
|
213
|
+
const observer = new DiagnosticObserver("sync-file", supportsPull);
|
|
193
214
|
const uri = fileToUri(filePath);
|
|
194
215
|
const syncStart = Date.now();
|
|
195
216
|
this.didChange(filePath, content);
|
|
217
|
+
observer.synchronized();
|
|
196
218
|
|
|
197
|
-
if (
|
|
219
|
+
if (supportsPull) {
|
|
198
220
|
const remaining = DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
221
|
+
try {
|
|
222
|
+
if (remaining <= 0) observer.pullTimedOut();
|
|
223
|
+
else if (await this.#pullDiagnosticsForUri(uri, remaining)) {
|
|
224
|
+
observer.pullCompleted(1);
|
|
225
|
+
return this.getDiagnostics(filePath);
|
|
226
|
+
} else observer.pullFailed(undefined);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
observer.pullFailed(error);
|
|
206
229
|
}
|
|
207
230
|
}
|
|
208
231
|
|
|
209
|
-
await this.#waitForDiagnostics(
|
|
232
|
+
const push = await this.#waitForDiagnostics(
|
|
233
|
+
uri,
|
|
234
|
+
Math.max(0, DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart)),
|
|
235
|
+
);
|
|
236
|
+
observer.pushWaitCompleted(1, push);
|
|
210
237
|
return this.getDiagnostics(filePath);
|
|
211
238
|
}
|
|
212
239
|
|
|
@@ -240,9 +267,9 @@ export class ClientDiagnostics {
|
|
|
240
267
|
);
|
|
241
268
|
|
|
242
269
|
const anySuccess = results.some((result) => result.status === "fulfilled" && result.value);
|
|
243
|
-
const
|
|
244
|
-
if ((
|
|
245
|
-
throw new
|
|
270
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
271
|
+
if ((failures.length > 0 || !anySuccess) && uris.length > 0) {
|
|
272
|
+
throw new DiagnosticPullError(failures.some((result) => isDiagnosticTimeout(result.reason)));
|
|
246
273
|
}
|
|
247
274
|
}
|
|
248
275
|
|
|
@@ -280,16 +307,25 @@ export class ClientDiagnostics {
|
|
|
280
307
|
syncStart: number,
|
|
281
308
|
maxWaitMs: number,
|
|
282
309
|
quietMs: number,
|
|
283
|
-
): Promise<
|
|
310
|
+
): Promise<DiagnosticSettleResult> {
|
|
284
311
|
const deadline = syncStart + maxWaitMs;
|
|
285
312
|
while (Date.now() < deadline) {
|
|
286
|
-
const
|
|
287
|
-
const elapsed = Date.now() -
|
|
288
|
-
if (elapsed >= quietMs)
|
|
313
|
+
const observedAt = this.#lastDiagnosticReceivedAfter(syncStart);
|
|
314
|
+
const elapsed = Date.now() - (observedAt || syncStart);
|
|
315
|
+
if (elapsed >= quietMs) {
|
|
316
|
+
return {
|
|
317
|
+
outcome: "quiet",
|
|
318
|
+
freshness: observedAt > 0 ? "observed" : "not-observed",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
289
321
|
await new Promise((resolve) =>
|
|
290
322
|
setTimeout(resolve, Math.min(quietMs - elapsed, deadline - Date.now(), 50)),
|
|
291
323
|
);
|
|
292
324
|
}
|
|
325
|
+
return {
|
|
326
|
+
outcome: "timed-out",
|
|
327
|
+
freshness: this.#lastDiagnosticReceivedAfter(syncStart) > 0 ? "observed" : "not-observed",
|
|
328
|
+
};
|
|
293
329
|
}
|
|
294
330
|
|
|
295
331
|
#lastDiagnosticReceivedAfter(afterTime: number): number {
|
|
@@ -319,18 +355,18 @@ export class ClientDiagnostics {
|
|
|
319
355
|
this.#releaseDiagnosticWaiters(uri);
|
|
320
356
|
}
|
|
321
357
|
|
|
322
|
-
#waitForDiagnostics(uri: string, timeoutMs: number): Promise<
|
|
323
|
-
if (timeoutMs <= 0) return Promise.resolve();
|
|
358
|
+
#waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticPushWaitOutcome> {
|
|
359
|
+
if (timeoutMs <= 0) return Promise.resolve("timed-out");
|
|
324
360
|
|
|
325
|
-
return new Promise<
|
|
326
|
-
const waiter = () => {
|
|
361
|
+
return new Promise<DiagnosticPushWaitOutcome>((resolve) => {
|
|
362
|
+
const waiter: DiagnosticWaiter = (outcome) => {
|
|
327
363
|
clearTimeout(timer);
|
|
328
364
|
this.#removeDiagnosticWaiter(uri, waiter);
|
|
329
|
-
resolve();
|
|
365
|
+
resolve(outcome);
|
|
330
366
|
};
|
|
331
367
|
const timer = setTimeout(() => {
|
|
332
368
|
this.#removeDiagnosticWaiter(uri, waiter);
|
|
333
|
-
resolve();
|
|
369
|
+
resolve("timed-out");
|
|
334
370
|
}, timeoutMs);
|
|
335
371
|
const waiters = this.#diagnosticWaiters.get(uri) ?? [];
|
|
336
372
|
waiters.push(waiter);
|
|
@@ -338,7 +374,7 @@ export class ClientDiagnostics {
|
|
|
338
374
|
});
|
|
339
375
|
}
|
|
340
376
|
|
|
341
|
-
#removeDiagnosticWaiter(uri: string, waiter:
|
|
377
|
+
#removeDiagnosticWaiter(uri: string, waiter: DiagnosticWaiter): void {
|
|
342
378
|
const waiters = this.#diagnosticWaiters.get(uri);
|
|
343
379
|
if (!waiters) return;
|
|
344
380
|
const next = waiters.filter((entry) => entry !== waiter);
|
|
@@ -352,10 +388,10 @@ export class ClientDiagnostics {
|
|
|
352
388
|
}
|
|
353
389
|
}
|
|
354
390
|
|
|
355
|
-
#releaseDiagnosticWaiters(uri: string): void {
|
|
391
|
+
#releaseDiagnosticWaiters(uri: string, outcome: "published" | "released" = "released"): void {
|
|
356
392
|
const waiters = this.#diagnosticWaiters.get(uri);
|
|
357
393
|
if (!waiters) return;
|
|
358
394
|
this.#diagnosticWaiters.delete(uri);
|
|
359
|
-
for (const waiter of waiters) waiter();
|
|
395
|
+
for (const waiter of waiters) waiter(outcome);
|
|
360
396
|
}
|
|
361
397
|
}
|
package/src/client/transport.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and notification/request dispatching through vscode-jsonrpc's MessageConnection.
|
|
4
4
|
|
|
5
5
|
import type { Readable, Writable } from "node:stream";
|
|
6
|
+
import { startDebugTimer } from "@mrclrchtr/supi-core/debug";
|
|
6
7
|
import {
|
|
7
8
|
CancellationTokenSource,
|
|
8
9
|
createMessageConnection,
|
|
@@ -20,6 +21,9 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
20
21
|
export type NotificationHandler = (method: string, params: unknown) => void;
|
|
21
22
|
export type RequestHandler = (method: string, params: unknown) => Promise<unknown> | unknown;
|
|
22
23
|
|
|
24
|
+
type RequestMethodClass = "diagnostic" | "lifecycle" | "other" | "refactor" | "semantic";
|
|
25
|
+
type RequestOutcome = "cancelled" | "completed" | "failed" | "timed-out";
|
|
26
|
+
|
|
23
27
|
/** Re-export ResponseError so callers don't need a separate vscode-jsonrpc import. */
|
|
24
28
|
const JsonRpcRequestError = ResponseError;
|
|
25
29
|
|
|
@@ -83,14 +87,23 @@ export class JsonRpcClient {
|
|
|
83
87
|
params?: unknown,
|
|
84
88
|
options?: { timeoutMs?: number },
|
|
85
89
|
): Promise<unknown> {
|
|
90
|
+
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
|
91
|
+
const methodClass = classifyRequestMethod(method);
|
|
92
|
+
const timer = startDebugTimer();
|
|
86
93
|
if (this.closed || !this.connection) {
|
|
94
|
+
recordRequestTiming(timer, {
|
|
95
|
+
methodClass,
|
|
96
|
+
outcome: "cancelled",
|
|
97
|
+
timeoutMs,
|
|
98
|
+
timedOut: false,
|
|
99
|
+
cancelled: true,
|
|
100
|
+
});
|
|
87
101
|
return Promise.reject(new Error("JSON-RPC client is closed"));
|
|
88
102
|
}
|
|
89
103
|
|
|
90
|
-
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
|
91
104
|
const tokenSource = new CancellationTokenSource();
|
|
92
|
-
|
|
93
105
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
let timedOut = false;
|
|
94
107
|
const timeoutError = new Error(`Request ${method} timed out after ${timeoutMs}ms`);
|
|
95
108
|
|
|
96
109
|
const request = this.connection.sendRequest(method, params, tokenSource.token);
|
|
@@ -106,13 +119,43 @@ export class JsonRpcClient {
|
|
|
106
119
|
request,
|
|
107
120
|
new Promise<never>((_resolve, reject) => {
|
|
108
121
|
timeout = setTimeout(() => {
|
|
122
|
+
timedOut = true;
|
|
109
123
|
tokenSource.cancel();
|
|
110
124
|
reject(timeoutError);
|
|
111
125
|
}, timeoutMs);
|
|
112
126
|
}),
|
|
113
|
-
])
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
])
|
|
128
|
+
.then(
|
|
129
|
+
(result) => {
|
|
130
|
+
recordRequestTiming(timer, {
|
|
131
|
+
methodClass,
|
|
132
|
+
outcome: "completed",
|
|
133
|
+
timeoutMs,
|
|
134
|
+
timedOut: false,
|
|
135
|
+
cancelled: false,
|
|
136
|
+
});
|
|
137
|
+
return result;
|
|
138
|
+
},
|
|
139
|
+
(error: unknown) => {
|
|
140
|
+
const cancelled = timedOut || this.closed || isCancellationError(error);
|
|
141
|
+
const outcome: RequestOutcome = timedOut
|
|
142
|
+
? "timed-out"
|
|
143
|
+
: cancelled
|
|
144
|
+
? "cancelled"
|
|
145
|
+
: "failed";
|
|
146
|
+
recordRequestTiming(timer, {
|
|
147
|
+
methodClass,
|
|
148
|
+
outcome,
|
|
149
|
+
timeoutMs,
|
|
150
|
+
timedOut,
|
|
151
|
+
cancelled,
|
|
152
|
+
});
|
|
153
|
+
throw error;
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
.finally(() => {
|
|
157
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
158
|
+
});
|
|
116
159
|
|
|
117
160
|
// Prevent unhandled rejection when dispose() cancels requests
|
|
118
161
|
promise.catch(() => {});
|
|
@@ -142,3 +185,59 @@ export class JsonRpcClient {
|
|
|
142
185
|
}
|
|
143
186
|
}
|
|
144
187
|
}
|
|
188
|
+
|
|
189
|
+
const SEMANTIC_REQUESTS = new Set([
|
|
190
|
+
"textDocument/definition",
|
|
191
|
+
"textDocument/documentSymbol",
|
|
192
|
+
"textDocument/hover",
|
|
193
|
+
"textDocument/implementation",
|
|
194
|
+
"textDocument/references",
|
|
195
|
+
"workspace/symbol",
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
/** Classify requests into bounded groups without retaining the raw method. */
|
|
199
|
+
function classifyRequestMethod(method: string): RequestMethodClass {
|
|
200
|
+
if (method === "initialize" || method === "shutdown") return "lifecycle";
|
|
201
|
+
if (method === "textDocument/diagnostic" || method === "workspace/diagnostic") {
|
|
202
|
+
return "diagnostic";
|
|
203
|
+
}
|
|
204
|
+
if (method === "textDocument/codeAction" || method === "textDocument/rename") {
|
|
205
|
+
return "refactor";
|
|
206
|
+
}
|
|
207
|
+
return SEMANTIC_REQUESTS.has(method) ? "semantic" : "other";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isCancellationError(error: unknown): boolean {
|
|
211
|
+
if (!(error instanceof Error)) return false;
|
|
212
|
+
const code = (error as Error & { code?: unknown }).code;
|
|
213
|
+
return (
|
|
214
|
+
code === -32_800 ||
|
|
215
|
+
code === -32_802 ||
|
|
216
|
+
error.name === "CancellationError" ||
|
|
217
|
+
/\bcancell?ed\b/i.test(error.message)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface RequestTimingObservation {
|
|
222
|
+
readonly methodClass: RequestMethodClass;
|
|
223
|
+
readonly outcome: RequestOutcome;
|
|
224
|
+
readonly timeoutMs: number;
|
|
225
|
+
readonly timedOut: boolean;
|
|
226
|
+
readonly cancelled: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function recordRequestTiming(
|
|
230
|
+
timer: ReturnType<typeof startDebugTimer>,
|
|
231
|
+
observation: RequestTimingObservation,
|
|
232
|
+
): void {
|
|
233
|
+
timer.finish(
|
|
234
|
+
() => ({
|
|
235
|
+
source: "lsp",
|
|
236
|
+
level: "debug",
|
|
237
|
+
category: "request.timing",
|
|
238
|
+
message: `LSP ${observation.methodClass} request ${observation.outcome}`,
|
|
239
|
+
data: { ...observation },
|
|
240
|
+
}),
|
|
241
|
+
"request",
|
|
242
|
+
);
|
|
243
|
+
}
|