fix(cli): drain perf_hooks buffer to stop Ink dev-mode heap leak
Ink/react-reconciler in dev mode emits performance.mark/measure() on every
render. Without a PerformanceObserver, Node retains every entry in the User
Timing buffer forever — ~165 renders/sec in the TUI accumulate ~600MB of
PerformanceMeasure objects + diff strings ("Components ⚛", "Changed Props",
"– children", etc.) over 20-30min, which is what was OOM-crashing long dev
sessions. A 30s interval calling clearMeasures()/clearMarks() drains it.
Also teach scripts/dev-with-memory.mjs to forward --inspect /
--inspect-brk, plus auto-add --heapsnapshot-near-heap-limit=3 so any future
heap-limit OOM auto-dumps. The script now invokes node directly with tsx's
preflight + loader (resolved via require.resolve) instead of going through
the .bin/tsx wrapper, so the inspector attaches to the real app process
instead of a parent wrapper that exits before serving the websocket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } fro
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
|
||||||
// @ts-expect-error -- Bun-only global; undefined in Node
|
// @ts-expect-error -- Bun-only global; undefined in Node
|
||||||
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
|
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
|
||||||
@@ -59,6 +60,14 @@ function configurePiPackage(): void {
|
|||||||
|
|
||||||
configurePiPackage();
|
configurePiPackage();
|
||||||
|
|
||||||
|
// Drain Node's User Timing buffer. Ink (react-reconciler) in dev mode emits
|
||||||
|
// performance.mark()/measure() on every render; entries accumulate forever
|
||||||
|
// without an observer, retaining ~600MB after 20-30min of TUI rendering.
|
||||||
|
setInterval(() => {
|
||||||
|
performance.clearMeasures();
|
||||||
|
performance.clearMarks();
|
||||||
|
}, 30_000).unref();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load `.env` (and `.env.local`) from the current working directory into
|
* Load `.env` (and `.env.local`) from the current working directory into
|
||||||
* process.env so that secrets like FUSION_DAEMON_TOKEN can live in a local
|
* process.env so that secrets like FUSION_DAEMON_TOKEN can live in a local
|
||||||
|
|||||||
@@ -53,28 +53,43 @@ const forwardedArgs = needsDevHostInjection
|
|||||||
? [...args, "--host", "0.0.0.0"]
|
? [...args, "--host", "0.0.0.0"]
|
||||||
: args;
|
: args;
|
||||||
|
|
||||||
|
// Resolve absolute paths to tsx loader so they survive shell quoting.
|
||||||
|
// Use Node's resolver instead of hardcoding the pnpm version-specific path.
|
||||||
|
const { createRequire } = await import("node:module");
|
||||||
|
const path = await import("node:path");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const tsxPkgJson = require.resolve("tsx/package.json");
|
||||||
|
const tsxDir = path.dirname(tsxPkgJson);
|
||||||
|
const PRELOAD = path.join(tsxDir, "dist", "preflight.cjs");
|
||||||
|
const LOADER = path.join(tsxDir, "dist", "loader.mjs");
|
||||||
|
const ENTRY = path.resolve(process.cwd(), "packages/cli/src/bin.ts");
|
||||||
|
|
||||||
|
// Spawn node directly (no shell) so the inspector attaches to the real app
|
||||||
|
// process and there's no parent/child wrapper consuming --inspect.
|
||||||
|
function runApp(extraArgs) {
|
||||||
|
const tsx = spawn(process.execPath, [
|
||||||
|
"--require", PRELOAD,
|
||||||
|
"--import", `file://${LOADER}`,
|
||||||
|
ENTRY,
|
||||||
|
...extraArgs,
|
||||||
|
], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, NODE_OPTIONS: runNodeOptions },
|
||||||
|
});
|
||||||
|
tsx.on("close", (c) => process.exit(c ?? 1));
|
||||||
|
}
|
||||||
|
|
||||||
// If no args, run default: build + CLI
|
// If no args, run default: build + CLI
|
||||||
if (forwardedArgs.length === 0) {
|
if (forwardedArgs.length === 0) {
|
||||||
const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
||||||
pnpm.on("close", (code) => {
|
pnpm.on("close", (code) => {
|
||||||
if (code !== 0) process.exit(code ?? 1);
|
if (code !== 0) process.exit(code ?? 1);
|
||||||
const tsx = spawn("node_modules/.bin/tsx", ["packages/cli/src/bin.ts"], {
|
runApp([]);
|
||||||
stdio: "inherit",
|
|
||||||
shell: true,
|
|
||||||
env: { ...process.env, NODE_OPTIONS: runNodeOptions },
|
|
||||||
});
|
|
||||||
tsx.on("close", (c) => process.exit(c ?? 1));
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Build first (without inspector), then exec the CLI with inspector flags.
|
|
||||||
const build = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
const build = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
||||||
build.on("close", (code) => {
|
build.on("close", (code) => {
|
||||||
if (code !== 0) process.exit(code ?? 1);
|
if (code !== 0) process.exit(code ?? 1);
|
||||||
const tsx = spawn("node_modules/.bin/tsx", ["packages/cli/src/bin.ts", ...forwardedArgs], {
|
runApp(forwardedArgs);
|
||||||
stdio: "inherit",
|
|
||||||
shell: true,
|
|
||||||
env: { ...process.env, NODE_OPTIONS: runNodeOptions },
|
|
||||||
});
|
|
||||||
tsx.on("close", (c) => process.exit(c ?? 1));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user