From b5aa0e71227abf43c2fbe028292106a236f4db51 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 28 Aug 2026 08:41:51 +0300 Subject: [PATCH] Serialize the sandbox's shared connection, not its owners The lock was applied to an owner as well, so every owner-backed file read queued behind the capability's tool calls to guard state it does not touch. An owner is a session of its own and is yielded straight through, which is what the docstring already claimed. --- haiku_rag_slim/haiku/rag/sandbox/sandbox.py | 10 +++-- tests/sandbox/test_sandbox_multi_db.py | 48 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py index 7b676683..24f260f2 100644 --- a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py +++ b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py @@ -237,13 +237,15 @@ class Sandbox: no repositories of its own, so those reads have to name their owner. An owner is a session of its own, so it is yielded unserialized. """ - connection = owner if owner is not None else self._rag - if connection is not None: + if owner is not None: + yield owner + return + if self._rag is not None: if self._lock is not None: async with self._lock: - yield connection + yield self._rag else: - yield connection + yield self._rag return if self._scope.covers_multiple: yield await self._open_connection() diff --git a/tests/sandbox/test_sandbox_multi_db.py b/tests/sandbox/test_sandbox_multi_db.py index 623abec8..86655ca7 100644 --- a/tests/sandbox/test_sandbox_multi_db.py +++ b/tests/sandbox/test_sandbox_multi_db.py @@ -20,6 +20,54 @@ async def _mounted(rag, sources=None): return sandbox, docs, owners +class TestSerializingTheConnection: + """The lock guards the shared connection, which the capability's own tool + calls also hold. An owner is a session of its own.""" + + @staticmethod + def _sandbox(rag, lock): + return Sandbox._covering( + rag._resolve_scope(), rag._config, AnalysisContext(), rag, lock + ) + + @pytest.mark.asyncio + async def test_the_shared_connection_is_serialized(self, tmp_path): + import asyncio + + config = _config(tmp_path, ["alpha", "beta"]) + await _seed(config, "alpha", ["alpha document about cats"]) + + lock = asyncio.Lock() + async with HaikuRAG(config=config) as rag: + sandbox = self._sandbox(rag, lock) + async with sandbox._connection(): + assert lock.locked() + assert not lock.locked() + + @pytest.mark.asyncio + async def test_an_owner_is_not(self, tmp_path): + """Serializing owner reads would queue every database's file read behind + the capability's searches, to guard state none of them touch.""" + import asyncio + + class Trap(asyncio.Lock): + """Refuses rather than waits: holding a real lock would wedge the + suite on a regression instead of failing it.""" + + async def acquire(self): + raise AssertionError("serialized a read on an owner's own session") + + config = _config(tmp_path, ["alpha", "beta"]) + await _seed(config, "alpha", ["alpha document about cats"]) + + async with HaikuRAG(config=config) as rag: + (alpha,) = await rag.clients_for(["alpha"]) + sandbox = self._sandbox(rag, Trap()) + + async with sandbox._connection(alpha) as connection: + assert connection is alpha + + class TestStandaloneAcrossDatabases: """Without a lent client the sandbox opens its own. The owners it hands out are stored for later file reads, so that connection has to outlive the call