Cap dev-runner output buffering

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 19:24:51 -05:00
parent 1e76bbe38c
commit bfa60338cc
5 changed files with 160 additions and 12 deletions

View file

@ -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,
};
},
};
}

View file

@ -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,
};
},
};
}

View file

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

View file

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

View file

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