From a0ba894b1ce1beb6357d52553087a473fa64b2b0 Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Wed, 4 Nov 2026 22:23:06 -0400 Subject: [PATCH] fix(orchestrate): demux Docker logs in a loop, not by recursion demuxLogs recursed once per log frame. Reading a 6-hour soak's sampler file from the UUT over SSH produced enough frames to overflow the call stack, and the agent failed with "Maximum call stack size exceeded" at the end of the run. --- package-lock.json | 4 ++-- package.json | 2 +- src/orchestrate/createDockerEngine.ts | 16 +++++++++------- test/orchestrate/helpers.unit.test.ts | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9de1076..c53ec5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perfkit", - "version": "0.1.9", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "perfkit", - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "dependencies": { "commander": "^14.0.3", diff --git a/package.json b/package.json index ac07eda..c1ab3ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perfkit", - "version": "0.1.9", + "version": "0.1.10", "description": "Benchmark harness for Fonoster projects: DigitalOcean runs, one report.json schema, one branded report page", "license": "MIT", "author": "Pedro Sanders ", diff --git a/src/orchestrate/createDockerEngine.ts b/src/orchestrate/createDockerEngine.ts index e001cb0..9ede4b1 100644 --- a/src/orchestrate/createDockerEngine.ts +++ b/src/orchestrate/createDockerEngine.ts @@ -60,15 +60,17 @@ export function createDockerEngine(socketPath: string): EngineRequest { export function demuxLogs(buffer: Buffer): { stdout: string; stderr: string } { const out: Buffer[] = []; const err: Buffer[] = []; - const read = (offset: number): void => { - if (offset + 8 > buffer.length) return; + // A loop, not recursion: a long command output arrives as tens of + // thousands of frames, past the call stack's depth. + let offset = 0; + while (offset + 8 <= buffer.length) { const stream = buffer[offset]; const size = buffer.readUInt32BE(offset + 4); - const frame = buffer.subarray(offset + 8, offset + 8 + size); - (stream === 2 ? err : out).push(frame); - read(offset + 8 + size); - }; - read(0); + (stream === 2 ? err : out).push( + buffer.subarray(offset + 8, offset + 8 + size) + ); + offset += 8 + size; + } return { stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") diff --git a/test/orchestrate/helpers.unit.test.ts b/test/orchestrate/helpers.unit.test.ts index a673eb9..bc3e223 100644 --- a/test/orchestrate/helpers.unit.test.ts +++ b/test/orchestrate/helpers.unit.test.ts @@ -99,6 +99,24 @@ describe("orchestration helpers", () => { ).to.deep.equal({ stdout: "out put", stderr: "err" }); }); + it("should demux a long log of small frames without overflowing the stack", () => { + // A 6-hour sampler file read over SSH arrives as tens of thousands of + // small frames; a recursive reader overflowed the stack on it. + const frame = (stream: number, text: string) => { + const body = Buffer.from(text); + const header = Buffer.alloc(8); + header[0] = stream; + header.writeUInt32BE(body.length, 4); + return Buffer.concat([header, body]); + }; + const lines = Array.from({ length: 200_000 }, (_, i) => `${i}\n`); + const { stdout, stderr } = demuxLogs( + Buffer.concat(lines.map((line) => frame(1, line))) + ); + expect(stdout).to.equal(lines.join("")); + expect(stderr).to.equal(""); + }); + it("should read docker info and refuse what it can't read", () => { expect( parseDockerInfo("8;17179869184;Ubuntu 24.04.1 LTS;27.3.1\n")