pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
149 lines (148 loc) • 7.67 kB
JavaScript
/**
* Cross-process LSP budget (#449 slice 2 — PROTOTYPE).
*
* Slice 1 (#472/#474/#475, `clients/instance-registry.ts` +
* `clients/instance-reaper.ts`) made every concurrent pi-lens process visible
* to every other one via `~/.pi-lens/instances.json`. This module is the
* first thing that DOES something with that visibility: a machine-wide cap
* on total live LSP server processes. When a NEW session starts and the
* machine is already over budget, it degrades its OWN spawn plan — it never
* touches another instance's already-running servers.
*
* Deliberately NOT a clearinghouse: no negotiation, no reservation, no
* cross-process locking. Each session reads the registry once at
* `session_start`, decides locally, and moves on — a "position limit," per
* the issue's own framing, not shared ownership of anyone else's servers.
*
* Degrade mechanism chosen for this first slice: skip spawning AUXILIARY LSP
* servers (role:"auxiliary" in clients/lsp/server.ts — opengrep, ast-grep,
* zizmor, typos, marksman) for the remainder of THIS session, keeping only
* the primary language server per file. Auxiliaries are cross-cutting
* scanners layered on top of (not required for) core diagnostics, so this is
* the cheapest, highest-signal thing to shed under machine-wide pressure.
* The issue's OTHER suggested mechanism — shortening this session's own
* idle-reaper timeout — is a documented follow-up, not implemented here (see
* the module docstring's "Not implemented" note below).
*
* Ceiling default (`DEFAULT_LSP_BUDGET_CEILING = 16`): a rough RAM-budget
* back-of-envelope, not a measured figure (#620's CPU/RSS sampling had not
* landed as of this prototype) — assume ~250MB average RSS per live LSP
* child process (typescript-language-server/pyright cold-index spikes
* higher, short-lived auxiliaries like typos-lsp are much lighter, so this
* is a rough blend) against a soft target of ~4GB machine-wide dedicated to
* the LSP fleet: 4000MB / 250MB ≈ 16. Deliberately conservative-permissive
* for a first cut — the goal is to catch the "25 concurrent node.exe, several
* at 600MB-2GB RSS" pathological pile-up this was written in response to
* (dogfooding note, 2026-07-12/13), not to micromanage the common 2-4-agent
* case. `PI_LENS_LSP_BUDGET_CEILING` overrides it once real-world data (#620)
* says otherwise.
*
* Kill switch: `PI_LENS_CROSS_PROCESS_BUDGET=0` disables this module
* entirely (every session always spawns its full fleet, today's behavior) —
* lazy env read, never memoized, matching the house style
* (session-lifecycle.ts / runtime-config.ts).
*/
import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
import { realIsPidAlive } from "./instance-reaper.js";
import { logLatency } from "./latency-logger.js";
/** See the module docstring for the derivation. */
export const DEFAULT_LSP_BUDGET_CEILING = 16;
/** `PI_LENS_CROSS_PROCESS_BUDGET=0` disables the budget check entirely —
* lazy env read (house style), never memoized so tests can flip it mid-run. */
export function isCrossProcessBudgetEnabled() {
return process.env.PI_LENS_CROSS_PROCESS_BUDGET !== "0";
}
/** `PI_LENS_LSP_BUDGET_CEILING` overrides {@link DEFAULT_LSP_BUDGET_CEILING}.
* Non-finite/non-positive overrides are ignored (NaN-guard house style, see
* clients/runtime-config.ts) — falls back to the default rather than
* silently producing a ceiling of 0 (which would degrade every session). */
export function getLspBudgetCeiling() {
const raw = Number(process.env.PI_LENS_LSP_BUDGET_CEILING);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LSP_BUDGET_CEILING;
}
/**
* PURE decision function — no I/O. Mirrors `decideOrphanReaping`'s shape
* (clients/instance-reaper.ts) for the same reason: injectable liveness
* predicate, fully unit-testable with fake registry data, zero real
* process.kill/spawn/fs calls.
*
* Liveness is counted, not just registry presence, because a dead-parent
* instance's `lspChildren` entries are exactly the orphan reaper's target —
* counting them here would double-penalize new sessions for load that's
* already being cleaned up (or was already cleaned up and the entry just
* hasn't been pruned from this snapshot yet).
*/
export function decideLspBudget(registry, isPidAlive, ceiling) {
const totalLiveLspServers = registry.reduce((sum, instance) => isPidAlive(instance.pid) ? sum + instance.lspChildren.length : sum, 0);
const overBudget = totalLiveLspServers >= ceiling;
return {
totalLiveLspServers,
ceiling,
overBudget,
degradeAuxiliary: overBudget,
};
}
// --- Module-scope decision cache, read by clients/dispatch/auxiliary-lsp.ts ---
//
// session_start fires this check fire-and-forget (must never block session
// start on a registry read) and stashes the result here; the auxiliary-LSP
// enablement gate reads it synchronously on every dispatch. Default (before
// the async check resolves, or if it's never run — e.g. the kill switch, or
// a process that never reached session_start) is "don't degrade" — fail
// toward today's behavior, exactly like the concurrent-session guard.
let cachedDecision;
/** True once the budget check has run this process and found the machine
* over the configured ceiling. Read by
* `clients/dispatch/auxiliary-lsp.ts#enabledAuxiliaryLspServerIds`. Never
* throws; absent-decision (not yet checked, or disabled) reads as `false`. */
export function shouldDegradeAuxiliaryLsp() {
return cachedDecision?.degradeAuxiliary ?? false;
}
/** Test-only: reset the module-scope cache between tests. */
export function _resetLspBudgetDecisionForTests() {
cachedDecision = undefined;
}
/**
* Fire-and-forget budget check for `session_start`. Reads the instance
* registry, decides via {@link decideLspBudget}, and caches the result for
* `shouldDegradeAuxiliaryLsp` to read on subsequent dispatch calls. Never
* throws — a failed check just leaves the cache at its "don't degrade"
* default, matching every other best-effort registry consumer in this
* codebase (registerInstance/sweepOrphans).
*
* Not implemented in this prototype (documented follow-up, see the issue):
* shortening THIS session's own idle-reaper timeout when over budget. Skip-
* auxiliary was chosen as the single mechanism for the first slice because
* it's a pure spawn-time decision (no interaction with already-warm clients),
* whereas an idle-timeout change would need to reach into already-configured
* per-server wait/reap state.
*/
export async function checkCrossProcessLspBudget() {
if (!isCrossProcessBudgetEnabled() || !isInstanceRegistryEnabled())
return;
try {
const registry = await readInstanceRegistry();
if (registry.length === 0)
return; // nothing to be over budget against
const ceiling = getLspBudgetCeiling();
const decision = decideLspBudget(registry, realIsPidAlive, ceiling);
cachedDecision = decision;
if (decision.overBudget) {
logLatency({
type: "phase",
phase: "cross_process_lsp_budget_degraded",
filePath: "",
durationMs: 0,
metadata: {
totalLiveLspServers: decision.totalLiveLspServers,
ceiling: decision.ceiling,
instanceCount: registry.length,
},
});
}
}
catch {
// Best-effort observability-driven check — never throw out of
// session_start over this.
}
}