pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
308 lines (307 loc) • 11.7 kB
JavaScript
/**
* Cross-process instance registry (#449 slice 1).
*
* Observability substrate for multi-agent LSP resource sharing. Records, in
* a single machine-global file (`~/.pi-lens/instances.json`), every live
* pi-lens process: its pid, project root, live LSP child servers, RSS, and a
* heartbeat timestamp. Later slices (cross-process budget, same-root warm
* attach) build on this; slice 1 is purely observational — it changes no
* dispatch/LSP behavior, it only records state and reaps stale entries /
* orphaned LSP children (#472).
*
* File shape: `{ instances: InstanceEntry[] }`. Missing or corrupt file is
* treated as `{ instances: [] }` — this module must never throw on a read.
*
* Concurrency: every write is read-modify-write-whole-file with an atomic
* tmp+rename (same pattern as clients/review-graph/builder.ts). Two
* processes racing a write is a KNOWN, ACCEPTED race for slice 1
* (last-writer-wins) — a lost update here only means a stale/missing
* observability entry, never data corruption (the tmp+rename guarantees the
* file itself is always valid JSON). A future slice can add file locking or
* per-pid shard files if this proves too lossy in practice.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getGlobalPiLensDir } from "./file-utils.js";
import { normalizeFilePath } from "./path-utils.js";
function registryPath() {
return path.join(getGlobalPiLensDir(), "instances.json");
}
// --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts) ---
let _enabledCache;
/**
* `PI_LENS_INSTANCE_REGISTRY=0` disables the registry entirely: every
* exported function in this module becomes a no-op (including the reaper
* sweep in clients/instance-reaper.ts, which checks this too).
*/
export function isInstanceRegistryEnabled() {
if (_enabledCache !== undefined)
return _enabledCache;
_enabledCache = process.env.PI_LENS_INSTANCE_REGISTRY !== "0";
return _enabledCache;
}
/** Test-only: clear the memoized kill-switch read. */
export function _resetInstanceRegistryEnabledForTests() {
_enabledCache = undefined;
}
// --- Read ---
function readRegistrySync() {
try {
const raw = fs.readFileSync(registryPath(), "utf-8");
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.instances)) {
return parsed;
}
return { instances: [] };
}
catch {
// Missing file, corrupt JSON, or wrong shape — treat as empty, never throw.
return { instances: [] };
}
}
async function readRegistryAsync() {
try {
const raw = await fs.promises.readFile(registryPath(), "utf-8");
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.instances)) {
return parsed;
}
return { instances: [] };
}
catch {
return { instances: [] };
}
}
/** Read-only snapshot of the whole registry (used by the reaper). */
export async function readInstanceRegistry() {
const file = await readRegistryAsync();
return file.instances;
}
// --- Write (atomic tmp + rename, same pattern as review-graph/builder.ts) ---
async function writeRegistryAsync(file) {
const dir = getGlobalPiLensDir();
const target = registryPath();
const tmpPath = `${target}.tmp-${process.pid}`;
try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8");
await fs.promises.rename(tmpPath, target);
}
catch {
// Best-effort observability substrate — a failed write just means this
// update is lost, never a thrown error for the caller.
try {
await fs.promises.rm(tmpPath, { force: true });
}
catch {
// ignore
}
}
}
function writeRegistrySync(file) {
const dir = getGlobalPiLensDir();
const target = registryPath();
const tmpPath = `${target}.tmp-${process.pid}`;
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(file), "utf-8");
fs.renameSync(tmpPath, target);
}
catch {
try {
fs.rmSync(tmpPath, { force: true });
}
catch {
// ignore
}
}
}
// --- Mutations (all read-modify-write whole file) ---
/** Create/overwrite this process's entry. */
export async function registerInstance(projectRoot) {
if (!isInstanceRegistryEnabled())
return;
const pid = process.pid;
const normalizedRoot = normalizeFilePath(projectRoot);
const file = await readRegistryAsync();
const now = new Date().toISOString();
const others = file.instances.filter((entry) => entry.pid !== pid);
const existing = file.instances.find((entry) => entry.pid === pid);
others.push({
pid,
startedAt: existing?.startedAt ?? now,
projectRoot: normalizedRoot,
lspChildren: existing?.lspChildren ?? [],
lspChildCount: existing?.lspChildren?.length ?? 0,
rssBytes: process.memoryUsage().rss,
heartbeatAt: now,
});
await writeRegistryAsync({ instances: others });
}
/** Update this process's heartbeat/rss (and, since #620, host CPU% + live
* LSP children's rss/CPU%). Cheap — safe to call every turn end. */
export async function updateHeartbeat(patch = {}) {
if (!isInstanceRegistryEnabled())
return;
const pid = process.pid;
const file = await readRegistryAsync();
const idx = file.instances.findIndex((entry) => entry.pid === pid);
if (idx === -1) {
// No prior registerInstance in this run (e.g. registry file was reaped
// out from under us, or heartbeat fired before session_start finished) —
// nothing to update against; skip rather than fabricate a projectRoot.
return;
}
const now = new Date().toISOString();
const current = file.instances[idx];
const lspChildren = patch.childUsage
? current.lspChildren.map((child) => {
const usage = patch.childUsage?.[child.pid];
if (!usage)
return child;
return {
...child,
rssBytes: usage.rssBytes ?? child.rssBytes,
cpuPercent: usage.cpuPercent ?? child.cpuPercent,
};
})
: current.lspChildren;
file.instances[idx] = {
...current,
rssBytes: patch.rssBytes ?? process.memoryUsage().rss,
cpuPercent: patch.cpuPercent ?? current.cpuPercent,
lspChildren,
lspChildCount: lspChildren.length,
heartbeatAt: now,
};
await writeRegistryAsync(file);
}
/** Append/replace (by pid) an LSP child under this process's entry. */
export async function recordLspChild(entry) {
if (!isInstanceRegistryEnabled())
return;
const pid = process.pid;
const file = await readRegistryAsync();
const idx = file.instances.findIndex((inst) => inst.pid === pid);
const now = new Date().toISOString();
const childEntry = {
pid: entry.pid,
serverId: entry.serverId,
command: entry.command,
marker: entry.marker,
spawnedAt: now,
};
if (idx === -1) {
// registerInstance hasn't run yet in this process (or was reaped) —
// synthesize a minimal entry so the child is still tracked.
file.instances.push({
pid,
startedAt: now,
projectRoot: normalizeFilePath(process.cwd()),
lspChildren: [childEntry],
lspChildCount: 1,
rssBytes: process.memoryUsage().rss,
heartbeatAt: now,
});
}
else {
const current = file.instances[idx];
const filtered = current.lspChildren.filter((child) => child.pid !== entry.pid);
filtered.push(childEntry);
file.instances[idx] = {
...current,
lspChildren: filtered,
lspChildCount: filtered.length,
};
}
await writeRegistryAsync(file);
}
/** Remove an LSP child (by pid) from this process's entry. */
export async function removeLspChild(pid) {
if (!isInstanceRegistryEnabled())
return;
const selfPid = process.pid;
const file = await readRegistryAsync();
const idx = file.instances.findIndex((inst) => inst.pid === selfPid);
if (idx === -1)
return;
const current = file.instances[idx];
const filtered = current.lspChildren.filter((child) => child.pid !== pid);
if (filtered.length === current.lspChildren.length)
return; // nothing removed
file.instances[idx] = {
...current,
lspChildren: filtered,
lspChildCount: filtered.length,
};
await writeRegistryAsync(file);
}
/**
* Remove this process's entry entirely. SYNC fs only — safe to call from
* `session_shutdown` (#234: no child spawns permitted at teardown; this
* function spawns nothing).
*/
export function deregisterInstance() {
if (!isInstanceRegistryEnabled())
return;
const pid = process.pid;
const file = readRegistrySync();
const remaining = file.instances.filter((entry) => entry.pid !== pid);
if (remaining.length === file.instances.length)
return; // nothing to remove
writeRegistrySync({ instances: remaining });
}
/**
* PURE aggregation over a registry snapshot: "how much CPU/RAM is pi-lens
* attributable to, right now, across every process it owns" (#620) — the
* host of every registered instance plus every one of its live LSP children.
* Missing/unsampled `rssBytes`/`cpuPercent` (best-effort sampling can fail)
* are treated as 0 for summation purposes — never as a full instance to
* exclude, since a partially-sampled instance's other numbers are still real
* data worth surfacing.
*
* Does NOT include transient analyzer children (jscpd/knip/etc.) — those are
* short-lived and sampled separately per-invocation via
* clients/resource-sampler.ts into clients/latency-logger.ts, not carried in
* the registry (see the module docstring's scope note).
*/
export function computeResourceFootprint(instances) {
const perInstance = instances.map((instance) => {
const lspChildRssBytes = instance.lspChildren.reduce((sum, child) => sum + (child.rssBytes ?? 0), 0);
const lspChildCpuPercent = instance.lspChildren.reduce((sum, child) => sum + (child.cpuPercent ?? 0), 0);
return {
pid: instance.pid,
projectRoot: instance.projectRoot,
rssBytes: instance.rssBytes ?? 0,
cpuPercent: instance.cpuPercent ?? 0,
lspChildCount: instance.lspChildren.length,
lspChildRssBytes,
lspChildCpuPercent,
};
});
let totalRssBytes = 0;
let totalCpuPercent = 0;
let totalLspChildCount = 0;
for (const inst of perInstance) {
totalRssBytes += inst.rssBytes + inst.lspChildRssBytes;
totalCpuPercent += inst.cpuPercent + inst.lspChildCpuPercent;
totalLspChildCount += inst.lspChildCount;
}
return {
instanceCount: perInstance.length,
totalRssBytes,
totalCpuPercent,
totalLspChildCount,
perInstance,
};
}
/**
* Read the live registry and compute the aggregate footprint — the query
* side of "how much CPU/RAM is pi-lens using right now" (#620). Best-effort
* (readInstanceRegistry never throws); the answer only reflects whatever
* heartbeats have landed so far.
*/
export async function getResourceFootprint() {
const instances = await readInstanceRegistry();
return computeResourceFootprint(instances);
}