From bfa60338cc7585d91f50f1c4d9ba05299c91980d Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 19:24:51 -0500 Subject: [PATCH] Cap dev-runner output buffering Co-Authored-By: Paperclip --- scripts/dev-runner-output.mjs | 53 +++++++++++++++++ scripts/dev-runner-output.ts | 59 +++++++++++++++++++ scripts/dev-runner.mjs | 16 +++-- scripts/dev-runner.ts | 15 +++-- .../src/__tests__/dev-runner-output.test.ts | 29 +++++++++ 5 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 scripts/dev-runner-output.mjs create mode 100644 scripts/dev-runner-output.ts create mode 100644 server/src/__tests__/dev-runner-output.test.ts diff --git a/scripts/dev-runner-output.mjs b/scripts/dev-runner-output.mjs new file mode 100644 index 00000000..bb0d68df --- /dev/null +++ b/scripts/dev-runner-output.mjs @@ -0,0 +1,53 @@ +const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024; + +export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) { + const limit = Math.max(1, Math.trunc(maxBytes)); + const chunks = []; + let bufferedBytes = 0; + let totalBytes = 0; + let truncated = false; + + return { + append(chunk) { + if (chunk === null || chunk === undefined) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (buffer.length === 0) return; + + chunks.push(buffer); + bufferedBytes += buffer.length; + totalBytes += buffer.length; + + while (bufferedBytes > limit && chunks.length > 0) { + const overflow = bufferedBytes - limit; + const head = chunks[0]; + if (head.length <= overflow) { + chunks.shift(); + bufferedBytes -= head.length; + truncated = true; + continue; + } + + chunks[0] = head.subarray(overflow); + bufferedBytes -= overflow; + truncated = true; + } + }, + + finish() { + const body = Buffer.concat(chunks).toString("utf8"); + if (!truncated) { + return { + text: body, + truncated, + totalBytes, + }; + } + + return { + text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`, + truncated, + totalBytes, + }; + }, + }; +} diff --git a/scripts/dev-runner-output.ts b/scripts/dev-runner-output.ts new file mode 100644 index 00000000..6fdc8cbd --- /dev/null +++ b/scripts/dev-runner-output.ts @@ -0,0 +1,59 @@ +const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024; + +export type CapturedOutput = { + text: string; + truncated: boolean; + totalBytes: number; +}; + +export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) { + const limit = Math.max(1, Math.trunc(maxBytes)); + const chunks: Buffer[] = []; + let bufferedBytes = 0; + let totalBytes = 0; + let truncated = false; + + return { + append(chunk: Buffer | string | null | undefined) { + if (chunk === null || chunk === undefined) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (buffer.length === 0) return; + + chunks.push(buffer); + bufferedBytes += buffer.length; + totalBytes += buffer.length; + + while (bufferedBytes > limit && chunks.length > 0) { + const overflow = bufferedBytes - limit; + const head = chunks[0]!; + if (head.length <= overflow) { + chunks.shift(); + bufferedBytes -= head.length; + truncated = true; + continue; + } + + chunks[0] = head.subarray(overflow); + bufferedBytes -= overflow; + truncated = true; + } + }, + + finish(): CapturedOutput { + const body = Buffer.concat(chunks).toString("utf8"); + if (!truncated) { + return { + text: body, + truncated, + totalBytes, + }; + } + + return { + text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`, + truncated, + totalBytes, + }; + }, + }; +} diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 091dbb19..8273a54c 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { fileURLToPath } from "node:url"; +import { createCapturedOutputBuffer } from "./dev-runner-output.mjs"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; @@ -250,30 +251,33 @@ async function runPnpm(args, options = {}) { const spawned = spawn(pnpmBin, args, { stdio: options.stdio ?? ["ignore", "pipe", "pipe"], env: options.env ?? process.env, + cwd: options.cwd, shell: process.platform === "win32", }); - let stdoutBuffer = ""; - let stderrBuffer = ""; + const stdoutBuffer = createCapturedOutputBuffer(); + const stderrBuffer = createCapturedOutputBuffer(); if (spawned.stdout) { spawned.stdout.on("data", (chunk) => { - stdoutBuffer += String(chunk); + stdoutBuffer.append(chunk); }); } if (spawned.stderr) { spawned.stderr.on("data", (chunk) => { - stderrBuffer += String(chunk); + stderrBuffer.append(chunk); }); } spawned.on("error", reject); spawned.on("exit", (code, signal) => { + const stdout = stdoutBuffer.finish(); + const stderr = stderrBuffer.finish(); resolve({ code: code ?? 0, signal, - stdout: stdoutBuffer, - stderr: stderrBuffer, + stdout: stdout.text, + stderr: stderr.text, }); }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index aed49c1b..fc4165b7 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import { createCapturedOutputBuffer } from "./dev-runner-output.mjs"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; import { @@ -315,27 +316,29 @@ async function runPnpm(args: string[], options: { shell: process.platform === "win32", }); - let stdoutBuffer = ""; - let stderrBuffer = ""; + const stdoutBuffer = createCapturedOutputBuffer(); + const stderrBuffer = createCapturedOutputBuffer(); if (spawned.stdout) { spawned.stdout.on("data", (chunk) => { - stdoutBuffer += String(chunk); + stdoutBuffer.append(chunk); }); } if (spawned.stderr) { spawned.stderr.on("data", (chunk) => { - stderrBuffer += String(chunk); + stderrBuffer.append(chunk); }); } spawned.on("error", reject); spawned.on("exit", (code, signal) => { + const stdout = stdoutBuffer.finish(); + const stderr = stderrBuffer.finish(); resolve({ code: code ?? 0, signal, - stdout: stdoutBuffer, - stderr: stderrBuffer, + stdout: stdout.text, + stderr: stderr.text, }); }); }); diff --git a/server/src/__tests__/dev-runner-output.test.ts b/server/src/__tests__/dev-runner-output.test.ts new file mode 100644 index 00000000..9e3f49b7 --- /dev/null +++ b/server/src/__tests__/dev-runner-output.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { createCapturedOutputBuffer } from "../../../scripts/dev-runner-output.mjs"; + +describe("createCapturedOutputBuffer", () => { + it("keeps small output unchanged", () => { + const capture = createCapturedOutputBuffer(32); + capture.append("hello"); + capture.append(" world"); + + expect(capture.finish()).toEqual({ + text: "hello world", + totalBytes: 11, + truncated: false, + }); + }); + + it("retains only the bounded tail when output grows large", () => { + const capture = createCapturedOutputBuffer(8); + capture.append("abcd"); + capture.append(Buffer.from("efgh")); + capture.append("ijkl"); + + const result = capture.finish(); + expect(result.truncated).toBe(true); + expect(result.totalBytes).toBe(12); + expect(result.text).toContain("total 12 bytes"); + expect(result.text.endsWith("efghijkl")).toBe(true); + }); +});