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.
- package/CHANGELOG.md +41 -5
- package/clients/biome-client.ts +5 -4
- package/clients/dispatch/integration.ts +2 -0
- package/clients/lsp/client.ts +62 -27
- package/clients/lsp/index.ts +12 -1
- package/clients/lsp/launch.ts +107 -34
- package/clients/lsp/server.ts +76 -57
- package/clients/pipeline.ts +21 -5
- package/clients/read-guard-tool-lines.ts +15 -2
- package/clients/read-guard.ts +56 -36
- package/clients/runtime-session.ts +2 -0
- package/clients/runtime-tool-result.ts +24 -1
- package/clients/tool-policy.ts +1982 -1936
- package/commands/booboo.ts +33 -1
- package/index.ts +31 -9
- package/package.json +2 -2
- package/rules/rule-catalog.json +25 -1
- package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
- package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
- package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
- package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
- package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
- package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
- package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
- package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
- package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
- package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
- 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
|
package/clients/biome-client.ts
CHANGED
|
@@ -263,9 +263,10 @@ export class BiomeClient {
|
|
|
263
263
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
264
264
|
|
|
265
265
|
try {
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
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
|
-
"
|
|
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 };
|
package/clients/lsp/client.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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 {
|
package/clients/lsp/index.ts
CHANGED
|
@@ -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:
|
|
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
|
/**
|
package/clients/lsp/launch.ts
CHANGED
|
@@ -402,7 +402,19 @@ function _attachErrorHandler(
|
|
|
402
402
|
proc.on("error", (err) => {
|
|
403
403
|
if (logContext) {
|
|
404
404
|
logSessionStart(
|
|
405
|
-
"lsp process " +
|
|
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 " +
|
|
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 " +
|
|
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 =
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
}
|