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:
10
.changeset/dashboard-async-pgrep-and-fs.md
Normal file
10
.changeset/dashboard-async-pgrep-and-fs.md
Normal 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`.
|
||||||
@@ -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.
|
||||||
.split("\n")
|
resolve(err ? "" : (typeof out === "string" ? out : ""));
|
||||||
.map((s) => Number.parseInt(s.trim(), 10))
|
});
|
||||||
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
|
});
|
||||||
} catch {
|
const pids = stdout
|
||||||
// pgrep exits non-zero when no matches — treat as "nothing to kill".
|
.split("\n")
|
||||||
return { killed: 0, pids: [] };
|
.map((s) => Number.parseInt(s.trim(), 10))
|
||||||
}
|
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user