Fix check-then-act race in container reuse (withReuse(true)) - #11980
Draft
stlahxm wants to merge 1 commit into
Draft
Fix check-then-act race in container reuse (withReuse(true))#11980stlahxm wants to merge 1 commit into
withReuse(true))#11980stlahxm wants to merge 1 commit into
Conversation
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
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 concurrentstart()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): aConcurrentHashMap<String, Object>keyed by the reuse hash, obtained viacomputeIfAbsent, guarding the existing check-then-create block withsynchronized. Mirrors thesynchronized+ create-once idiom already used inNetwork.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:HASH_LABELactually 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 matchingAbstractWaitStrategy'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 spotlessApplyandcheckstyleMain/checkstyleTestlocally 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:ReusabilityUnitTests$CanBeReusedTestReusabilityUnitTests$CopyFilesHashTestReusabilityUnitTests$HashTestReusabilityUnitTests$HooksTestReusabilityRaceConditionTest(updated)ReusabilityNameConflictTest(new)ReusabilityRaceConditionTestpreviously asserted 2 containers were created under a same-JVM race (documenting the bug); now asserts exactly 1, plus a 5-thread variant.ReusabilityNameConflictTestcovers: reuse when found-and-running, refusal on label mismatch, retry-by-reinspecting whencreated, cleanup-and-retry whenexited/dead, tolerating aNotFoundExceptionwhen the conflicting container is removed by someone else concurrently../gradlew spotlessApply,checkstyleMain,checkstyleTestall pass.Real Docker verification (no mocks, actual
dockerd29.2.0,alpine:3.17):Sequential control (same config, one call after another): second call reused the first's container.
5 cross-process trials (two separate OS processes, concurrent, distinct config per trial so each trial uses a fresh hash):
1c631f2015c1...1c631f2015c1...7b7f2692e5e8...7b7f2692e5e8...ae8760d6a6db...ae8760d6a6db...022f53deecd6...022f53deecd6...21d6b1752eac...21d6b1752eac...docker ps -aafter all 5 trials confirmed exactly 5 containers total (one per trial, not 10).Same-JVM multi-thread races:
All containers removed after verification, confirmed zero leftover (
docker ps -a --filter "label=org.testcontainers.hash"returns empty).Open questions for maintainers
testcontainers-reuse-<hash>). Happy to match an existing convention if there's a preferred one.AbstractWaitStrategy's default). Should this be configurable instead of fixed?@UnstableAPImarker asfindContainerForReuse/hash.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.