Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/testcontainers/core/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions tests/core/test_ryuk.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from time import perf_counter, sleep

import pytest
Expand Down Expand Up @@ -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)