Skip to content

Reuse insecure HTTP wait agent across retries#1382

Open
cristianrgreco wants to merge 3 commits into
mainfrom
claude/fix-http-wait-strategy
Open

Reuse insecure HTTP wait agent across retries#1382
cristianrgreco wants to merge 3 commits into
mainfrom
claude/fix-http-wait-strategy

Conversation

@cristianrgreco

Copy link
Copy Markdown
Collaborator

Summary

Two changes to HttpWaitStrategy:

  1. Fix undici Agent leakgetAgent() constructed a new undici.Agent on every retry attempt when allowInsecure() was set (once per readTimeoutMs, default 1000ms) and never disposed it, leaking agents/sockets for slow-starting containers. The insecure Agent is now memoized (created once, reused across retries) and closed in a finally block when the wait completes — on both success and failure.
  2. Rename misleading parameterwithReadTimeout(startupTimeoutMs) is renamed to withReadTimeout(readTimeoutMs) to match the field it assigns (startupTimeoutMs is a different concept used elsewhere).

The large source diff is mostly re-indentation from wrapping the existing retry loop in try/finally; the behavioural change is small.

Verification

  • Added a describe.sequential("agent lifecycle") block: asserts exactly one Agent is constructed across multiple retries and that it is closed afterwards, and that no Agent is constructed when allowInsecure() is not set. Runs without Docker (mocked undici.request + container-runtime client).
  • Red-green confirmed: pre-fix the test observes 3 Agent constructions across retries; post-fix exactly 1.
  • npm run check-compiles → clean · npm run lint → clean · focused new tests pass.

Test results

2 new tests passing. The existing Docker-backed tests in the same file were not run in this environment (no container runtime) and are unaffected — the module-level undici mock leaves request defaulting to the real implementation.

Not breaking

withReadTimeout keeps the same arity and type (only the parameter name changes — not part of the call signature). The Agent change is internal. No public API change.

🤖 Generated with Claude Code

HttpWaitStrategy.getAgent() constructed a new undici Agent on every retry
attempt when allowInsecure() was set (once per readTimeoutMs) and never
disposed it, leaking agents and sockets for slow-starting containers.
Memoize the insecure Agent so it is created once and reused, and close it
in a finally block once the wait completes (success or failure).

Also rename the misleading withReadTimeout() parameter from
startupTimeoutMs to readTimeoutMs to match the field it assigns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cristianrgreco cristianrgreco added bug Something isn't working patch Backward compatible bug fix labels Jun 22, 2026
@netlify

netlify Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploy Preview for testcontainers-node ready!

Name Link
🔨 Latest commit 311ae0f
🔍 Latest deploy log https://app.netlify.com/projects/testcontainers-node/deploys/6a3a3f87fa01d200089725ec
😎 Deploy Preview https://deploy-preview-1382--testcontainers-node.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4f2190472

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (this.insecureAgent) {
const agent = this.insecureAgent;
this.insecureAgent = undefined;
await agent.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain unread bodies before closing the insecure agent

When allowInsecure() is used and the health endpoint returns a passing status (or a non-matching status) with a large or streaming body, the status-only paths above never consume or cancel the undici.request body. This new graceful Agent.close() waits for outstanding requests to complete, so waitUntilReady can hang after the predicate has already succeeded or after the timeout path is taken; drain/cancel the response body or force-close the dispatcher before awaiting shutdown.

Useful? React with 👍 / 👎.

Comment on lines +47 to +49
vi.doMock("../container-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("../container-runtime")>();
return { ...actual, getContainerRuntimeClient: async () => client };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Install the runtime mock before importing the strategy

This vi.doMock is registered after the file's static Wait import has already loaded HttpWaitStrategy through ./wait, and vi.doMock only affects subsequent imports rather than already-cached modules. As a result, these supposedly Docker-free lifecycle tests still use the real getContainerRuntimeClient and will try to initialize a real container runtime in environments without Docker; reset modules/remove the eager import before dynamically importing the strategy under the mock.

Useful? React with 👍 / 👎.

Address review feedback on the insecure HTTP wait Agent:

- Use Agent.destroy() instead of close() on teardown. Status-only
  predicate paths never consume the response body, so a graceful close()
  could hang waiting for those connections to drain. The wait has already
  finished at that point, so a forceful destroy is correct.
- Move the agent-lifecycle tests into their own file with hoisted,
  top-level mocks for undici and the container runtime, importing
  HttpWaitStrategy directly. Previously the vi.doMock("../container-runtime")
  ran after the static "./wait" import had already cached HttpWaitStrategy,
  so the mock was a no-op and the "Docker-free" tests still required a real
  container runtime. The dedicated file makes them genuinely Docker-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b325235e22

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

private readonly predicates: Array<(response: Response) => Promise<boolean>> = [];
private _allowInsecure = false;
private readTimeoutMs = 1000;
private insecureAgent?: Agent;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep insecure agents scoped to one wait

When a single HttpWaitStrategy instance is shared, this instance field makes concurrent waitUntilReady calls share the same dispatcher. DockerComposeEnvironment.up() runs waits in parallel with Promise.all and passes the same defaultWaitStrategy object to every service (docker-compose-environment.ts:169-181), so a compose setup using a default HTTPS allowInsecure() wait can have the first service that becomes ready call closeAgent() and destroy the dispatcher while another service is still awaiting or reading its response, causing that other wait to retry, fail, or time out. Scope the insecure agent to each waitUntilReady invocation instead of storing it on the reusable strategy object.

Useful? React with 👍 / 👎.

A single HttpWaitStrategy instance can drive multiple concurrent
waitUntilReady calls — DockerComposeEnvironment.up() runs waits in
parallel and passes the same defaultWaitStrategy object to every service.
Memoizing the insecure Agent on the instance meant those concurrent waits
shared one dispatcher, so the first to finish would destroy() it while
others were still using it.

Create the insecure Agent as a local in waitUntilReady (still built once
per wait and reused across that wait's retries) and destroy it in the same
call's finally. Adds a regression test asserting concurrent waits on a
shared strategy each get their own agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working patch Backward compatible bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant