From 061e9d6f45a320bd8ff4a8fd9af7373e35895200 Mon Sep 17 00:00:00 2001 From: Mikhail Solomenik <56078241+pantafive@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:31:28 +0200 Subject: [PATCH] fix(core): close the reaper socket at exit instead of removing the reaper The atexit handler added in #1017 called Reaper.delete_instance(), which force-removes the Ryuk container. Ryuk then never reached its reconnection timeout, so the containers of a session that exits normally were never reaped. At exit only the socket is closed now; delete_instance() stays for explicit use. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Mikhail Solomenik <56078241+pantafive@users.noreply.github.com> --- src/testcontainers/core/container.py | 9 +++++-- tests/core/test_ryuk.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/testcontainers/core/container.py b/src/testcontainers/core/container.py index 76fce9c14..a44109735 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 81bb736ca..8ce1c56be 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)