diff --git a/src/testcontainers/core/container.py b/src/testcontainers/core/container.py index 76fce9c1..a4410973 100644 --- a/src/testcontainers/core/container.py +++ b/src/testcontainers/core/container.py @@ -474,11 +474,15 @@ def get_instance(cls) -> "Reaper": return Reaper._instance @classmethod - def delete_instance(cls) -> None: + def close_socket(cls) -> None: if Reaper._socket is not None: Reaper._socket.close() Reaper._socket = None + @classmethod + def delete_instance(cls) -> None: + Reaper.close_socket() + if Reaper._container is not None and Reaper._container._container is not None: with contextlib.suppress(docker.errors.NotFound): Reaper._container.stop() @@ -541,6 +545,7 @@ def _create_instance(cls) -> "Reaper": rs.send(f"label={LABEL_SESSION_ID}={SESSION_ID}\r\n".encode()) Reaper._instance = Reaper() - atexit.register(Reaper.delete_instance) + # Only close the socket: Ryuk reaps the session after RYUK_RECONNECTION_TIMEOUT unless it is removed first. + atexit.register(Reaper.close_socket) return Reaper._instance diff --git a/tests/core/test_ryuk.py b/tests/core/test_ryuk.py index 81bb736c..8ce1c56b 100644 --- a/tests/core/test_ryuk.py +++ b/tests/core/test_ryuk.py @@ -1,5 +1,10 @@ +import os +import subprocess +import sys +from contextlib import suppress from time import perf_counter, sleep +import docker import pytest from docker import DockerClient from docker.errors import NotFound @@ -95,3 +100,33 @@ 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 + + +@pytest.mark.skipif( + is_mac(), + reason="Ryuk container reaping is unreliable on Docker Desktop for macOS due to VM-based container lifecycle handling", +) +@pytest.mark.inside_docker_check +def test_reaper_removes_session_containers_after_interpreter_exit(): + script = ( + "from testcontainers.core.container import DockerContainer\n" + "container = DockerContainer('hello-world').start()\n" + "print(container.get_wrapped_container().id)\n" + ) + child = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "RYUK_RECONNECTION_TIMEOUT": "1s"}, + capture_output=True, + text=True, + timeout=120, + ) + assert child.returncode == 0, child.stderr + container_id = child.stdout.strip().splitlines()[-1] + + docker_client = docker.from_env() + try: + # The process exited normally without stopping its container: Ryuk must reap it. + _wait_for_container_removed(docker_client, container_id, timeout=30) + finally: + with suppress(NotFound): + docker_client.containers.get(container_id).remove(force=True)