pi-lens 3.8.67 → 3.8.69
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 +58 -0
- package/README.md +57 -38
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bus-publish.js +110 -0
- 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 +2 -2
- package/dist/clients/diagnostics-publish.js +180 -0
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/installer/index.js +32 -1
- package/dist/clients/instance-reaper.js +516 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +44 -10
- package/dist/clients/lsp/cascade-tier.js +254 -0
- package/dist/clients/lsp/client.js +81 -3
- package/dist/clients/lsp/index.js +3 -0
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +71 -0
- package/dist/clients/lsp/server.js +119 -6
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/module-report.js +163 -18
- package/dist/clients/path-utils.js +25 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +82 -2
- package/dist/clients/project-diagnostics/extractors.js +30 -4
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +168 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/review-graph/builder.js +20 -2
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +154 -48
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/runtime-turn.js +9 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +246 -38
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +62 -14
- package/dist/clients/subagent-mode.js +87 -0
- package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +27 -30
- package/dist/clients/word-index.js +296 -1
- package/dist/index.js +62809 -1633
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +377 -43
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +42 -12
- package/dist/tools/lsp-diagnostics.js +117 -4
- package/dist/tools/module-report.js +14 -11
- package/dist/tools/symbol-search.js +110 -0
- package/package.json +3 -2
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- package/dist/clients/tree-sitter-fixer.js +0 -127
|
@@ -0,0 +1,516 @@
|
|
|
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
|
+
* #525 root cause: a parent instance was ONLY ever considered dead via
|
|
20
|
+
* `isPidAlive(instance.pid)` — a raw `process.kill(pid, 0)` check. Unlike
|
|
21
|
+
* child pids (which get a command-line/marker identity check via
|
|
22
|
+
* `matchProcess` to guard against a recycled pid), the PARENT pid had no
|
|
23
|
+
* identity verification at all, because `InstanceEntry` never recorded the
|
|
24
|
+
* parent's own command line. Windows recycles pids far more aggressively than
|
|
25
|
+
* POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so
|
|
26
|
+
* over a long enough window (observed: ~13h) a dead parent's pid is very
|
|
27
|
+
* plausibly reassigned to a live, unrelated process — `isPidAlive` then
|
|
28
|
+
* (correctly, per its own conservative contract) reports "alive", and the
|
|
29
|
+
* stale instance is never reaped, no matter how old its heartbeat is.
|
|
30
|
+
*
|
|
31
|
+
* The #525 fix is deliberately ASYMMETRIC by consequence:
|
|
32
|
+
*
|
|
33
|
+
* - **Heartbeat staleness (`STALE_HEARTBEAT_MS`) cleans REGISTRY ENTRIES,
|
|
34
|
+
* never kills.** A stale-heartbeat-but-pid-alive instance goes into
|
|
35
|
+
* `staleInstances` (entry dropped from instances.json) with ZERO process
|
|
36
|
+
* kills and its children still marker-protected. Why: the heartbeat call
|
|
37
|
+
* sites are runtime-turn.ts (per turn end) and quiet-window.ts (per run
|
|
38
|
+
* settle) ONLY — no timer exists. A pi session left OPEN but UNUSED
|
|
39
|
+
* overnight fires neither, so its heartbeat legitimately goes >6h stale
|
|
40
|
+
* while the session and its warm LSP fleet are genuinely alive. Killing on
|
|
41
|
+
* staleness would take that fleet down under the idle session — and
|
|
42
|
+
* `matchProcess` would NOT save it (its children really ARE that
|
|
43
|
+
* instance's LSP servers; identity verification guards against pid reuse,
|
|
44
|
+
* not against misclassifying a live parent). Removing just the entry is
|
|
45
|
+
* safe: the idle session's next turn re-registers via `registerInstance`.
|
|
46
|
+
* - **Process kills require a pid-confirmed-DEAD parent** (`deadInstances`),
|
|
47
|
+
* exactly as before #525. Only then are its children classified for
|
|
48
|
+
* kill/marker-search.
|
|
49
|
+
*
|
|
50
|
+
* This still fixes both observed #525 cases: the 13h-stale test-fixture
|
|
51
|
+
* entry AND the recycled-parent-pid case (a dead parent whose pid now
|
|
52
|
+
* belongs to an unrelated live process — `isPidAlive` lies, but the stale
|
|
53
|
+
* heartbeat gets the ENTRY dropped, which is all the pollution fix needs).
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* A pid-ALIVE instance whose heartbeat is older than this gets its registry
|
|
57
|
+
* ENTRY removed — it is NEVER kill-eligible on staleness alone (see the
|
|
58
|
+
* module docstring's asymmetry note: an overnight-idle-but-alive session
|
|
59
|
+
* legitimately exceeds this threshold because heartbeats only fire at turn
|
|
60
|
+
* end / run settle, so staleness must clean records, not kill processes).
|
|
61
|
+
* 6 hours comfortably catches the observed 13h-stale pollution case.
|
|
62
|
+
*/
|
|
63
|
+
export const STALE_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
|
|
64
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
65
|
+
import * as fs from "node:fs";
|
|
66
|
+
import * as path from "node:path";
|
|
67
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
68
|
+
import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
|
|
69
|
+
import { logLatency } from "./latency-logger.js";
|
|
70
|
+
const isWindows = process.platform === "win32";
|
|
71
|
+
/**
|
|
72
|
+
* KILL eligibility: pid-confirmed-dead ONLY. Heartbeat staleness must never
|
|
73
|
+
* make an instance kill-eligible — an overnight-idle-but-alive session
|
|
74
|
+
* legitimately goes >STALE_HEARTBEAT_MS stale (heartbeats fire only at turn
|
|
75
|
+
* end / run settle; no timer exists), and killing its genuine live LSP
|
|
76
|
+
* children would pass `matchProcess` identity verification (they really are
|
|
77
|
+
* that instance's servers — the matcher guards against pid reuse, not
|
|
78
|
+
* against misclassifying a live parent). See the module docstring (#525).
|
|
79
|
+
*/
|
|
80
|
+
function isInstanceKillEligible(instance, isPidAlive) {
|
|
81
|
+
return !isPidAlive(instance.pid);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* REGISTRY-ENTRY staleness: heartbeat older than `STALE_HEARTBEAT_MS` (or
|
|
85
|
+
* unparseable — missing data must never keep a polluted entry alive
|
|
86
|
+
* forever). Drives entry removal ONLY, never kills (#525). This is what
|
|
87
|
+
* cleans the recycled-parent-pid case: `isPidAlive` lies for a long-dead
|
|
88
|
+
* parent whose pid the OS reassigned to an unrelated live process, but the
|
|
89
|
+
* stale heartbeat still gets the ENTRY dropped.
|
|
90
|
+
*/
|
|
91
|
+
function isInstanceEntryStale(instance, now) {
|
|
92
|
+
const heartbeatMs = Date.parse(instance.heartbeatAt);
|
|
93
|
+
if (Number.isNaN(heartbeatMs))
|
|
94
|
+
return true;
|
|
95
|
+
return now - heartbeatMs > STALE_HEARTBEAT_MS;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Markers claimed by any pid-ALIVE instance's children. A marker search
|
|
99
|
+
* kills by command-line match, so a marker that a live session also uses
|
|
100
|
+
* must never be searched — killing it would take down the live session's
|
|
101
|
+
* server. Protection is deliberately keyed on pid-liveness ALONE, regardless
|
|
102
|
+
* of heartbeat staleness (#525): a stale-heartbeat-but-alive instance (e.g.
|
|
103
|
+
* overnight-idle) must keep its children protected — protection stays
|
|
104
|
+
* conservative even where entry cleanup does not.
|
|
105
|
+
* Markers are per-process-unique by construction (sgconfig.ts embeds the
|
|
106
|
+
* pid), so this is defense in depth against non-unique markers ever
|
|
107
|
+
* reappearing (#472: the original shared baseline.sgconfig.yml would have
|
|
108
|
+
* made the fallback kill every live ast-grep on the machine).
|
|
109
|
+
*/
|
|
110
|
+
function collectLiveMarkers(registry, isPidAlive) {
|
|
111
|
+
const liveMarkers = new Set();
|
|
112
|
+
for (const instance of registry) {
|
|
113
|
+
if (!isPidAlive(instance.pid))
|
|
114
|
+
continue;
|
|
115
|
+
for (const child of instance.lspChildren) {
|
|
116
|
+
if (child.marker)
|
|
117
|
+
liveMarkers.add(child.marker);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return liveMarkers;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Classify one dead-parent instance's children into kills / marker-searches,
|
|
124
|
+
* appending onto the shared `out` accumulator. Extracted from
|
|
125
|
+
* `decideOrphanReaping` to keep cognitive complexity in check — no behavior
|
|
126
|
+
* change, just the per-instance inner loop pulled out.
|
|
127
|
+
*/
|
|
128
|
+
function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
|
|
129
|
+
for (const child of instance.lspChildren) {
|
|
130
|
+
const childAlive = isPidAlive(child.pid);
|
|
131
|
+
if (childAlive) {
|
|
132
|
+
const identityOk = matchProcess
|
|
133
|
+
? matchProcess(child.pid, {
|
|
134
|
+
command: child.command,
|
|
135
|
+
marker: child.marker,
|
|
136
|
+
})
|
|
137
|
+
: true;
|
|
138
|
+
if (identityOk) {
|
|
139
|
+
out.childrenToKill.push({
|
|
140
|
+
pid: child.pid,
|
|
141
|
+
serverId: child.serverId,
|
|
142
|
+
command: child.command,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
|
|
148
|
+
// if we have a marker, surface it so the caller can find a live
|
|
149
|
+
// process (e.g. the native exe grandchild) by command-line match.
|
|
150
|
+
// Never surface a marker a live instance also claims (see above).
|
|
151
|
+
if (child.marker && !liveMarkers.has(child.marker)) {
|
|
152
|
+
out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Pure decision function: given the registry state and injectable liveness /
|
|
158
|
+
* identity predicates, decide what to kill. Performs zero I/O.
|
|
159
|
+
*
|
|
160
|
+
* @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
|
|
161
|
+
* CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
|
|
162
|
+
* ambiguous result (EPERM, or the caller's fake table saying "unknown")
|
|
163
|
+
* must be treated as alive — never kill on an ambiguous signal-check.
|
|
164
|
+
* @param matchProcess - optional identity verification (e.g. confirm the
|
|
165
|
+
* live pid's command line still matches what we recorded) to guard against
|
|
166
|
+
* a recycled pid coincidentally matching. If omitted, liveness alone is used.
|
|
167
|
+
* @param now - epoch ms "now", for heartbeat-staleness comparison (#525).
|
|
168
|
+
* Injectable for deterministic tests; defaults to `Date.now()`. The two
|
|
169
|
+
* signals are ASYMMETRIC by consequence: pid-confirmed-dead ⇒
|
|
170
|
+
* `deadInstances` (kill-eligible + entry removal); pid-alive but heartbeat
|
|
171
|
+
* older than `STALE_HEARTBEAT_MS` ⇒ `staleInstances` (entry removal ONLY —
|
|
172
|
+
* never kills, never loses marker protection; the parent may be an
|
|
173
|
+
* overnight-idle-but-alive session). See the module docstring.
|
|
174
|
+
*/
|
|
175
|
+
export function decideOrphanReaping(registry, isPidAlive, matchProcess, now = Date.now()) {
|
|
176
|
+
const deadInstances = [];
|
|
177
|
+
const staleInstances = [];
|
|
178
|
+
const childrenToKill = [];
|
|
179
|
+
const markerSearches = [];
|
|
180
|
+
// Marker protection is pid-liveness ONLY — a stale-heartbeat-but-alive
|
|
181
|
+
// instance keeps its children protected (conservative on the kill side).
|
|
182
|
+
const liveMarkers = collectLiveMarkers(registry, isPidAlive);
|
|
183
|
+
for (const instance of registry) {
|
|
184
|
+
if (isInstanceKillEligible(instance, isPidAlive)) {
|
|
185
|
+
// pid-confirmed-dead: entry removal + children classified for kills.
|
|
186
|
+
deadInstances.push(instance);
|
|
187
|
+
classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
|
|
188
|
+
childrenToKill,
|
|
189
|
+
markerSearches,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else if (isInstanceEntryStale(instance, now)) {
|
|
193
|
+
// pid-alive but stale heartbeat: record cleanup only — NO kills.
|
|
194
|
+
staleInstances.push(instance);
|
|
195
|
+
}
|
|
196
|
+
// else: alive + fresh heartbeat — leave it alone entirely.
|
|
197
|
+
}
|
|
198
|
+
return { deadInstances, staleInstances, childrenToKill, markerSearches };
|
|
199
|
+
}
|
|
200
|
+
// --- Impure liveness / identity / kill helpers ---
|
|
201
|
+
/** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
|
|
202
|
+
* (EPERM, or no error thrown at all) ⇒ conservatively alive. */
|
|
203
|
+
function realIsPidAlive(pid) {
|
|
204
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
205
|
+
return false;
|
|
206
|
+
try {
|
|
207
|
+
process.kill(pid, 0);
|
|
208
|
+
return true; // no throw — process exists and we can signal it
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const code = err?.code;
|
|
212
|
+
if (code === "ESRCH")
|
|
213
|
+
return false; // definitively dead
|
|
214
|
+
// EPERM (exists, no permission) or any other/unknown errno: ambiguous —
|
|
215
|
+
// never treat as dead.
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function windowsExe(name) {
|
|
220
|
+
return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
|
|
221
|
+
}
|
|
222
|
+
/** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
|
|
223
|
+
* Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
|
|
224
|
+
* `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
|
|
225
|
+
* (spawn will then fail closed rather than silently resolving via PATH). */
|
|
226
|
+
function posixPsPath() {
|
|
227
|
+
if (fs.existsSync("/bin/ps"))
|
|
228
|
+
return "/bin/ps";
|
|
229
|
+
if (fs.existsSync("/usr/bin/ps"))
|
|
230
|
+
return "/usr/bin/ps";
|
|
231
|
+
return "/bin/ps";
|
|
232
|
+
}
|
|
233
|
+
/** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
|
|
234
|
+
* string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
|
|
235
|
+
* is an opaque path string, so escape all three before interpolating. */
|
|
236
|
+
function escapeWqlLikeValue(value) {
|
|
237
|
+
return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
|
|
238
|
+
}
|
|
239
|
+
/** Search running processes whose command line contains `marker` (Windows,
|
|
240
|
+
* via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
|
|
241
|
+
async function findPidsByMarkerWindows(marker) {
|
|
242
|
+
if (!isWindows || !marker)
|
|
243
|
+
return [];
|
|
244
|
+
const escaped = escapeWqlLikeValue(marker);
|
|
245
|
+
// $PID exclusion: the query's own powershell.exe command line embeds the
|
|
246
|
+
// marker string, so it would match itself.
|
|
247
|
+
const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
|
|
248
|
+
`| Where-Object { $_.ProcessId -ne $PID } ` +
|
|
249
|
+
`| Select-Object -ExpandProperty ProcessId`;
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
try {
|
|
252
|
+
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
253
|
+
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
254
|
+
let out = "";
|
|
255
|
+
child.stdout?.on("data", (chunk) => {
|
|
256
|
+
out += chunk.toString();
|
|
257
|
+
});
|
|
258
|
+
child.once("error", () => resolve([]));
|
|
259
|
+
child.once("close", () => {
|
|
260
|
+
const pids = out
|
|
261
|
+
.split(/\r?\n/)
|
|
262
|
+
.map((line) => Number(line.trim()))
|
|
263
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
264
|
+
resolve(pids);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
resolve([]);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
|
|
273
|
+
* `ps`). Returns a pid → command-line map; pids that can't be resolved are
|
|
274
|
+
* simply absent (the caller treats absent as "identity unverifiable — do not
|
|
275
|
+
* kill by pid"). Best-effort: any failure ⇒ empty map. */
|
|
276
|
+
async function queryCommandLines(pids) {
|
|
277
|
+
const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
|
|
278
|
+
const map = new Map();
|
|
279
|
+
if (valid.length === 0)
|
|
280
|
+
return map;
|
|
281
|
+
if (isWindows) {
|
|
282
|
+
const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
|
|
283
|
+
const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
|
|
284
|
+
`| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
try {
|
|
287
|
+
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
288
|
+
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
289
|
+
let out = "";
|
|
290
|
+
child.stdout?.on("data", (chunk) => {
|
|
291
|
+
out += chunk.toString();
|
|
292
|
+
});
|
|
293
|
+
child.once("error", () => resolve(map));
|
|
294
|
+
child.once("close", () => {
|
|
295
|
+
for (const line of out.split(/\r?\n/)) {
|
|
296
|
+
const tab = line.indexOf("\t");
|
|
297
|
+
if (tab <= 0)
|
|
298
|
+
continue;
|
|
299
|
+
const pid = Number(line.slice(0, tab).trim());
|
|
300
|
+
if (Number.isFinite(pid) && pid > 0)
|
|
301
|
+
map.set(pid, line.slice(tab + 1));
|
|
302
|
+
}
|
|
303
|
+
resolve(map);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
resolve(map);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
try {
|
|
313
|
+
const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
|
|
314
|
+
let out = "";
|
|
315
|
+
child.stdout?.on("data", (chunk) => {
|
|
316
|
+
out += chunk.toString();
|
|
317
|
+
});
|
|
318
|
+
child.once("error", () => resolve(map));
|
|
319
|
+
child.once("close", () => {
|
|
320
|
+
for (const line of out.split(/\r?\n/)) {
|
|
321
|
+
// Linear parse (S8786/S6594: avoid regex backtracking on
|
|
322
|
+
// attacker-lengthenable ps output) — trim leading whitespace,
|
|
323
|
+
// then split on the first whitespace run: " 1234 args here".
|
|
324
|
+
const trimmed = line.trimStart();
|
|
325
|
+
if (!trimmed)
|
|
326
|
+
continue;
|
|
327
|
+
let i = 0;
|
|
328
|
+
while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
|
|
329
|
+
i++;
|
|
330
|
+
if (i === 0)
|
|
331
|
+
continue;
|
|
332
|
+
const pidStr = trimmed.slice(0, i);
|
|
333
|
+
let j = i;
|
|
334
|
+
while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
|
|
335
|
+
j++;
|
|
336
|
+
const pid = Number(pidStr);
|
|
337
|
+
if (Number.isFinite(pid) && pid > 0)
|
|
338
|
+
map.set(pid, trimmed.slice(j));
|
|
339
|
+
}
|
|
340
|
+
resolve(map);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
resolve(map);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Build a `matchProcess` identity predicate from a pid → command-line map
|
|
350
|
+
* (as produced by `queryCommandLines`). PURE — exported for unit testing.
|
|
351
|
+
*
|
|
352
|
+
* Semantics (guarding pid kills against pid recycling):
|
|
353
|
+
* - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
|
|
354
|
+
* by pid (the marker-search fallback may still catch a real orphan).
|
|
355
|
+
* - marker recorded and present in the command line ⇒ match (strongest
|
|
356
|
+
* signal — markers are per-spawn-unique).
|
|
357
|
+
* - else: the recorded command's basename appears (case-insensitive) in the
|
|
358
|
+
* command line ⇒ match. Empty basename never matches (guard against a
|
|
359
|
+
* recorded empty/odd command matching everything via `includes("")`).
|
|
360
|
+
*/
|
|
361
|
+
export function buildIdentityMatcher(cmdlines) {
|
|
362
|
+
return (pid, expected) => {
|
|
363
|
+
const cmdline = cmdlines.get(pid);
|
|
364
|
+
if (cmdline === undefined)
|
|
365
|
+
return false; // unverifiable ⇒ never kill by pid
|
|
366
|
+
if (expected.marker && cmdline.includes(expected.marker))
|
|
367
|
+
return true;
|
|
368
|
+
const basename = path.basename(expected.command ?? "").toLowerCase();
|
|
369
|
+
if (!basename)
|
|
370
|
+
return false;
|
|
371
|
+
return cmdline.toLowerCase().includes(basename);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
|
|
375
|
+
* mirror killProcessTree's process-group kill, falling back to a direct
|
|
376
|
+
* signal. Best-effort: swallow all errors. */
|
|
377
|
+
async function killPidTree(pid) {
|
|
378
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
379
|
+
return;
|
|
380
|
+
if (isWindows) {
|
|
381
|
+
try {
|
|
382
|
+
const taskkill = windowsExe("taskkill.exe");
|
|
383
|
+
const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
|
|
384
|
+
shell: false,
|
|
385
|
+
windowsHide: true,
|
|
386
|
+
stdio: "ignore",
|
|
387
|
+
});
|
|
388
|
+
await new Promise((resolve) => {
|
|
389
|
+
killer.once("close", () => resolve());
|
|
390
|
+
killer.once("error", () => resolve());
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// best-effort
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
process.kill(-pid, "SIGKILL");
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
try {
|
|
403
|
+
process.kill(pid, "SIGKILL");
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// best-effort — process may already be gone
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Fire-and-forget orphan sweep: reads the registry, decides what's dead via
|
|
412
|
+
* `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
|
|
413
|
+
* marker-based command-line search fallback), then drops fully-dead
|
|
414
|
+
* instances from the registry. Never throws — every step is wrapped so a
|
|
415
|
+
* reap failure cannot block or crash the caller (session_start).
|
|
416
|
+
*/
|
|
417
|
+
export async function sweepOrphans() {
|
|
418
|
+
if (!isInstanceRegistryEnabled())
|
|
419
|
+
return;
|
|
420
|
+
const startedAt = Date.now();
|
|
421
|
+
try {
|
|
422
|
+
const registry = await readInstanceRegistry();
|
|
423
|
+
if (registry.length === 0)
|
|
424
|
+
return;
|
|
425
|
+
// Identity verification before any pid kill (recycled-pid guard): fetch
|
|
426
|
+
// the command lines of every recorded child pid in ONE batched query,
|
|
427
|
+
// then let the pure decision function verify each live child's identity
|
|
428
|
+
// against what was recorded at spawn. A pid whose command line can't be
|
|
429
|
+
// fetched is treated as unverifiable and never killed by pid — the
|
|
430
|
+
// marker-search fallback may still catch it.
|
|
431
|
+
const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
|
|
432
|
+
const cmdlines = await queryCommandLines(candidatePids);
|
|
433
|
+
const matchProcess = buildIdentityMatcher(cmdlines);
|
|
434
|
+
const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
|
|
435
|
+
let killedCount = 0;
|
|
436
|
+
const killedServerIds = [];
|
|
437
|
+
for (const child of decision.childrenToKill) {
|
|
438
|
+
await killPidTree(child.pid);
|
|
439
|
+
killedCount++;
|
|
440
|
+
killedServerIds.push(child.serverId);
|
|
441
|
+
}
|
|
442
|
+
for (const search of decision.markerSearches) {
|
|
443
|
+
try {
|
|
444
|
+
const pids = await findPidsByMarkerWindows(search.marker);
|
|
445
|
+
for (const pid of pids) {
|
|
446
|
+
await killPidTree(pid);
|
|
447
|
+
killedCount++;
|
|
448
|
+
killedServerIds.push(search.serverId);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// best-effort — a failed marker search just misses that orphan this sweep
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Entry removal covers BOTH sets: pid-dead instances AND stale-heartbeat
|
|
456
|
+
// (pid-alive) instances — the latter is record cleanup only (#525);
|
|
457
|
+
// nothing belonging to a stale instance was killed above.
|
|
458
|
+
if (decision.deadInstances.length > 0 || decision.staleInstances.length > 0) {
|
|
459
|
+
try {
|
|
460
|
+
const prunePids = new Set([
|
|
461
|
+
...decision.deadInstances.map((i) => i.pid),
|
|
462
|
+
...decision.staleInstances.map((i) => i.pid),
|
|
463
|
+
]);
|
|
464
|
+
await pruneDeadInstances(prunePids);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// best-effort — a stale registry entry is re-evaluated next sweep
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
logLatency({
|
|
472
|
+
type: "phase",
|
|
473
|
+
phase: "orphan_lsp_reaped",
|
|
474
|
+
filePath: "",
|
|
475
|
+
durationMs: Date.now() - startedAt,
|
|
476
|
+
metadata: {
|
|
477
|
+
deadInstances: decision.deadInstances.length,
|
|
478
|
+
staleInstances: decision.staleInstances.length,
|
|
479
|
+
killed: killedCount,
|
|
480
|
+
serverIds: killedServerIds,
|
|
481
|
+
markerSearches: decision.markerSearches.length,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// best-effort logging only
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// The sweep must never throw out of session_start.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** Drop dead-parent AND stale-heartbeat (#525, record-cleanup-only)
|
|
494
|
+
* instances from the registry. Re-reads immediately before
|
|
495
|
+
* writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
|
|
496
|
+
* to narrow — not eliminate — the last-writer-wins race already accepted for
|
|
497
|
+
* slice 1's read-modify-write model. */
|
|
498
|
+
async function pruneDeadInstances(deadPids) {
|
|
499
|
+
const target = path.join(getGlobalPiLensDir(), "instances.json");
|
|
500
|
+
try {
|
|
501
|
+
const raw = await fs.promises.readFile(target, "utf-8");
|
|
502
|
+
const parsed = JSON.parse(raw);
|
|
503
|
+
if (!parsed || !Array.isArray(parsed.instances))
|
|
504
|
+
return;
|
|
505
|
+
const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
|
|
506
|
+
if (remaining.length === parsed.instances.length)
|
|
507
|
+
return;
|
|
508
|
+
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
509
|
+
await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
|
|
510
|
+
await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
|
|
511
|
+
await fs.promises.rename(tmpPath, target);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// best-effort
|
|
515
|
+
}
|
|
516
|
+
}
|