fix(tui): stop vitest auto-kill firing on garbage memory metric and killing non-vitest processes

Two compounding bugs made the memory-pressure vitest auto-kill a
30-second SIGKILL sweep of anything mentioning vitest:

1. False pressure: getAvailableMemory probed os.availableMemory, which
   does not exist, and silently fell back to os.freemem() — on macOS
   that reads ~99% used on an idle 256GB machine, permanently above the
   90% threshold. Now reads process.availableMemory() (Node 22+) and
   refuses to auto-kill when only the unreliable freemem fallback is
   available.

2. Overbroad targeting: pgrep -f vitest matches full command lines, so
   the sweep also killed wrapper shells (zsh -c '... npx vitest run'),
   monitor loops, and anything else whose argv mentions vitest —
   stranding exit handlers and taking out unrelated process trees.
   New shared findVitestProcessIds (@fusion/core) filters matches to
   actual node executables.

Surface enumeration (all vitest-process kill/count surfaces):
- TUI memory-pressure auto-kill (controller.killVitestProcesses)
- TUI manual kill-vitest command (same method)
- dashboard POST /api/kill-vitest
- dashboard GET /api/system-stats vitestProcessCount (display)
All four now route through findVitestProcessIds.
This commit is contained in:
gsxdsm
2026-06-03 13:33:38 -07:00
parent 3a62b6820f
commit 614bec2126
8 changed files with 298 additions and 50 deletions

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import os from "node:os";
import { getAvailableMemoryInfo } from "../controller.js";
type ProcessWithAvailableMemory = NodeJS.Process & { availableMemory?: () => number };
describe("getAvailableMemoryInfo", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reports a reliable reading from process.availableMemory when present", () => {
const proc = process as ProcessWithAvailableMemory;
if (typeof proc.availableMemory !== "function") {
// Older runtime without the API — covered by the fallback test below.
return;
}
const spy = vi.spyOn(proc, "availableMemory").mockReturnValue(123_456_789);
expect(getAvailableMemoryInfo()).toEqual({ bytes: 123_456_789, reliable: true });
expect(spy).toHaveBeenCalled();
});
it("falls back to os.freemem and flags the reading unreliable when the API is missing", () => {
const proc = process as ProcessWithAvailableMemory;
const original = proc.availableMemory;
// Simulate a runtime without process.availableMemory (Node < 22). The
// freemem fallback must be flagged unreliable: on macOS freemem reads
// ~99% used on an idle machine, and treating it as a pressure signal made
// the vitest auto-kill fire every 30s (2026-06-03 incident).
Reflect.deleteProperty(proc, "availableMemory");
const freememSpy = vi.spyOn(os, "freemem").mockReturnValue(42);
try {
expect(getAvailableMemoryInfo()).toEqual({ bytes: 42, reliable: false });
} finally {
if (original) proc.availableMemory = original;
freememSpy.mockRestore();
}
});
it("falls back unreliable when process.availableMemory throws", () => {
const proc = process as ProcessWithAvailableMemory;
if (typeof proc.availableMemory !== "function") return;
vi.spyOn(proc, "availableMemory").mockImplementation(() => {
throw new Error("not supported");
});
const freememSpy = vi.spyOn(os, "freemem").mockReturnValue(7);
try {
expect(getAvailableMemoryInfo()).toEqual({ bytes: 7, reliable: false });
} finally {
freememSpy.mockRestore();
}
});
});

View File

@@ -1,24 +1,39 @@
import os from "node:os";
import v8 from "node:v8";
import { execFile } from "node:child_process";
import { appendFileSync } from "node:fs";
import { findVitestProcessIds } from "@fusion/core";
// `os.freemem()` on macOS only counts truly-free pages and excludes the large
// "inactive"/cached pool that the OS will reclaim on demand — so total-free
// reads ~95%+ used on an otherwise-idle machine. `os.availableMemory()` (Node
// 22+) reports memory the OS considers available, matching Activity Monitor's
// notion of "used". Fall back to freemem on older runtimes.
function getAvailableMemory(): number {
const fn = (os as unknown as { availableMemory?: () => number }).availableMemory;
if (typeof fn === "function") {
// reads ~95%+ used on an otherwise-idle machine. `process.availableMemory()`
// (Node 22+ — NOT `os.availableMemory`, which does not exist and silently
// fell through to the freemem trap this function was written to avoid)
// reports memory the OS considers available, matching Activity Monitor's
// notion of "used". The freemem fallback is flagged unreliable so pressure-
// triggered actions can refuse to fire on a garbage ratio: with freemem, an
// idle 256GB Mac reads ~99% used and the vitest auto-kill fired every 30s
// regardless of real pressure (2026-06-03 incident).
interface AvailableMemoryReading {
bytes: number;
/** False when only `os.freemem()` was available — unusable as a pressure signal. */
reliable: boolean;
}
export function getAvailableMemoryInfo(): AvailableMemoryReading {
const processFn = (process as unknown as { availableMemory?: () => number }).availableMemory;
if (typeof processFn === "function") {
try {
const v = fn.call(os);
if (Number.isFinite(v) && v >= 0) return v;
const v = processFn.call(process);
if (Number.isFinite(v) && v >= 0) return { bytes: v, reliable: true };
} catch {
// fall through
}
}
return os.freemem();
return { bytes: os.freemem(), reliable: false };
}
function getAvailableMemory(): number {
return getAvailableMemoryInfo().bytes;
}
const TUI_DEBUG_LOG = process.env.FUSION_TUI_DEBUG_LOG;
@@ -299,8 +314,10 @@ export class DashboardTUI {
if (this.autoKillVitestOnPressure) {
const total = os.totalmem();
const free = getAvailableMemory();
if (total > 0) {
const { bytes: free, reliable } = getAvailableMemoryInfo();
// Without a reliable availability reading the ratio is garbage (freemem
// on macOS ≈ always >90% used) — never SIGKILL on a garbage signal.
if (total > 0 && reliable) {
const usedRatio = (total - free) / total;
// 30s minimum gap between auto-kills — vitest restart and OS reclaim
// both take a few seconds; firing every 2s would flap.
@@ -325,24 +342,13 @@ export class DashboardTUI {
* gone by the time we send the signal).
*/
async killVitestProcesses(): Promise<{ killed: number; pids: number[] }> {
// pgrep is POSIX-only; Windows path is a no-op above.
if (process.platform === "win32") {
return { killed: 0, pids: [] };
}
const selfPid = process.pid;
// execFile (not execSync) so the TUI render loop stays responsive while
// pgrep walks the process table — that walk can take 100ms+ on a busy
// machine and previously froze the UI on every memory-pressure check.
const stdout: string = await new Promise((resolve) => {
execFile("pgrep", ["-f", "vitest"], { encoding: "utf8" }, (err, out) => {
// pgrep exits non-zero when no matches — treat as empty result.
resolve(err ? "" : (typeof out === "string" ? out : ""));
});
});
const pids = stdout
.split("\n")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
// findVitestProcessIds is pgrep-based (POSIX-only; no-op on Windows) and
// uses async execFile so the TUI render loop stays responsive while the
// process table is walked. Crucially it filters matches to actual node
// processes: a bare `pgrep -f vitest` also matches wrapper shells whose
// command line mentions vitest, monitors, and editors — SIGKILLing those
// took out unrelated process trees (2026-06-03 incident).
const pids = await findVitestProcessIds();
let killed = 0;
for (const pid of pids) {