@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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.8.0",
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.8.0",
41
- "@mrclrchtr/supi-core": "4.8.0"
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, Array<() => void>>();
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
- if (!this.host.isOperational()) return;
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
- if (this.#openDocs.size === 0) return;
189
+ observer.synchronized();
190
+ const documentCount = this.#openDocs.size;
191
+ if (documentCount === 0) {
192
+ observer.skipped(0);
193
+ return;
194
+ }
178
195
 
179
- if (this.host.supportsPullDiagnostics()) {
196
+ if (supportsPull) {
180
197
  try {
181
198
  await this.#pullDiagnosticsForOpenDocuments(syncStart, maxWaitMs);
199
+ observer.pullCompleted(documentCount);
182
200
  return;
183
- } catch {
184
- // Pull diagnostics failed. Wait for push diagnostics instead.
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 (this.host.supportsPullDiagnostics()) {
219
+ if (supportsPull) {
198
220
  const remaining = DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart);
199
- if (remaining > 0) {
200
- try {
201
- const pulled = await this.#pullDiagnosticsForUri(uri, remaining);
202
- if (pulled) return this.getDiagnostics(filePath);
203
- } catch {
204
- // Pull diagnostics failed. Wait for push diagnostics instead.
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(uri, Math.max(0, DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart)));
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 hadFailure = results.some((result) => result.status === "rejected");
244
- if ((hadFailure || !anySuccess) && uris.length > 0) {
245
- throw new Error("pull diagnostics incomplete");
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<void> {
310
+ ): Promise<DiagnosticSettleResult> {
284
311
  const deadline = syncStart + maxWaitMs;
285
312
  while (Date.now() < deadline) {
286
- const lastReceived = this.#lastDiagnosticReceivedAfter(syncStart) || syncStart;
287
- const elapsed = Date.now() - lastReceived;
288
- if (elapsed >= quietMs) return;
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<void> {
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<void>((resolve) => {
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: () => void): void {
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
  }
@@ -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
- ]).finally(() => {
114
- if (timeout !== undefined) clearTimeout(timeout);
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
+ }