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 c719720004
commit 3bafc48000
4 changed files with 123 additions and 60 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": patch
---
Fix periodic dashboard event-loop stalls caused by synchronous shell-outs and filesystem reads on hot request paths.
Two distinct sources, both replaced with async equivalents:
- **`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 (every 2s when over threshold), the dashboard variant fired on every system-stats poll (every 5s while the modal is open). Both now use `execFile` with a callback wrapped in a Promise.
- **`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`.

View File

@@ -1,6 +1,6 @@
import os from "node:os"; import os from "node:os";
import v8 from "node:v8"; import v8 from "node:v8";
import { execSync } from "node:child_process"; import { execFile } from "node:child_process";
import { appendFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
// `os.freemem()` on macOS only counts truly-free pages and excludes the large // `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. // both take a few seconds; firing every 2s would flap.
if (usedRatio > this.vitestKillThreshold && now - this.lastAutoKillAt > 30_000) { if (usedRatio > this.vitestKillThreshold && now - this.lastAutoKillAt > 30_000) {
this.lastAutoKillAt = now; this.lastAutoKillAt = now;
const result = this.killVitestProcesses(); void this.killVitestProcesses().then((result) => {
if (result.killed > 0) { if (result.killed > 0) {
this.warn( 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)}%)`, `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", "memory-guard",
); );
} }
}).catch(() => {});
} }
} }
} }
@@ -304,25 +305,25 @@ export class DashboardTUI {
* itself. Returns a count of pids signalled (best-effort — a pid may be * itself. Returns a count of pids signalled (best-effort — a pid may be
* gone by the time we send the signal). * 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. // pgrep is POSIX-only; Windows path is a no-op above.
if (process.platform === "win32") { if (process.platform === "win32") {
return { killed: 0, pids: [] }; return { killed: 0, pids: [] };
} }
const selfPid = process.pid; const selfPid = process.pid;
let pids: number[] = []; // execFile (not execSync) so the TUI render loop stays responsive while
try { // pgrep walks the process table — that walk can take 100ms+ on a busy
// pgrep -f matches against the full command line. -a would include the // machine and previously froze the UI on every memory-pressure check.
// command, but we only need pids. macOS and Linux both support -f. const stdout: string = await new Promise((resolve) => {
const out = execSync("pgrep -f vitest", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); execFile("pgrep", ["-f", "vitest"], { encoding: "utf8" }, (err, out) => {
pids = out // pgrep exits non-zero when no matches — treat as empty result.
resolve(err ? "" : (typeof out === "string" ? out : ""));
});
});
const pids = stdout
.split("\n") .split("\n")
.map((s) => Number.parseInt(s.trim(), 10)) .map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid); .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; let killed = 0;
for (const pid of pids) { for (const pid of pids) {
@@ -572,7 +573,7 @@ export class DashboardTUI {
} }
break; break;
case "k": { case "k": {
const result = this.killVitestProcesses(); const result = await this.killVitestProcesses();
if (result.killed === 0) { if (result.killed === 0) {
this.log("No vitest processes found.", "kill-vitest"); this.log("No vitest processes found.", "kill-vitest");
} else { } else {

View File

@@ -44,10 +44,11 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined); const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined); const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined); const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync } = vi.hoisted(() => ({ const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(), mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(), mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(), mockExecSync: vi.fn(),
mockExecFile: vi.fn(),
})); }));
vi.mock("../update-check.js", async () => { vi.mock("../update-check.js", async () => {
@@ -62,9 +63,32 @@ vi.mock("../update-check.js", async () => {
vi.mock("node:child_process", async () => { vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process"); const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync); mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
// Default execFile mock blocks host-process pgrep calls used by /kill-vitest
// but passes through all other commands (including git) to preserve route
// behavior for integration-style API tests in this file.
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
const [file, argsOrCb, maybeOptions, maybeCb] = callArgs as [string, unknown, unknown, unknown];
const args = Array.isArray(argsOrCb) ? argsOrCb : [];
const cb =
typeof maybeCb === "function"
? (maybeCb as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof maybeOptions === "function"
? (maybeOptions as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof argsOrCb === "function"
? (argsOrCb as (err: unknown, stdout?: string, stderr?: string) => void)
: null;
if (file === "pgrep" && args[0] === "-f" && args[1] === "vitest") {
if (cb) queueMicrotask(() => cb(null, "", ""));
return;
}
return (actual.execFile as (...innerArgs: unknown[]) => unknown)(...callArgs);
});
return { return {
...actual, ...actual,
execSync: mockExecSync, execSync: mockExecSync,
execFile: mockExecFile,
}; };
}); });
@@ -306,7 +330,10 @@ describe("GET /api/system-stats", () => {
getFusionDir: vi.fn().mockReturnValue("/fake/default"), getFusionDir: vi.fn().mockReturnValue("/fake/default"),
}); });
mockExecSync.mockReturnValue(`${process.pid}\n111\n222\n` as never); mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, `${process.pid}\n111\n222\n`, "");
});
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined); vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([ vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
@@ -357,7 +384,7 @@ describe("GET /api/system-stats", () => {
}); });
expect(res.body.vitestProcessCount).toBe(2); expect(res.body.vitestProcessCount).toBe(2);
expect(res.body.vitestLastAutoKillAt).toBeNull(); expect(res.body.vitestLastAutoKillAt).toBeNull();
mockExecSync.mockReset(); mockExecFile.mockClear();
}); });
it("includes last auto-kill timestamp when available in global settings", async () => { it("includes last auto-kill timestamp when available in global settings", async () => {
@@ -458,18 +485,24 @@ describe("POST /api/kill-vitest", () => {
it("returns killed: 0 when no vitest processes are found", async () => { it("returns killed: 0 when no vitest processes are found", async () => {
const store = createMockStore(); const store = createMockStore();
mockExecSync.mockReturnValue("" as never); mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, "", "");
});
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest"); const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] }); expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecSync.mockReset(); mockExecFile.mockClear();
}); });
it("kills all matched vitest pids except the current dashboard process", async () => { it("kills all matched vitest pids except the current dashboard process", async () => {
const store = createMockStore(); const store = createMockStore();
mockExecSync.mockReturnValue(`${process.pid}\n1001\n1002\nnot-a-pid\n` as never); mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, `${process.pid}\n1001\n1002\nnot-a-pid\n`, "");
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest"); const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
@@ -481,20 +514,22 @@ describe("POST /api/kill-vitest", () => {
expect(res.body).toEqual({ killed: 2, pids: [1001, 1002] }); expect(res.body).toEqual({ killed: 2, pids: [1001, 1002] });
killSpy.mockRestore(); killSpy.mockRestore();
mockExecSync.mockReset(); mockExecFile.mockClear();
}); });
it("returns killed: 0 when pgrep exits with no matches", async () => { it("returns killed: 0 when pgrep exits with no matches", async () => {
const store = createMockStore(); const store = createMockStore();
mockExecSync.mockImplementation(() => { mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
throw new Error("pgrep exited 1"); const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
const err = Object.assign(new Error("pgrep exited 1"), { code: 1 });
cb(err);
}); });
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest"); const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] }); expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecSync.mockReset(); mockExecFile.mockClear();
}); });
}); });

View File

@@ -188,36 +188,50 @@ const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
}); });
function readJsonObject(path: string): Record<string, unknown> { // Async variants — sync fs.* on a settings route blocks every concurrent
if (!nodeFs.existsSync(path)) { // request. discoverDashboardPiExtensions is called from 3 settings endpoints,
return {}; // and the previous sync implementation paid 6+ blocking syscalls per call.
} async function readJsonObject(path: string): Promise<Record<string, unknown>> {
try { try {
const parsed = JSON.parse(nodeFs.readFileSync(path, "utf-8")); const raw = await nodeFs.promises.readFile(path, "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {}; return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
} catch { } catch {
// ENOENT, parse errors, etc. — treat as missing/empty.
return {}; return {};
} }
} }
async function pathExists(path: string): Promise<boolean> {
try {
await nodeFs.promises.access(path);
return true;
} catch {
return false;
}
}
function hasPackageManagerSettings(settings: Record<string, unknown>): boolean { function hasPackageManagerSettings(settings: Record<string, unknown>): boolean {
return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand); return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand);
} }
function getPiPackageManagerAgentDir(): string { async function getPiPackageManagerAgentDir(): Promise<string> {
const fusionAgentDir = getFusionAgentDir(); const fusionAgentDir = getFusionAgentDir();
const legacyAgentDir = getLegacyPiAgentDir(); const legacyAgentDir = getLegacyPiAgentDir();
const fusionSettings = readJsonObject(join(fusionAgentDir, "settings.json")); const [fusionSettings, legacySettings, legacyExists, fusionExists] = await Promise.all([
const legacySettings = readJsonObject(join(legacyAgentDir, "settings.json")); readJsonObject(join(fusionAgentDir, "settings.json")),
readJsonObject(join(legacyAgentDir, "settings.json")),
pathExists(legacyAgentDir),
pathExists(fusionAgentDir),
]);
if (hasPackageManagerSettings(fusionSettings) || !nodeFs.existsSync(legacyAgentDir)) { if (hasPackageManagerSettings(fusionSettings) || !legacyExists) {
return fusionAgentDir; return fusionAgentDir;
} }
if (hasPackageManagerSettings(legacySettings)) { if (hasPackageManagerSettings(legacySettings)) {
return legacyAgentDir; return legacyAgentDir;
} }
return nodeFs.existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir; return fusionExists ? fusionAgentDir : legacyAgentDir;
} }
function packageExtensionName(extensionPath: string, source: string): string { function packageExtensionName(extensionPath: string, source: string): string {
@@ -235,11 +249,13 @@ async function discoverDashboardPiExtensions(cwd: string): Promise<PiExtensionSe
try { try {
const { DefaultPackageManager } = await import("@mariozechner/pi-coding-agent"); const { DefaultPackageManager } = await import("@mariozechner/pi-coding-agent");
const agentDir = getPiPackageManagerAgentDir(); const [agentDir, legacyGlobalSettings, fusionGlobalSettings, projectSettings] = await Promise.all([
const legacyGlobalSettings = readJsonObject(join(getLegacyPiAgentDir(), "settings.json")); getPiPackageManagerAgentDir(),
const fusionGlobalSettings = readJsonObject(join(getFusionAgentDir(), "settings.json")); readJsonObject(join(getLegacyPiAgentDir(), "settings.json")),
readJsonObject(join(getFusionAgentDir(), "settings.json")),
readJsonObject(join(cwd, ".fusion", "settings.json")),
]);
const globalSettings = { ...legacyGlobalSettings, ...fusionGlobalSettings }; const globalSettings = { ...legacyGlobalSettings, ...fusionGlobalSettings };
const projectSettings = readJsonObject(join(cwd, ".fusion", "settings.json"));
const mergedSettings = { ...globalSettings, ...projectSettings }; const mergedSettings = { ...globalSettings, ...projectSettings };
const packageManager = new DefaultPackageManager({ const packageManager = new DefaultPackageManager({
cwd, cwd,
@@ -1256,21 +1272,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}); });
const getVitestProcessIds = async (): Promise<number[]> => { const getVitestProcessIds = async (): Promise<number[]> => {
const { execSync } = await import("node:child_process"); // execFile (not execSync) so the dashboard's event loop stays responsive
// while pgrep walks the process table — that walk can take 100ms+ on a
// busy machine and previously froze every concurrent request.
const { execFile } = await import("node:child_process");
try { const stdout: string = await new Promise((resolve) => {
const output = execSync("pgrep -f vitest", { execFile("pgrep", ["-f", "vitest"], { encoding: "utf8" }, (err, out) => {
encoding: "utf8", // pgrep exits non-zero when no matches — treat as empty result.
stdio: ["ignore", "pipe", "ignore"], resolve(err ? "" : (typeof out === "string" ? out : ""));
});
}); });
return output return stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => Number.parseInt(line.trim(), 10)) .map((line) => Number.parseInt(line.trim(), 10))
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
} catch {
return [];
}
}; };
/** /**