fix(cli): drain TUI performance entries

This commit is contained in:
gsxdsm
2026-06-23 12:10:40 -07:00
parent 61d2a1a609
commit 4704e58ed8
5 changed files with 27 additions and 68 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {