pi-lens 3.6.6 → 3.6.7

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.
@@ -83,6 +83,8 @@ export interface LSPClientInfo {
83
83
  serverId: string;
84
84
  root: string;
85
85
  connection: MessageConnection;
86
+ /** Check if the connection is still alive */
87
+ isAlive: () => boolean;
86
88
  notify: {
87
89
  open(filePath: string, content: string, languageId: string): Promise<void>;
88
90
  change(filePath: string, content: string): Promise<void>;
@@ -127,9 +129,13 @@ export interface LSPClientInfo {
127
129
  character: number,
128
130
  ): Promise<LSPCallHierarchyItem[]>;
129
131
  /** Find incoming calls (callers) */
130
- incomingCalls(item: LSPCallHierarchyItem): Promise<LSPCallHierarchyIncomingCall[]>;
132
+ incomingCalls(
133
+ item: LSPCallHierarchyItem,
134
+ ): Promise<LSPCallHierarchyIncomingCall[]>;
131
135
  /** Find outgoing calls (callees) */
132
- outgoingCalls(item: LSPCallHierarchyItem): Promise<LSPCallHierarchyOutgoingCall[]>;
136
+ outgoingCalls(
137
+ item: LSPCallHierarchyItem,
138
+ ): Promise<LSPCallHierarchyOutgoingCall[]>;
133
139
  shutdown(): Promise<void>;
134
140
  }
135
141
 
@@ -148,6 +154,46 @@ export async function createLSPClient(options: {
148
154
  }): Promise<LSPClientInfo> {
149
155
  const { serverId, process: lspProcess, root, initialization } = options;
150
156
 
157
+ // Attach persistent 'error' listeners to all three stdio streams.
158
+ //
159
+ // Why: when the LSP process exits, Node.js destroys its stdio streams and
160
+ // may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them.
161
+ // Without a listener that becomes an uncaught exception.
162
+ //
163
+ // vscode-jsonrpc attaches its own error listeners to stdin/stdout via
164
+ // WritableStreamWrapper / ReadableStreamWrapper, but those listeners are
165
+ // removed when connection.dispose() is called. Our listeners are permanent
166
+ // so they cover the window between dispose() and process.kill(), as well as
167
+ // any cases where the process dies before the connection is set up.
168
+ //
169
+ // stderr: nobody else ever attaches an error listener here.
170
+ // stdout: vscode-jsonrpc covers it during the connection lifetime, but not
171
+ // after dispose(). After dispose() the stream is about to be
172
+ // destroyed anyway, so we just swallow the error.
173
+ const streamErrorHandler =
174
+ (label: string) => (err: Error & { code?: string }) => {
175
+ if (
176
+ err.code === "ERR_STREAM_DESTROYED" ||
177
+ err.code === "EPIPE" ||
178
+ err.code === "ECONNRESET"
179
+ )
180
+ return;
181
+ console.error(`[lsp] ${serverId} ${label} stream error:`, err.message);
182
+ };
183
+
184
+ (lspProcess.stdin as NodeJS.WritableStream).on(
185
+ "error",
186
+ streamErrorHandler("stdin"),
187
+ );
188
+ (lspProcess.stdout as NodeJS.ReadableStream).on(
189
+ "error",
190
+ streamErrorHandler("stdout"),
191
+ );
192
+ (lspProcess.stderr as NodeJS.ReadableStream).on(
193
+ "error",
194
+ streamErrorHandler("stderr"),
195
+ );
196
+
151
197
  // Create JSON-RPC connection
152
198
  const connection = createMessageConnection(
153
199
  new StreamMessageReader(lspProcess.stdout),
@@ -205,9 +251,41 @@ export async function createLSPClient(options: {
205
251
  // Start listening
206
252
  connection.listen();
207
253
 
208
- // Send initialize request
209
- await withTimeout(
210
- connection.sendRequest("initialize", {
254
+ // Track connection state
255
+ let isConnected = true;
256
+ let lastError: Error | undefined;
257
+ let isDestroyed = false;
258
+
259
+ // Handle connection errors and close events
260
+ connection.onError((error) => {
261
+ lastError = error instanceof Error ? error : new Error(String(error));
262
+ isConnected = false;
263
+ isDestroyed = true;
264
+ console.error(`[lsp] ${serverId} connection error:`, lastError.message);
265
+ });
266
+
267
+ connection.onClose(() => {
268
+ isConnected = false;
269
+ isDestroyed = true;
270
+ });
271
+
272
+ // Also handle process exit to catch crashes immediately
273
+ lspProcess.process.on("exit", (code) => {
274
+ if (code !== 0 && code !== null) {
275
+ isConnected = false;
276
+ isDestroyed = true;
277
+ console.error(`[lsp] ${serverId} process exited with code ${code}`);
278
+ }
279
+ });
280
+
281
+ // Helper to check if process is still alive before operations
282
+ function isProcessAlive(): boolean {
283
+ return isConnected && !isDestroyed && !lspProcess.process.killed;
284
+ }
285
+
286
+ // Send initialize request with error handling
287
+ const initResult = await withTimeout(
288
+ safeSendRequest(connection, "initialize", {
211
289
  processId: process.pid,
212
290
  rootUri: pathToFileURL(root).href,
213
291
  workspaceFolders: [
@@ -242,12 +320,19 @@ export async function createLSPClient(options: {
242
320
  INITIALIZE_TIMEOUT_MS,
243
321
  );
244
322
 
323
+ if (initResult === undefined) {
324
+ throw new Error(
325
+ `[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` +
326
+ `The server binary may be missing or crashed immediately. Try reinstalling: npm install -g ${serverId}-language-server`,
327
+ );
328
+ }
329
+
245
330
  // Send initialized notification
246
- await connection.sendNotification("initialized", {});
331
+ await safeSendNotification(connection, "initialized", {});
247
332
 
248
333
  // Send configuration if provided (helps pyright and other servers)
249
334
  if (initialization) {
250
- await connection.sendNotification("workspace/didChangeConfiguration", {
335
+ await safeSendNotification(connection, "workspace/didChangeConfiguration", {
251
336
  settings: initialization,
252
337
  });
253
338
  }
@@ -259,9 +344,11 @@ export async function createLSPClient(options: {
259
344
  serverId,
260
345
  root,
261
346
  connection,
347
+ isAlive: () => isProcessAlive(),
262
348
 
263
349
  notify: {
264
350
  async open(filePath, content, languageId) {
351
+ if (!isProcessAlive()) return;
265
352
  const uri = pathToFileURL(filePath).href;
266
353
  // Normalize path for Windows case-insensitive lookup
267
354
  const normalizedPath = normalizeMapKey(filePath);
@@ -269,16 +356,22 @@ export async function createLSPClient(options: {
269
356
  diagnostics.delete(normalizedPath); // Clear stale diagnostics
270
357
 
271
358
  // Send workspace notification first (like opencode does)
272
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
273
- changes: [
274
- {
275
- uri,
276
- type: 1, // Created
277
- },
278
- ],
279
- });
359
+ await safeSendNotification(
360
+ connection,
361
+ "workspace/didChangeWatchedFiles",
362
+ {
363
+ changes: [
364
+ {
365
+ uri,
366
+ type: 1, // Created
367
+ },
368
+ ],
369
+ },
370
+ );
280
371
 
281
- await connection.sendNotification("textDocument/didOpen", {
372
+ if (!isProcessAlive()) return;
373
+
374
+ await safeSendNotification(connection, "textDocument/didOpen", {
282
375
  textDocument: {
283
376
  uri,
284
377
  languageId,
@@ -289,13 +382,14 @@ export async function createLSPClient(options: {
289
382
  },
290
383
 
291
384
  async change(filePath, content) {
385
+ if (!isProcessAlive()) return;
292
386
  const uri = pathToFileURL(filePath).href;
293
387
  // Normalize path for Windows case-insensitive lookup
294
388
  const normalizedPath = normalizeMapKey(filePath);
295
389
  const version = (documentVersions.get(normalizedPath) ?? 0) + 1;
296
390
  documentVersions.set(normalizedPath, version);
297
391
 
298
- await connection.sendNotification("textDocument/didChange", {
392
+ await safeSendNotification(connection, "textDocument/didChange", {
299
393
  textDocument: { uri, version },
300
394
  contentChanges: [{ text: content }],
301
395
  });
@@ -350,138 +444,130 @@ export async function createLSPClient(options: {
350
444
  },
351
445
 
352
446
  async definition(filePath, line, character) {
447
+ if (!isProcessAlive()) return [];
353
448
  const uri = pathToFileURL(filePath).href;
354
- try {
355
- const result = await connection.sendRequest("textDocument/definition", {
449
+ const result = await safeSendRequest<LSPLocation | LSPLocation[]>(
450
+ connection,
451
+ "textDocument/definition",
452
+ {
356
453
  textDocument: { uri },
357
454
  position: { line, character },
358
- });
359
- if (!result) return [];
360
- return Array.isArray(result) ? result : [result];
361
- } catch {
362
- return [];
363
- }
455
+ },
456
+ );
457
+ if (!result) return [];
458
+ return Array.isArray(result) ? result : [result];
364
459
  },
365
460
 
366
461
  async references(filePath, line, character, includeDeclaration = true) {
462
+ if (!isProcessAlive()) return [];
367
463
  const uri = pathToFileURL(filePath).href;
368
- try {
369
- const result = await connection.sendRequest("textDocument/references", {
464
+ const result = await safeSendRequest<LSPLocation[]>(
465
+ connection,
466
+ "textDocument/references",
467
+ {
370
468
  textDocument: { uri },
371
469
  position: { line, character },
372
470
  context: { includeDeclaration },
373
- });
374
- return Array.isArray(result) ? result : [];
375
- } catch {
376
- return [];
377
- }
471
+ },
472
+ );
473
+ return result ?? [];
378
474
  },
379
475
 
380
476
  async hover(filePath, line, character) {
477
+ if (!isProcessAlive()) return null;
381
478
  const uri = pathToFileURL(filePath).href;
382
- try {
383
- return (await connection.sendRequest("textDocument/hover", {
479
+ const result = await safeSendRequest<LSPHover>(
480
+ connection,
481
+ "textDocument/hover",
482
+ {
384
483
  textDocument: { uri },
385
484
  position: { line, character },
386
- })) as LSPHover | null;
387
- } catch {
388
- return null;
389
- }
485
+ },
486
+ );
487
+ return result ?? null;
390
488
  },
391
489
 
392
490
  async documentSymbol(filePath) {
491
+ if (!isProcessAlive()) return [];
393
492
  const uri = pathToFileURL(filePath).href;
394
- try {
395
- const result = await connection.sendRequest(
396
- "textDocument/documentSymbol",
397
- {
398
- textDocument: { uri },
399
- },
400
- );
401
- return Array.isArray(result) ? result : [];
402
- } catch {
403
- return [];
404
- }
493
+ const result = await safeSendRequest<LSPSymbol[]>(
494
+ connection,
495
+ "textDocument/documentSymbol",
496
+ {
497
+ textDocument: { uri },
498
+ },
499
+ );
500
+ return result ?? [];
405
501
  },
406
502
 
407
503
  async workspaceSymbol(query) {
408
- try {
409
- const result = await connection.sendRequest("workspace/symbol", {
504
+ if (!isProcessAlive()) return [];
505
+ const result = await safeSendRequest<LSPSymbol[]>(
506
+ connection,
507
+ "workspace/symbol",
508
+ {
410
509
  query,
411
- });
412
- return Array.isArray(result) ? result : [];
413
- } catch {
414
- return [];
415
- }
510
+ },
511
+ );
512
+ return result ?? [];
416
513
  },
417
514
 
418
515
  async implementation(filePath, line, character) {
516
+ if (!isProcessAlive()) return [];
419
517
  const uri = pathToFileURL(filePath).href;
420
- try {
421
- const result = await connection.sendRequest(
422
- "textDocument/implementation",
423
- {
424
- textDocument: { uri },
425
- position: { line, character },
426
- },
427
- );
428
- if (!result) return [];
429
- return Array.isArray(result) ? result : [result];
430
- } catch {
431
- return [];
432
- }
518
+ const result = await safeSendRequest<LSPLocation | LSPLocation[]>(
519
+ connection,
520
+ "textDocument/implementation",
521
+ {
522
+ textDocument: { uri },
523
+ position: { line, character },
524
+ },
525
+ );
526
+ if (!result) return [];
527
+ return Array.isArray(result) ? result : [result];
433
528
  },
434
529
 
435
530
  // --- Call Hierarchy Methods ---
436
531
 
437
532
  async prepareCallHierarchy(filePath, line, character) {
533
+ if (!isProcessAlive()) return [];
438
534
  const uri = pathToFileURL(filePath).href;
439
- try {
440
- const result = await connection.sendRequest(
441
- "textDocument/prepareCallHierarchy",
442
- {
443
- textDocument: { uri },
444
- position: { line, character },
445
- },
446
- );
447
- if (!result) return [];
448
- return Array.isArray(result) ? result : [result];
449
- } catch {
450
- return [];
451
- }
535
+ const result = await safeSendRequest<
536
+ LSPCallHierarchyItem | LSPCallHierarchyItem[]
537
+ >(connection, "textDocument/prepareCallHierarchy", {
538
+ textDocument: { uri },
539
+ position: { line, character },
540
+ });
541
+ if (!result) return [];
542
+ return Array.isArray(result) ? result : [result];
452
543
  },
453
544
 
454
545
  async incomingCalls(item) {
455
- try {
456
- const result = await connection.sendRequest(
457
- "callHierarchy/incomingCalls",
458
- {
459
- item,
460
- },
461
- );
462
- if (!result) return [];
463
- return Array.isArray(result) ? result : [];
464
- } catch {
465
- return [];
466
- }
546
+ if (!isProcessAlive()) return [];
547
+ const result = await safeSendRequest<LSPCallHierarchyIncomingCall[]>(
548
+ connection,
549
+ "callHierarchy/incomingCalls",
550
+ {
551
+ item,
552
+ },
553
+ );
554
+ return result ?? [];
467
555
  },
468
556
 
469
557
  async outgoingCalls(item) {
470
- try {
471
- const result = await connection.sendRequest(
472
- "callHierarchy/outgoingCalls",
473
- {
474
- item,
475
- },
476
- );
477
- if (!result) return [];
478
- return Array.isArray(result) ? result : [];
479
- } catch {
480
- return [];
481
- }
558
+ if (!isProcessAlive()) return [];
559
+ const result = await safeSendRequest<LSPCallHierarchyOutgoingCall[]>(
560
+ connection,
561
+ "callHierarchy/outgoingCalls",
562
+ {
563
+ item,
564
+ },
565
+ );
566
+ return result ?? [];
482
567
  },
483
568
 
484
569
  async shutdown() {
570
+ isConnected = false;
485
571
  // Clear pending timers
486
572
  for (const timer of pendingDiagnostics.values()) {
487
573
  clearTimeout(timer);
@@ -491,21 +577,74 @@ export async function createLSPClient(options: {
491
577
  // Remove all diagnostic listeners (cancels any in-flight waitForDiagnostics)
492
578
  diagnosticEmitter.removeAllListeners();
493
579
 
494
- // Graceful shutdown
580
+ // Graceful shutdown - ignore errors from destroyed streams
581
+ try {
582
+ await safeSendRequest(connection, "shutdown", {});
583
+ } catch {
584
+ /* ignore */
585
+ }
495
586
  try {
496
- await connection.sendRequest("shutdown");
497
- await connection.sendNotification("exit");
587
+ await safeSendNotification(connection, "exit", {});
498
588
  } catch {
499
589
  /* ignore */
500
590
  }
501
591
 
592
+ connection.end();
502
593
  connection.dispose();
503
594
  lspProcess.process.kill();
504
595
  },
505
596
  };
506
597
  }
507
598
 
508
- // --- Utilities ---
599
+ // Helper to safely send notifications - catches stream destruction
600
+ async function safeSendNotification(
601
+ connection: MessageConnection,
602
+ method: string,
603
+ params: unknown,
604
+ ): Promise<void> {
605
+ try {
606
+ await connection.sendNotification(method as never, params as never);
607
+ } catch (err) {
608
+ if (isStreamError(err)) {
609
+ // Silently ignore - stream was destroyed, connection error handlers will update state
610
+ return;
611
+ }
612
+ throw err;
613
+ }
614
+ }
615
+
616
+ // Helper to safely send requests - catches stream destruction
617
+ async function safeSendRequest<T>(
618
+ connection: MessageConnection,
619
+ method: string,
620
+ params: unknown,
621
+ ): Promise<T | undefined> {
622
+ try {
623
+ return (await connection.sendRequest(
624
+ method as never,
625
+ params as never,
626
+ )) as T;
627
+ } catch (err) {
628
+ if (isStreamError(err)) {
629
+ // Silently ignore - stream was destroyed
630
+ return undefined;
631
+ }
632
+ throw err;
633
+ }
634
+ }
635
+
636
+ // Helper to detect stream destruction errors
637
+ function isStreamError(err: unknown): boolean {
638
+ if (!(err instanceof Error)) return false;
639
+ const msg = err.message.toLowerCase();
640
+ return (
641
+ msg.includes("stream") ||
642
+ msg.includes("destroyed") ||
643
+ msg.includes("closed") ||
644
+ (err as { code?: string }).code === "ERR_STREAM_DESTROYED" ||
645
+ (err as { code?: string }).code === "EPIPE"
646
+ );
647
+ }
509
648
 
510
649
  // Using shared path utilities from path-utils.ts
511
650