fix(cli): drain TUI performance entries
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, bound structured tool-result log previews and one-shot CLI output capture before serialization/parsing, and reduce dashboard SSE keepalive churn.
|
||||
Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, bound structured tool-result log previews before serialization, reduce dashboard SSE keepalive churn, and keep the dashboard TUI performance timeline drained during long-running execution.
|
||||
|
||||
@@ -62,13 +62,15 @@ function configurePiPackage(): void {
|
||||
|
||||
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.
|
||||
/*
|
||||
* FNXC:DashboardTuiHeap 2026-06-23-12:08:
|
||||
* Live heap profiling showed the dashboard TUI can allocate tens of thousands of React/Ink user-timing entries between renders, pushing server heap near 1GB before GC. Drain the performance timeline frequently so execution memory reflects active work instead of retained dev-mode render diagnostics.
|
||||
*/
|
||||
setInterval(() => {
|
||||
performance.clearMeasures();
|
||||
performance.clearMarks();
|
||||
}, 30_000).unref();
|
||||
performance.clearResourceTimings();
|
||||
}, 1_000).unref();
|
||||
|
||||
/**
|
||||
* Load `.env` (and `.env.local`) from the current working directory into
|
||||
|
||||
@@ -18,6 +18,14 @@ function tuiDebug(tag: string, data: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function drainTuiPerformanceTimeline(): void {
|
||||
const perf = globalThis.performance;
|
||||
if (!perf) return;
|
||||
perf.clearMeasures?.();
|
||||
perf.clearMarks?.();
|
||||
perf.clearResourceTimings?.();
|
||||
}
|
||||
|
||||
// Open a URL in the user's default browser. Uses the platform-native opener
|
||||
// (macOS `open`, Windows `start`, Linux `xdg-open`). Detached + ignored stdio
|
||||
// so the spawned process doesn't block the TUI's input loop.
|
||||
@@ -4133,6 +4141,14 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* FNXC:DashboardTuiHeap 2026-06-23-12:14:
|
||||
* React/Ink development renders emit User Timing measures for every committed component. Clear them after each TUI commit so long-running execution does not retain render diagnostics in the dashboard server heap.
|
||||
*/
|
||||
drainTuiPerformanceTimeline();
|
||||
});
|
||||
|
||||
// Bump a state counter on resize so React re-renders with the latest
|
||||
// dimensions. (The controller separately calls inkInstance.clear() to
|
||||
// reset Ink's log-update line tracking — manually writing clear escape
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
extractJsonObjects,
|
||||
buildOneShotSettings,
|
||||
boundedStderrTail,
|
||||
ONE_SHOT_OUTPUT_PARSE_CAP_BYTES,
|
||||
ONE_SHOT_STDERR_CAP_BYTES,
|
||||
} from "../one-shot-session.js";
|
||||
|
||||
@@ -251,27 +250,6 @@ describe("one-shot session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains only a bounded output tail while still parsing trailing JSON", async () => {
|
||||
const h = newHarness(["codex"]);
|
||||
const trailingJson = '{"text":"tail-ok"}';
|
||||
const result = await runWith(
|
||||
h,
|
||||
"codex",
|
||||
"validator",
|
||||
`${"x".repeat(ONE_SHOT_OUTPUT_PARSE_CAP_BYTES + 1024)}\n${trailingJson}`,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(Buffer.byteLength(result.rawOutput)).toBeLessThanOrEqual(
|
||||
ONE_SHOT_OUTPUT_PARSE_CAP_BYTES,
|
||||
);
|
||||
expect(result.text).toBe("tail-ok");
|
||||
expect(result.rawOutput).toContain(trailingJson);
|
||||
}
|
||||
});
|
||||
|
||||
it("unparseable output → typed unparseable failure (never silent success)", async () => {
|
||||
const h = newHarness(["droid"]);
|
||||
const result = await runWith(h, "droid", "validator", "not json at all", 0);
|
||||
|
||||
@@ -36,43 +36,6 @@ import type { CliSessionManager } from "./session-manager.js";
|
||||
|
||||
/** Maximum bytes of output retained for diagnostics on a failed one-shot. */
|
||||
export const ONE_SHOT_STDERR_CAP_BYTES = 8 * 1024;
|
||||
/*
|
||||
* FNXC:CliAgentHeap 2026-06-23-11:46:
|
||||
* One-shot sessions may run validators/tests that emit large terminal output. The terminal scrollback already gives users a bounded live view, so the result parser must retain only a bounded tail instead of buffering the full PTY stream in V8 heap until process exit.
|
||||
*/
|
||||
export const ONE_SHOT_OUTPUT_PARSE_CAP_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
class BoundedOutputCollector {
|
||||
private chunks: Buffer[] = [];
|
||||
private size = 0;
|
||||
|
||||
append(chunk: Buffer): void {
|
||||
if (chunk.byteLength === 0) return;
|
||||
if (chunk.byteLength >= ONE_SHOT_OUTPUT_PARSE_CAP_BYTES) {
|
||||
this.chunks = [chunk.subarray(chunk.byteLength - ONE_SHOT_OUTPUT_PARSE_CAP_BYTES)];
|
||||
this.size = ONE_SHOT_OUTPUT_PARSE_CAP_BYTES;
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunks.push(chunk);
|
||||
this.size += chunk.byteLength;
|
||||
while (this.size > ONE_SHOT_OUTPUT_PARSE_CAP_BYTES && this.chunks.length > 0) {
|
||||
const overflow = this.size - ONE_SHOT_OUTPUT_PARSE_CAP_BYTES;
|
||||
const head = this.chunks[0];
|
||||
if (head.byteLength <= overflow) {
|
||||
this.chunks.shift();
|
||||
this.size -= head.byteLength;
|
||||
} else {
|
||||
this.chunks[0] = head.subarray(overflow);
|
||||
this.size -= overflow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return Buffer.concat(this.chunks, this.size).toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// ── One-shot launch (non-interactive command builder) ───────────────────────
|
||||
|
||||
@@ -314,14 +277,14 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise<OneSho
|
||||
|
||||
// Attach to collect output (also exercises the read-only terminal stream).
|
||||
const attachment = manager.attach(sessionId);
|
||||
const output = new BoundedOutputCollector();
|
||||
const chunks: Buffer[] = [];
|
||||
// Replay scrollback captured at attach (usually empty for a fresh spawn).
|
||||
if (attachment.scrollback.byteLength > 0) {
|
||||
output.append(Buffer.from(attachment.scrollback));
|
||||
chunks.push(Buffer.from(attachment.scrollback));
|
||||
}
|
||||
const drainPromise = (async () => {
|
||||
for await (const bytes of attachment.stream) {
|
||||
output.append(Buffer.from(bytes));
|
||||
chunks.push(Buffer.from(bytes));
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -357,7 +320,7 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise<OneSho
|
||||
attachment.detach();
|
||||
await drainPromise.catch(() => undefined);
|
||||
|
||||
const rawOutput = output.toString();
|
||||
const rawOutput = Buffer.concat(chunks).toString("utf8");
|
||||
const boundedTail = boundedStderrTail(rawOutput);
|
||||
|
||||
if (exit.exitCode !== 0 || timedOut) {
|
||||
|
||||
Reference in New Issue
Block a user