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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user