From a545541b48b75cf885c16e7452996bb863290fce Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 12 Oct 2026 15:33:58 +0100 Subject: [PATCH 1/4] Fail the HTTP wait as soon as the container exits Wait.forHttp() polled until the startup timeout after the container had exited. Exit detection (abortOnContainerExit) is now on by default; pass { abortOnContainerExit: false } to keep waiting. --- docs/features/wait-strategies.md | 6 +++-- ...http-wait-strategy.agent-lifecycle.test.ts | 2 +- .../http-wait-strategy.test.ts | 23 +++++++++++++++++++ .../src/wait-strategies/http-wait-strategy.ts | 2 +- .../src/wait-strategies/wait.ts | 6 +---- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index e9c27e04a..f92e293cb 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -125,13 +125,15 @@ const container = await new GenericContainer("redis") .start(); ``` -Stop waiting after container exited if waiting for container restart not needed. +If the container exits before the condition is satisfied, the wait fails straight away instead of polling until the startup timeout, and the error includes the last 50 lines of the container's logs. A container that is being restarted by its restart policy is not treated as exited. + +To keep waiting after the container has exited, for example when something other than a restart policy starts it again, set `abortOnContainerExit` to `false`: ```js const { GenericContainer, Wait } = require("testcontainers"); const container = await new GenericContainer("redis") - .withWaitStrategy(Wait.forHttp("/health", 8080, { abortOnContainerExit: true })) + .withWaitStrategy(Wait.forHttp("/health", 8080, { abortOnContainerExit: false })) .start(); ``` diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts index 87a980c56..523f31175 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts @@ -26,7 +26,7 @@ vi.mock("../container-runtime", async (importOriginal) => { ...actual, getContainerRuntimeClient: async () => ({ info: { containerRuntime: { host: "localhost" } }, - container: { inspect: vi.fn() }, + container: { inspect: async () => ({ State: { Status: "running" } }) }, }), }; }); diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts index 67a6f10b6..2a173eaea 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts @@ -3,6 +3,8 @@ import { GenericContainer } from "../generic-container/generic-container"; import { checkContainerIsHealthy, checkContainerIsHealthyTls, stopStartingContainer } from "../utils/test-helper"; import { Wait } from "./wait"; +const exitAfterFirstRequest = ["/bin/sh", "-c", "nc -l -p 8080; exit 1"]; + describe("HttpWaitStrategy", { timeout: 180_000 }, () => { it("should wait for 200", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") @@ -76,6 +78,27 @@ describe("HttpWaitStrategy", { timeout: 180_000 }, () => { ).rejects.toThrowError("URL /hello-world not accessible after 3000ms"); }); + it("should fail when the container exits before the wait succeeds", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withEntrypoint(exitAfterFirstRequest) + .withWaitStrategy(Wait.forHttp("/hello-world", 8080)) + .start() + ).rejects.toThrowError("Container exited during HTTP healthCheck"); + }); + + it("should keep waiting after the container exits when abortOnContainerExit is false", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withStartupTimeout(3000) + .withEntrypoint(exitAfterFirstRequest) + .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: false })) + .start() + ).rejects.toThrowError("URL /hello-world not accessible after 3000ms"); + }); + describe("when options.abortOnContainerExit is true", () => { it("should fail if container exited before waiting pass", async () => { const name = `container-name-${new RandomUuid().nextUuid()}`; diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts index b707ba564..178eb0de4 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts @@ -78,7 +78,7 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { const exitStatus = "exited"; let containerExited = false; const client = await getContainerRuntimeClient(); - const { abortOnContainerExit } = this.options; + const abortOnContainerExit = this.options.abortOnContainerExit ?? true; // Scoped per invocation: one strategy instance can drive concurrent waits, so a shared // agent would let one finished wait destroy a dispatcher another is still using. const agent = this.createInsecureAgent(); diff --git a/packages/testcontainers/src/wait-strategies/wait.ts b/packages/testcontainers/src/wait-strategies/wait.ts index 58283f8fc..1c359ce48 100644 --- a/packages/testcontainers/src/wait-strategies/wait.ts +++ b/packages/testcontainers/src/wait-strategies/wait.ts @@ -29,11 +29,7 @@ export class Wait { return new OneShotStartupCheckStrategy(); } - public static forHttp( - path: string, - port: number, - options: HttpWaitStrategyOptions = { abortOnContainerExit: false } - ): HttpWaitStrategy { + public static forHttp(path: string, port: number, options: HttpWaitStrategyOptions = {}): HttpWaitStrategy { return new HttpWaitStrategy(path, port, options); } From 4ad78a76a2c2c9d0d2ef1194bc3f0c662b05736b Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 12 Oct 2026 15:34:08 +0100 Subject: [PATCH 2/4] Fix the insecure TLS method name in the wait strategy docs --- docs/features/wait-strategies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index f92e293cb..4157638dd 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -176,7 +176,7 @@ const container = await new GenericContainer("redis") ```js .withWaitStrategy(Wait.forHttp("/health", 8443) .usingTls() - .insecureTls()) + .allowInsecure()) ``` ## Shell command From 1c32b657f3758742034a4b132cb9838f1c86760a Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 17:08:15 +0100 Subject: [PATCH 3/4] Remove the exit behaviour paragraph from the HTTP wait docs --- docs/features/wait-strategies.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 4157638dd..b4a9066ce 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -125,8 +125,6 @@ const container = await new GenericContainer("redis") .start(); ``` -If the container exits before the condition is satisfied, the wait fails straight away instead of polling until the startup timeout, and the error includes the last 50 lines of the container's logs. A container that is being restarted by its restart policy is not treated as exited. - To keep waiting after the container has exited, for example when something other than a restart policy starts it again, set `abortOnContainerExit` to `false`: ```js From bceb0a521ffde3780e3256de69c69d79c0c59e20 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Wed, 14 Oct 2026 15:30:26 +0100 Subject: [PATCH 4/4] Fail every wait strategy as soon as the container exits --- docs/features/wait-strategies.md | 20 ++--- .../testcontainers/src/utils/test-helper.ts | 26 ------- .../composite-wait-strategy.test.ts | 10 +++ .../composite-wait-strategy.ts | 9 +++ .../health-check-wait-strategy.test.ts | 10 +++ .../health-check-wait-strategy.ts | 10 ++- .../host-port-wait-strategy.ts | 9 ++- .../http-wait-strategy.test.ts | 43 +--------- .../src/wait-strategies/http-wait-strategy.ts | 58 +++----------- .../wait-strategies/log-wait-strategy.test.ts | 16 ++++ .../src/wait-strategies/log-wait-strategy.ts | 8 +- .../shell-wait-strategy.test.ts | 9 +++ .../wait-strategies/shell-wait-strategy.ts | 9 ++- .../src/wait-strategies/wait-strategy.test.ts | 78 +++++++++++++++++++ .../src/wait-strategies/wait-strategy.ts | 57 ++++++++++++++ .../src/wait-strategies/wait.ts | 6 +- 16 files changed, 235 insertions(+), 143 deletions(-) create mode 100644 packages/testcontainers/src/wait-strategies/wait-strategy.test.ts diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index b4a9066ce..7eee3bf4b 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,6 +10,16 @@ const container = await new GenericContainer("alpine") .start(); ``` +To keep waiting after the container has exited, for example when something other than a restart policy starts it again: + +```js +const { GenericContainer, Wait } = require("testcontainers"); + +const container = await new GenericContainer("alpine") + .withWaitStrategy(Wait.forListeningPorts().withAbortOnContainerExit(false)) + .start(); +``` + ## Default wait strategy By default, Testcontainers waits for a container health check when one is defined by the image or configured with `withHealthCheck`. If no health check is defined, or the image disables health checks with `HEALTHCHECK NONE`, it waits up to 60 seconds for mapped network ports to be bound. @@ -125,16 +135,6 @@ const container = await new GenericContainer("redis") .start(); ``` -To keep waiting after the container has exited, for example when something other than a restart policy starts it again, set `abortOnContainerExit` to `false`: - -```js -const { GenericContainer, Wait } = require("testcontainers"); - -const container = await new GenericContainer("redis") - .withWaitStrategy(Wait.forHttp("/health", 8080, { abortOnContainerExit: false })) - .start(); -``` - ### For status code ```js diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 57911b019..efa7b47a5 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -6,10 +6,8 @@ import path from "node:path"; import type { Readable } from "node:stream"; import type { GetEventsOptions, ImageInspectInfo } from "dockerode"; import { Agent, request } from "undici"; -import { IntervalRetry } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { StartedDockerComposeEnvironment } from "../docker-compose-environment/started-docker-compose-environment"; -import type { GenericContainer } from "../generic-container/generic-container"; import type { StartedTestContainer } from "../test-container"; import type { HealthCheckStatus } from "../types"; import { getHealthCheckStatusFromInspect } from "../wait-strategies/utils/health-check"; @@ -142,30 +140,6 @@ export async function deleteImageByName(imageName: string): Promise { await dockerode.getImage(imageName).remove(); } -export async function stopStartingContainer(container: GenericContainer, name: string) { - const client = await getContainerRuntimeClient(); - const containerStartPromise = container.start(); - - const status = await new IntervalRetry(500).retryUntil( - () => - client.container - .getById(name) - .inspect() - .then((i) => i.State.Running) - .catch(() => false), - (running) => running, - () => false, - 10000 - ); - - if (!status) { - throw Error("failed start container"); - } - - await client.container.getById(name).stop(); - await containerStartPromise; -} - export async function createTestServer(port: number): Promise { const server = createServer((_req, res) => { res.writeHead(200); diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts index 4c1604c6e..279fc4082 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts @@ -35,6 +35,16 @@ describe("CompositeWaitStrategy", { timeout: 180_000 }, () => { await expect(container.start()).rejects.toThrowError(`Shell command "exit 1" not successful after 100ms`); }); + it("should apply abortOnContainerExit to its wait strategies", async () => { + const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy( + Wait.forAll([Wait.forSuccessfulCommand("stat /tmp/never.sock")]).withAbortOnContainerExit(false) + ); + + await expect(container.start()).rejects.not.toThrowError("Container exited"); + }); + it("should work with maximum outer timeout", async () => { const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14").withWaitStrategy( Wait.forAll([ diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts index c673607bb..5c2dee63b 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts @@ -48,6 +48,15 @@ export class CompositeWaitStrategy extends AbstractWaitStrategy { return this; } + public override withAbortOnContainerExit(abortOnContainerExit: boolean): this { + this.waitStrategies.forEach((waitStrategy) => { + if (waitStrategy instanceof AbstractWaitStrategy) { + waitStrategy.withAbortOnContainerExit(abortOnContainerExit); + } + }); + return this; + } + public withDeadline(deadline: number): this { this.deadline = deadline; return this; diff --git a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts index d0addb112..9348a1ae7 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts @@ -66,6 +66,16 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { expect(await getRunningContainerNames()).not.toContain(containerName); }); + it("should fail when the container exits", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withHealthCheck({ test: ["CMD-SHELL", "exit 0"] }) + .withWaitStrategy(Wait.forHealthCheck()) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); + it("should wait for custom health check using CMD to run the command directly without a shell", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) diff --git a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts index 6066eb6fd..3be02fc93 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import { getHealthCheckStatusFromInspect } from "./utils/health-check"; import { AbstractWaitStrategy } from "./wait-strategy"; @@ -9,7 +9,9 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { log.debug(`Waiting for health check...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); - const status = await new IntervalRetry(100).retryUntil( + const status = await this.retryUntil( + container, + 100, async () => getHealthCheckStatusFromInspect(await client.container.inspect(container)), (healthCheckStatus) => healthCheckStatus === "healthy" || healthCheckStatus === "unhealthy", () => { @@ -17,11 +19,11 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { const message = `Health check not healthy after ${timeout}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); if (status !== "healthy") { + await this.throwIfContainerExited(container); const message = `Health check failed: ${status}`; log.error(message, { containerId: container.id }); throw new Error(message); diff --git a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts index 1cc5695dc..6668eaa7e 100644 --- a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import { HostPortCheck, InternalPortCheck, type PortCheck } from "./utils/port-check"; @@ -60,15 +60,16 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { port: number | string, portCheck: PortCheck ): Promise { - await new IntervalRetry(100).retryUntil( + await this.retryUntil( + container, + 100, () => portCheck.isBound(port), (isBound) => isBound, () => { const message = `Port ${port} not bound after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); } } diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts index 2a173eaea..4c01578bf 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts @@ -1,6 +1,5 @@ -import { RandomUuid } from "../common"; import { GenericContainer } from "../generic-container/generic-container"; -import { checkContainerIsHealthy, checkContainerIsHealthyTls, stopStartingContainer } from "../utils/test-helper"; +import { checkContainerIsHealthy, checkContainerIsHealthyTls } from "../utils/test-helper"; import { Wait } from "./wait"; const exitAfterFirstRequest = ["/bin/sh", "-c", "nc -l -p 8080; exit 1"]; @@ -85,7 +84,7 @@ describe("HttpWaitStrategy", { timeout: 180_000 }, () => { .withEntrypoint(exitAfterFirstRequest) .withWaitStrategy(Wait.forHttp("/hello-world", 8080)) .start() - ).rejects.toThrowError("Container exited during HTTP healthCheck"); + ).rejects.toThrowError("Container exited with code 1"); }); it("should keep waiting after the container exits when abortOnContainerExit is false", async () => { @@ -99,44 +98,6 @@ describe("HttpWaitStrategy", { timeout: 180_000 }, () => { ).rejects.toThrowError("URL /hello-world not accessible after 3000ms"); }); - describe("when options.abortOnContainerExit is true", () => { - it("should fail if container exited before waiting pass", async () => { - const name = `container-name-${new RandomUuid().nextUuid()}`; - const data = [1, 2, 3]; - const tail = 50; - const echoCmd = data.map((i) => `echo ${i}`).join(" && "); - const lastLogs = data.join("\n"); - const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withStartupTimeout(20000) - .withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`]) - .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true })) - .withName(name); - - await expect(stopStartingContainer(container, name)).rejects.toThrowError( - new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`) - ); - }); - - it("should log only $tail logs if container exited before waiting pass", async () => { - const name = `container-name-${new RandomUuid().nextUuid()}`; - const tail = 50; - const data = [...Array(tail + 5).keys()]; - const echoCmd = data.map((i) => `echo ${i}`).join(" && "); - const lastLogs = data.slice(tail * -1).join("\n"); - const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withStartupTimeout(20000) - .withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`]) - .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true })) - .withName(name); - - await expect(stopStartingContainer(container, name)).rejects.toThrowError( - new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`) - ); - }); - }); - it("should set method", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts index f75a028b3..50b2f3b80 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts @@ -1,7 +1,6 @@ -import { text } from "node:stream/consumers"; import type Dockerode from "dockerode"; import { Agent, request } from "undici"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import { undiciResponseToFetchResponse } from "./utils/undici-response-parser"; @@ -22,9 +21,12 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { constructor( private readonly path: string, private readonly port: number, - private readonly options: HttpWaitStrategyOptions + options: HttpWaitStrategyOptions ) { super(); + if (options.abortOnContainerExit !== undefined) { + this.withAbortOnContainerExit(options.abortOnContainerExit); + } } public forStatusCode(statusCode: number): this { @@ -76,49 +78,31 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise { log.debug(`Waiting for HTTP...`, { containerId: container.id }); - const exitStatus = "exited"; - let containerExited = false; const client = await getContainerRuntimeClient(); - const abortOnContainerExit = this.options.abortOnContainerExit ?? true; // Scoped per invocation: one strategy instance can drive concurrent waits, so a shared // agent would let one finished wait destroy a dispatcher another is still using. const agent = this.createInsecureAgent(); try { - await new IntervalRetry(this.readTimeoutMs).retryUntil( + await this.retryUntil( + container, + this.readTimeoutMs, async () => { try { const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${ this.path }`; - - if (abortOnContainerExit) { - const containerStatus = (await client.container.inspect(container)).State.Status; - - if (containerStatus === exitStatus) { - containerExited = true; - return; - } - } - return await this.sendRequest(url, agent); } catch { return undefined; } }, - async (response) => { - if (abortOnContainerExit && containerExited) { - return true; - } - - return this.matchesPredicates(response); - }, + (response) => this.matchesPredicates(response), () => { const message = `URL ${this.path} not accessible after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); } finally { // Force-close: status-only predicates never read the body, so a graceful close() could @@ -126,10 +110,6 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { await agent?.destroy(); } - if (abortOnContainerExit && containerExited) { - return this.handleContainerExit(container); - } - log.debug(`HTTP wait strategy complete`, { containerId: container.id }); } @@ -160,24 +140,6 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { return true; } - private async handleContainerExit(container: Dockerode.Container) { - const tail = 50; - const client = await getContainerRuntimeClient(); - let message: string; - - try { - const lastLogs = await text(await client.container.logs(container, { tail })); - - message = `Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs.trim()}`; - } catch (_err) { - message = "Container exited during HTTP healthCheck, failed to get last logs"; - } - - log.error(message, { containerId: container.id }); - - throw new Error(message); - } - private createInsecureAgent(): Agent | undefined { if (!this._allowInsecure) { return undefined; diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts index 9a101e9b5..41da57ec6 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts @@ -34,6 +34,22 @@ describe("LogWaitStrategy", { timeout: 180_000 }, () => { expect(Date.now() - start.getTime()).toBeGreaterThanOrEqual(4_000); }); + it("should wait for a log message from a container that then exits", async () => { + await using _ = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "echo Ready"]) + .withWaitStrategy(Wait.forLogMessage("Ready")) + .start(); + }); + + it("should fail when the container exits before logging the message", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy(Wait.forLogMessage("Ready")) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); + it("should stop the container when the log message wait strategy times out", async () => { const containerName = `container-${new RandomUuid().nextUuid()}`; diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts index 51af54586..dc21fa0cd 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts @@ -58,9 +58,11 @@ export class LogWaitStrategy extends AbstractWaitStrategy { .on("end", () => { stream.destroy(); clearTimeout(timeout); - const message = `Log stream ended and message "${this.message}" was not received`; - log.error(message, { containerId: container.id }); - reject(new Error(message)); + this.throwIfContainerExited(container).then(() => { + const message = `Log stream ended and message "${this.message}" was not received`; + log.error(message, { containerId: container.id }); + reject(new Error(message)); + }, reject); }); }); } diff --git a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts index 026a6eb5e..2e31c176f 100644 --- a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts @@ -17,4 +17,13 @@ describe("ShellWaitStrategy", { timeout: 180_000 }, () => { .start() ).rejects.toThrowError(`Shell command "stat /tmp/test.lock" not successful`); }); + + it("should fail when the container exits", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy(Wait.forSuccessfulCommand("stat /tmp/test.lock")) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); }); diff --git a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts index 70101e535..69e3970ea 100644 --- a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import { AbstractWaitStrategy } from "./wait-strategy"; @@ -12,7 +12,9 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { log.debug(`Waiting for successful shell command "${this.command}"...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); - await new IntervalRetry(100).retryUntil( + await this.retryUntil( + container, + 100, async () => { const { exitCode } = await client.container.exec(container, ["/bin/sh", "-c", this.command], { log: false, @@ -24,8 +26,7 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { const message = `Shell command "${this.command}" not successful after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); log.debug(`Shell wait strategy complete`, { containerId: container.id }); diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts new file mode 100644 index 000000000..e40a6cc20 --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts @@ -0,0 +1,78 @@ +import type Dockerode from "dockerode"; +import { AbstractWaitStrategy } from "./wait-strategy"; + +const { inspect } = vi.hoisted(() => ({ inspect: vi.fn() })); + +vi.mock("../container-runtime", async (importOriginal) => ({ + ...(await importOriginal()), + getContainerRuntimeClient: async () => ({ container: { inspect } }), +})); + +class TestWaitStrategy extends AbstractWaitStrategy { + constructor(private readonly attempt: () => Promise) { + super(); + } + + public async waitUntilReady(container: Dockerode.Container): Promise { + await this.retryUntil( + container, + 10, + this.attempt, + (ready) => ready, + () => new Error("Not ready") + ); + } +} + +const stubContainer = { id: "container-id" } as Dockerode.Container; + +const inspectResult = (Status: string, ExitCode = 0) => ({ State: { Status, ExitCode } }); + +describe("AbstractWaitStrategy", { concurrent: false }, () => { + it("fails as soon as the container has exited", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + const attempt = vi.fn(async () => false); + + await expect(new TestWaitStrategy(attempt).waitUntilReady(stubContainer)).rejects.toThrowError( + "Container exited with code 1" + ); + expect(attempt).toHaveBeenCalledTimes(1); + }); + + it("reports the exit when an attempt fails because the container has exited", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + + await expect( + new TestWaitStrategy(() => Promise.reject(new Error("container is not running"))).waitUntilReady(stubContainer) + ).rejects.toThrowError("Container exited with code 1"); + }); + + it("rethrows the error of a failed attempt while the container is running", async () => { + inspect.mockResolvedValue(inspectResult("running")); + + await expect( + new TestWaitStrategy(() => Promise.reject(new Error("attempt failed"))).waitUntilReady(stubContainer) + ).rejects.toThrowError("attempt failed"); + }); + + it("keeps waiting while the container is restarting", async () => { + inspect.mockResolvedValueOnce(inspectResult("restarting")).mockResolvedValueOnce(inspectResult("running")); + const attempt = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await new TestWaitStrategy(attempt).waitUntilReady(stubContainer); + + expect(attempt).toHaveBeenCalledTimes(3); + }); + + it("keeps waiting after the container has exited when abortOnContainerExit is false", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + + await expect( + new TestWaitStrategy(async () => false) + .withAbortOnContainerExit(false) + .withStartupTimeout(50) + .waitUntilReady(stubContainer) + ).rejects.toThrowError("Not ready"); + expect(inspect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.ts index a93d8c482..68e36013d 100644 --- a/packages/testcontainers/src/wait-strategies/wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.ts @@ -1,4 +1,6 @@ import type Dockerode from "dockerode"; +import { IntervalRetry, log } from "../common"; +import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; export interface WaitStrategy { @@ -14,6 +16,7 @@ export interface WaitStrategy { export abstract class AbstractWaitStrategy implements WaitStrategy { protected startupTimeoutMs = 60_000; private startupTimeoutSet = false; + private abortOnContainerExit = true; public abstract waitUntilReady( container: Dockerode.Container, @@ -34,4 +37,58 @@ export abstract class AbstractWaitStrategy implements WaitStrategy { public getStartupTimeout(): number { return this.startupTimeoutMs; } + + public withAbortOnContainerExit(abortOnContainerExit: boolean): this { + this.abortOnContainerExit = abortOnContainerExit; + return this; + } + + protected async retryUntil( + container: Dockerode.Container, + intervalMs: number, + fn: () => Promise, + predicate: (result: T) => boolean | Promise, + onTimeout: () => Error + ): Promise { + const result = await new IntervalRetry(intervalMs).retryUntil( + async () => { + try { + return await fn(); + } catch (err) { + await this.throwIfContainerExited(container); + throw err; + } + }, + async (value) => { + if (await predicate(value)) { + return true; + } + await this.throwIfContainerExited(container); + return false; + }, + onTimeout, + this.startupTimeoutMs + ); + + if (result instanceof Error) { + throw result; + } + + return result; + } + + protected async throwIfContainerExited(container: Dockerode.Container): Promise { + if (!this.abortOnContainerExit) { + return; + } + + const client = await getContainerRuntimeClient(); + const { State: state } = await client.container.inspect(container); + + if (state.Status === "exited") { + const message = `Container exited with code ${state.ExitCode}`; + log.error(message, { containerId: container.id }); + throw new Error(message); + } + } } diff --git a/packages/testcontainers/src/wait-strategies/wait.ts b/packages/testcontainers/src/wait-strategies/wait.ts index 1c359ce48..f5ac9196e 100644 --- a/packages/testcontainers/src/wait-strategies/wait.ts +++ b/packages/testcontainers/src/wait-strategies/wait.ts @@ -13,15 +13,15 @@ export class Wait { return new CompositeWaitStrategy(waitStrategies); } - public static forListeningPorts(): WaitStrategy { + public static forListeningPorts(): HostPortWaitStrategy { return new HostPortWaitStrategy(); } - public static forLogMessage(message: Log | RegExp, times = 1): WaitStrategy { + public static forLogMessage(message: Log | RegExp, times = 1): LogWaitStrategy { return new LogWaitStrategy(message, times); } - public static forHealthCheck(): WaitStrategy { + public static forHealthCheck(): HealthCheckWaitStrategy { return new HealthCheckWaitStrategy(); }