feat(tui): add vitest kill + memory-pressure auto-kill to Utilities
[k] kills all running vitest processes via pgrep -f. [v] toggles a sampler-driven guard that SIGKILLs vitest when system memory exceeds 90%, throttled to once per 30s. Auto-kill defaults ON. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -589,11 +589,14 @@ function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number;
|
||||
|
||||
// ── Utilities panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function UtilitiesPanel({ isFocused }: { isFocused: boolean }) {
|
||||
const actions = [
|
||||
function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
|
||||
const autoKill = state.autoKillVitestOnPressure;
|
||||
const actions: Array<{ key: string; label: string }> = [
|
||||
{ key: "r", label: "Refresh Stats" },
|
||||
{ key: "c", label: "Clear Logs" },
|
||||
{ key: "t", label: "Toggle Engine Pause" },
|
||||
{ key: "k", label: "Kill Vitest Processes" },
|
||||
{ key: "v", label: `Auto-Kill Vitest >90% Mem: ${autoKill ? "ON" : "OFF"}` },
|
||||
{ key: "?", label: "Help" },
|
||||
];
|
||||
return (
|
||||
@@ -628,6 +631,8 @@ function HelpOverlay() {
|
||||
["[←] / [p]", "Previous panel (Main)"],
|
||||
["[r]", "Refresh stats (Utilities)"],
|
||||
["[c]", "Clear logs (Utilities)"],
|
||||
["[k]", "Kill all vitest processes (Utilities)"],
|
||||
["[v]", "Toggle auto-kill vitest >90% mem (Utilities)"],
|
||||
["[↑/↓/k/j]", "Navigate list / log entries"],
|
||||
["[Home / G]", "First / last log entry (Logs)"],
|
||||
["[Enter/Space]", "Expand log entry (Logs)"],
|
||||
@@ -707,7 +712,7 @@ function StatusModeGrid({
|
||||
/>
|
||||
<Box flexDirection="row" overflow="hidden">
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<UtilitiesPanel isFocused={focused === "utilities"} />
|
||||
<UtilitiesPanel state={state} isFocused={focused === "utilities"} />
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<SettingsPanel state={state} isFocused={focused === "settings"} />
|
||||
@@ -736,7 +741,7 @@ function StatusModeSingle({
|
||||
switch (focused) {
|
||||
case "system": return <SystemPanel state={state} isFocused />;
|
||||
case "logs": return <LogsPanel state={state} isFocused availableRows={Math.max(4, (process.stdout.rows ?? 24) - 8)} />;
|
||||
case "utilities": return <UtilitiesPanel isFocused />;
|
||||
case "utilities": return <UtilitiesPanel state={state} isFocused />;
|
||||
case "stats": return <StatsPanel state={state} isFocused />;
|
||||
case "settings": return <SettingsPanel state={state} isFocused />;
|
||||
}
|
||||
@@ -764,7 +769,7 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
|
||||
if (activeSection === "logs") {
|
||||
hotkeys.push("↑↓ navigate", "w wrap", "f filter", "Enter expand");
|
||||
} else if (activeSection === "utilities") {
|
||||
hotkeys.push("r refresh", "c clear logs", "t toggle pause");
|
||||
hotkeys.push("r refresh", "c clear logs", "t toggle pause", "k kill vitest", "v auto-kill");
|
||||
} else {
|
||||
hotkeys.push("Tab cycle panel", "1-5 jump");
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ export class DashboardTUI {
|
||||
logsViewportStart = 0;
|
||||
loadingStatus = "Starting…";
|
||||
mode: "status" | "interactive" = "status";
|
||||
// When true, sampleSystemStats() kills any running vitest processes if
|
||||
// system memory usage crosses 90%. Toggled by [v] in the Utilities panel.
|
||||
autoKillVitestOnPressure = true;
|
||||
// Throttle so we don't spam kills while the sampler keeps firing during
|
||||
// sustained pressure (sampler runs every 2s).
|
||||
private lastAutoKillAt = 0;
|
||||
interactiveData: InteractiveData | null = null;
|
||||
interactiveView: InteractiveView = "board";
|
||||
|
||||
@@ -110,6 +116,7 @@ export class DashboardTUI {
|
||||
mode: this.mode,
|
||||
interactiveData: this.interactiveData,
|
||||
interactiveView: this.interactiveView,
|
||||
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -195,6 +202,68 @@ export class DashboardTUI {
|
||||
nodeVersion: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
});
|
||||
|
||||
if (this.autoKillVitestOnPressure) {
|
||||
const total = os.totalmem();
|
||||
const free = os.freemem();
|
||||
if (total > 0) {
|
||||
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.
|
||||
if (usedRatio > 0.9 && now - this.lastAutoKillAt > 30_000) {
|
||||
this.lastAutoKillAt = now;
|
||||
const result = this.killVitestProcesses();
|
||||
if (result.killed > 0) {
|
||||
this.warn(
|
||||
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%)`,
|
||||
"memory-guard",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and SIGKILL any running vitest processes, excluding this dashboard
|
||||
* itself. Returns a count of pids signalled (best-effort — a pid may be
|
||||
* gone by the time we send the signal).
|
||||
*/
|
||||
killVitestProcesses(): { killed: number; pids: number[] } {
|
||||
const selfPid = process.pid;
|
||||
let pids: number[] = [];
|
||||
try {
|
||||
// pgrep -f matches against the full command line. -a would include the
|
||||
// command, but we only need pids. macOS and Linux both support -f.
|
||||
const out = execSync("pgrep -f vitest", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
||||
pids = out
|
||||
.split("\n")
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
|
||||
} catch {
|
||||
// pgrep exits non-zero when no matches — treat as "nothing to kill".
|
||||
return { killed: 0, pids: [] };
|
||||
}
|
||||
|
||||
let killed = 0;
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
killed += 1;
|
||||
} catch {
|
||||
// Process already exited or we lack permission — skip.
|
||||
}
|
||||
}
|
||||
return { killed, pids };
|
||||
}
|
||||
|
||||
toggleAutoKillVitest(): boolean {
|
||||
this.autoKillVitestOnPressure = !this.autoKillVitestOnPressure;
|
||||
if (!this.autoKillVitestOnPressure) {
|
||||
this.lastAutoKillAt = 0;
|
||||
}
|
||||
this.notify();
|
||||
return this.autoKillVitestOnPressure;
|
||||
}
|
||||
|
||||
setSettings(settings: SettingsValues): void {
|
||||
@@ -335,6 +404,26 @@ export class DashboardTUI {
|
||||
this.setSettings(newSettings);
|
||||
}
|
||||
break;
|
||||
case "k": {
|
||||
const result = this.killVitestProcesses();
|
||||
if (result.killed === 0) {
|
||||
this.log("No vitest processes found.", "kill-vitest");
|
||||
} else {
|
||||
this.warn(
|
||||
`Killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"}: ${result.pids.join(", ")}`,
|
||||
"kill-vitest",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "v": {
|
||||
const enabled = this.toggleAutoKillVitest();
|
||||
this.log(
|
||||
`Auto-kill vitest on memory pressure (>90%): ${enabled ? "ON" : "OFF"}`,
|
||||
"memory-guard",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ export interface DashboardState {
|
||||
mode: AppMode;
|
||||
interactiveData: InteractiveData | null;
|
||||
interactiveView: InteractiveView;
|
||||
autoKillVitestOnPressure: boolean;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
@@ -302,5 +303,6 @@ export function createInitialState(): DashboardState {
|
||||
mode: "status",
|
||||
interactiveData: null,
|
||||
interactiveView: "board",
|
||||
autoKillVitestOnPressure: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user