Skip to content

Commit e4beb34

Browse files
fix: bind event loop lazily in _check_in_use, not only in connect
Previously _bound_loop was set only inside connect(). Tests/subclasses that construct a bare DqliteConnection and swap in a mocked _protocol bypassed the loop-mismatch guard entirely — _check_in_use returned early when _bound_loop was None. Bind the loop on first _check_in_use so the guard is always active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9364008 commit e4beb34

2 files changed

Lines changed: 59 additions & 12 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,18 +172,22 @@ def _check_in_use(self) -> None:
172172
"This connection has been returned to the pool and can no longer "
173173
"be used directly. Acquire a new connection from the pool."
174174
)
175-
if self._bound_loop is not None:
176-
try:
177-
current_loop = asyncio.get_running_loop()
178-
except RuntimeError:
179-
raise InterfaceError(
180-
"DqliteConnection must be used from within an async context."
181-
) from None
182-
if current_loop is not self._bound_loop:
183-
raise InterfaceError(
184-
"DqliteConnection is bound to a different event loop. "
185-
"Do not share connections across event loops or OS threads."
186-
)
175+
try:
176+
current_loop = asyncio.get_running_loop()
177+
except RuntimeError:
178+
raise InterfaceError(
179+
"DqliteConnection must be used from within an async context."
180+
) from None
181+
if self._bound_loop is None:
182+
# Lazily bind on first use so the guard is always active, even
183+
# for bare-instantiation / mocked-protocol patterns that skip
184+
# connect().
185+
self._bound_loop = current_loop
186+
elif current_loop is not self._bound_loop:
187+
raise InterfaceError(
188+
"DqliteConnection is bound to a different event loop. "
189+
"Do not share connections across event loops or OS threads."
190+
)
187191
if self._in_use:
188192
raise InterfaceError(
189193
"Cannot perform operation: another operation is in progress on this "

tests/test_connection.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,49 @@ async def tx_b():
684684
)
685685
assert "Nested" in str(errors[0]) or "nested" in str(errors[0])
686686

687+
async def test_loop_guard_active_even_without_connect(self) -> None:
688+
"""The loop-mismatch guard must bind on first _check_in_use, not only
689+
inside connect(). Otherwise bare-instantiation + mocked _protocol
690+
patterns (common in tests) bypass the guard.
691+
"""
692+
import asyncio
693+
import threading
694+
695+
from dqliteclient.exceptions import InterfaceError
696+
697+
conn = DqliteConnection("localhost:9001")
698+
# Simulate a test that mocks the protocol without calling connect().
699+
conn._protocol = MagicMock()
700+
conn._db_id = 1
701+
702+
# First operation in this loop should bind the loop implicitly.
703+
conn._check_in_use()
704+
first_loop = conn._bound_loop
705+
assert first_loop is asyncio.get_running_loop(), (
706+
"first _check_in_use should bind the current event loop"
707+
)
708+
709+
# Crossing into a new loop from another thread should raise.
710+
error_from_thread: Exception | None = None
711+
712+
def run_in_other_loop() -> None:
713+
nonlocal error_from_thread
714+
715+
async def use_conn() -> None:
716+
conn._check_in_use()
717+
718+
try:
719+
asyncio.run(use_conn())
720+
except Exception as e:
721+
error_from_thread = e
722+
723+
thread = threading.Thread(target=run_in_other_loop)
724+
thread.start()
725+
thread.join(timeout=5)
726+
727+
assert isinstance(error_from_thread, InterfaceError)
728+
assert "event loop" in str(error_from_thread).lower()
729+
687730
async def test_cross_event_loop_raises_interface_error(self) -> None:
688731
"""Using a connection from a different event loop must raise InterfaceError."""
689732
import asyncio

0 commit comments

Comments
 (0)