diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md index 09d1211d7..89a8dafe7 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. `containerStarting` and `containerStarted` are passed an `AbortSignal` for it: pass it to `waitForContainer`, `poll` and `exec` so a wait there ends with the startup timeout instead of starting a new one. - 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`. - For readiness that a wait strategy can't express, such as calling an API in `containerStarted`, use `poll` from `testcontainers` (docs: `docs/features/wait-strategies.md#polling`). Don't hand-roll a loop or use the deprecated `IntervalRetry`. @@ -26,6 +27,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..0da94f90e --- /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 is passed the startup signal its callback receives, so it ends with the user's startup timeout instead of starting a new 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. diff --git a/docs/features/containers.md b/docs/features/containers.md index 950370bdf..6e3134560 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -568,7 +568,8 @@ class CustomContainer extends GenericContainer { protected override async containerStarting( inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupSignal?: AbortSignal ): Promise { // ... } @@ -576,7 +577,8 @@ class CustomContainer extends GenericContainer { protected override async containerStarted( container: StartedTestContainer, inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupSignal?: AbortSignal ): Promise { // ... } @@ -597,7 +599,25 @@ class CustomStartedContainer extends AbstractStartedContainer { } ``` -To wait for a condition in a callback, such as an API becoming available after the container has started, use [`poll`](wait-strategies.md#polling). +`containerStarting` and `containerStarted` are passed an `AbortSignal` that aborts when the container's [startup timeout](wait-strategies.md) is reached. It is `undefined` when no startup timeout is set. Pass it to whatever waits in the callback, so that the wait ends with the startup timeout instead of running to a timeout of its own. + +To wait for a condition in a callback, such as an API becoming available after the container has started, use [`poll`](wait-strategies.md#polling): + +```ts +protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult, + reused: boolean, + startupSignal?: AbortSignal +): Promise { + await poll(({ signal }) => container.exec(["stat", "/tmp/app.lock"], { signal }), { + until: ({ exitCode }) => exitCode === 0, + intervalMs: 100, + timeoutMs: 60_000, + signal: startupSignal, + }); +} +``` ## Exposing container ports diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 1ae846564..0e03ac124 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,6 +10,10 @@ 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. The wait strategy stops when it is reached, and the [lifecycle callbacks](containers.md#lifecycle-callbacks) are passed an `AbortSignal` for 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. @@ -304,3 +308,5 @@ const { exitCode } = await poll(({ signal }) => container.exec(["stat", "/tmp/ap ``` The timeout error message defaults to `Timed out after ${timeoutMs}ms` and can be set with `timeoutMessage`. + +To stop polling before the timeout, pass an `AbortSignal` as `signal`, for example the one a lifecycle callback is given. When it aborts, `poll` rejects with the timeout error. diff --git a/packages/modules/couchbase/src/couchbase-container.ts b/packages/modules/couchbase/src/couchbase-container.ts index 97d1605c6..956fd554b 100644 --- a/packages/modules/couchbase/src/couchbase-container.ts +++ b/packages/modules/couchbase/src/couchbase-container.ts @@ -175,14 +175,17 @@ export class CouchbaseContainer extends GenericContainer { client: ContainerRuntimeClient, inspectResult: InspectResult, startedTestContainer: StartedTestContainer, - timeoutMs: number + timeoutMs: number, + startupSignal?: AbortSignal ) { return Wait.forHttp("/pools", PORTS.MGMT_PORT) .forStatusCode(200) .withStartupTimeout(timeoutMs) .waitUntilReady( client.container.getById(startedTestContainer.getId()), - BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult) + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult), + undefined, + startupSignal ); } @@ -381,7 +384,8 @@ export class CouchbaseContainer extends GenericContainer { client: ContainerRuntimeClient, inspectResult: InspectResult, startedTestContainer: StartedTestContainer, - remainingStartupMs: () => number + timeoutMs: number, + startupSignal?: AbortSignal ) { log.debug(`Creating ${this.buckets.size} buckets (and corresponding indexes).`); for (const bucket of this.buckets) { @@ -407,7 +411,7 @@ export class CouchbaseContainer extends GenericContainer { await Wait.forHttp(`/pools/default/b/${bucket.getName()}`, PORTS.MGMT_PORT) .withBasicCredentials(this.username, this.password) .forStatusCode(200) - .withStartupTimeout(remainingStartupMs()) + .withStartupTimeout(timeoutMs) .forResponsePredicate((response) => { try { const jsonResponse = JSON.parse(response); @@ -432,13 +436,15 @@ export class CouchbaseContainer extends GenericContainer { }) .waitUntilReady( client.container.getById(startedTestContainer.getId()), - BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult) + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult), + undefined, + startupSignal ); await Wait.forHttp(`/pools/default/buckets/${bucket.getName()}`, PORTS.MGMT_PORT) .withBasicCredentials(this.username, this.password) .forStatusCode(200) - .withStartupTimeout(remainingStartupMs()) + .withStartupTimeout(timeoutMs) .forResponsePredicate((response) => { try { const jsonResponse = JSON.parse(response); @@ -450,12 +456,12 @@ export class CouchbaseContainer extends GenericContainer { }) .waitUntilReady( client.container.getById(startedTestContainer.getId()), - BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult) + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult), + undefined, + startupSignal ); if (this.enabledServices.has(CouchbaseService.QUERY)) { - const timeoutMs = remainingStartupMs(); - await poll( async () => { try { @@ -492,6 +498,7 @@ export class CouchbaseContainer extends GenericContainer { intervalMs: 1000, timeoutMs, timeoutMessage: `URL /query/service not accessible after ${timeoutMs}ms`, + signal: startupSignal, } ); } @@ -512,8 +519,6 @@ export class CouchbaseContainer extends GenericContainer { await this.checkResponse(response, `Could not create primary index for bucket ${bucket.getName()}`); - const timeoutMs = remainingStartupMs(); - await poll( async () => { try { @@ -553,6 +558,7 @@ export class CouchbaseContainer extends GenericContainer { intervalMs: 1000, timeoutMs, timeoutMessage: `URL /query/service not accessible after ${timeoutMs}ms`, + signal: startupSignal, } ); } else { @@ -566,13 +572,14 @@ export class CouchbaseContainer extends GenericContainer { protected override async containerStarted( startedTestContainer: StartedTestContainer, - inspectResult: InspectResult + inspectResult: InspectResult, + _reused?: boolean, + startupSignal?: AbortSignal ): Promise { - const deadline = this.startupTimeoutMs === undefined ? undefined : Date.now() + this.startupTimeoutMs; - const remainingStartupMs = () => (deadline === undefined ? 60_000 : Math.max(deadline - Date.now(), 0)); + const timeoutMs = this.startupTimeoutMs ?? 60_000; const client = await getContainerRuntimeClient(); - await this.waitUntilNodeIsOnline(client, inspectResult, startedTestContainer, remainingStartupMs()); + await this.waitUntilNodeIsOnline(client, inspectResult, startedTestContainer, timeoutMs, startupSignal); await this.initializeIsEnterprise(startedTestContainer); await this.renameNode(startedTestContainer, inspectResult); await this.initializeServices(startedTestContainer); @@ -586,19 +593,21 @@ export class CouchbaseContainer extends GenericContainer { await Promise.all( this.constructWaitStrategies().map((s) => s - .withStartupTimeout(remainingStartupMs()) + .withStartupTimeout(timeoutMs) .waitUntilReady( client.container.getById(startedTestContainer.getId()), - BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult) + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult), + undefined, + startupSignal ) ) ); if (this.enabledServices.has(CouchbaseService.KV)) { - await this.waitUntilKvAcceptsCredentials(startedTestContainer, remainingStartupMs()); + await this.waitUntilKvAcceptsCredentials(startedTestContainer, timeoutMs, startupSignal); } - await this.createBuckets(client, inspectResult, startedTestContainer, remainingStartupMs); + await this.createBuckets(client, inspectResult, startedTestContainer, timeoutMs, startupSignal); log.info( `Couchbase container is ready! UI available at http://${startedTestContainer.getHost()}:${startedTestContainer.getMappedPort( @@ -607,7 +616,11 @@ export class CouchbaseContainer extends GenericContainer { ); } - private async waitUntilKvAcceptsCredentials(container: StartedTestContainer, timeoutMs: number) { + private async waitUntilKvAcceptsCredentials( + container: StartedTestContainer, + timeoutMs: number, + startupSignal?: AbortSignal + ) { await poll( async ({ signal }) => { const { exitCode } = await container.exec( @@ -622,6 +635,7 @@ export class CouchbaseContainer extends GenericContainer { timeoutMs, timeoutMessage: `KV service did not accept credentials after ${timeoutMs}ms`, retryOnError: false, + signal: startupSignal, } ); } diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index 5c6ff485f..57125ce68 100644 --- a/packages/modules/kafka/src/kafka-container.ts +++ b/packages/modules/kafka/src/kafka-container.ts @@ -161,7 +161,9 @@ export class KafkaContainer extends GenericContainer { protected override async containerStarted( container: StartedTestContainer, - inspectResult: InspectResult + inspectResult: InspectResult, + _reused?: boolean, + startupSignal?: AbortSignal ): Promise { let command = "#!/bin/bash\n"; const advertisedListeners = this.updateAdvertisedListeners(container, inspectResult); @@ -197,7 +199,7 @@ export class KafkaContainer extends GenericContainer { if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, dockerContainer, waitStrategy, boundPorts); + await waitForContainer(client, dockerContainer, waitStrategy, boundPorts, undefined, startupSignal); if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) { await this.createUser(container, this.saslSslConfig.sasl); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index 60a950498..a9715d1f8 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -57,7 +57,9 @@ export class RedpandaContainer extends GenericContainer { protected override async containerStarted( container: StartedTestContainer, - inspectResult: InspectResult + inspectResult: InspectResult, + _reused?: boolean, + startupSignal?: AbortSignal ): Promise { // Docker writes a copied file in place, so the starter could read the marker before the rest. // A rename on the same filesystem publishes the whole file at once. @@ -83,7 +85,7 @@ export class RedpandaContainer extends GenericContainer { if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, dockerContainer, waitStrategy, boundPorts); + await waitForContainer(client, dockerContainer, waitStrategy, boundPorts, undefined, startupSignal); } private renderRedpandaFile(host: string, port: number): string { diff --git a/packages/testcontainers/src/common/index.ts b/packages/testcontainers/src/common/index.ts index 4557ee3e6..52436b36f 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, PollOptions, poll, Retry } from "./retry"; +export { createStartupSignal } from "./startup-signal"; 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 770a3e548..e49320bab 100644 --- a/packages/testcontainers/src/common/retry.test.ts +++ b/packages/testcontainers/src/common/retry.test.ts @@ -152,6 +152,47 @@ describe("Retry", { concurrent: false }, () => { expect(fnMock).toHaveBeenCalledTimes(callsAtTimeout); }); + + it("should reject with the timeout message when the given signal aborts", async () => { + const controller = new AbortController(); + const promise = poll(async () => false, { + until: (result) => result, + intervalMs: 1, + timeoutMs: 60_000, + timeoutMessage: "not ready after 60000ms", + signal: controller.signal, + }); + + controller.abort(); + + await expect(promise).rejects.toThrow("not ready after 60000ms"); + }); + + it("should abort the attempt's signal when the given signal aborts", async () => { + const controller = new AbortController(); + let attemptSignal: AbortSignal | undefined; + const promise = poll( + ({ signal }) => { + attemptSignal = signal; + return new Promise(() => {}); + }, + { intervalMs: 1, timeoutMs: 60_000, signal: controller.signal } + ); + + controller.abort(); + + await expect(promise).rejects.toThrow("Timed out after 60000ms"); + expect(attemptSignal?.aborted).toBe(true); + }); + + it("should not start an attempt when the given signal has already aborted", async () => { + const fnMock = vi.fn().mockResolvedValue("result"); + + await expect( + poll(() => fnMock(), { intervalMs: 1, timeoutMs: 60_000, signal: AbortSignal.abort() }) + ).rejects.toThrow("Timed out after 60000ms"); + expect(fnMock).not.toHaveBeenCalled(); + }); }); describe("IntervalRetry", () => { diff --git a/packages/testcontainers/src/common/retry.ts b/packages/testcontainers/src/common/retry.ts index 96824c067..ec9277240 100644 --- a/packages/testcontainers/src/common/retry.ts +++ b/packages/testcontainers/src/common/retry.ts @@ -1,7 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; // setTimeout fires after 1ms when the delay exceeds this -const MAX_TIMEOUT_MS = 2 ** 31 - 1; +export const MAX_TIMEOUT_MS = 2 ** 31 - 1; export type PollOptions = { until?: (result: T) => boolean | Promise; @@ -9,6 +9,7 @@ export type PollOptions = { timeoutMs: number; timeoutMessage?: string; retryOnError?: boolean; + signal?: AbortSignal; }; export async function poll( @@ -21,6 +22,7 @@ export async function poll( timeoutMs, timeoutMessage = `Timed out after ${timeoutMs}ms`, retryOnError = true, + signal: parentSignal, } = options; const controller = new AbortController(); const { signal } = controller; @@ -48,18 +50,26 @@ export async function poll( }; return new Promise((resolve, reject) => { - const deadline = setTimeout( - () => { - const error = new Error(timeoutMessage, lastError === undefined ? {} : { cause: lastError }); - reject(error); - controller.abort(error); - }, - Math.min(timeoutMs, MAX_TIMEOUT_MS) - ); + const timeOut = () => { + const error = new Error(timeoutMessage, lastError === undefined ? {} : { cause: lastError }); + reject(error); + controller.abort(error); + }; + + if (parentSignal?.aborted) { + timeOut(); + return; + } + + const deadline = setTimeout(timeOut, Math.min(timeoutMs, MAX_TIMEOUT_MS)); + parentSignal?.addEventListener("abort", timeOut, { once: true }); attempts() .then(resolve, reject) - .finally(() => clearTimeout(deadline)); + .finally(() => { + clearTimeout(deadline); + parentSignal?.removeEventListener("abort", timeOut); + }); }); } diff --git a/packages/testcontainers/src/common/startup-signal.ts b/packages/testcontainers/src/common/startup-signal.ts new file mode 100644 index 000000000..4ee5d0ff5 --- /dev/null +++ b/packages/testcontainers/src/common/startup-signal.ts @@ -0,0 +1,9 @@ +import { MAX_TIMEOUT_MS } from "./retry"; + +export function createStartupSignal(startupTimeoutMs: number | undefined): AbortSignal | undefined { + if (startupTimeoutMs === undefined) { + return undefined; + } + + return AbortSignal.timeout(Math.min(Math.max(Math.ceil(startupTimeoutMs), 0), MAX_TIMEOUT_MS)); +} 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..1771c4bc9 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, createStartupSignal, log, RandomUuid, type Uuid } 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 startupSignal = createStartupSignal(this.startupTimeoutMs); const startedContainers = (await client.container.list()).filter( (container) => container.Labels["com.docker.compose.project"] === this.projectName @@ -193,7 +194,7 @@ export class DockerComposeEnvironment { } try { - await waitForContainer(client, container, waitStrategy, boundPorts); + await waitForContainer(client, container, waitStrategy, boundPorts, undefined, startupSignal); } catch (err) { try { await client.compose.down(options, { removeVolumes: true, timeout: 0 }); @@ -210,7 +211,8 @@ export class DockerComposeEnvironment { boundPorts, containerName, waitStrategy, - true + true, + this.startupTimeoutMs ); }) ) diff --git a/packages/testcontainers/src/generic-container/generic-container-startup-timeout.test.ts b/packages/testcontainers/src/generic-container/generic-container-startup-timeout.test.ts new file mode 100644 index 000000000..65f8a3782 --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-startup-timeout.test.ts @@ -0,0 +1,119 @@ +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"; + +describe("GenericContainer startup timeout", { timeout: 180_000 }, () => { + it("should give the lifecycle callbacks a signal for the startup timeout", async () => { + const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14") + .withStartupTimeout(60_000) + .withWaitStrategy(new SlowWaitStrategy()); + + await using _ = await container.start(); + + expect(container.startingSignals).toEqual([expect.any(AbortSignal)]); + expect(container.startedSignals[0]).toBe(container.startingSignals[0]); + expect(container.startedSignalAborted).toEqual([false]); + }); + + it("should start the startup timeout before the wait strategy", async () => { + const container = new RecordingContainer("cristianrgreco/testcontainer:1.1.14") + .withStartupTimeout(50) + .withWaitStrategy(new SlowWaitStrategy()); + + await using _ = await container.start(); + + expect(container.startedSignalAborted).toEqual([true]); + }); + + it("should not give the lifecycle callbacks a signal 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.startingSignals).toEqual([undefined]); + expect(container.startedSignals).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); + }); + + 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 startingSignals: Array = []; + public readonly startedSignals: Array = []; + public readonly startedSignalAborted: Array = []; + + protected override async containerStarting( + _inspectResult: InspectResult, + _reused: boolean, + startupSignal?: AbortSignal + ): Promise { + this.startingSignals.push(startupSignal); + } + + protected override async containerStarted( + _container: StartedTestContainer, + _inspectResult: InspectResult, + _reused: boolean, + startupSignal?: AbortSignal + ): Promise { + this.startedSignals.push(startupSignal); + this.startedSignalAborted.push(startupSignal?.aborted); + } + } + + class WaitingContainer extends GenericContainer { + protected override async containerStarted( + container: StartedTestContainer, + inspectResult: InspectResult, + _reused: boolean, + startupSignal?: AbortSignal + ): Promise { + const client = await getContainerRuntimeClient(); + await waitForContainer( + client, + client.container.getById(container.getId()), + Wait.forLogMessage("never logged"), + BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult), + undefined, + startupSignal + ); + } + } + + class SlowWaitStrategy extends AbstractWaitStrategy { + public async waitUntilReady(): Promise { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } +}); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 59b705ac8..2519aadeb 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, createStartupSignal, hash, log, toNanos } 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"; @@ -89,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, + startupSignal?: AbortSignal + ): Promise; public async start(): Promise { const shouldPull = this.pullPolicy.shouldPull(); @@ -167,6 +171,7 @@ export class GenericContainer implements TestContainer { } private async reuseContainer(client: ContainerRuntimeClient, container: Container) { + const startupSignal = createStartupSignal(this.startupTimeoutMs); let inspectResult = await client.container.inspect(container); if (!inspectResult.State.Running) { log.debug("Reused container is not running, attempting to start it"); @@ -186,7 +191,7 @@ export class GenericContainer implements TestContainer { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, waitStrategy, boundPorts); + await waitForContainer(client, container, waitStrategy, boundPorts, undefined, startupSignal); return new StartedGenericContainer( container, @@ -195,7 +200,8 @@ export class GenericContainer implements TestContainer { boundPorts, inspectResult.Name, waitStrategy, - this.autoRemove + this.autoRemove, + this.startupTimeoutMs ); } @@ -228,6 +234,7 @@ export class GenericContainer implements TestContainer { await this.containerCreated(container.id); } + const startupSignal = createStartupSignal(this.startupTimeoutMs); await client.container.start(container); log.info(`Started container for image "${this.createOpts.Image}"`, { containerId: container.id }); @@ -253,7 +260,7 @@ export class GenericContainer implements TestContainer { } if (this.containerStarting) { - await this.containerStarting(mappedInspectResult, false); + await this.containerStarting(mappedInspectResult, false, startupSignal); } const waitStrategy = await this.selectWaitStrategy(client, inspectResult); @@ -261,7 +268,7 @@ export class GenericContainer implements TestContainer { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, waitStrategy, boundPorts); + await waitForContainer(client, container, waitStrategy, boundPorts, undefined, startupSignal); const startedContainer = new StartedGenericContainer( container, @@ -270,11 +277,12 @@ export class GenericContainer implements TestContainer { boundPorts, inspectResult.Name, waitStrategy, - this.autoRemove + this.autoRemove, + this.startupTimeoutMs ); if (this.containerStarted) { - await this.containerStarted(startedContainer, mappedInspectResult, false); + await this.containerStarted(startedContainer, mappedInspectResult, false, startupSignal); } return startedContainer; @@ -316,7 +324,8 @@ export class GenericContainer implements TestContainer { protected containerStarted?( container: StartedTestContainer, inspectResult: InspectResult, - reused: boolean + reused: boolean, + startupSignal?: AbortSignal ): Promise; public withCommand(command: string[]): this { diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index b96ed2856..02a29c38d 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, createStartupSignal, log } 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 startupSignal = createStartupSignal(this.startupTimeoutMs); this.inspectResult = await inspectContainerUntilPortsExposed( (signal) => client.container.inspect(this.container, { signal }), @@ -114,7 +116,7 @@ export class StartedGenericContainer implements StartedTestContainer { }) ); - await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime); + await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime, startupSignal); log.info(`Restarted container`, { containerId: this.container.id }); } diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts index c673607bb..8c7a805b5 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts @@ -10,7 +10,12 @@ export class CompositeWaitStrategy extends AbstractWaitStrategy { super(); } - public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts, startTime?: Date): Promise { + public async waitUntilReady( + container: Dockerode.Container, + boundPorts: BoundPorts, + startTime?: Date, + startupSignal?: AbortSignal + ): Promise { log.debug(`Waiting for composite...`, { containerId: container.id }); return new Promise((resolve, reject) => { @@ -24,7 +29,9 @@ export class CompositeWaitStrategy extends AbstractWaitStrategy { } Promise.all( - this.waitStrategies.map((waitStrategy) => waitStrategy.waitUntilReady(container, boundPorts, startTime)) + this.waitStrategies.map((waitStrategy) => + waitStrategy.waitUntilReady(container, boundPorts, startTime, startupSignal) + ) ) .then(() => { log.debug(`Composite wait strategy complete`, { containerId: container.id }); 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 eb8ce2421..c860ef1ce 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts @@ -1,11 +1,17 @@ import type Dockerode from "dockerode"; import { log, poll } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import type { BoundPorts } from "../utils/bound-ports"; import { getHealthCheckStatusFromInspect } from "./utils/health-check"; import { AbstractWaitStrategy } from "./wait-strategy"; export class HealthCheckWaitStrategy extends AbstractWaitStrategy { - public async waitUntilReady(container: Dockerode.Container): Promise { + public async waitUntilReady( + container: Dockerode.Container, + _boundPorts?: BoundPorts, + _startTime?: Date, + startupSignal?: AbortSignal + ): Promise { log.debug(`Waiting for health check...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); @@ -17,6 +23,7 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { timeoutMs: this.startupTimeoutMs, timeoutMessage: `Health check not healthy after ${this.startupTimeoutMs}ms`, retryOnError: false, + signal: startupSignal, } ); 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 18eead3d9..ea5205e50 100644 --- a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts @@ -6,21 +6,27 @@ import { HostPortCheck, InternalPortCheck, type PortCheck } from "./utils/port-c import { AbstractWaitStrategy } from "./wait-strategy"; export class HostPortWaitStrategy extends AbstractWaitStrategy { - public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise { + public async waitUntilReady( + container: Dockerode.Container, + boundPorts: BoundPorts, + _startTime?: Date, + startupSignal?: AbortSignal + ): Promise { const client = await getContainerRuntimeClient(); const hostPortCheck = new HostPortCheck(client); const internalPortCheck = new InternalPortCheck(client, container); await Promise.all([ - this.waitForHostPorts(hostPortCheck, container, boundPorts), - this.waitForInternalPorts(internalPortCheck, container, boundPorts), + this.waitForHostPorts(hostPortCheck, container, boundPorts, startupSignal), + this.waitForInternalPorts(internalPortCheck, container, boundPorts, startupSignal), ]); } private async waitForHostPorts( portCheck: PortCheck, container: Dockerode.Container, - boundPorts: BoundPorts + boundPorts: BoundPorts, + startupSignal?: AbortSignal ): Promise { for (const [portKey, hostPort] of boundPorts.iterator()) { if (portKey.toLowerCase().endsWith("/udp")) { @@ -30,7 +36,7 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { continue; } log.debug(`Waiting for host port ${hostPort}...`, { containerId: container.id }); - await this.waitForPort(hostPort, portCheck); + await this.waitForPort(hostPort, portCheck, startupSignal); log.debug(`Host port ${hostPort} ready`, { containerId: container.id }); } log.debug(`Host port wait strategy complete`, { containerId: container.id }); @@ -39,7 +45,8 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { private async waitForInternalPorts( portCheck: PortCheck, container: Dockerode.Container, - boundPorts: BoundPorts + boundPorts: BoundPorts, + startupSignal?: AbortSignal ): Promise { for (const [internalPort] of boundPorts.iterator()) { if (internalPort.toLowerCase().endsWith("/udp")) { @@ -49,19 +56,20 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { continue; } log.debug(`Waiting for internal port ${internalPort}...`, { containerId: container.id }); - await this.waitForPort(internalPort, portCheck); + await this.waitForPort(internalPort, portCheck, startupSignal); log.debug(`Internal port ${internalPort} ready`, { containerId: container.id }); } log.debug(`Internal port wait strategy complete`, { containerId: container.id }); } - private async waitForPort(port: number | string, portCheck: PortCheck): Promise { + private async waitForPort(port: number | string, portCheck: PortCheck, startupSignal?: AbortSignal): Promise { await poll(({ signal }) => portCheck.isBound(port, signal), { until: (isBound) => isBound, intervalMs: 100, timeoutMs: this.startupTimeoutMs, timeoutMessage: `Port ${port} not bound after ${this.startupTimeoutMs}ms`, retryOnError: false, + signal: startupSignal, }); } } diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts index a42870d87..8661faea4 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts @@ -73,7 +73,12 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { return this; } - public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise { + public async waitUntilReady( + container: Dockerode.Container, + boundPorts: BoundPorts, + _startTime?: Date, + startupSignal?: AbortSignal + ): Promise { log.debug(`Waiting for HTTP...`, { containerId: container.id }); const exitStatus = "exited"; @@ -132,6 +137,7 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { intervalMs: this.readTimeoutMs, timeoutMs: this.startupTimeoutMs, timeoutMessage: `URL ${this.path} not accessible after ${this.startupTimeoutMs}ms`, + signal: startupSignal, } ); } finally { diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts index 51ca97bed..cefd4ec2b 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts @@ -18,17 +18,28 @@ export class LogWaitStrategy extends AbstractWaitStrategy { public async waitUntilReady( container: Dockerode.Container, _boundPorts: BoundPorts, - startTime?: Date + startTime?: Date, + startupSignal?: AbortSignal ): Promise { log.debug(`Waiting for log message "${this.message}"...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); const stream = await client.container.logs(container, { since: startTime ? startTime.getTime() / 1000 : 0 }); return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + const timeOut = () => { + stopTimeout(); const message = `Log message "${this.message}" not received after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); reject(new Error(message)); - }, this.startupTimeoutMs); + }; + const timeout = setTimeout(timeOut, this.startupTimeoutMs); + startupSignal?.addEventListener("abort", timeOut, { once: true }); + const stopTimeout = () => { + clearTimeout(timeout); + startupSignal?.removeEventListener("abort", timeOut); + }; + if (startupSignal?.aborted) { + timeOut(); + } const comparisonFn: (line: string) => boolean = (line: string) => { if (this.message instanceof RegExp) { @@ -46,7 +57,7 @@ export class LogWaitStrategy extends AbstractWaitStrategy { if (comparisonFn(line)) { if (++count === this.times) { stream.destroy(); - clearTimeout(timeout); + stopTimeout(); log.debug(`Log wait strategy complete`, { containerId: container.id }); resolve(); } @@ -58,7 +69,7 @@ export class LogWaitStrategy extends AbstractWaitStrategy { .on("err", lineProcessor) .on("end", () => { stream.destroy(); - clearTimeout(timeout); + stopTimeout(); const message = `Log stream ended and message "${this.message}" was not received`; log.error(message, { containerId: container.id }); reject(new Error(message)); diff --git a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts index ee463abed..4cf82703f 100644 --- a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts @@ -1,6 +1,7 @@ import type Dockerode from "dockerode"; import { log, poll } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import type { BoundPorts } from "../utils/bound-ports"; import { AbstractWaitStrategy } from "./wait-strategy"; export class ShellWaitStrategy extends AbstractWaitStrategy { @@ -8,7 +9,12 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { super(); } - public async waitUntilReady(container: Dockerode.Container): Promise { + public async waitUntilReady( + container: Dockerode.Container, + _boundPorts?: BoundPorts, + _startTime?: Date, + startupSignal?: AbortSignal + ): Promise { log.debug(`Waiting for successful shell command "${this.command}"...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); @@ -26,6 +32,7 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { timeoutMs: this.startupTimeoutMs, timeoutMessage: `Shell command "${this.command}" not successful after ${this.startupTimeoutMs}ms`, retryOnError: false, + signal: startupSignal, } ); diff --git a/packages/testcontainers/src/wait-strategies/startup-check-strategy.ts b/packages/testcontainers/src/wait-strategies/startup-check-strategy.ts index 164ed3013..361e3e349 100644 --- a/packages/testcontainers/src/wait-strategies/startup-check-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/startup-check-strategy.ts @@ -1,6 +1,7 @@ import type Dockerode from "dockerode"; import { poll } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import type { BoundPorts } from "../utils/bound-ports"; import { AbstractWaitStrategy } from "./wait-strategy"; export type StartupStatus = "PENDING" | "SUCCESS" | "FAIL"; @@ -12,7 +13,12 @@ export abstract class StartupCheckStrategy extends AbstractWaitStrategy { signal?: AbortSignal ): Promise; - public override async waitUntilReady(container: Dockerode.Container): Promise { + public override async waitUntilReady( + container: Dockerode.Container, + _boundPorts?: BoundPorts, + _startTime?: Date, + startupSignal?: AbortSignal + ): Promise { const client = await getContainerRuntimeClient(); const startupStatus = await poll( @@ -23,6 +29,7 @@ export abstract class StartupCheckStrategy extends AbstractWaitStrategy { timeoutMs: this.startupTimeoutMs, timeoutMessage: `Container not accessible after ${this.startupTimeoutMs}ms`, retryOnError: false, + signal: startupSignal, } ); diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index ca8a3f190..04f88139e 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -9,12 +9,13 @@ export const waitForContainer = async ( container: Container, waitStrategy: WaitStrategy, boundPorts: BoundPorts, - startTime?: Date + startTime?: Date, + startupSignal?: AbortSignal ): Promise => { log.debug(`Waiting for container to be ready...`, { containerId: container.id }); try { - await waitStrategy.waitUntilReady(container, boundPorts, startTime); + await waitStrategy.waitUntilReady(container, boundPorts, startTime, startupSignal); log.info(`Container is ready`, { containerId: container.id }); } catch (err) { log.error(`Container failed to be ready: ${err}`, { containerId: container.id }); diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.ts index a93d8c482..9f4a2bb86 100644 --- a/packages/testcontainers/src/wait-strategies/wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.ts @@ -2,7 +2,12 @@ import type Dockerode from "dockerode"; import type { BoundPorts } from "../utils/bound-ports"; export interface WaitStrategy { - waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts, startTime?: Date): Promise; + waitUntilReady( + container: Dockerode.Container, + boundPorts: BoundPorts, + startTime?: Date, + startupSignal?: AbortSignal + ): Promise; withStartupTimeout(startupTimeoutMs: number): WaitStrategy; @@ -18,7 +23,8 @@ export abstract class AbstractWaitStrategy implements WaitStrategy { public abstract waitUntilReady( container: Dockerode.Container, boundPorts: BoundPorts, - startTime?: Date + startTime?: Date, + startupSignal?: AbortSignal ): Promise; public withStartupTimeout(startupTimeoutMs: number): this {