pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
744 lines (743 loc) • 34.4 kB
JavaScript
/**
* Orphaned LSP process reaper (#472), built on the instance registry (#449
* slice 1).
*
* Split into a PURE decision function (`decideOrphanReaping`) and an IMPURE
* sweep (`sweepOrphans`) so the decision logic is unit-testable with fake
* pid tables — no real process spawns/kills in tests.
*
* Why the registry reaper, not EOF/processId alone (see issue #472): both are
* best-effort hints a well-behaved server may honor (typescript-language-server
* does; ast-grep's native exe does not — an upstream LSP-spec violation). The
* registry reaper works regardless of why a stdin pipe write-end stayed open
* after the parent died (Windows handle-inheritance capture) — it identifies
* dead-parent instances directly and kills the full recorded child tree, with
* a command-line marker fallback for the case where the pid itself was
* recycled or the mid-tree pid link is broken (e.g. a dead node-wrapper whose
* native-exe grandchild is still alive under a different, unrecorded pid).
*
* #525 root cause: a parent instance was ONLY ever considered dead via
* `isPidAlive(instance.pid)` — a raw `process.kill(pid, 0)` check. Unlike
* child pids (which get a command-line/marker identity check via
* `matchProcess` to guard against a recycled pid), the PARENT pid had no
* identity verification at all, because `InstanceEntry` never recorded the
* parent's own command line. Windows recycles pids far more aggressively than
* POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so
* over a long enough window (observed: ~13h) a dead parent's pid is very
* plausibly reassigned to a live, unrelated process — `isPidAlive` then
* (correctly, per its own conservative contract) reports "alive", and the
* stale instance is never reaped, no matter how old its heartbeat is.
*
* The #525 fix is deliberately ASYMMETRIC by consequence:
*
* - **Heartbeat staleness (`STALE_HEARTBEAT_MS`) cleans REGISTRY ENTRIES,
* never kills.** A stale-heartbeat-but-pid-alive instance goes into
* `staleInstances` (entry dropped from instances.json) with ZERO process
* kills and its children still marker-protected. Why: the heartbeat call
* sites are runtime-turn.ts (per turn end) and quiet-window.ts (per run
* settle) ONLY — no timer exists. A pi session left OPEN but UNUSED
* overnight fires neither, so its heartbeat legitimately goes >6h stale
* while the session and its warm LSP fleet are genuinely alive. Killing on
* staleness would take that fleet down under the idle session — and
* `matchProcess` would NOT save it (its children really ARE that
* instance's LSP servers; identity verification guards against pid reuse,
* not against misclassifying a live parent). Removing just the entry is
* safe: the idle session's next turn re-registers via `registerInstance`.
* - **Process kills require a pid-confirmed-DEAD parent** (`deadInstances`),
* exactly as before #525. Only then are its children classified for
* kill/marker-search.
*
* This still fixes both observed #525 cases: the 13h-stale test-fixture
* entry AND the recycled-parent-pid case (a dead parent whose pid now
* belongs to an unrelated live process — `isPidAlive` lies, but the stale
* heartbeat gets the ENTRY dropped, which is all the pollution fix needs).
*/
/**
* A pid-ALIVE instance whose heartbeat is older than this gets its registry
* ENTRY removed — it is NEVER kill-eligible on staleness alone (see the
* module docstring's asymmetry note: an overnight-idle-but-alive session
* legitimately exceeds this threshold because heartbeats only fire at turn
* end / run settle, so staleness must clean records, not kill processes).
* 6 hours comfortably catches the observed 13h-stale pollution case.
*/
export const STALE_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
import { spawn as nodeSpawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { getGlobalPiLensDir } from "./file-utils.js";
import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
import { logLatency } from "./latency-logger.js";
const isWindows = process.platform === "win32";
/**
* KILL eligibility: pid-confirmed-dead ONLY. Heartbeat staleness must never
* make an instance kill-eligible — an overnight-idle-but-alive session
* legitimately goes >STALE_HEARTBEAT_MS stale (heartbeats fire only at turn
* end / run settle; no timer exists), and killing its genuine live LSP
* children would pass `matchProcess` identity verification (they really are
* that instance's servers — the matcher guards against pid reuse, not
* against misclassifying a live parent). See the module docstring (#525).
*/
function isInstanceKillEligible(instance, isPidAlive) {
return !isPidAlive(instance.pid);
}
/**
* REGISTRY-ENTRY staleness: heartbeat older than `STALE_HEARTBEAT_MS` (or
* unparseable — missing data must never keep a polluted entry alive
* forever). Drives entry removal ONLY, never kills (#525). This is what
* cleans the recycled-parent-pid case: `isPidAlive` lies for a long-dead
* parent whose pid the OS reassigned to an unrelated live process, but the
* stale heartbeat still gets the ENTRY dropped.
*/
function isInstanceEntryStale(instance, now) {
const heartbeatMs = Date.parse(instance.heartbeatAt);
if (Number.isNaN(heartbeatMs))
return true;
return now - heartbeatMs > STALE_HEARTBEAT_MS;
}
/**
* Markers claimed by any pid-ALIVE instance's children. A marker search
* kills by command-line match, so a marker that a live session also uses
* must never be searched — killing it would take down the live session's
* server. Protection is deliberately keyed on pid-liveness ALONE, regardless
* of heartbeat staleness (#525): a stale-heartbeat-but-alive instance (e.g.
* overnight-idle) must keep its children protected — protection stays
* conservative even where entry cleanup does not.
* Markers are per-process-unique by construction (sgconfig.ts embeds the
* pid), so this is defense in depth against non-unique markers ever
* reappearing (#472: the original shared baseline.sgconfig.yml would have
* made the fallback kill every live ast-grep on the machine).
*/
function collectLiveMarkers(registry, isPidAlive) {
const liveMarkers = new Set();
for (const instance of registry) {
if (!isPidAlive(instance.pid))
continue;
for (const child of instance.lspChildren) {
if (child.marker)
liveMarkers.add(child.marker);
}
}
return liveMarkers;
}
/**
* Classify one dead-parent instance's children into kills / marker-searches,
* appending onto the shared `out` accumulator. Extracted from
* `decideOrphanReaping` to keep cognitive complexity in check — no behavior
* change, just the per-instance inner loop pulled out.
*/
function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
for (const child of instance.lspChildren) {
const childAlive = isPidAlive(child.pid);
if (childAlive) {
const identityOk = matchProcess
? matchProcess(child.pid, {
command: child.command,
marker: child.marker,
})
: true;
if (identityOk) {
out.childrenToKill.push({
pid: child.pid,
serverId: child.serverId,
command: child.command,
});
continue;
}
}
// Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
// if we have a marker, surface it so the caller can find a live
// process (e.g. the native exe grandchild) by command-line match.
// Never surface a marker a live instance also claims (see above).
if (child.marker && !liveMarkers.has(child.marker)) {
out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
}
}
}
/**
* Pure decision function: given the registry state and injectable liveness /
* identity predicates, decide what to kill. Performs zero I/O.
*
* @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
* CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
* ambiguous result (EPERM, or the caller's fake table saying "unknown")
* must be treated as alive — never kill on an ambiguous signal-check.
* @param matchProcess - optional identity verification (e.g. confirm the
* live pid's command line still matches what we recorded) to guard against
* a recycled pid coincidentally matching. If omitted, liveness alone is used.
* @param now - epoch ms "now", for heartbeat-staleness comparison (#525).
* Injectable for deterministic tests; defaults to `Date.now()`. The two
* signals are ASYMMETRIC by consequence: pid-confirmed-dead ⇒
* `deadInstances` (kill-eligible + entry removal); pid-alive but heartbeat
* older than `STALE_HEARTBEAT_MS` ⇒ `staleInstances` (entry removal ONLY —
* never kills, never loses marker protection; the parent may be an
* overnight-idle-but-alive session). See the module docstring.
*/
export function decideOrphanReaping(registry, isPidAlive, matchProcess, now = Date.now()) {
const deadInstances = [];
const staleInstances = [];
const childrenToKill = [];
const markerSearches = [];
// Marker protection is pid-liveness ONLY — a stale-heartbeat-but-alive
// instance keeps its children protected (conservative on the kill side).
const liveMarkers = collectLiveMarkers(registry, isPidAlive);
for (const instance of registry) {
if (isInstanceKillEligible(instance, isPidAlive)) {
// pid-confirmed-dead: entry removal + children classified for kills.
deadInstances.push(instance);
classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
childrenToKill,
markerSearches,
});
}
else if (isInstanceEntryStale(instance, now)) {
// pid-alive but stale heartbeat: record cleanup only — NO kills.
staleInstances.push(instance);
}
// else: alive + fresh heartbeat — leave it alone entirely.
}
return { deadInstances, staleInstances, childrenToKill, markerSearches };
}
// --- Impure liveness / identity / kill helpers ---
/** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
* (EPERM, or no error thrown at all) ⇒ conservatively alive. Exported so
* other registry consumers (clients/lsp-budget.ts, #449 slice 2) reuse this
* exact liveness check rather than inventing a second one. */
export function realIsPidAlive(pid) {
if (!Number.isFinite(pid) || pid <= 0)
return false;
try {
process.kill(pid, 0);
return true; // no throw — process exists and we can signal it
}
catch (err) {
const code = err?.code;
if (code === "ESRCH")
return false; // definitively dead
// EPERM (exists, no permission) or any other/unknown errno: ambiguous —
// never treat as dead.
return true;
}
}
function windowsExe(name) {
return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
}
/** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
* Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
* `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
* (spawn will then fail closed rather than silently resolving via PATH). */
function posixPsPath() {
if (fs.existsSync("/bin/ps"))
return "/bin/ps";
if (fs.existsSync("/usr/bin/ps"))
return "/usr/bin/ps";
return "/bin/ps";
}
/** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
* string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
* is an opaque path string, so escape all three before interpolating. */
function escapeWqlLikeValue(value) {
return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
}
/** Search running processes whose command line contains `marker` (Windows,
* via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
async function findPidsByMarkerWindows(marker) {
if (!isWindows || !marker)
return [];
const escaped = escapeWqlLikeValue(marker);
// $PID exclusion: the query's own powershell.exe command line embeds the
// marker string, so it would match itself.
const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
`| Where-Object { $_.ProcessId -ne $PID } ` +
`| Select-Object -ExpandProperty ProcessId`;
return new Promise((resolve) => {
try {
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString();
});
child.once("error", () => resolve([]));
child.once("close", () => {
const pids = out
.split(/\r?\n/)
.map((line) => Number(line.trim()))
.filter((n) => Number.isFinite(n) && n > 0);
resolve(pids);
});
}
catch {
resolve([]);
}
});
}
/** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
* `ps`). Returns a pid → command-line map; pids that can't be resolved are
* simply absent (the caller treats absent as "identity unverifiable — do not
* kill by pid"). Best-effort: any failure ⇒ empty map. */
async function queryCommandLines(pids) {
const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
const map = new Map();
if (valid.length === 0)
return map;
if (isWindows) {
const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
`| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
return new Promise((resolve) => {
try {
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString();
});
child.once("error", () => resolve(map));
child.once("close", () => {
for (const line of out.split(/\r?\n/)) {
const tab = line.indexOf("\t");
if (tab <= 0)
continue;
const pid = Number(line.slice(0, tab).trim());
if (Number.isFinite(pid) && pid > 0)
map.set(pid, line.slice(tab + 1));
}
resolve(map);
});
}
catch {
resolve(map);
}
});
}
return new Promise((resolve) => {
try {
const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString();
});
child.once("error", () => resolve(map));
child.once("close", () => {
for (const line of out.split(/\r?\n/)) {
// Linear parse (S8786/S6594: avoid regex backtracking on
// attacker-lengthenable ps output) — trim leading whitespace,
// then split on the first whitespace run: " 1234 args here".
const trimmed = line.trimStart();
if (!trimmed)
continue;
let i = 0;
while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
i++;
if (i === 0)
continue;
const pidStr = trimmed.slice(0, i);
let j = i;
while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
j++;
const pid = Number(pidStr);
if (Number.isFinite(pid) && pid > 0)
map.set(pid, trimmed.slice(j));
}
resolve(map);
});
}
catch {
resolve(map);
}
});
}
/**
* Build a `matchProcess` identity predicate from a pid → command-line map
* (as produced by `queryCommandLines`). PURE — exported for unit testing.
*
* Semantics (guarding pid kills against pid recycling):
* - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
* by pid (the marker-search fallback may still catch a real orphan).
* - marker recorded and present in the command line ⇒ match (strongest
* signal — markers are per-spawn-unique).
* - else: the recorded command's basename appears (case-insensitive) in the
* command line ⇒ match. Empty basename never matches (guard against a
* recorded empty/odd command matching everything via `includes("")`).
*/
export function buildIdentityMatcher(cmdlines) {
return (pid, expected) => {
const cmdline = cmdlines.get(pid);
if (cmdline === undefined)
return false; // unverifiable ⇒ never kill by pid
if (expected.marker && cmdline.includes(expected.marker))
return true;
const basename = path.basename(expected.command ?? "").toLowerCase();
if (!basename)
return false;
return cmdline.toLowerCase().includes(basename);
};
}
/** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
* mirror killProcessTree's process-group kill, falling back to a direct
* signal. Best-effort: swallow all errors. */
async function killPidTree(pid) {
if (!Number.isFinite(pid) || pid <= 0)
return;
if (isWindows) {
try {
const taskkill = windowsExe("taskkill.exe");
const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
shell: false,
windowsHide: true,
stdio: "ignore",
});
await new Promise((resolve) => {
killer.once("close", () => resolve());
killer.once("error", () => resolve());
});
}
catch {
// best-effort
}
return;
}
try {
process.kill(-pid, "SIGKILL");
}
catch {
try {
process.kill(pid, "SIGKILL");
}
catch {
// best-effort — process may already be gone
}
}
}
/**
* #658: known pi-lens-managed LSP/scanner binary names (clients/lsp/server.ts
* spawn candidates), used by the registry-INDEPENDENT backstop sweep below.
* `opengrep-core` is opengrep's own native subprocess (not spawned directly by
* pi-lens, but still part of the tree we manage). `yaml-language-server` and
* `typescript-language-server` are node-launched — their OS image name is
* `node`/`node.exe`, so matching is done against the full command line
* (script path), not the process image name, exactly like the existing
* marker-search's WQL LIKE query below.
*/
export const MANAGED_BINARY_NAMES = [
"ast-grep",
"opengrep-core",
"opengrep",
"marksman",
"zizmor",
"typos-lsp",
"yaml-language-server",
"typescript-language-server",
];
/**
* PURE decision function for the #658 registry-independent backstop: given a
* live OS-process snapshot (already filtered to known managed binary names)
* and the current registry snapshot, decide which processes are backstop-
* kill-eligible. Zero I/O — unit-testable with fake data, mirroring
* `decideOrphanReaping`.
*
* A process is eligible ONLY when ALL of:
* - its pid is NOT already tracked in any instance's `lspChildren[]` (tracked
* pids stay owned by the registry-driven `decideOrphanReaping` path above —
* this backstop must never race or duplicate that logic);
* - its reported parent pid is a verifiable, well-formed pid (finite,
* positive, and not equal to its own pid) — an unresolvable/malformed
* parent pid is UNVERIFIABLE, never treated as "confirmed dead" (this is a
* stricter contract than `realIsPidAlive`'s own conservatism, because here
* an invalid value means "the OS couldn't tell us", not "confirmed gone");
* - `isPidAlive(parentPid)` reports the parent as dead.
*
* Note the direction of the ambiguity guard: if `parentPid` itself was
* recycled onto an unrelated live process, `isPidAlive` conservatively
* reports "alive" and the process is (safely) left alone — a false negative,
* never a false positive kill. Binary name alone is never sufficient (name
* matching only decides which processes are candidates for this check at
* all); a live parent is never overridden "however unfamiliar" the process.
*/
export function decideBackstopOrphanReaping(processes, registry, isPidAlive) {
const trackedPids = new Set();
for (const instance of registry) {
for (const child of instance.lspChildren)
trackedPids.add(child.pid);
}
const out = [];
for (const proc of processes) {
if (trackedPids.has(proc.pid))
continue; // owned by the registry-driven reaper
if (!Number.isFinite(proc.parentPid) || proc.parentPid <= 0)
continue; // unverifiable
if (proc.parentPid === proc.pid)
continue; // malformed data guard
if (isPidAlive(proc.parentPid))
continue; // live parent — never kill
out.push(proc);
}
return out;
}
/** Enumerate live OS processes whose command line contains one of
* `MANAGED_BINARY_NAMES` (#658), independent of the instance registry.
* Windows: one batched CIM/WQL query (mirrors `findPidsByMarkerWindows`'s
* query pattern). POSIX: `ps -eo pid=,ppid=,args=`, filtered in JS. Returns
* `{pid, parentPid, command}` rows. Best-effort: any failure ⇒ []. */
async function enumerateManagedProcesses() {
if (isWindows) {
const clauses = MANAGED_BINARY_NAMES.map((name) => `CommandLine LIKE '%${escapeWqlLikeValue(name)}%'`).join(" OR ");
const psScript = `Get-CimInstance Win32_Process -Filter "${clauses}" ` +
`| ForEach-Object { "$($_.ProcessId)\t$($_.ParentProcessId)\t$($_.CommandLine)" }`;
return new Promise((resolve) => {
try {
const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString();
});
child.once("error", () => resolve([]));
child.once("close", () => {
const results = [];
for (const line of out.split(/\r?\n/)) {
const firstTab = line.indexOf("\t");
if (firstTab <= 0)
continue;
const secondTab = line.indexOf("\t", firstTab + 1);
if (secondTab <= 0)
continue;
const pid = Number(line.slice(0, firstTab).trim());
const parentPid = Number(line.slice(firstTab + 1, secondTab).trim());
const command = line.slice(secondTab + 1);
if (Number.isFinite(pid) && pid > 0) {
results.push({ pid, parentPid, command });
}
}
resolve(results);
});
}
catch {
resolve([]);
}
});
}
// POSIX: enumerate everything, filter in JS by managed-name substring —
// there is no single-query WQL-style server-side filter available.
return new Promise((resolve) => {
try {
const child = nodeSpawn(posixPsPath(), ["-eo", "pid=,ppid=,args="], {
shell: false,
stdio: ["ignore", "pipe", "ignore"],
});
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString();
});
child.once("error", () => resolve([]));
child.once("close", () => {
const results = [];
for (const line of out.split(/\r?\n/)) {
// Linear parse (S8786/S6594): " pid ppid args here".
const trimmed = line.trimStart();
if (!trimmed)
continue;
let i = 0;
while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
i++;
if (i === 0)
continue;
const pid = Number(trimmed.slice(0, i));
let rest = trimmed.slice(i);
let k = 0;
while (k < rest.length && (rest[k] === " " || rest[k] === "\t"))
k++;
rest = rest.slice(k);
let j = 0;
while (j < rest.length && rest[j] >= "0" && rest[j] <= "9")
j++;
if (j === 0)
continue;
const parentPid = Number(rest.slice(0, j));
let m = j;
while (m < rest.length && (rest[m] === " " || rest[m] === "\t"))
m++;
const args = rest.slice(m);
if (!Number.isFinite(pid) || pid <= 0)
continue;
const lowerArgs = args.toLowerCase();
if (MANAGED_BINARY_NAMES.some((name) => lowerArgs.includes(name.toLowerCase()))) {
results.push({ pid, parentPid, command: args });
}
}
resolve(results);
});
}
catch {
resolve([]);
}
});
}
/**
* Fire-and-forget REGISTRY-INDEPENDENT backstop sweep (#658): finds
* pi-lens-managed LSP/scanner processes the registry-driven `sweepOrphans`
* can never see again once their registry trace is lost (a stale-heartbeat
* entry removal, or a `killPidTree` call that failed silently) — enumerates
* live OS processes by known binary name, and kills any that are both
* untracked and have a confirmed-dead parent. A strictly ADDITIVE second
* layer: `sweepOrphans`'s registry-driven path is untouched and remains the
* cheap, correct common case.
*
* Kill-attempt retry: deliberately NO separate retry-tracking state. If
* `killPidTree` fails silently, the process stays alive, untracked, and its
* parent stays dead — so it stays classified as backstop-kill-eligible and
* this exact sweep simply tries again at the next `session_start`, with zero
* extra bookkeeping. This only breaks if the orphan's *parent* pid gets
* reused by a live process in the interim (closes the window), which is the
* same residual risk `decideOrphanReaping`'s registry path already accepts.
* A future hardening (making `killPidTree` return a success signal so a
* failed kill can be logged distinctly from "already gone") is a reasonable
* follow-up, not required for correctness here.
*
* Never throws — every step is wrapped so a reap failure cannot block or
* crash the caller (session_start).
*/
export async function sweepUntrackedOrphans() {
if (!isInstanceRegistryEnabled())
return;
const startedAt = Date.now();
try {
const [registry, processes] = await Promise.all([
readInstanceRegistry(),
enumerateManagedProcesses(),
]);
if (processes.length === 0)
return;
const candidates = decideBackstopOrphanReaping(processes, registry, realIsPidAlive);
let killedCount = 0;
for (const proc of candidates) {
await killPidTree(proc.pid);
killedCount++;
}
try {
logLatency({
type: "phase",
phase: "orphan_backstop_reaped",
filePath: "",
durationMs: Date.now() - startedAt,
metadata: {
scanned: processes.length,
killed: killedCount,
},
});
}
catch {
// best-effort logging only
}
}
catch {
// The sweep must never throw out of session_start.
}
}
/**
* Fire-and-forget orphan sweep: reads the registry, decides what's dead via
* `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
* marker-based command-line search fallback), then drops fully-dead
* instances from the registry. Never throws — every step is wrapped so a
* reap failure cannot block or crash the caller (session_start).
*/
export async function sweepOrphans() {
if (!isInstanceRegistryEnabled())
return;
const startedAt = Date.now();
try {
const registry = await readInstanceRegistry();
if (registry.length === 0)
return;
// Identity verification before any pid kill (recycled-pid guard): fetch
// the command lines of every recorded child pid in ONE batched query,
// then let the pure decision function verify each live child's identity
// against what was recorded at spawn. A pid whose command line can't be
// fetched is treated as unverifiable and never killed by pid — the
// marker-search fallback may still catch it.
const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
const cmdlines = await queryCommandLines(candidatePids);
const matchProcess = buildIdentityMatcher(cmdlines);
const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
let killedCount = 0;
const killedServerIds = [];
for (const child of decision.childrenToKill) {
await killPidTree(child.pid);
killedCount++;
killedServerIds.push(child.serverId);
}
for (const search of decision.markerSearches) {
try {
const pids = await findPidsByMarkerWindows(search.marker);
for (const pid of pids) {
await killPidTree(pid);
killedCount++;
killedServerIds.push(search.serverId);
}
}
catch {
// best-effort — a failed marker search just misses that orphan this sweep
}
}
// Entry removal covers BOTH sets: pid-dead instances AND stale-heartbeat
// (pid-alive) instances — the latter is record cleanup only (#525);
// nothing belonging to a stale instance was killed above.
if (decision.deadInstances.length > 0 || decision.staleInstances.length > 0) {
try {
const prunePids = new Set([
...decision.deadInstances.map((i) => i.pid),
...decision.staleInstances.map((i) => i.pid),
]);
await pruneDeadInstances(prunePids);
}
catch {
// best-effort — a stale registry entry is re-evaluated next sweep
}
}
try {
logLatency({
type: "phase",
phase: "orphan_lsp_reaped",
filePath: "",
durationMs: Date.now() - startedAt,
metadata: {
deadInstances: decision.deadInstances.length,
staleInstances: decision.staleInstances.length,
killed: killedCount,
serverIds: killedServerIds,
markerSearches: decision.markerSearches.length,
},
});
}
catch {
// best-effort logging only
}
}
catch {
// The sweep must never throw out of session_start.
}
}
/** Drop dead-parent AND stale-heartbeat (#525, record-cleanup-only)
* instances from the registry. Re-reads immediately before
* writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
* to narrow — not eliminate — the last-writer-wins race already accepted for
* slice 1's read-modify-write model. */
async function pruneDeadInstances(deadPids) {
const target = path.join(getGlobalPiLensDir(), "instances.json");
try {
const raw = await fs.promises.readFile(target, "utf-8");
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.instances))
return;
const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
if (remaining.length === parsed.instances.length)
return;
const tmpPath = `${target}.tmp-${process.pid}`;
await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
await fs.promises.rename(tmpPath, target);
}
catch {
// best-effort
}
}