pi-lens 3.8.66 → 3.8.68
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 +44 -0
- package/README.md +2 -1
- package/dist/clients/actionable-warnings-logger.js +11 -36
- package/dist/clients/ast-grep-tool-logger.js +11 -36
- package/dist/clients/cascade-logger.js +7 -12
- package/dist/clients/dead-code-logger.js +11 -22
- package/dist/clients/deps/ast-grep-napi.js +20 -1
- package/dist/clients/deps/pi-tui.js +4 -1
- package/dist/clients/deps/typebox.js +5 -2
- package/dist/clients/deps/web-tree-sitter.js +22 -1
- package/dist/clients/diagnostic-logger.js +7 -31
- package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
- package/dist/clients/dispatch/dispatcher.js +1 -36
- package/dist/clients/dispatch/inline-suppressions.js +61 -0
- package/dist/clients/dispatch/integration.js +75 -23
- package/dist/clients/dispatch/runners/lsp.js +17 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
- package/dist/clients/instance-reaper.js +418 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/latency-logger.js +10 -15
- package/dist/clients/lsp/client.js +76 -2
- package/dist/clients/lsp/config.js +61 -3
- package/dist/clients/lsp/index.js +43 -3
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +61 -0
- package/dist/clients/lsp/server.js +8 -1
- package/dist/clients/ndjson-logger.js +150 -0
- package/dist/clients/pipeline.js +25 -41
- package/dist/clients/project-diagnostics/scanner.js +8 -2
- package/dist/clients/read-guard-logger.js +11 -36
- package/dist/clients/review-graph/builder.js +336 -17
- package/dist/clients/review-graph/git-identity.js +150 -0
- package/dist/clients/review-graph/service.js +3 -3
- package/dist/clients/runtime-coordinator.js +83 -0
- package/dist/clients/runtime-session.js +89 -8
- package/dist/clients/runtime-tool-result.js +10 -2
- package/dist/clients/runtime-turn.js +30 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +31 -1
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +18 -5
- package/dist/clients/subagent-mode.js +53 -0
- package/dist/clients/tree-sitter-logger.js +7 -12
- package/dist/clients/tree-sitter-query-loader.js +1 -0
- package/dist/index.js +60587 -1636
- package/dist/tools/lens-diagnostics.js +203 -12
- package/package.json +3 -2
- package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
- package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
- package/rules/tree-sitter-queries/python/python-assert-production.yml +4 -0
|
@@ -292,6 +292,7 @@ export function resetDispatchBaselines(cwd) {
|
|
|
292
292
|
resetSessionSlopScore();
|
|
293
293
|
clearCoverageNoticeState();
|
|
294
294
|
clearReviewGraphWorkspaceCache();
|
|
295
|
+
clearReverseDepsIndexCache();
|
|
295
296
|
clearModuleGraphCache();
|
|
296
297
|
neighborTouchCache.clear();
|
|
297
298
|
recentlyCleanNeighborCache.clear();
|
|
@@ -337,6 +338,15 @@ function ensureCascadeTurnScope(turnSeq) {
|
|
|
337
338
|
const CASCADE_TTL_MS = 240_000;
|
|
338
339
|
const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
|
|
339
340
|
const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
|
|
341
|
+
const reverseDepsIndexCache = new Map();
|
|
342
|
+
function reverseDepsReuseEnabled() {
|
|
343
|
+
const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
|
|
344
|
+
return raw !== "0" && raw !== "false";
|
|
345
|
+
}
|
|
346
|
+
/** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
|
|
347
|
+
export function clearReverseDepsIndexCache() {
|
|
348
|
+
reverseDepsIndexCache.clear();
|
|
349
|
+
}
|
|
340
350
|
// Bounded transitive cascade (#162): expand neighbour derivation beyond the
|
|
341
351
|
// one-hop importers/callers to depth-2 dependents, so an edit's blast radius
|
|
342
352
|
// reaches indirect dependents — capped so the per-edit cost stays bounded. The
|
|
@@ -383,7 +393,7 @@ function isIgnoredCascadeNeighbor(filePath, cwd) {
|
|
|
383
393
|
}
|
|
384
394
|
}
|
|
385
395
|
export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
386
|
-
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq } = options;
|
|
396
|
+
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState } = options;
|
|
387
397
|
ensureCascadeTurnScope(turnSeq);
|
|
388
398
|
if (hasBlockers) {
|
|
389
399
|
logCascade({
|
|
@@ -429,32 +439,71 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
429
439
|
let referenceCount = 0;
|
|
430
440
|
if (CASCADE_GRAPH_KINDS.has(fileKind)) {
|
|
431
441
|
const graphStart = Date.now();
|
|
432
|
-
const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
|
|
442
|
+
const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
|
|
433
443
|
const graphMs = Date.now() - graphStart;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
444
|
+
// #459: the reuse decision keys on graph.buildGeneration — a stamp that
|
|
445
|
+
// travels WITH the graph instance this cascade holds. Deliberately NOT the
|
|
446
|
+
// global last-build-info slot: post-#450 cascades overlap, and another
|
|
447
|
+
// cascade's cache-hit build can overwrite that slot (graphChanged:false)
|
|
448
|
+
// between this build mutating the graph and this read — which would turn a
|
|
449
|
+
// changed graph into a spurious reuse of a stale index that steady-state
|
|
450
|
+
// cache hits then never heal. Generation equality can't be clobbered into
|
|
451
|
+
// a false positive: a graph-mutating build always mints a new generation.
|
|
452
|
+
// An unstamped graph (mode "skipped") always rebuilds.
|
|
453
|
+
const graphBuildInfo = getLastGraphBuildInfo();
|
|
454
|
+
const workspaceKey = normalizeMapKey(cwd);
|
|
455
|
+
const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
|
|
456
|
+
const canReuse = reverseDepsReuseEnabled() &&
|
|
457
|
+
cachedReverseDeps !== undefined &&
|
|
458
|
+
graph.buildGeneration !== undefined &&
|
|
459
|
+
cachedReverseDeps.generation === graph.buildGeneration;
|
|
460
|
+
let reverseDepsIndex;
|
|
461
|
+
let reverseDepsSaved;
|
|
462
|
+
if (canReuse && cachedReverseDeps) {
|
|
463
|
+
reverseDepsIndex = cachedReverseDeps.index;
|
|
464
|
+
reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
|
|
465
|
+
logCascade({
|
|
466
|
+
phase: "reverse_deps_cache",
|
|
467
|
+
filePath,
|
|
468
|
+
durationMs: Date.now() - graphStart,
|
|
469
|
+
metadata: {
|
|
470
|
+
action: "reused_unchanged",
|
|
471
|
+
savedToSnapshot: reverseDepsSaved,
|
|
472
|
+
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
473
|
+
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
reverseDepsIndex = buildReverseDependencyIndexFromGraph({
|
|
479
|
+
cwd,
|
|
480
|
+
graph,
|
|
481
|
+
});
|
|
482
|
+
reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
|
|
483
|
+
cwd,
|
|
484
|
+
index: reverseDepsIndex,
|
|
485
|
+
dbg,
|
|
486
|
+
});
|
|
487
|
+
reverseDepsIndexCache.set(workspaceKey, {
|
|
488
|
+
index: reverseDepsIndex,
|
|
449
489
|
savedToSnapshot: reverseDepsSaved,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
490
|
+
generation: graph.buildGeneration,
|
|
491
|
+
});
|
|
492
|
+
logCascade({
|
|
493
|
+
phase: "reverse_deps_cache",
|
|
494
|
+
filePath,
|
|
495
|
+
durationMs: Date.now() - graphStart,
|
|
496
|
+
metadata: {
|
|
497
|
+
action: "refresh_from_review_graph",
|
|
498
|
+
savedToSnapshot: reverseDepsSaved,
|
|
499
|
+
importsFileCount: Object.keys(reverseDepsIndex.imports).length,
|
|
500
|
+
importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
|
|
501
|
+
importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
}
|
|
455
505
|
// Count files represented in the graph (nodes with a filePath).
|
|
456
506
|
const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
|
|
457
|
-
const graphBuildInfo = getLastGraphBuildInfo();
|
|
458
507
|
logCascade({
|
|
459
508
|
phase: "graph_build",
|
|
460
509
|
filePath,
|
|
@@ -468,6 +517,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
468
517
|
skipReason: graphBuildInfo.skipReason,
|
|
469
518
|
sourceFileCount: graphBuildInfo.sourceFileCount,
|
|
470
519
|
maxFileCount: graphBuildInfo.maxFileCount,
|
|
520
|
+
// #451: when the seq fast path fell back (or was skipped), why — so
|
|
521
|
+
// cascade.log surfaces the fast-path hit/miss rate.
|
|
522
|
+
seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
|
|
471
523
|
},
|
|
472
524
|
});
|
|
473
525
|
impact = computeImpactCascade(graph, normalizedFile, cwd);
|
|
@@ -189,7 +189,7 @@ const lspRunner = {
|
|
|
189
189
|
.map((d, idx) => ({ d, idx }))
|
|
190
190
|
.filter(({ d }) => d.severity === 1)
|
|
191
191
|
.slice(0, MAX_CODE_ACTION_LOOKUPS);
|
|
192
|
-
|
|
192
|
+
await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
|
|
193
193
|
try {
|
|
194
194
|
const start = d.range.start;
|
|
195
195
|
const end = d.range.end ?? d.range.start;
|
|
@@ -202,17 +202,26 @@ const lspRunner = {
|
|
|
202
202
|
catch {
|
|
203
203
|
// Best-effort enrichment only; base diagnostics remain authoritative.
|
|
204
204
|
}
|
|
205
|
-
}
|
|
205
|
+
}));
|
|
206
206
|
const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
|
|
207
207
|
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
|
|
208
208
|
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
|
|
209
209
|
// their tool id + semantic policy — language-server diagnostics keep "lsp".
|
|
210
210
|
// blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
|
|
211
211
|
const blockingAllowedByProfile = new Map();
|
|
212
|
+
// Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
|
|
213
|
+
// `# nosemgrep`, #441). Reuses `content` from the sync read above.
|
|
214
|
+
const suppressedIndices = new Set();
|
|
212
215
|
for (let i = 0; i < diagnostics.length; i++) {
|
|
213
216
|
const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
|
|
214
217
|
if (!profile)
|
|
215
218
|
continue;
|
|
219
|
+
if (profile.isSuppressed) {
|
|
220
|
+
if (profile.isSuppressed(validLspDiags[i], content)) {
|
|
221
|
+
suppressedIndices.add(i);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
216
225
|
let blockingAllowed = blockingAllowedByProfile.get(profile);
|
|
217
226
|
if (blockingAllowed === undefined) {
|
|
218
227
|
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
|
|
@@ -228,10 +237,13 @@ const lspRunner = {
|
|
|
228
237
|
if (defectClass)
|
|
229
238
|
d.defectClass = defectClass;
|
|
230
239
|
}
|
|
231
|
-
const
|
|
240
|
+
const keptDiagnostics = suppressedIndices.size
|
|
241
|
+
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
|
|
242
|
+
: diagnostics;
|
|
243
|
+
const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
|
|
232
244
|
const resultSemantic = hasErrors
|
|
233
245
|
? "blocking"
|
|
234
|
-
:
|
|
246
|
+
: keptDiagnostics.length > 0
|
|
235
247
|
? "warning"
|
|
236
248
|
: "none";
|
|
237
249
|
return {
|
|
@@ -239,7 +251,7 @@ const lspRunner = {
|
|
|
239
251
|
// "failed" here means the file has blocking type errors — the check ran
|
|
240
252
|
// fine. Tag it so the smell analyzer doesn't read it as a runner crash.
|
|
241
253
|
failureKind: hasErrors ? "blocking_diagnostics" : undefined,
|
|
242
|
-
diagnostics,
|
|
254
|
+
diagnostics: keptDiagnostics,
|
|
243
255
|
semantic: resultSemantic,
|
|
244
256
|
};
|
|
245
257
|
},
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { RuleCache } from "../../cache/rule-cache.js";
|
|
10
|
+
import { isTestFile } from "../../file-utils.js";
|
|
10
11
|
import { resolvePackagePath } from "../../package-root.js";
|
|
11
12
|
import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
|
|
12
13
|
import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
|
|
@@ -403,9 +404,13 @@ const treeSitterRunner = {
|
|
|
403
404
|
// Run all queries regardless of blockingOnly — warning-tier results are logged
|
|
404
405
|
// for diagnostic history but filtered from agent output by the dispatcher.
|
|
405
406
|
// Only skip "review" tier queries on write (too noisy / expensive).
|
|
406
|
-
|
|
407
|
+
// Per-rule test-file carve-out (#440): rules that are noise in tests (e.g.
|
|
408
|
+
// python-assert-production — `assert` is the idiomatic test assertion) opt
|
|
409
|
+
// out via `skip_test_files` while the runner otherwise runs on test files.
|
|
410
|
+
const fileIsTest = isTestFile(filePath);
|
|
411
|
+
const effectiveQueries = (ctx.blockingOnly
|
|
407
412
|
? languageQueries.filter((q) => q.inline_tier !== "review")
|
|
408
|
-
: languageQueries;
|
|
413
|
+
: languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
|
|
409
414
|
logTreeSitter({
|
|
410
415
|
phase: "queries_loaded",
|
|
411
416
|
filePath,
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned LSP process reaper (#472), built on the instance registry (#449
|
|
3
|
+
* slice 1).
|
|
4
|
+
*
|
|
5
|
+
* Split into a PURE decision function (`decideOrphanReaping`) and an IMPURE
|
|
6
|
+
* sweep (`sweepOrphans`) so the decision logic is unit-testable with fake
|
|
7
|
+
* pid tables — no real process spawns/kills in tests.
|
|
8
|
+
*
|
|
9
|
+
* Why the registry reaper, not EOF/processId alone (see issue #472): both are
|
|
10
|
+
* best-effort hints a well-behaved server may honor (typescript-language-server
|
|
11
|
+
* does; ast-grep's native exe does not — an upstream LSP-spec violation). The
|
|
12
|
+
* registry reaper works regardless of why a stdin pipe write-end stayed open
|
|
13
|
+
* after the parent died (Windows handle-inheritance capture) — it identifies
|
|
14
|
+
* dead-parent instances directly and kills the full recorded child tree, with
|
|
15
|
+
* a command-line marker fallback for the case where the pid itself was
|
|
16
|
+
* recycled or the mid-tree pid link is broken (e.g. a dead node-wrapper whose
|
|
17
|
+
* native-exe grandchild is still alive under a different, unrecorded pid).
|
|
18
|
+
*/
|
|
19
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
23
|
+
import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
|
|
24
|
+
import { logLatency } from "./latency-logger.js";
|
|
25
|
+
const isWindows = process.platform === "win32";
|
|
26
|
+
/**
|
|
27
|
+
* Markers claimed by any LIVE instance's children. A marker search kills by
|
|
28
|
+
* command-line match, so a marker that a live session also uses must never
|
|
29
|
+
* be searched — killing it would take down the live session's server.
|
|
30
|
+
* Markers are per-process-unique by construction (sgconfig.ts embeds the
|
|
31
|
+
* pid), so this is defense in depth against non-unique markers ever
|
|
32
|
+
* reappearing (#472: the original shared baseline.sgconfig.yml would have
|
|
33
|
+
* made the fallback kill every live ast-grep on the machine).
|
|
34
|
+
*/
|
|
35
|
+
function collectLiveMarkers(registry, isPidAlive) {
|
|
36
|
+
const liveMarkers = new Set();
|
|
37
|
+
for (const instance of registry) {
|
|
38
|
+
if (!isPidAlive(instance.pid))
|
|
39
|
+
continue;
|
|
40
|
+
for (const child of instance.lspChildren) {
|
|
41
|
+
if (child.marker)
|
|
42
|
+
liveMarkers.add(child.marker);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return liveMarkers;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Classify one dead-parent instance's children into kills / marker-searches,
|
|
49
|
+
* appending onto the shared `out` accumulator. Extracted from
|
|
50
|
+
* `decideOrphanReaping` to keep cognitive complexity in check — no behavior
|
|
51
|
+
* change, just the per-instance inner loop pulled out.
|
|
52
|
+
*/
|
|
53
|
+
function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
|
|
54
|
+
for (const child of instance.lspChildren) {
|
|
55
|
+
const childAlive = isPidAlive(child.pid);
|
|
56
|
+
if (childAlive) {
|
|
57
|
+
const identityOk = matchProcess
|
|
58
|
+
? matchProcess(child.pid, {
|
|
59
|
+
command: child.command,
|
|
60
|
+
marker: child.marker,
|
|
61
|
+
})
|
|
62
|
+
: true;
|
|
63
|
+
if (identityOk) {
|
|
64
|
+
out.childrenToKill.push({
|
|
65
|
+
pid: child.pid,
|
|
66
|
+
serverId: child.serverId,
|
|
67
|
+
command: child.command,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
|
|
73
|
+
// if we have a marker, surface it so the caller can find a live
|
|
74
|
+
// process (e.g. the native exe grandchild) by command-line match.
|
|
75
|
+
// Never surface a marker a live instance also claims (see above).
|
|
76
|
+
if (child.marker && !liveMarkers.has(child.marker)) {
|
|
77
|
+
out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pure decision function: given the registry state and injectable liveness /
|
|
83
|
+
* identity predicates, decide what to kill. Performs zero I/O.
|
|
84
|
+
*
|
|
85
|
+
* @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
|
|
86
|
+
* CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
|
|
87
|
+
* ambiguous result (EPERM, or the caller's fake table saying "unknown")
|
|
88
|
+
* must be treated as alive — never kill on an ambiguous signal-check.
|
|
89
|
+
* @param matchProcess - optional identity verification (e.g. confirm the
|
|
90
|
+
* live pid's command line still matches what we recorded) to guard against
|
|
91
|
+
* a recycled pid coincidentally matching. If omitted, liveness alone is used.
|
|
92
|
+
*/
|
|
93
|
+
export function decideOrphanReaping(registry, isPidAlive, matchProcess) {
|
|
94
|
+
const deadInstances = [];
|
|
95
|
+
const childrenToKill = [];
|
|
96
|
+
const markerSearches = [];
|
|
97
|
+
const liveMarkers = collectLiveMarkers(registry, isPidAlive);
|
|
98
|
+
for (const instance of registry) {
|
|
99
|
+
if (isPidAlive(instance.pid)) {
|
|
100
|
+
continue; // parent still alive — leave its children alone entirely
|
|
101
|
+
}
|
|
102
|
+
deadInstances.push(instance);
|
|
103
|
+
classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
|
|
104
|
+
childrenToKill,
|
|
105
|
+
markerSearches,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return { deadInstances, childrenToKill, markerSearches };
|
|
109
|
+
}
|
|
110
|
+
// --- Impure liveness / identity / kill helpers ---
|
|
111
|
+
/** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
|
|
112
|
+
* (EPERM, or no error thrown at all) ⇒ conservatively alive. */
|
|
113
|
+
function realIsPidAlive(pid) {
|
|
114
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
115
|
+
return false;
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
return true; // no throw — process exists and we can signal it
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const code = err?.code;
|
|
122
|
+
if (code === "ESRCH")
|
|
123
|
+
return false; // definitively dead
|
|
124
|
+
// EPERM (exists, no permission) or any other/unknown errno: ambiguous —
|
|
125
|
+
// never treat as dead.
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function windowsExe(name) {
|
|
130
|
+
return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
|
|
131
|
+
}
|
|
132
|
+
/** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
|
|
133
|
+
* Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
|
|
134
|
+
* `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
|
|
135
|
+
* (spawn will then fail closed rather than silently resolving via PATH). */
|
|
136
|
+
function posixPsPath() {
|
|
137
|
+
if (fs.existsSync("/bin/ps"))
|
|
138
|
+
return "/bin/ps";
|
|
139
|
+
if (fs.existsSync("/usr/bin/ps"))
|
|
140
|
+
return "/usr/bin/ps";
|
|
141
|
+
return "/bin/ps";
|
|
142
|
+
}
|
|
143
|
+
/** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
|
|
144
|
+
* string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
|
|
145
|
+
* is an opaque path string, so escape all three before interpolating. */
|
|
146
|
+
function escapeWqlLikeValue(value) {
|
|
147
|
+
return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
|
|
148
|
+
}
|
|
149
|
+
/** Search running processes whose command line contains `marker` (Windows,
|
|
150
|
+
* via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
|
|
151
|
+
async function findPidsByMarkerWindows(marker) {
|
|
152
|
+
if (!isWindows || !marker)
|
|
153
|
+
return [];
|
|
154
|
+
const escaped = escapeWqlLikeValue(marker);
|
|
155
|
+
// $PID exclusion: the query's own powershell.exe command line embeds the
|
|
156
|
+
// marker string, so it would match itself.
|
|
157
|
+
const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
|
|
158
|
+
`| Where-Object { $_.ProcessId -ne $PID } ` +
|
|
159
|
+
`| Select-Object -ExpandProperty ProcessId`;
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
try {
|
|
162
|
+
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
163
|
+
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
164
|
+
let out = "";
|
|
165
|
+
child.stdout?.on("data", (chunk) => {
|
|
166
|
+
out += chunk.toString();
|
|
167
|
+
});
|
|
168
|
+
child.once("error", () => resolve([]));
|
|
169
|
+
child.once("close", () => {
|
|
170
|
+
const pids = out
|
|
171
|
+
.split(/\r?\n/)
|
|
172
|
+
.map((line) => Number(line.trim()))
|
|
173
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
174
|
+
resolve(pids);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
resolve([]);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
|
|
183
|
+
* `ps`). Returns a pid → command-line map; pids that can't be resolved are
|
|
184
|
+
* simply absent (the caller treats absent as "identity unverifiable — do not
|
|
185
|
+
* kill by pid"). Best-effort: any failure ⇒ empty map. */
|
|
186
|
+
async function queryCommandLines(pids) {
|
|
187
|
+
const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
|
|
188
|
+
const map = new Map();
|
|
189
|
+
if (valid.length === 0)
|
|
190
|
+
return map;
|
|
191
|
+
if (isWindows) {
|
|
192
|
+
const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
|
|
193
|
+
const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
|
|
194
|
+
`| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
try {
|
|
197
|
+
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
198
|
+
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
199
|
+
let out = "";
|
|
200
|
+
child.stdout?.on("data", (chunk) => {
|
|
201
|
+
out += chunk.toString();
|
|
202
|
+
});
|
|
203
|
+
child.once("error", () => resolve(map));
|
|
204
|
+
child.once("close", () => {
|
|
205
|
+
for (const line of out.split(/\r?\n/)) {
|
|
206
|
+
const tab = line.indexOf("\t");
|
|
207
|
+
if (tab <= 0)
|
|
208
|
+
continue;
|
|
209
|
+
const pid = Number(line.slice(0, tab).trim());
|
|
210
|
+
if (Number.isFinite(pid) && pid > 0)
|
|
211
|
+
map.set(pid, line.slice(tab + 1));
|
|
212
|
+
}
|
|
213
|
+
resolve(map);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
resolve(map);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
try {
|
|
223
|
+
const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
|
|
224
|
+
let out = "";
|
|
225
|
+
child.stdout?.on("data", (chunk) => {
|
|
226
|
+
out += chunk.toString();
|
|
227
|
+
});
|
|
228
|
+
child.once("error", () => resolve(map));
|
|
229
|
+
child.once("close", () => {
|
|
230
|
+
for (const line of out.split(/\r?\n/)) {
|
|
231
|
+
// Linear parse (S8786/S6594: avoid regex backtracking on
|
|
232
|
+
// attacker-lengthenable ps output) — trim leading whitespace,
|
|
233
|
+
// then split on the first whitespace run: " 1234 args here".
|
|
234
|
+
const trimmed = line.trimStart();
|
|
235
|
+
if (!trimmed)
|
|
236
|
+
continue;
|
|
237
|
+
let i = 0;
|
|
238
|
+
while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
|
|
239
|
+
i++;
|
|
240
|
+
if (i === 0)
|
|
241
|
+
continue;
|
|
242
|
+
const pidStr = trimmed.slice(0, i);
|
|
243
|
+
let j = i;
|
|
244
|
+
while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
|
|
245
|
+
j++;
|
|
246
|
+
const pid = Number(pidStr);
|
|
247
|
+
if (Number.isFinite(pid) && pid > 0)
|
|
248
|
+
map.set(pid, trimmed.slice(j));
|
|
249
|
+
}
|
|
250
|
+
resolve(map);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
resolve(map);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Build a `matchProcess` identity predicate from a pid → command-line map
|
|
260
|
+
* (as produced by `queryCommandLines`). PURE — exported for unit testing.
|
|
261
|
+
*
|
|
262
|
+
* Semantics (guarding pid kills against pid recycling):
|
|
263
|
+
* - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
|
|
264
|
+
* by pid (the marker-search fallback may still catch a real orphan).
|
|
265
|
+
* - marker recorded and present in the command line ⇒ match (strongest
|
|
266
|
+
* signal — markers are per-spawn-unique).
|
|
267
|
+
* - else: the recorded command's basename appears (case-insensitive) in the
|
|
268
|
+
* command line ⇒ match. Empty basename never matches (guard against a
|
|
269
|
+
* recorded empty/odd command matching everything via `includes("")`).
|
|
270
|
+
*/
|
|
271
|
+
export function buildIdentityMatcher(cmdlines) {
|
|
272
|
+
return (pid, expected) => {
|
|
273
|
+
const cmdline = cmdlines.get(pid);
|
|
274
|
+
if (cmdline === undefined)
|
|
275
|
+
return false; // unverifiable ⇒ never kill by pid
|
|
276
|
+
if (expected.marker && cmdline.includes(expected.marker))
|
|
277
|
+
return true;
|
|
278
|
+
const basename = path.basename(expected.command ?? "").toLowerCase();
|
|
279
|
+
if (!basename)
|
|
280
|
+
return false;
|
|
281
|
+
return cmdline.toLowerCase().includes(basename);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
|
|
285
|
+
* mirror killProcessTree's process-group kill, falling back to a direct
|
|
286
|
+
* signal. Best-effort: swallow all errors. */
|
|
287
|
+
async function killPidTree(pid) {
|
|
288
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
289
|
+
return;
|
|
290
|
+
if (isWindows) {
|
|
291
|
+
try {
|
|
292
|
+
const taskkill = windowsExe("taskkill.exe");
|
|
293
|
+
const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
|
|
294
|
+
shell: false,
|
|
295
|
+
windowsHide: true,
|
|
296
|
+
stdio: "ignore",
|
|
297
|
+
});
|
|
298
|
+
await new Promise((resolve) => {
|
|
299
|
+
killer.once("close", () => resolve());
|
|
300
|
+
killer.once("error", () => resolve());
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// best-effort
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
process.kill(-pid, "SIGKILL");
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
try {
|
|
313
|
+
process.kill(pid, "SIGKILL");
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// best-effort — process may already be gone
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Fire-and-forget orphan sweep: reads the registry, decides what's dead via
|
|
322
|
+
* `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
|
|
323
|
+
* marker-based command-line search fallback), then drops fully-dead
|
|
324
|
+
* instances from the registry. Never throws — every step is wrapped so a
|
|
325
|
+
* reap failure cannot block or crash the caller (session_start).
|
|
326
|
+
*/
|
|
327
|
+
export async function sweepOrphans() {
|
|
328
|
+
if (!isInstanceRegistryEnabled())
|
|
329
|
+
return;
|
|
330
|
+
const startedAt = Date.now();
|
|
331
|
+
try {
|
|
332
|
+
const registry = await readInstanceRegistry();
|
|
333
|
+
if (registry.length === 0)
|
|
334
|
+
return;
|
|
335
|
+
// Identity verification before any pid kill (recycled-pid guard): fetch
|
|
336
|
+
// the command lines of every recorded child pid in ONE batched query,
|
|
337
|
+
// then let the pure decision function verify each live child's identity
|
|
338
|
+
// against what was recorded at spawn. A pid whose command line can't be
|
|
339
|
+
// fetched is treated as unverifiable and never killed by pid — the
|
|
340
|
+
// marker-search fallback may still catch it.
|
|
341
|
+
const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
|
|
342
|
+
const cmdlines = await queryCommandLines(candidatePids);
|
|
343
|
+
const matchProcess = buildIdentityMatcher(cmdlines);
|
|
344
|
+
const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
|
|
345
|
+
let killedCount = 0;
|
|
346
|
+
const killedServerIds = [];
|
|
347
|
+
for (const child of decision.childrenToKill) {
|
|
348
|
+
await killPidTree(child.pid);
|
|
349
|
+
killedCount++;
|
|
350
|
+
killedServerIds.push(child.serverId);
|
|
351
|
+
}
|
|
352
|
+
for (const search of decision.markerSearches) {
|
|
353
|
+
try {
|
|
354
|
+
const pids = await findPidsByMarkerWindows(search.marker);
|
|
355
|
+
for (const pid of pids) {
|
|
356
|
+
await killPidTree(pid);
|
|
357
|
+
killedCount++;
|
|
358
|
+
killedServerIds.push(search.serverId);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// best-effort — a failed marker search just misses that orphan this sweep
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (decision.deadInstances.length > 0) {
|
|
366
|
+
try {
|
|
367
|
+
const deadPids = new Set(decision.deadInstances.map((i) => i.pid));
|
|
368
|
+
await pruneDeadInstances(deadPids);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// best-effort — a stale registry entry is re-evaluated next sweep
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
logLatency({
|
|
376
|
+
type: "phase",
|
|
377
|
+
phase: "orphan_lsp_reaped",
|
|
378
|
+
filePath: "",
|
|
379
|
+
durationMs: Date.now() - startedAt,
|
|
380
|
+
metadata: {
|
|
381
|
+
deadInstances: decision.deadInstances.length,
|
|
382
|
+
killed: killedCount,
|
|
383
|
+
serverIds: killedServerIds,
|
|
384
|
+
markerSearches: decision.markerSearches.length,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// best-effort logging only
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// The sweep must never throw out of session_start.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Drop dead-parent instances from the registry. Re-reads immediately before
|
|
397
|
+
* writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
|
|
398
|
+
* to narrow — not eliminate — the last-writer-wins race already accepted for
|
|
399
|
+
* slice 1's read-modify-write model. */
|
|
400
|
+
async function pruneDeadInstances(deadPids) {
|
|
401
|
+
const target = path.join(getGlobalPiLensDir(), "instances.json");
|
|
402
|
+
try {
|
|
403
|
+
const raw = await fs.promises.readFile(target, "utf-8");
|
|
404
|
+
const parsed = JSON.parse(raw);
|
|
405
|
+
if (!parsed || !Array.isArray(parsed.instances))
|
|
406
|
+
return;
|
|
407
|
+
const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
|
|
408
|
+
if (remaining.length === parsed.instances.length)
|
|
409
|
+
return;
|
|
410
|
+
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
411
|
+
await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
|
|
412
|
+
await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
|
|
413
|
+
await fs.promises.rename(tmpPath, target);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// best-effort
|
|
417
|
+
}
|
|
418
|
+
}
|