perf(dashboard): replace blocking pgrep execSync with async execFile

Two sync hot paths were stalling the dashboard event loop on a periodic
timer:

1. `pgrep -f vitest` ran via `execSync` in `getVitestProcessIds`
   (`/api/system-stats`, `/api/kill-vitest`) and `killVitestProcesses`
   (TUI memory-pressure check). On a busy machine pgrep walking the
   process table can take 100ms+; execSync blocks the entire Node event
   loop for that duration, so every concurrent dashboard request hangs
   while pgrep runs. The TUI variant fired on every memory-pressure tick
   (~2s when over threshold), the dashboard variant fired on every
   system-stats poll (5s while the modal is open). Both now use execFile
   with a callback wrapped in a Promise.

2. `discoverDashboardPiExtensions` (called from 3 /api/settings/pi-
   extensions routes) did 6+ blocking existsSync/readFileSync calls per
   invocation across legacy and fusion settings paths. Converted to
   fs.promises.readFile/access and parallelized via Promise.all.

Behavior preserved:
- TUI memory-pressure detection still works (sync os.totalmem path
  unchanged); auto-kill still fires on threshold breach.
- The `lastAutoKillAt` 30s re-fire gate is set before the async kill
  starts, so concurrent ticks can't trigger duplicate kills.
- system-stats still polls every 5s while the modal is open and still
  returns vitestProcessCount.
- All 7 system-stats / kill-vitest tests pass; all 6 settings/pi-
  extensions tests pass. Test mocks updated for the (err, stdout, stderr)
  callback signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-02 14:57:54 -07:00
parent f6c8ac7a5a
commit 60a2947718
4 changed files with 123 additions and 60 deletions

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import v8 from "node:v8";
import { execSync } from "node:child_process";
import { execFile } from "node:child_process";
import { appendFileSync } from "node:fs";
// `os.freemem()` on macOS only counts truly-free pages and excludes the large
@@ -287,13 +287,14 @@ export class DashboardTUI {
// both take a few seconds; firing every 2s would flap.
if (usedRatio > this.vitestKillThreshold && 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)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
"memory-guard",
);
}
void this.killVitestProcesses().then((result) => {
if (result.killed > 0) {
this.warn(
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
"memory-guard",
);
}
}).catch(() => {});
}
}
}
@@ -304,25 +305,25 @@ export class DashboardTUI {
* 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[] } {
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;
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: [] };
}
// 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);
let killed = 0;
for (const pid of pids) {
@@ -572,7 +573,7 @@ export class DashboardTUI {
}
break;
case "k": {
const result = this.killVitestProcesses();
const result = await this.killVitestProcesses();
if (result.killed === 0) {
this.log("No vitest processes found.", "kill-vitest");
} else {