From 0e0e9da007313121af7f24cc5626d4555f0aeac2 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 22 Jun 2026 19:57:41 +0100 Subject: [PATCH] Fix container exec output truncation DockerContainerClient.exec() resolved as soon as the raw multiplexed stream emitted "end", then immediately read the captured chunk arrays. Those arrays are filled by "data" handlers on the demuxed stdout/stderr PassThroughs, which can still be flushing buffered frames at that point, so output/stdout/stderr could be returned truncated. End the demuxed PassThroughs once the raw stream ends and await their completion before reading the chunks. Adds a deterministic regression test reproducing the flush race. Co-Authored-By: Claude Opus 4.8 --- .../container/docker-container-client.test.ts | 64 +++++++++++++++++++ .../container/docker-container-client.ts | 25 ++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts new file mode 100644 index 000000000..9edb62f8f --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts @@ -0,0 +1,64 @@ +import { PassThrough, Readable } from "stream"; +import { DockerContainerClient } from "./docker-container-client"; + +describe("DockerContainerClient", () => { + describe("exec", () => { + it("should not truncate output when the demuxed streams flush after the raw stream ends", async () => { + // Reproduces the output-truncation race. Like the real docker-modem, our fake + // demuxStream writes demuxed content into the stdout PassThrough as the raw stream + // emits "data" and never ends the PassThrough itself. We cork the PassThrough so the + // write stays buffered on its writable side and is only released on a macrotask + // (setImmediate) — after the raw stream's "end" and the microtask-resolved + // exec.inspect() have completed. Pre-fix, exec() reads its still-empty chunk arrays at + // that point and truncates the output; the fix must end + flush the PassThroughs and + // let their "data" handlers run before reading the arrays. + const payload = "the-final-line-that-must-not-be-truncated\n"; + + // Raw multiplexed stream as handed to us by Dockerode. + const rawStream = new PassThrough(); + + const exec = { + start: vi.fn(async () => { + // Emit the final frame, then end, once exec() has wired up its listeners. + process.nextTick(() => { + rawStream.write(payload); + rawStream.end(); + }); + return rawStream; + }), + // Resolves on a microtask — represents the inspect() HTTP round-trip that, in the + // buggy version, completes before the demuxed data has been flushed. + inspect: vi.fn(async () => ({ ExitCode: 0 })), + }; + + const container = { + id: "container-id", + exec: vi.fn(async () => exec), + }; + + const dockerode = { + modem: { + // Mimic docker-modem's demuxStream: forward raw "data" into the stdout + // PassThrough without ever ending it. The PassThrough is corked, so the written + // payload stays buffered (undelivered to "data" handlers) until it is uncorked on + // a macrotask — i.e. after the raw stream's "end" has already fired. + demuxStream: (raw: Readable, stdout: PassThrough) => { + stdout.cork(); + raw.on("data", (chunk) => stdout.write(chunk)); + setImmediate(() => stdout.uncork()); + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const client = new DockerContainerClient(dockerode); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await client.exec(container as any, ["echo", "hi"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(payload); + expect(result.output).toBe(payload); + }); + }); +}); diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 4ddef30e1..45b1d9ef0 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -9,6 +9,7 @@ import Dockerode, { } from "dockerode"; import { IncomingMessage } from "http"; import { PassThrough, Readable } from "stream"; +import { finished } from "stream/promises"; import { execLog, log, streamToString, toSeconds } from "../../../common"; import { CopyToContainerOptions } from "../../../types"; import { ContainerClient } from "./container-client"; @@ -253,11 +254,25 @@ export class DockerContainerClient implements ContainerClient { processStream(stdoutStream, stdoutChunks); processStream(stderrStream, stderrChunks); - await new Promise((res, rej) => { - stream.on("end", res); - stream.on("error", rej); - }); - stream.destroy(); + try { + // Wait for the raw multiplexed stream to end. `demuxStream` only forwards "data" + // from the raw stream into the PassThroughs; it never ends them, so we end them + // ourselves once the raw stream is done. + await new Promise((res, rej) => { + stream.on("end", res); + stream.on("error", rej); + }); + + // Crucially, wait for the demuxed stdout/stderr PassThrough streams to fully flush + // before reading the chunk arrays. Those arrays are filled by the PassThroughs' + // "data" handlers, which can still be draining buffered frames when the raw stream + // emits "end" — reading them too early truncates the captured output. + stdoutStream.end(); + stderrStream.end(); + await Promise.all([finished(stdoutStream), finished(stderrStream)]); + } finally { + stream.destroy(); + } const inspectResult = await exec.inspect(); const exitCode = inspectResult.ExitCode ?? -1;