diff --git a/src/testcontainers/core/container.py b/src/testcontainers/core/container.py index 76fce9c1..0c37c8c5 100644 --- a/src/testcontainers/core/container.py +++ b/src/testcontainers/core/container.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from os import PathLike from socket import socket +from threading import RLock from types import TracebackType from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union @@ -465,11 +466,16 @@ class Reaper: _instance: "Optional[Reaper]" = None _container: Optional[DockerContainer] = None _socket: Optional[socket] = None + # Reentrant so that a nested get_instance() on the same thread, while Ryuk itself + # is starting, behaves as it did before the lock instead of deadlocking. + _lock = RLock() @classmethod def get_instance(cls) -> "Reaper": if Reaper._instance is None: - Reaper._instance = Reaper._create_instance() + with Reaper._lock: + if Reaper._instance is None: + Reaper._instance = Reaper._create_instance() return Reaper._instance diff --git a/tests/core/test_ryuk.py b/tests/core/test_ryuk.py index 81bb736c..0f7b0e1f 100644 --- a/tests/core/test_ryuk.py +++ b/tests/core/test_ryuk.py @@ -1,3 +1,5 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier from time import perf_counter, sleep import pytest @@ -95,3 +97,31 @@ def test_ryuk_is_reused_in_same_process(): with DockerContainer("hello-world") as container: wait_for_logs(container, "Hello from Docker!") assert reaper_instance is Reaper._instance + + +def test_ryuk_is_created_once_when_containers_start_concurrently(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(Reaper, "_instance", None) + created: list[Reaper] = [] + + def slow_create_instance(cls: type[Reaper]) -> Reaper: + # Starting Ryuk takes seconds, which is the window the other threads race through. + sleep(0.2) + instance = Reaper() + created.append(instance) + Reaper._instance = instance + return instance + + monkeypatch.setattr(Reaper, "_create_instance", classmethod(slow_create_instance)) + + workers = 8 + barrier = Barrier(workers) + + def get_instance(_: int) -> Reaper: + barrier.wait() + return Reaper.get_instance() + + with ThreadPoolExecutor(max_workers=workers) as executor: + instances = list(executor.map(get_instance, range(workers))) + + assert len(created) == 1 + assert all(instance is created[0] for instance in instances)