From b80513fb9b2de03355c5bc4e8fd5c1bcc0867726 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 12 Oct 2026 17:27:22 +0100 Subject: [PATCH 1/4] Pass the remaining startup timeout to the containerStarted hook --- docs/features/containers.md | 18 ++++++- ...generic-container-startup-deadline.test.ts | 53 +++++++++++++++++++ .../generic-container/generic-container.ts | 8 ++- .../src/generic-container/startup-deadline.ts | 11 ++++ packages/testcontainers/src/index.ts | 1 + 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts create mode 100644 packages/testcontainers/src/generic-container/startup-deadline.ts diff --git a/docs/features/containers.md b/docs/features/containers.md index 6310b872c..122b6b815 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -554,6 +554,7 @@ import { GenericContainer, AbstractStartedContainer, StartedTestContainer, + StartupDeadline, InspectResult } from "testcontainers"; @@ -576,7 +577,8 @@ class CustomContainer extends GenericContainer { protected override async containerStarted( container: StartedTestContainer, inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupDeadline?: StartupDeadline ): Promise { // ... } @@ -597,6 +599,20 @@ class CustomStartedContainer extends AbstractStartedContainer { } ``` +`containerStarted` runs after the [wait strategy](wait-strategies.md). When a startup timeout is set on the container, `startupDeadline.getRemainingMs()` returns how much of it is left, so that further waiting in the callback shares the timeout with the wait strategy instead of starting a new one. It is `undefined` when no startup timeout is set: + +```ts +protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult, + reused: boolean, + startupDeadline?: StartupDeadline +): Promise { + const timeoutMs = startupDeadline?.getRemainingMs() ?? 60_000; + // ... +} +``` + ## Exposing container ports Specify which container ports you want accessible by the host: diff --git a/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts new file mode 100644 index 000000000..b7c40ce01 --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts @@ -0,0 +1,53 @@ +import type { StartedTestContainer } from "../test-container"; +import type { InspectResult } from "../types"; +import { AbstractWaitStrategy } from "../wait-strategies/wait-strategy"; +import { GenericContainer } from "./generic-container"; +import type { StartupDeadline } from "./startup-deadline"; + +describe("GenericContainer startup deadline", { timeout: 180_000 }, () => { + it("should give containerStarted the startup timeout left after the wait strategy", async () => { + const waitStrategy = new SlowWaitStrategy(); + const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14") + .withStartupTimeout(60_000) + .withWaitStrategy(waitStrategy); + + await using _ = await container.start(); + + const [remainingMs] = container.remainingStartupMs; + expect(remainingMs).toBeGreaterThan(0); + expect(remainingMs).toBeLessThanOrEqual(60_000 - waitStrategy.waitedMs); + }); + + it("should not give containerStarted a startup deadline when no startup timeout is set", async () => { + const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14").withWaitStrategy( + new SlowWaitStrategy() + ); + + await using _ = await container.start(); + + expect(container.remainingStartupMs).toEqual([undefined]); + }); + + class RecordingContainer extends GenericContainer { + public readonly remainingStartupMs: Array = []; + + protected override async containerStarted( + _container: StartedTestContainer, + _inspectResult: InspectResult, + _reused: boolean, + startupDeadline?: StartupDeadline + ): Promise { + this.remainingStartupMs.push(startupDeadline?.getRemainingMs()); + } + } + + class SlowWaitStrategy extends AbstractWaitStrategy { + public waitedMs = 0; + + public async waitUntilReady(): Promise { + const startTime = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 100)); + this.waitedMs = Date.now() - startTime; + } + } +}); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index d9b2f8ffd..2d7e55f8a 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -41,6 +41,7 @@ import { GenericContainerBuilder } from "./generic-container-builder"; import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; // biome-ignore lint/suspicious/noImportCycles: Ryuk and sshd run as GenericContainers; only dereferenced at runtime import { StartedGenericContainer } from "./started-generic-container"; +import { StartupDeadline } from "./startup-deadline"; const reusableContainerCreationLock = new AsyncLock(); @@ -258,6 +259,8 @@ export class GenericContainer implements TestContainer { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } + const startupDeadline = + this.startupTimeoutMs !== undefined ? new StartupDeadline(this.startupTimeoutMs) : undefined; await waitForContainer(client, container, waitStrategy, boundPorts); const startedContainer = new StartedGenericContainer( @@ -271,7 +274,7 @@ export class GenericContainer implements TestContainer { ); if (this.containerStarted) { - await this.containerStarted(startedContainer, mappedInspectResult, false); + await this.containerStarted(startedContainer, mappedInspectResult, false, startupDeadline); } return startedContainer; @@ -313,7 +316,8 @@ export class GenericContainer implements TestContainer { protected containerStarted?( container: StartedTestContainer, inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupDeadline?: StartupDeadline ): Promise; public withCommand(command: string[]): this { diff --git a/packages/testcontainers/src/generic-container/startup-deadline.ts b/packages/testcontainers/src/generic-container/startup-deadline.ts new file mode 100644 index 000000000..24c006fd2 --- /dev/null +++ b/packages/testcontainers/src/generic-container/startup-deadline.ts @@ -0,0 +1,11 @@ +export class StartupDeadline { + private readonly deadline: number; + + constructor(startupTimeoutMs: number) { + this.deadline = Date.now() + startupTimeoutMs; + } + + public getRemainingMs(): number { + return Math.max(this.deadline - Date.now(), 0); + } +} diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 247498fb6..68f6cbe27 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -8,6 +8,7 @@ export { AbstractStartedContainer } from "./generic-container/abstract-started-c export { AbstractStoppedContainer } from "./generic-container/abstract-stopped-container"; export { GenericContainer } from "./generic-container/generic-container"; export { BuildOptions, GenericContainerBuilder } from "./generic-container/generic-container-builder"; +export { StartupDeadline } from "./generic-container/startup-deadline"; export { Network, StartedNetwork, StoppedNetwork } from "./network/network"; export { getReaper } from "./reaper/reaper"; export { SocatContainer, StartedSocatContainer } from "./socat/socat-container"; From 17316dbd001bf7468023087e3620a134466f32c5 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 10:26:01 +0100 Subject: [PATCH 2/4] Add a write-code skill for code conventions (#1517) --- .agents/skills/add-module/SKILL.md | 1 + .agents/skills/review-pr/SKILL.md | 1 + .agents/skills/write-code/SKILL.md | 25 +++++++++++++++++++++++++ .claude/skills/write-code | 1 + AGENTS.md | 3 ++- 5 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/write-code/SKILL.md create mode 120000 .claude/skills/write-code diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md index 3322878bf..7ab315083 100644 --- a/.agents/skills/add-module/SKILL.md +++ b/.agents/skills/add-module/SKILL.md @@ -25,6 +25,7 @@ For the container class, tests and docs page, start from the module closest in s - Started-container getters call `getMappedPort()` when they're invoked. A restarted container can get different host ports. - Incompatible major versions (different ports, auth or startup) get separate classes, not image-tag parsing. - Keep it small: no getters that exist only for tests, and no speculative options. +- Write the class following `write-code`. ## Tests diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md index 578e0a7e1..81069152b 100644 --- a/.agents/skills/review-pr/SKILL.md +++ b/.agents/skills/review-pr/SKILL.md @@ -40,6 +40,7 @@ gh pr checks - Zero-config defaults work, and invalid or half-set config fails fast. - The wait strategy is set in the constructor, and waits are robust (listening ports, health check or HTTP rather than log regexes). - Images are pinned in the module `Dockerfile`. +- **Style:** new code follows `write-code`. - **Scope:** - Flag new files for a few lines of logic, dead fallbacks, duplicated constants, getters that exist only for tests, and tests of third-party behaviour. - Flag unrelated dependency bumps; leave those to Dependabot. diff --git a/.agents/skills/write-code/SKILL.md b/.agents/skills/write-code/SKILL.md new file mode 100644 index 000000000..a5ee2dcb9 --- /dev/null +++ b/.agents/skills/write-code/SKILL.md @@ -0,0 +1,25 @@ +--- +name: write-code +description: Writes and refactors TypeScript in testcontainers-node by reusing what the project already has (existing helpers, shared functions, core's building blocks) instead of adding another copy. Use when writing, changing or refactoring code under packages/, e.g. "add an option to the Kafka module", "fix this bug", "add a wait for X", and when review feedback says code is duplicated or should be refactored. +--- + +# Write code + +Biome enforces the lint and formatting rules (`biome.json`). This skill covers what a linter can't see. The most common review finding is new code that repeats something the project already has. + +## Before adding code + +- Search for code that already does it: in the file, in the package, and in what `testcontainers` exports (`packages/testcontainers/src/index.ts`). If it exists, use it. +- If something close exists, change it so it serves both callers. Don't copy it and adapt the copy. + +## When the same code is needed twice + +- In one file, extract a helper: a private method, or a function the file doesn't export. Define it below the first function that calls it. +- Across files in a package, move it into a function in its own file. +- Across modules, it belongs in core, exported from `testcontainers`, because modules can't import each other. That adds public API, so propose it instead of copying the code. + +## Use what core provides + +- Configure containers through the `with*` methods. Don't assign to `this.createOpts` or `this.hostConfig`. +- Wait with a `WaitStrategy` run through `waitForContainer`. Don't call `waitUntilReady` on a strategy or write a polling loop. +- A wait added after start gets what is left of the user's startup timeout, never a fresh one. diff --git a/.claude/skills/write-code b/.claude/skills/write-code new file mode 120000 index 000000000..ce96cc1bb --- /dev/null +++ b/.claude/skills/write-code @@ -0,0 +1 @@ +../../.agents/skills/write-code \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 37dea63bf..74b7f5963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This is a working guide for contributors and coding agents in this repository. It captures practical rules that prevent avoidable CI and PR churn. -This file holds the rules for every task. Workflows live in skills under `.agents/skills/`, which Claude Code reads through symlinks in `.claude/skills/`. They cover opening and reviewing PRs, adding modules, issue triage, CI and dependency maintenance, and release review. +This file holds the rules for every task. Workflows live in skills under `.agents/skills/`, which Claude Code reads through symlinks in `.claude/skills/`. They cover writing code, opening and reviewing PRs, adding modules, issue triage, CI and dependency maintenance, and release review. If a skill or this file turns out to be wrong or incomplete, update it in the same PR. ## Repository Layout @@ -30,6 +30,7 @@ If a skill or this file turns out to be wrong or incomplete, update it in the sa ### Code +- Follow the `write-code` skill when writing or changing code under `packages/`. - Keep code comments to a minimum; the codebase has very few. Add one only when it tells the reader something they can't get from the code, the PR or the git history, such as why a non-obvious approach was needed, or a link to an upstream bug. - Don't narrate what the code does, describe the change you made, or reference the task or review that prompted it. - Fix the root cause. A change that only makes a failure less likely is not a fix. From 78f053aa2f78f54a91bcc061ad56cda147a9ad8b Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 10:35:11 +0100 Subject: [PATCH 3/4] Apply the startup timeout as one deadline from container start --- .agents/skills/add-module/SKILL.md | 1 + docs/features/containers.md | 9 ++- docs/features/wait-strategies.md | 2 + .../couchbase/src/couchbase-container.ts | 8 ++- packages/testcontainers/src/common/index.ts | 1 + .../testcontainers/src/common/retry.test.ts | 14 +++++ packages/testcontainers/src/common/retry.ts | 4 +- .../src/common/startup-deadline.test.ts | 34 +++++++++++ .../src/common/startup-deadline.ts | 39 ++++++++++++ ...generic-container-startup-deadline.test.ts | 60 ++++++++++++++++--- .../generic-container/generic-container.ts | 33 +++++++--- .../src/generic-container/startup-deadline.ts | 11 ---- packages/testcontainers/src/index.ts | 3 +- .../src/wait-strategies/log-wait-strategy.ts | 4 +- 14 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 packages/testcontainers/src/common/startup-deadline.test.ts create mode 100644 packages/testcontainers/src/common/startup-deadline.ts delete mode 100644 packages/testcontainers/src/generic-container/startup-deadline.ts diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md index 3322878bf..15676ea2f 100644 --- a/.agents/skills/add-module/SKILL.md +++ b/.agents/skills/add-module/SKILL.md @@ -19,6 +19,7 @@ For the container class, tests and docs page, start from the module closest in s ## Container class - The constructor sets exposed ports, the wait strategy and `withStartupTimeout(120_000)`. Setting the wait strategy there lets users override it. + - That timeout is one deadline for the whole startup, including waits in `containerStarting` and `containerStarted`. Wait strategies and `IntervalRetry` used there are limited to it already. For any other wait, take the time left from the callback's `startupDeadline` argument; don't start a new timeout from `this.startupTimeoutMs`. - Prefer listening-port, health-check or HTTP waits over log regexes. Log output changes between image versions. Shell-less images need a health-check or HTTP wait. - Zero config must work: `new XContainer(IMAGE).start()` gives a container a client can connect to. Prefer defaults to getters that can return `undefined`. - Validate `with*` inputs, and fail fast on half-set config (for example a username without a password) instead of waiting out the startup timeout. diff --git a/docs/features/containers.md b/docs/features/containers.md index 122b6b815..b2a4c740b 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -569,7 +569,8 @@ class CustomContainer extends GenericContainer { protected override async containerStarting( inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupDeadline?: StartupDeadline ): Promise { // ... } @@ -599,7 +600,9 @@ class CustomStartedContainer extends AbstractStartedContainer { } ``` -`containerStarted` runs after the [wait strategy](wait-strategies.md). When a startup timeout is set on the container, `startupDeadline.getRemainingMs()` returns how much of it is left, so that further waiting in the callback shares the timeout with the wait strategy instead of starting a new one. It is `undefined` when no startup timeout is set: +`containerStarting` and `containerStarted` run within the container's [startup timeout](wait-strategies.md). Wait strategies and `IntervalRetry` used in them stop when the startup timeout is reached, even when their own timeout is longer. + +For anything else that waits, `startupDeadline.getRemainingMs()` returns how much of the startup timeout is left. `startupDeadline` is `undefined` when no startup timeout is set on the container: ```ts protected override async containerStarted( @@ -609,7 +612,7 @@ protected override async containerStarted( startupDeadline?: StartupDeadline ): Promise { const timeoutMs = startupDeadline?.getRemainingMs() ?? 60_000; - // ... + await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); } ``` diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index e9c27e04a..05babaa7a 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,6 +10,8 @@ const container = await new GenericContainer("alpine") .start(); ``` +A startup timeout set on the container is one deadline for the whole startup. It is counted from when the container is started, and the wait strategy and the [lifecycle callbacks](containers.md#lifecycle-callbacks) share it. Pulling the image is not included. + ## 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. diff --git a/packages/modules/couchbase/src/couchbase-container.ts b/packages/modules/couchbase/src/couchbase-container.ts index 6b1232f58..77a2dafc1 100644 --- a/packages/modules/couchbase/src/couchbase-container.ts +++ b/packages/modules/couchbase/src/couchbase-container.ts @@ -8,6 +8,7 @@ import { IntervalRetry, log, type StartedTestContainer, + type StartupDeadline, Wait, type WaitStrategy, } from "testcontainers"; @@ -570,10 +571,11 @@ export class CouchbaseContainer extends GenericContainer { protected override async containerStarted( startedTestContainer: StartedTestContainer, - inspectResult: InspectResult + inspectResult: InspectResult, + _reused: boolean, + startupDeadline?: StartupDeadline ): Promise { - const deadline = this.startupTimeoutMs === undefined ? undefined : Date.now() + this.startupTimeoutMs; - const remainingStartupMs = () => (deadline === undefined ? 60_000 : Math.max(deadline - Date.now(), 0)); + const remainingStartupMs = () => startupDeadline?.getRemainingMs() ?? 60_000; const client = await getContainerRuntimeClient(); await this.waitUntilNodeIsOnline(client, inspectResult, startedTestContainer, remainingStartupMs()); diff --git a/packages/testcontainers/src/common/index.ts b/packages/testcontainers/src/common/index.ts index f9cd5d29f..f530d5445 100644 --- a/packages/testcontainers/src/common/index.ts +++ b/packages/testcontainers/src/common/index.ts @@ -2,6 +2,7 @@ export { withFileLock } from "./file-lock"; export { hash } from "./hash"; export { buildLog, composeLog, containerLog, execLog, Logger, log, pullLog } from "./logger"; export { IntervalRetry, Retry } from "./retry"; +export { limitToStartupDeadline, StartupDeadline, withStartupDeadline } from "./startup-deadline"; export * from "./time"; export * from "./type-guards"; export { RandomUuid, randomUuid, Uuid } from "./uuid"; diff --git a/packages/testcontainers/src/common/retry.test.ts b/packages/testcontainers/src/common/retry.test.ts index 6674e7228..8089506fa 100644 --- a/packages/testcontainers/src/common/retry.test.ts +++ b/packages/testcontainers/src/common/retry.test.ts @@ -1,4 +1,5 @@ import { IntervalRetry } from "./retry"; +import { StartupDeadline, withStartupDeadline } from "./startup-deadline"; describe("Retry", () => { describe("IntervalRetry", () => { @@ -44,5 +45,18 @@ describe("Retry", () => { expect(result).toEqual(new Error()); expect(timeoutMock).toHaveBeenCalledTimes(1); }); + + it("should time out at the startup deadline when it is sooner than the timeout", async () => { + const result = await withStartupDeadline(new StartupDeadline(20), () => + new IntervalRetry(1).retryUntil( + async () => false, + (result) => result, + () => "timed out", + 60_000 + ) + ); + + expect(result).toEqual("timed out"); + }); }); }); diff --git a/packages/testcontainers/src/common/retry.ts b/packages/testcontainers/src/common/retry.ts index 3d07821ef..9454ca088 100644 --- a/packages/testcontainers/src/common/retry.ts +++ b/packages/testcontainers/src/common/retry.ts @@ -1,4 +1,5 @@ import { type Clock, SystemClock, type Time } from "./clock"; +import { limitToStartupDeadline } from "./startup-deadline"; export interface Retry { retryUntil( @@ -40,12 +41,13 @@ export class IntervalRetry extends AbstractRetry { timeoutMs: number ): Promise { const startTime = this.clock.getTime(); + const limitedTimeoutMs = limitToStartupDeadline(timeoutMs); let attemptNumber = 0; let result = await fn(attemptNumber++); while (!(await predicate(result))) { - if (this.hasTimedOut(timeoutMs, startTime)) { + if (this.hasTimedOut(limitedTimeoutMs, startTime)) { return onTimeout(); } await this.wait(this.interval); diff --git a/packages/testcontainers/src/common/startup-deadline.test.ts b/packages/testcontainers/src/common/startup-deadline.test.ts new file mode 100644 index 000000000..f419e6fad --- /dev/null +++ b/packages/testcontainers/src/common/startup-deadline.test.ts @@ -0,0 +1,34 @@ +import { AsyncResource } from "node:async_hooks"; +import { limitToStartupDeadline, StartupDeadline, withStartupDeadline } from "./startup-deadline"; + +describe("StartupDeadline", () => { + it("should not limit a timeout outside of a startup", () => { + expect(limitToStartupDeadline(60_000)).toBe(60_000); + }); + + it("should limit a timeout to the time left of the startup deadline", async () => { + const timeoutMs = await withStartupDeadline(new StartupDeadline(1_000), async () => limitToStartupDeadline(60_000)); + + expect(timeoutMs).toBeLessThanOrEqual(1_000); + }); + + it("should not extend a timeout that is shorter than the time left", async () => { + const timeoutMs = await withStartupDeadline(new StartupDeadline(60_000), async () => limitToStartupDeadline(100)); + + expect(timeoutMs).toBe(100); + }); + + it("should not limit a timeout when the startup has no deadline", async () => { + const timeoutMs = await withStartupDeadline(undefined, async () => limitToStartupDeadline(60_000)); + + expect(timeoutMs).toBe(60_000); + }); + + it("should not limit a timeout in a callback that runs after the startup", async () => { + const callback = await withStartupDeadline(new StartupDeadline(1_000), async () => + AsyncResource.bind(() => limitToStartupDeadline(60_000)) + ); + + expect(callback()).toBe(60_000); + }); +}); diff --git a/packages/testcontainers/src/common/startup-deadline.ts b/packages/testcontainers/src/common/startup-deadline.ts new file mode 100644 index 000000000..23424544a --- /dev/null +++ b/packages/testcontainers/src/common/startup-deadline.ts @@ -0,0 +1,39 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export class StartupDeadline { + private readonly deadline: number; + + constructor(startupTimeoutMs: number) { + this.deadline = Date.now() + startupTimeoutMs; + } + + public getRemainingMs(): number { + return Math.max(this.deadline - Date.now(), 0); + } +} + +type Startup = { deadline: StartupDeadline; inProgress: boolean }; + +const currentStartup = new AsyncLocalStorage(); + +export async function withStartupDeadline( + startupDeadline: StartupDeadline | undefined, + fn: () => Promise +): Promise { + if (startupDeadline === undefined) { + return fn(); + } + + const startup = { deadline: startupDeadline, inProgress: true }; + try { + return await currentStartup.run(startup, fn); + } finally { + // Callbacks registered during the startup, such as log consumers, keep this context after it ends. + startup.inProgress = false; + } +} + +export function limitToStartupDeadline(timeoutMs: number): number { + const startup = currentStartup.getStore(); + return startup?.inProgress ? Math.min(timeoutMs, startup.deadline.getRemainingMs()) : timeoutMs; +} diff --git a/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts index b7c40ce01..70940fcfd 100644 --- a/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts @@ -1,11 +1,15 @@ +import type { StartupDeadline } from "../common"; +import { getContainerRuntimeClient } from "../container-runtime"; import type { StartedTestContainer } from "../test-container"; import type { InspectResult } from "../types"; +import { BoundPorts } from "../utils/bound-ports"; +import { Wait } from "../wait-strategies/wait"; +import { waitForContainer } from "../wait-strategies/wait-for-container"; import { AbstractWaitStrategy } from "../wait-strategies/wait-strategy"; import { GenericContainer } from "./generic-container"; -import type { StartupDeadline } from "./startup-deadline"; describe("GenericContainer startup deadline", { timeout: 180_000 }, () => { - it("should give containerStarted the startup timeout left after the wait strategy", async () => { + it("should give the lifecycle callbacks the startup timeout that is left", async () => { const waitStrategy = new SlowWaitStrategy(); const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14") .withStartupTimeout(60_000) @@ -13,23 +17,46 @@ describe("GenericContainer startup deadline", { timeout: 180_000 }, () => { await using _ = await container.start(); - const [remainingMs] = container.remainingStartupMs; - expect(remainingMs).toBeGreaterThan(0); - expect(remainingMs).toBeLessThanOrEqual(60_000 - waitStrategy.waitedMs); + const [startingMs] = container.remainingMsWhenStarting; + const [startedMs] = container.remainingMsWhenStarted; + expect(startingMs).toBeLessThanOrEqual(60_000); + expect(startedMs).toBeGreaterThan(0); + expect(startedMs).toBeLessThanOrEqual(60_000 - waitStrategy.waitedMs); }); - it("should not give containerStarted a startup deadline when no startup timeout is set", async () => { + it("should not give the lifecycle callbacks a startup deadline when no startup timeout is set", async () => { const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14").withWaitStrategy( new SlowWaitStrategy() ); await using _ = await container.start(); - expect(container.remainingStartupMs).toEqual([undefined]); + expect(container.remainingMsWhenStarting).toEqual([undefined]); + expect(container.remainingMsWhenStarted).toEqual([undefined]); + }); + + it("should apply the startup timeout to a wait in containerStarted", async () => { + const container = new WaitingContainer("cristianrgreco/testcontainer:1.1.14") + .withStartupTimeout(2_000) + .withWaitStrategy(new SlowWaitStrategy()); + const startTime = Date.now(); + + await expect(container.start()).rejects.toThrow('Log message "never logged" not received'); + + expect(Date.now() - startTime).toBeLessThan(60_000); }); class RecordingContainer extends GenericContainer { - public readonly remainingStartupMs: Array = []; + public readonly remainingMsWhenStarting: Array = []; + public readonly remainingMsWhenStarted: Array = []; + + protected override async containerStarting( + _inspectResult: InspectResult, + _reused: boolean, + startupDeadline?: StartupDeadline + ): Promise { + this.remainingMsWhenStarting.push(startupDeadline?.getRemainingMs()); + } protected override async containerStarted( _container: StartedTestContainer, @@ -37,7 +64,22 @@ describe("GenericContainer startup deadline", { timeout: 180_000 }, () => { _reused: boolean, startupDeadline?: StartupDeadline ): Promise { - this.remainingStartupMs.push(startupDeadline?.getRemainingMs()); + this.remainingMsWhenStarted.push(startupDeadline?.getRemainingMs()); + } + } + + class WaitingContainer extends GenericContainer { + protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult + ): Promise { + const client = await getContainerRuntimeClient(); + await waitForContainer( + client, + client.container.getById(container.getId()), + Wait.forLogMessage("never logged"), + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult) + ); } } diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 2d7e55f8a..3c36b844a 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -3,7 +3,7 @@ import type { Readable } from "node:stream"; import archiver from "archiver"; import AsyncLock from "async-lock"; import type { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; -import { containerLog, hash, log, toNanos } from "../common"; +import { containerLog, hash, log, StartupDeadline, toNanos, withStartupDeadline } from "../common"; import { type ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; import type { StartedNetwork } from "../network/network"; @@ -41,7 +41,6 @@ import { GenericContainerBuilder } from "./generic-container-builder"; import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; // biome-ignore lint/suspicious/noImportCycles: Ryuk and sshd run as GenericContainers; only dereferenced at runtime import { StartedGenericContainer } from "./started-generic-container"; -import { StartupDeadline } from "./startup-deadline"; const reusableContainerCreationLock = new AsyncLock(); @@ -90,7 +89,11 @@ export class GenericContainer implements TestContainer { protected containerCreated?(containerId: string): Promise; - protected containerStarting?(inspectResult: InspectResult, reused: boolean): Promise; + protected containerStarting?( + inspectResult: InspectResult, + reused: boolean, + startupDeadline?: StartupDeadline + ): Promise; public async start(): Promise { const shouldPull = this.pullPolicy.shouldPull(); @@ -168,6 +171,7 @@ export class GenericContainer implements TestContainer { } private async reuseContainer(client: ContainerRuntimeClient, container: Container) { + const startupDeadline = this.createStartupDeadline(); let inspectResult = await client.container.inspect(container); if (!inspectResult.State.Running) { log.debug("Reused container is not running, attempting to start it"); @@ -184,7 +188,7 @@ export class GenericContainer implements TestContainer { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, waitStrategy, boundPorts); + await withStartupDeadline(startupDeadline, () => waitForContainer(client, container, waitStrategy, boundPorts)); return new StartedGenericContainer( container, @@ -226,6 +230,7 @@ export class GenericContainer implements TestContainer { await this.containerCreated(container.id); } + const startupDeadline = this.createStartupDeadline(); await client.container.start(container); log.info(`Started container for image "${this.createOpts.Image}"`, { containerId: container.id }); @@ -233,6 +238,22 @@ export class GenericContainer implements TestContainer { () => client.container.inspect(container), container.id ); + + return withStartupDeadline(startupDeadline, () => + this.waitUntilContainerReady(client, container, inspectResult, startupDeadline) + ); + } + + private createStartupDeadline(): StartupDeadline | undefined { + return this.startupTimeoutMs !== undefined ? new StartupDeadline(this.startupTimeoutMs) : undefined; + } + + private async waitUntilContainerReady( + client: ContainerRuntimeClient, + container: Container, + inspectResult: ContainerInspectInfo, + startupDeadline: StartupDeadline | undefined + ): Promise { const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts @@ -251,7 +272,7 @@ export class GenericContainer implements TestContainer { } if (this.containerStarting) { - await this.containerStarting(mappedInspectResult, false); + await this.containerStarting(mappedInspectResult, false, startupDeadline); } const waitStrategy = await this.selectWaitStrategy(client, inspectResult); @@ -259,8 +280,6 @@ export class GenericContainer implements TestContainer { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - const startupDeadline = - this.startupTimeoutMs !== undefined ? new StartupDeadline(this.startupTimeoutMs) : undefined; await waitForContainer(client, container, waitStrategy, boundPorts); const startedContainer = new StartedGenericContainer( diff --git a/packages/testcontainers/src/generic-container/startup-deadline.ts b/packages/testcontainers/src/generic-container/startup-deadline.ts deleted file mode 100644 index 24c006fd2..000000000 --- a/packages/testcontainers/src/generic-container/startup-deadline.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class StartupDeadline { - private readonly deadline: number; - - constructor(startupTimeoutMs: number) { - this.deadline = Date.now() + startupTimeoutMs; - } - - public getRemainingMs(): number { - return Math.max(this.deadline - Date.now(), 0); - } -} diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 68f6cbe27..02e139345 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -1,4 +1,4 @@ -export { IntervalRetry, log, RandomUuid, Retry, randomUuid, Uuid } from "./common"; +export { IntervalRetry, log, RandomUuid, Retry, randomUuid, StartupDeadline, Uuid } from "./common"; export { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime"; export { DockerComposeEnvironment } from "./docker-compose-environment/docker-compose-environment"; export { DownedDockerComposeEnvironment } from "./docker-compose-environment/downed-docker-compose-environment"; @@ -8,7 +8,6 @@ export { AbstractStartedContainer } from "./generic-container/abstract-started-c export { AbstractStoppedContainer } from "./generic-container/abstract-stopped-container"; export { GenericContainer } from "./generic-container/generic-container"; export { BuildOptions, GenericContainerBuilder } from "./generic-container/generic-container-builder"; -export { StartupDeadline } from "./generic-container/startup-deadline"; export { Network, StartedNetwork, StoppedNetwork } from "./network/network"; export { getReaper } from "./reaper/reaper"; export { SocatContainer, StartedSocatContainer } from "./socat/socat-container"; diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts index 51ca97bed..a73b1d63b 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts @@ -1,6 +1,6 @@ import byline from "byline"; import type Dockerode from "dockerode"; -import { log } from "../common"; +import { limitToStartupDeadline, log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import { AbstractWaitStrategy } from "./wait-strategy"; @@ -28,7 +28,7 @@ export class LogWaitStrategy extends AbstractWaitStrategy { const message = `Log message "${this.message}" not received after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); reject(new Error(message)); - }, this.startupTimeoutMs); + }, limitToStartupDeadline(this.startupTimeoutMs)); const comparisonFn: (line: string) => boolean = (line: string) => { if (this.message instanceof RegExp) { From 2df2b077c4869d983d05b7f5043ca677df0bca93 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 10:44:43 +0100 Subject: [PATCH 4/4] Apply the startup deadline to restarts and Compose environments --- docs/features/wait-strategies.md | 2 ++ packages/testcontainers/src/common/index.ts | 7 +++++- .../src/common/startup-deadline.ts | 4 ++++ .../docker-compose-environment.test.ts | 15 ++++++++++++ .../docker-compose-environment.ts | 10 +++++--- ...generic-container-startup-deadline.test.ts | 16 +++++++++++++ .../generic-container/generic-container.ts | 24 ++++++++++++------- .../started-generic-container.ts | 10 +++++--- 8 files changed, 72 insertions(+), 16 deletions(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 05babaa7a..55089c805 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -12,6 +12,8 @@ const container = await new GenericContainer("alpine") A startup timeout set on the container is one deadline for the whole startup. It is counted from when the container is started, and the wait strategy and the [lifecycle callbacks](containers.md#lifecycle-callbacks) share it. Pulling the image is not included. +The deadline starts again when the container is restarted. The startup timeout of a Compose environment is one deadline for all of its containers, counted from when Compose has started them. + ## 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. diff --git a/packages/testcontainers/src/common/index.ts b/packages/testcontainers/src/common/index.ts index f530d5445..19e40f809 100644 --- a/packages/testcontainers/src/common/index.ts +++ b/packages/testcontainers/src/common/index.ts @@ -2,7 +2,12 @@ export { withFileLock } from "./file-lock"; export { hash } from "./hash"; export { buildLog, composeLog, containerLog, execLog, Logger, log, pullLog } from "./logger"; export { IntervalRetry, Retry } from "./retry"; -export { limitToStartupDeadline, StartupDeadline, withStartupDeadline } from "./startup-deadline"; +export { + createStartupDeadline, + limitToStartupDeadline, + StartupDeadline, + withStartupDeadline, +} from "./startup-deadline"; export * from "./time"; export * from "./type-guards"; export { RandomUuid, randomUuid, Uuid } from "./uuid"; diff --git a/packages/testcontainers/src/common/startup-deadline.ts b/packages/testcontainers/src/common/startup-deadline.ts index 23424544a..8ef175705 100644 --- a/packages/testcontainers/src/common/startup-deadline.ts +++ b/packages/testcontainers/src/common/startup-deadline.ts @@ -12,6 +12,10 @@ export class StartupDeadline { } } +export function createStartupDeadline(startupTimeoutMs: number | undefined): StartupDeadline | undefined { + return startupTimeoutMs !== undefined ? new StartupDeadline(startupTimeoutMs) : undefined; +} + type Startup = { deadline: StartupDeadline; inProgress: boolean }; const currentStartup = new AsyncLocalStorage(); diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index c7980286f..247c1b01d 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -151,6 +151,21 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { expect(await getRunningContainerNames()).not.toContain(name); }); + it("should apply the startup timeout to a wait strategy with a longer timeout", async () => { + const name = `custom_container_name_${randomUuid()}`; + const startTime = Date.now(); + + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml") + .withEnvironment({ CONTAINER_NAME: name }) + .withWaitStrategy(name, Wait.forAll([Wait.forLogMessage("unexpected").withStartupTimeout(60_000)])) + .withStartupTimeout(2_000) + .up() + ).rejects.toThrow(`Log message "unexpected" not received`); + + expect(Date.now() - startTime).toBeLessThan(60_000); + }); + it("should support health check wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck.yml") .withWaitStrategy("container-1", Wait.forHealthCheck()) diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 2d22e3f89..92aa3d6d4 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,5 +1,5 @@ import type { ContainerInfo } from "dockerode"; -import { containerLog, log, RandomUuid, type Uuid } from "../common"; +import { containerLog, createStartupDeadline, log, RandomUuid, type Uuid, withStartupDeadline } from "../common"; import { type ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; import { getReaper } from "../reaper/reaper"; @@ -152,6 +152,7 @@ export class DockerComposeEnvironment { }, services ); + const startupDeadline = createStartupDeadline(this.startupTimeoutMs); const startedContainers = (await client.container.list()).filter( (container) => container.Labels["com.docker.compose.project"] === this.projectName @@ -193,7 +194,9 @@ export class DockerComposeEnvironment { } try { - await waitForContainer(client, container, waitStrategy, boundPorts); + await withStartupDeadline(startupDeadline, () => + waitForContainer(client, container, waitStrategy, boundPorts) + ); } catch (err) { try { await client.compose.down(options, { removeVolumes: true, timeout: 0 }); @@ -210,7 +213,8 @@ export class DockerComposeEnvironment { boundPorts, containerName, waitStrategy, - true + true, + this.startupTimeoutMs ); }) ) diff --git a/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts index 70940fcfd..23c8edc1f 100644 --- a/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-startup-deadline.test.ts @@ -46,6 +46,22 @@ describe("GenericContainer startup deadline", { timeout: 180_000 }, () => { expect(Date.now() - startTime).toBeLessThan(60_000); }); + it("should apply the startup timeout to a wait strategy with a longer timeout after a restart", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withStartupTimeout(2_000) + .withWaitStrategy( + Wait.forAll([ + Wait.forSuccessfulCommand("[ ! -f /tmp/started ] && touch /tmp/started").withStartupTimeout(60_000), + ]) + ) + .start(); + const startTime = Date.now(); + + await expect(container.restart()).rejects.toThrow("not successful"); + + expect(Date.now() - startTime).toBeLessThan(60_000); + }); + class RecordingContainer extends GenericContainer { public readonly remainingMsWhenStarting: Array = []; public readonly remainingMsWhenStarted: Array = []; diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 3c36b844a..76b7a48f7 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -3,7 +3,15 @@ import type { Readable } from "node:stream"; import archiver from "archiver"; import AsyncLock from "async-lock"; import type { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; -import { containerLog, hash, log, StartupDeadline, toNanos, withStartupDeadline } from "../common"; +import { + containerLog, + createStartupDeadline, + hash, + log, + type StartupDeadline, + toNanos, + withStartupDeadline, +} from "../common"; import { type ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; import type { StartedNetwork } from "../network/network"; @@ -171,7 +179,7 @@ export class GenericContainer implements TestContainer { } private async reuseContainer(client: ContainerRuntimeClient, container: Container) { - const startupDeadline = this.createStartupDeadline(); + const startupDeadline = createStartupDeadline(this.startupTimeoutMs); let inspectResult = await client.container.inspect(container); if (!inspectResult.State.Running) { log.debug("Reused container is not running, attempting to start it"); @@ -197,7 +205,8 @@ export class GenericContainer implements TestContainer { boundPorts, inspectResult.Name, waitStrategy, - this.autoRemove + this.autoRemove, + this.startupTimeoutMs ); } @@ -230,7 +239,7 @@ export class GenericContainer implements TestContainer { await this.containerCreated(container.id); } - const startupDeadline = this.createStartupDeadline(); + const startupDeadline = createStartupDeadline(this.startupTimeoutMs); await client.container.start(container); log.info(`Started container for image "${this.createOpts.Image}"`, { containerId: container.id }); @@ -244,10 +253,6 @@ export class GenericContainer implements TestContainer { ); } - private createStartupDeadline(): StartupDeadline | undefined { - return this.startupTimeoutMs !== undefined ? new StartupDeadline(this.startupTimeoutMs) : undefined; - } - private async waitUntilContainerReady( client: ContainerRuntimeClient, container: Container, @@ -289,7 +294,8 @@ export class GenericContainer implements TestContainer { boundPorts, inspectResult.Name, waitStrategy, - this.autoRemove + this.autoRemove, + this.startupTimeoutMs ); if (this.containerStarted) { diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index fa23559ad..004e38038 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -4,7 +4,7 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; import type Dockerode from "dockerode"; import type { ContainerInspectInfo } from "dockerode"; -import { containerLog, log } from "../common"; +import { containerLog, createStartupDeadline, log, withStartupDeadline } from "../common"; import { type ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; // biome-ignore lint/suspicious/noImportCycles: Ryuk and sshd run as GenericContainers; only dereferenced at runtime import { getReaper } from "../reaper/reaper"; @@ -39,7 +39,8 @@ export class StartedGenericContainer implements StartedTestContainer { private boundPorts: BoundPorts, private readonly name: string, private readonly waitStrategy: WaitStrategy, - private readonly autoRemove: boolean + private readonly autoRemove: boolean, + private readonly startupTimeoutMs?: number ) {} protected containerIsStopping?(): Promise; @@ -93,6 +94,7 @@ export class StartedGenericContainer implements StartedTestContainer { const client = await getContainerRuntimeClient(); const resolvedOptions: RestartOptions = { timeout: 0, ...options }; await client.container.restart(this.container, resolvedOptions); + const startupDeadline = createStartupDeadline(this.startupTimeoutMs); this.inspectResult = await inspectContainerUntilPortsExposed( () => client.container.inspect(this.container), @@ -114,7 +116,9 @@ export class StartedGenericContainer implements StartedTestContainer { }) ); - await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime); + await withStartupDeadline(startupDeadline, () => + waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime) + ); log.info(`Restarted container`, { containerId: this.container.id }); }