Skip to content

Fix check-then-act race in container reuse (withReuse(true)) - #11980

Draft
stlahxm wants to merge 1 commit into
testcontainers:mainfrom
stlahxm:fix/reuse-race-condition
Draft

Fix check-then-act race in container reuse (withReuse(true))#11980
stlahxm wants to merge 1 commit into
testcontainers:mainfrom
stlahxm:fix/reuse-race-condition

Conversation

@stlahxm

@stlahxm stlahxm commented Aug 25, 2026

Copy link
Copy Markdown

Fixes #11979

Opening as draft, want feedback on the approach before finalizing (see Open questions below).

Problem

GenericContainer.tryStart()'s reuse logic (findContainerForReuse(hash) then, if not found, createCommand.exec()) has no synchronization between the check and the act. Two concurrent start() calls with an identical config hash can both observe "not found" and both create a container. This was reproduced with a mock test and, with no artificial delay, against a real Docker daemon: 5/5 cross-process trials and both same-JVM multi-thread trials (2 and 5 threads) produced duplicate containers, while a sequential control trial did not. Full detail in the linked issue.

Changes

Two layers, matching the two different races:

1. Same-JVM lock (GenericContainer.java): a ConcurrentHashMap<String, Object> keyed by the reuse hash, obtained via computeIfAbsent, guarding the existing check-then-create block with synchronized. Mirrors the synchronized + create-once idiom already used in Network.getId(). Only fixes same-JVM races; processes don't share memory.

2. Cross-process arbiter using Docker's own name uniqueness (new createOrJoinReusableContainer(...) / resolveConflictingReusableContainer(...)): the create call sets a deterministic name (testcontainers-reuse-<hash>). If creation succeeds, this call is the winner. If it fails on a name conflict:

  • Inspect the conflicting container and check its HASH_LABEL actually matches. If not, this is an unrelated name clash, not a reuse race, and we fail loudly instead of guessing.
  • running → reuse it.
  • created (winner still mid-setup) → poll by re-inspecting (not by retrying the create call, which would just fail again) with exponential backoff (100ms, doubling, capped at 2s), bounded by a 60s deadline matching AbstractWaitStrategy's default startup timeout.
  • exited/dead (winner crashed before starting) → remove it (tolerating a concurrent removal already having happened, e.g. by another loser or by Ryuk) and let the caller retry the create.

The existing label-based findContainerForReuse(hash) lookup is unchanged, so containers created by an older testcontainers version (without the deterministic name) are still found and reused.

Ran ./gradlew spotlessApply and checkstyleMain/checkstyleTest locally per the contributing guidelines; both pass.

Test plan

Mock suite (./gradlew :testcontainers:test --tests "org.testcontainers.containers.Reusability*"): 40 tests, 0 failures. Breakdown by class:

Test class Tests Failures
ReusabilityUnitTests$CanBeReusedTest 5 0
ReusabilityUnitTests$CopyFilesHashTest 20 0
ReusabilityUnitTests$HashTest 5 0
ReusabilityUnitTests$HooksTest 3 0
ReusabilityRaceConditionTest (updated) 2 0
ReusabilityNameConflictTest (new) 5 0

ReusabilityRaceConditionTest previously asserted 2 containers were created under a same-JVM race (documenting the bug); now asserts exactly 1, plus a 5-thread variant. ReusabilityNameConflictTest covers: reuse when found-and-running, refusal on label mismatch, retry-by-reinspecting when created, cleanup-and-retry when exited/dead, tolerating a NotFoundException when the conflicting container is removed by someone else concurrently.

./gradlew spotlessApply, checkstyleMain, checkstyleTest all pass.

Real Docker verification (no mocks, actual dockerd 29.2.0, alpine:3.17):

Sequential control (same config, one call after another): second call reused the first's container.

seqA containerId=0de3ee921faf... tookMs=7128   (fresh create)
seqB containerId=0de3ee921faf... tookMs=2039   (reused, log: "Reusing container with ID: 0de3ee921faf... and hash: 876e80fe2d0a...")

5 cross-process trials (two separate OS processes, concurrent, distinct config per trial so each trial uses a fresh hash):

Trial Process A container ID Process B container ID Result
1 1c631f2015c1... 1c631f2015c1... identical (reused)
2 7b7f2692e5e8... 7b7f2692e5e8... identical (reused)
3 ae8760d6a6db... ae8760d6a6db... identical (reused)
4 022f53deecd6... 022f53deecd6... identical (reused)
5 21d6b1752eac... 21d6b1752eac... identical (reused)

docker ps -a after all 5 trials confirmed exactly 5 containers total (one per trial, not 10).

Same-JVM multi-thread races:

2 threads: thread0=177d20743a4a...  thread1=177d20743a4a...   (identical, 1 container in docker ps)
5 threads: thread0..thread4 all = 14bd1c48d791...              (identical, 1 container in docker ps)

All containers removed after verification, confirmed zero leftover (docker ps -a --filter "label=org.testcontainers.hash" returns empty).

Open questions for maintainers

  • Name prefix (testcontainers-reuse-<hash>). Happy to match an existing convention if there's a preferred one.
  • Retry/backoff tuning (100ms/2s cap/60s deadline, chosen to match AbstractWaitStrategy's default). Should this be configurable instead of fixed?
  • Whether the new method(s) should carry the same @UnstableAPI marker as findContainerForReuse/hash.
  • Whether the dead-container removal should force-remove or use a plain remove.
  • The per-hash lock map (REUSE_LOCKS) is never evicted, so it grows with the number of distinct reuse configurations over a JVM's lifetime. In practice that's bounded by how many distinct container configs a test run actually uses, not by test count, but flagging it in case a bound is wanted.

Adds a same-JVM lock keyed by the reuse hash around the existing
check-then-create block in GenericContainer.tryStart(), mirroring the
synchronized + create-once idiom already used in Network.getId().

For cross-process races, uses Docker's own container-name uniqueness
as an arbiter: the create call sets a deterministic name derived from
the hash, and a name conflict triggers a bounded retry/lookup instead
of failing (verifying the found container's reuse label, waiting out
an in-progress creation, cleaning up a dead one).

Fixes testcontainers#11979
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: withReuse(true) has a check-then-act race: concurrent starts with the same config create duplicate containers

1 participant