From d0df382ce885f74a19598de4b4a2b08daa80d73e Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 28 Aug 2026 15:13:28 +0300 Subject: [PATCH] Release every sandbox resource, whichever teardown fails Sandbox.close released the pool and the held federated client only when the monty session's __aexit__ returned. Each release now runs under suppress(Exception), matching _discard_session and aclose_quietly, so a raising step neither masks an unwinding error nor leaks the databases a federated `_opened` holds. --- haiku_rag_slim/haiku/rag/sandbox/sandbox.py | 19 +++++++++------ tests/sandbox/test_sandbox.py | 26 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py index d88a60f0..f9ac23db 100644 --- a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py +++ b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py @@ -354,16 +354,21 @@ class Sandbox: await session.__aexit__(None, None, None) async def close(self) -> None: - """Return the worker to the pool and shut the pool down. Idempotent.""" + """Return the worker to the pool, shut the pool down and release any + held connection. Idempotent, and each release happens whatever the + others raise.""" if self._session is not None: - await self._session.__aexit__(None, None, None) - self._session = None + session, self._session = self._session, None + with suppress(Exception): + await session.__aexit__(None, None, None) if self._pool is not None: - await self._pool.__aexit__(None, None, None) - self._pool = None + pool, self._pool = self._pool, None + with suppress(Exception): + await pool.__aexit__(None, None, None) if self._opened is not None: - await self._opened.__aexit__(None, None, None) - self._opened = None + opened, self._opened = self._opened, None + with suppress(Exception): + await opened.__aexit__(None, None, None) def _build_external_functions(self) -> dict[str, Any]: """Build async external functions for the Monty interpreter.""" diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index a5883345..5999f894 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -935,3 +935,29 @@ class TestSandboxRequestTimeout: assert "alive" in recovered.stdout finally: await sb.close() + + +class TestSandboxClose: + @pytest.mark.asyncio + async def test_a_failing_teardown_still_releases_the_rest(self, tmp_path): + """Each of the session, the pool and the held connection is released, + whichever of them fails.""" + from unittest.mock import AsyncMock + + sb = Sandbox( + db_path=tmp_path / "x.lancedb", + config=AppConfig(), + context=AnalysisContext(), + ) + session, pool, opened = AsyncMock(), AsyncMock(), AsyncMock() + session.__aexit__.side_effect = RuntimeError("worker already gone") + sb._session, sb._pool = session, pool # ty: ignore[invalid-assignment] + sb._opened = opened + + await sb.close() + + pool.__aexit__.assert_awaited_once() + opened.__aexit__.assert_awaited_once() + assert sb._session is None + assert sb._pool is None + assert sb._opened is None