pi-lens 3.8.39 → 3.8.40

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +41 -5
  2. package/clients/biome-client.ts +5 -4
  3. package/clients/dispatch/integration.ts +2 -0
  4. package/clients/lsp/client.ts +62 -27
  5. package/clients/lsp/index.ts +12 -1
  6. package/clients/lsp/launch.ts +107 -34
  7. package/clients/lsp/server.ts +76 -57
  8. package/clients/pipeline.ts +21 -5
  9. package/clients/read-guard-tool-lines.ts +15 -2
  10. package/clients/read-guard.ts +56 -36
  11. package/clients/runtime-session.ts +2 -0
  12. package/clients/runtime-tool-result.ts +24 -1
  13. package/clients/tool-policy.ts +1982 -1936
  14. package/commands/booboo.ts +33 -1
  15. package/index.ts +31 -9
  16. package/package.json +2 -2
  17. package/rules/rule-catalog.json +25 -1
  18. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  19. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  20. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  21. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  22. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  23. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  24. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  25. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  26. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  27. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  28. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  29. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  30. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  31. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  32. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  33. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  34. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  35. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  36. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  37. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  38. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.40] - 2026-05-04
8
+
9
+ ### Added
10
+
11
+ - **60+ SonarCloud BLOCKER tree-sitter rules** — comprehensive BLOCKER severity rules across 13 languages:
12
+ - **Java (11 rules)**: no-exit-methods, no-threads-in-constructors, switch-fall-through, no-wait-notify-on-thread, no-double-checked-locking, no-future-keywords, no-field-shadowing, junit-call-super, no-octal-values, short-circuit-logic, infinite-loop, infinite-recursion, name-capitalization-conflict, mockito-initialized, resources-closed, unnecessary-bit-ops-java
13
+ - **TypeScript (5 rules)**: infinite-loop, self-assignment, duplicate-function-arg, empty-switch-case, default-not-last, switch-case-termination
14
+ - **JavaScript (1 rule)**: switch-case-termination-js (replaces switch-fall-through-js)
15
+ - **PL/SQL (7 rules)**: forallsave-exceptions, not-null-initialization, end-loop-semicolon, raise-application-error-codes, no-synchronize, lock-table, nchar-nvarchar2-bytes, delete-update-where, fetch-bulk-collect-limit
16
+ - **Python (8 rules)**: send-file-mimetype, no-super-torchscript, return-in-init, yield-return-outside-function, notimplemented-boolean-context, exit-signature-check, return-in-generator, iter-return-iterator, in-operator-unsupported
17
+ - **C++ (5 rules)**: unnecessary-bit-ops, noexcept-functions, no-auto-ptr, no-memset-sensitive-data, no-scoped-lock-without-args, no-confused-move-forward
18
+ - **PHP (2 rules)**: this-in-static-context, no-exit-die
19
+ - **C (3 rules)**: case-range-multiple-values, goto-label-order, goto-into-block
20
+ - **C# (5 rules)**: is-with-this, no-operator-eq-reference, no-dangerous-get-handle, no-thread-resume-suspend, async-await-identifiers
21
+ - **Kotlin (1 rule)**: prepared-statement-indices
22
+ - **ABAP (1 rule)**: delete-where
23
+ - **COBOL (2 rules)**: alter-statement, lock-table-cobol
24
+ - **CSS (1 rule)**: calc-spacing
25
+ - **rule-catalog.json** updated with all 60+ new rule registrations
26
+
27
+ ### Fixed
28
+
29
+ - **Read-guard: false `file_modified` blocks after own edits** — `ReadGuard` was blocking the second edit to a file because the model's first write changed the file's mtime, making `FileTime.hasChanged()` return `true` on the next `checkEdit`. Added `recordWritten(filePath)` to `ReadGuard` and wired it into the `tool_result` handler (post-write, file already on disk), so the FileTime stamp stays in sync with the model's own writes. Eliminates the spurious `file_modified` blocks that appeared on every multi-edit file in a session.
30
+
31
+ - **LSP: parallel-turn root-resolution timeouts** — `NearestRoot` performed a fresh `fs.stat` directory walk on every call with no caching. When Claude Code edited multiple files simultaneously (e.g. a 4-file turn), all pipelines raced `NearestRoot` concurrently, saturating Windows filesystem I/O and triggering the 750ms `lsp_client_wait_timeout` on all but the first. `NearestRoot` now maintains per-instance result and in-flight caches keyed by resolved directory: successful roots are cached for the session lifetime; concurrent calls for the same directory share one walk promise. Only successful roots are cached so a `package.json` created mid-session is still detected on the next call.
32
+
33
+ - **Memory: `lastAnalyzedStateByFile` cleared each turn** — module-level Map in `runtime-tool-result.ts` accumulated dead entries across turns (entries from previous turns can never match the new `turnIndex`). Now cleared at `turn_start` alongside `runtime.beginTurn()`, keeping the map bounded to files touched in the current turn only. (refs #50)
34
+ - **Memory: `recentTouches` stale entry eviction** — `LSPService.recentTouches` grew unboundedly across a session with one entry per unique file path. Entries older than `TOUCH_DEBOUNCE_MS` are already ignored by `shouldSkipTouch`; a threshold-based sweep (triggered when size > 200) now removes them. (refs #50)
35
+ - **Memory: orphaned LSP child processes on Windows** — `clientShutdown` only called `process.kill()` which on Windows terminates the direct child but leaves grandchildren (e.g. `tsserver.js`) as orphaned OS processes each holding 300–600MB. Both the normal shutdown and crash paths now go through a shared `killProcessTree` helper: on Windows it runs `taskkill /F /T` via absolute `SystemRoot` path and awaits completion before returning; on other platforms it sends `SIGTERM`. The SIGKILL fallback timer is also skipped on Windows since `taskkill /F` already force-terminates. (refs #50)
36
+ - **Memory: file-time session state not cleared on session reset** — `clearAllSessions()` from `file-time.ts` is now called during `handleSessionStart`, clearing stale file timestamp state that previously accumulated across session switches. (refs #50)
37
+ - **Memory: pending ast-grep warn timers not cancelled on session reset** — `resetDispatchBaselines()` left active `astGrepWarnDebounceTimers` running into a cleared session context. Now explicitly cancelled and cleared on reset. (refs #50)
38
+ - **Security: `taskkill` spawned via absolute path** — both the normal shutdown and crash paths now resolve `taskkill.exe` through `process.env.SystemRoot` instead of relying on PATH, eliminating the SonarCloud PATH-injection hotspot.
39
+ - **LSP: shutdown cannot hang indefinitely** — `client.shutdown()` now bounds the graceful `shutdown` request and proceeds to `exit`/process-tree kill if a server stops responding.
40
+ - **LSP: test cleanup stop helper hardened on Windows** — `stopLSP()` now uses the absolute `taskkill.exe` path, handles already-exited processes, and avoids orphaning grandchildren by killing the process tree before the direct child on Windows.
41
+
42
+ - **booboo project root detection** — `resolveProjectRoot` now walks up to the nearest ancestor with a root marker (`package.json`, `tsconfig.json`, `.git`, etc.), then falls back to walking down one level if exactly one immediate subdirectory has a root marker. Fixes scans running against the wrong directory in nested-project layouts (e.g. `pi-models/pi-models/`).
43
+
44
+ - **Switch-case false positives eliminated** — replaced naive `switch-fall-through` rules with `switch-case-termination` rules that properly recognize `return`, `throw`, and `continue` as valid case terminators. Reduced false positive hits from 174 to 0.
45
+ - **Self-assignment false positives fixed** — changed from `post_filter: same_identifier` to inline `#eq?` predicate so `wave = nextWave` is no longer flagged as self-assignment
46
+
7
47
  ## [3.8.39] - 2026-05-02
8
48
 
9
49
  ### Fixed
@@ -12,6 +52,7 @@ All notable changes to pi-lens will be documented in this file.
12
52
  - **jscpd no longer runs on YAML/JSON/Markdown files** — `getFilesForJscpd` now filters to source code extensions only, preventing multi-second delays at `turn_end` when editing rule YAMLs or config files.
13
53
  - **ReDoS S5852 final (gleam/zig parsers)** — rewrote `gleamRe` and `zigRe` as line-by-line parsers, eliminating the multiline flag that SonarCloud continued to flag despite `[ \t]*` substitution.
14
54
  - **SonarCloud MAJOR code smells (batch 1 & 2)** — `readonly` members, `void` operator removals, nested ternaries, nested template literals, optional chains, duplicate branches, and redundant type alias across 15+ files.
55
+ - **Type-narrow `severityMap` for `Diagnostic.severity` union** — properly satisfies the union type for diagnostic severity mapping.
15
56
  - **9 tree-sitter query bugs in new rule files** — predicate outside outermost parens (`cpp/no-auto-ptr`); false-positive `post_filter` gate added (`cpp/no-confused-move-forward`); leaf-node child match removed (`php/this-in-static-context`); invalid node name `class_hereditary` replaced (`java/no-field-shadowing`); field order corrected (`java/no-wait-notify-on-thread`); duplicate `modifiers` blocks merged (`java/spring-session-attributes-setcomplete`); invalid anonymous-node field label removed (`csharp/is-with-this`); inline alternation replaced with two patterns (`python/in-operator-unsupported`); adjacent sibling requirement removed, delegated to `post_filter` (`python/return-in-generator`).
16
57
 
17
58
  ## [3.8.38] - 2026-05-02
@@ -1133,7 +1174,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1133
1174
  - **Rust performance core (`pi-lens-core`)** — Optional Rust binary for CPU-intensive operations.
1134
1175
  All features fall back to TypeScript automatically if the binary is not available (it is **not**
1135
1176
  built automatically on `npm install` — run `npm run rust:build` once if you have Rust installed).
1136
-
1137
1177
  - **File scanning** — ripgrep’s `ignore` crate for `.gitignore`-aware project scanning
1138
1178
  - **Similarity detection** — parallel 57×72 state-matrix index, persisted to
1139
1179
  `.pi-lens/rust-index.json` between invocations (fixes in-memory cache that reset on every
@@ -1187,7 +1227,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1187
1227
  - Removed `clients/interviewer-templates.ts` (240 lines)
1188
1228
  - Removed initialization from `index.ts`
1189
1229
  - **Deleted deprecated commands** — All were superseded by `/lens-booboo`:
1190
-
1191
1230
  - `/lens-booboo-fix` command (fix-from-booboo.ts, 430 lines) — showed warning to use `/lens-booboo`
1192
1231
  - `/lens-fix-simplified` command (fix-simplified.ts, 770 lines) — never registered, unused
1193
1232
  - `/lens-rate` command (rate.ts, 340 lines) — showed warning to use `/lens-booboo`
@@ -1206,7 +1245,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1206
1245
  - Broken runner tests (7 files) — thin CLI wrappers with wrong imports
1207
1246
  - Trivial utility tests (5 files) — file extension parsing, string sanitization
1208
1247
  - **Added meaningful integration tests**:
1209
-
1210
1248
  - `tests/clients/dispatch/dispatcher-flow.test.ts` — Runner registration, execution, delta mode, conditional runners
1211
1249
  - `tests/extension-hooks.test.ts` — pi API: tool/command/flag registration, event handlers
1212
1250
  - `tests/mocks/runner-factory.ts` — Mock runners for testing without real CLI tools
@@ -1542,7 +1580,6 @@ Migrated 20 critical security rules to NAPI (fast native execution):
1542
1580
  Three new lint runners with full test coverage:
1543
1581
 
1544
1582
  - **Spellcheck runner** (`clients/dispatch/runners/spellcheck.ts`): Markdown spellchecking
1545
-
1546
1583
  - Uses `typos-cli` (Rust-based, fast, low false positives)
1547
1584
  - Checks `.md` and `.mdx` files
1548
1585
  - Priority 30, runs after code quality checks
@@ -1550,7 +1587,6 @@ Three new lint runners with full test coverage:
1550
1587
  - Install: `cargo install typos-cli`
1551
1588
 
1552
1589
  - **Oxlint runner** (`clients/dispatch/runners/oxlint.ts`): Fast JS/TS linting
1553
-
1554
1590
  - Uses `oxlint` from Oxc project (Rust-based, ~100x faster than ESLint)
1555
1591
  - Zero-config by default
1556
1592
  - JSON output with fix suggestions
@@ -263,9 +263,10 @@ export class BiomeClient {
263
263
  const content = fs.readFileSync(absolutePath, "utf-8");
264
264
 
265
265
  try {
266
- // Single invocation: check --write applies safe formatting + lint fixes.
267
- // No pre-flight checkFile() needed content diff tells us if anything changed.
268
- const result = this.spawnBiome(["check", "--write", absolutePath]);
266
+ // lint --write applies safe lint fixes only — no formatting.
267
+ // Formatting is deferred to agent_end to avoid mid-turn file modifications
268
+ // that trigger read-guard "file modified since read" blocks.
269
+ const result = this.spawnBiome(["lint", "--write", absolutePath]);
269
270
 
270
271
  if (result.error) {
271
272
  return {
@@ -325,7 +326,7 @@ export class BiomeClient {
325
326
  try {
326
327
  const before = await fs.promises.readFile(absolutePath, "utf-8");
327
328
  const result = await this.spawnBiomeAsync([
328
- "check",
329
+ "lint",
329
330
  "--write",
330
331
  absolutePath,
331
332
  ]);
@@ -358,6 +358,8 @@ export function resetDispatchBaselines(): void {
358
358
  primaryFilesThisTurn.clear();
359
359
  cascadeDiagnosticBaselines.clear();
360
360
  cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
361
+ for (const timer of astGrepWarnDebounceTimers.values()) clearTimeout(timer);
362
+ astGrepWarnDebounceTimers.clear();
361
363
  }
362
364
 
363
365
  let cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
@@ -8,9 +8,9 @@
8
8
  * - Request/response handling
9
9
  */
10
10
 
11
- import { existsSync } from "node:fs";
12
11
  import { spawn as nodeSpawn } from "node:child_process";
13
12
  import { EventEmitter } from "node:events";
13
+ import { existsSync } from "node:fs";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import type { MessageConnection } from "vscode-jsonrpc";
16
16
  import {
@@ -252,6 +252,10 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv(
252
252
  "PI_LENS_LSP_PULL_RETRY_INTERVAL_MS",
253
253
  250,
254
254
  );
255
+ const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv(
256
+ "PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS",
257
+ 1000,
258
+ );
255
259
 
256
260
  const LSP_CRASH_CODES = new Set([
257
261
  "ERR_STREAM_DESTROYED",
@@ -340,6 +344,33 @@ function disposeClientConnection(state: LSPClientState): void {
340
344
  }
341
345
  }
342
346
 
347
+ async function killProcessTree(
348
+ proc: { kill(signal?: NodeJS.Signals | number): boolean },
349
+ pid: number,
350
+ ): Promise<void> {
351
+ if (process.platform === "win32" && pid > 0) {
352
+ await new Promise<void>((resolve) => {
353
+ try {
354
+ // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
355
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
356
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
357
+ shell: false,
358
+ windowsHide: true,
359
+ });
360
+ killer.once("close", () => resolve());
361
+ killer.once("error", () => resolve());
362
+ } catch {
363
+ resolve();
364
+ }
365
+ });
366
+ return;
367
+ }
368
+
369
+ try {
370
+ proc.kill("SIGTERM");
371
+ } catch {}
372
+ }
373
+
343
374
  function mergeDiagnosticLists(
344
375
  push: LSPDiagnostic[] | undefined,
345
376
  pull: LSPDiagnostic[] | undefined,
@@ -483,9 +514,7 @@ function setupIncomingHandlers(
483
514
  );
484
515
  state.connection.onRequest(
485
516
  "client/unregisterCapability",
486
- async (params: {
487
- unregisterations?: Array<{ id: string }>;
488
- }) => {
517
+ async (params: { unregisterations?: Array<{ id: string }> }) => {
489
518
  for (const unreg of params?.unregisterations ?? []) {
490
519
  if (unreg.id) {
491
520
  state.dynamicRegistrations.delete(unreg.id);
@@ -715,9 +744,12 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
715
744
  state.openDocuments.clear();
716
745
  state.diagnosticEmitter.removeAllListeners();
717
746
  try {
718
- await safeSendRequest(state.connection, "shutdown", {});
747
+ await withTimeout(
748
+ safeSendRequest(state.connection, "shutdown", {}),
749
+ SHUTDOWN_REQUEST_TIMEOUT_MS,
750
+ );
719
751
  } catch {
720
- /* ignore */
752
+ /* ignore — proceed to exit/kill so shutdown cannot hang the session */
721
753
  }
722
754
  try {
723
755
  await safeSendNotification(state.connection, "exit", {});
@@ -725,7 +757,10 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
725
757
  /* ignore */
726
758
  }
727
759
  disposeClientConnection(state);
728
- state.lspProcess.process.kill();
760
+ const pid = state.lspProcess.pid;
761
+ // On Windows, killing the direct child first can orphan grandchildren before
762
+ // taskkill can traverse the tree. Kill the full tree first and wait briefly.
763
+ await killProcessTree(state.lspProcess.process, pid);
729
764
  }
730
765
 
731
766
  async function navRequest<T>(
@@ -902,17 +937,11 @@ export async function createLSPClient(options: {
902
937
  // Hard-kill the hung process so it doesn't become a zombie.
903
938
  // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
904
939
  const pid = lspProcess.pid;
905
- lspProcess.process.kill("SIGTERM");
906
- if (process.platform === "win32" && pid > 0) {
907
- try {
908
- nodeSpawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
909
- shell: false,
910
- windowsHide: true,
911
- });
912
- } catch {}
913
- }
940
+ void killProcessTree(lspProcess.process, pid);
914
941
  setTimeout(() => {
915
- if (!lspProcess.process.killed) lspProcess.process.kill("SIGKILL");
942
+ if (!lspProcess.process.killed && process.platform !== "win32") {
943
+ lspProcess.process.kill("SIGKILL");
944
+ }
916
945
  }, 2000);
917
946
  throw err;
918
947
  } finally {
@@ -940,7 +969,8 @@ export async function createLSPClient(options: {
940
969
  );
941
970
  }
942
971
 
943
- state.workspaceDiagnosticsSupport = detectWorkspaceDiagnosticsSupport(initResult);
972
+ state.workspaceDiagnosticsSupport =
973
+ detectWorkspaceDiagnosticsSupport(initResult);
944
974
  state.operationSupport = detectOperationSupport(initResult);
945
975
  state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
946
976
 
@@ -1247,19 +1277,24 @@ async function withTimeout<T>(
1247
1277
  promise: Promise<T>,
1248
1278
  timeoutMs: number,
1249
1279
  ): Promise<T> {
1280
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1250
1281
  // Suppress unhandled rejection if `promise` rejects AFTER the timeout
1251
1282
  // wins the race — Promise.race settles on the first result but the
1252
1283
  // losing promises still run, and any later rejection would be uncaught.
1253
1284
  promise.catch(() => {});
1254
- return Promise.race([
1255
- promise,
1256
- new Promise<T>((_, reject) =>
1257
- setTimeout(
1258
- () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1259
- timeoutMs,
1260
- ),
1261
- ),
1262
- ]);
1285
+ try {
1286
+ return await Promise.race([
1287
+ promise,
1288
+ new Promise<T>((_, reject) => {
1289
+ timeout = setTimeout(
1290
+ () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1291
+ timeoutMs,
1292
+ );
1293
+ }),
1294
+ ]);
1295
+ } finally {
1296
+ if (timeout) clearTimeout(timeout);
1297
+ }
1263
1298
  }
1264
1299
 
1265
1300
  function positiveIntFromEnv(name: string, fallback: number): number {
@@ -197,11 +197,22 @@ export class LSPService {
197
197
  clientScope: "primary" | "all",
198
198
  ): void {
199
199
  const key = `${normalizeMapKey(filePath)}:${clientScope}`;
200
+ const now = Date.now();
200
201
  this.recentTouches.set(key, {
201
202
  fingerprint: this.fingerprintContent(content),
202
- touchedAt: Date.now(),
203
+ touchedAt: now,
203
204
  clientScope,
204
205
  });
206
+ // Trim entries that are already past the debounce window — shouldSkipTouch
207
+ // ignores them anyway, so they serve no purpose. Only sweep when the map
208
+ // exceeds the threshold to avoid iterating on every call.
209
+ if (this.recentTouches.size > 200) {
210
+ for (const [k, v] of this.recentTouches) {
211
+ if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
212
+ this.recentTouches.delete(k);
213
+ }
214
+ }
215
+ }
205
216
  }
206
217
 
207
218
  /**
@@ -402,7 +402,19 @@ function _attachErrorHandler(
402
402
  proc.on("error", (err) => {
403
403
  if (logContext) {
404
404
  logSessionStart(
405
- "lsp process " + context + ": spawn-error command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + " error=" + err.message + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
405
+ "lsp process " +
406
+ context +
407
+ ": spawn-error command=" +
408
+ logContext.command +
409
+ " args=" +
410
+ JSON.stringify(logContext.args) +
411
+ " cwd=" +
412
+ logContext.cwd +
413
+ " pid=" +
414
+ (logContext.pid ?? 0) +
415
+ " error=" +
416
+ err.message +
417
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
406
418
  );
407
419
  }
408
420
 
@@ -422,12 +434,37 @@ function _attachErrorHandler(
422
434
  if (code !== 0 && code !== null) {
423
435
  if (logContext) {
424
436
  logSessionStart(
425
- "lsp process " + context + ": closed code=" + code + (signal ? " signal=" + signal : "") + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
437
+ "lsp process " +
438
+ context +
439
+ ": closed code=" +
440
+ code +
441
+ (signal ? " signal=" + signal : "") +
442
+ " command=" +
443
+ logContext.command +
444
+ " args=" +
445
+ JSON.stringify(logContext.args) +
446
+ " cwd=" +
447
+ logContext.cwd +
448
+ " pid=" +
449
+ (logContext.pid ?? 0) +
450
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
426
451
  );
427
452
  }
428
453
  } else if (signal && logContext) {
429
454
  logSessionStart(
430
- "lsp process " + context + ": closed signal=" + signal + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
455
+ "lsp process " +
456
+ context +
457
+ ": closed signal=" +
458
+ signal +
459
+ " command=" +
460
+ logContext.command +
461
+ " args=" +
462
+ JSON.stringify(logContext.args) +
463
+ " cwd=" +
464
+ logContext.cwd +
465
+ " pid=" +
466
+ (logContext.pid ?? 0) +
467
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
431
468
  );
432
469
  }
433
470
  });
@@ -451,7 +488,7 @@ export async function launchLSP(
451
488
  command: string,
452
489
  args: string[] = [],
453
490
  options: SpawnOptions & {
454
- startupFailureWindowMs?: number,
491
+ startupFailureWindowMs?: number;
455
492
  } = {},
456
493
  ): Promise<LSPProcess> {
457
494
  const cwd = String(options.cwd ?? process.cwd());
@@ -467,7 +504,9 @@ export async function launchLSP(
467
504
  // - If already absolute, use as-is
468
505
  // - If it's a simple command (no path separators), let system find it via PATH
469
506
  // - Otherwise, resolve relative to cwd
470
- const isRelativePath = !path.isAbsolute(command) && (command.includes(path.sep) || command.includes("/"));
507
+ const isRelativePath =
508
+ !path.isAbsolute(command) &&
509
+ (command.includes(path.sep) || command.includes("/"));
471
510
  const explicitCommand = isRelativePath ? path.resolve(cwd, command) : command;
472
511
  const resolvedCommand =
473
512
  !path.isAbsolute(command) &&
@@ -645,7 +684,7 @@ export async function launchLSP(
645
684
  return DEFAULT_STARTUP_FAILURE_WINDOW_MS;
646
685
  }
647
686
  })();
648
-
687
+
649
688
  // Give shell-backed Windows launches a slightly longer window because
650
689
  // npm/cmd shims can fail asynchronously after the initial spawn succeeds.
651
690
  setTimeout(() => {
@@ -803,38 +842,72 @@ export async function launchViaPython(
803
842
  * Stop an LSP process gracefully
804
843
  */
805
844
  export async function stopLSP(handle: LSPProcess): Promise<void> {
845
+ if (handle.process.exitCode !== null || handle.process.signalCode !== null) {
846
+ return;
847
+ }
848
+
806
849
  return new Promise((resolve) => {
807
- // Send SIGTERM first
808
- handle.process.kill("SIGTERM");
809
-
810
- // Force kill after timeout
811
- const timeout = setTimeout(() => {
812
- if (!handle.process.killed) {
813
- if (isWindows && handle.pid > 0) {
814
- // SIGKILL is unreliable for cmd.exe/PowerShell child trees on Windows.
815
- // taskkill /F /T kills the process and all its children.
816
- try {
817
- nodeSpawn("taskkill", ["/F", "/T", "/PID", String(handle.pid)], {
818
- shell: false,
819
- windowsHide: true,
820
- });
821
- } catch {
822
- handle.process.kill("SIGKILL");
823
- }
824
- } else {
850
+ let settled = false;
851
+ let forceTimeout: ReturnType<typeof setTimeout> | undefined;
852
+ let giveUpTimeout: ReturnType<typeof setTimeout> | undefined;
853
+
854
+ const done = () => {
855
+ if (settled) return;
856
+ settled = true;
857
+ if (forceTimeout) clearTimeout(forceTimeout);
858
+ if (giveUpTimeout) clearTimeout(giveUpTimeout);
859
+ handle.process.off("exit", done);
860
+ handle.process.off("error", done);
861
+ resolve();
862
+ };
863
+
864
+ handle.process.once("exit", done);
865
+ handle.process.once("error", done);
866
+
867
+ const killWindowsTree = (): boolean => {
868
+ if (!isWindows || handle.pid <= 0) return false;
869
+ try {
870
+ // Absolute path avoids PATH-resolution substitution on Windows.
871
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
872
+ const killer = nodeSpawn(
873
+ taskkill,
874
+ ["/F", "/T", "/PID", String(handle.pid)],
875
+ {
876
+ shell: false,
877
+ windowsHide: true,
878
+ },
879
+ );
880
+ killer.once("error", done);
881
+ return true;
882
+ } catch {
883
+ return false;
884
+ }
885
+ };
886
+
887
+ try {
888
+ // On Windows, kill the tree first; killing the direct child can orphan
889
+ // grandchildren (e.g. tsserver.js behind a cmd/npm shim).
890
+ if (!killWindowsTree()) {
891
+ handle.process.kill("SIGTERM");
892
+ }
893
+ } catch {
894
+ done();
895
+ return;
896
+ }
897
+
898
+ forceTimeout = setTimeout(() => {
899
+ if (settled) return;
900
+ try {
901
+ if (!killWindowsTree()) {
825
902
  handle.process.kill("SIGKILL");
826
903
  }
904
+ } catch {
905
+ done();
906
+ return;
827
907
  }
908
+ // If the process had already exited before listeners were attached, no
909
+ // exit event will arrive. Resolve rather than hanging test cleanup forever.
910
+ giveUpTimeout = setTimeout(done, 500);
828
911
  }, 5000);
829
-
830
- handle.process.on("exit", () => {
831
- clearTimeout(timeout);
832
- resolve();
833
- });
834
-
835
- handle.process.on("error", () => {
836
- clearTimeout(timeout);
837
- resolve();
838
- });
839
912
  });
840
913
  }