Skip to content

Commit a278765

Browse files
fix: check _in_use guard in close() to prevent closing during in-flight operations
close() was the only public method on DqliteConnection that did not call _check_in_use(). This allowed a concurrent coroutine to close the connection while another coroutine was mid-operation, producing a misleading DqliteConnectionError("Connection closed by server") instead of a clear InterfaceError. Fixes #095 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f491edf commit a278765

File tree

2 files changed

+50
-0
lines changed

2 files changed

+50
-0
lines changed

src/dqliteclient/connection.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ async def connect(self) -> None:
117117

118118
async def close(self) -> None:
119119
"""Close the connection."""
120+
self._check_in_use()
120121
if self._protocol is not None:
121122
self._protocol.close()
122123
await self._protocol.wait_closed()

tests/test_connection.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,52 @@ async def second_connect():
726726

727727
assert len(errors) == 1
728728
assert "another operation is in progress" in str(errors[0])
729+
730+
async def test_close_while_in_use_raises_interface_error(self) -> None:
731+
"""close() must raise InterfaceError if an operation is in progress."""
732+
import asyncio
733+
734+
from dqliteclient.exceptions import InterfaceError
735+
736+
conn = DqliteConnection("localhost:9001")
737+
738+
mock_reader = AsyncMock()
739+
mock_writer = MagicMock()
740+
mock_writer.drain = AsyncMock()
741+
mock_writer.close = MagicMock()
742+
mock_writer.wait_closed = AsyncMock()
743+
744+
from dqlitewire.messages import DbResponse, WelcomeResponse
745+
746+
responses = [
747+
WelcomeResponse(heartbeat_timeout=15000).encode(),
748+
DbResponse(db_id=1).encode(),
749+
]
750+
mock_reader.read.side_effect = responses
751+
752+
with patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)):
753+
await conn.connect()
754+
755+
# Make execute hang so close() runs while execute is in progress
756+
execute_entered = asyncio.Event()
757+
758+
async def slow_exec_sql(db_id, sql, params=None):
759+
execute_entered.set()
760+
await asyncio.sleep(10)
761+
return (0, 1)
762+
763+
conn._protocol.exec_sql = AsyncMock(side_effect=slow_exec_sql) # type: ignore[union-attr]
764+
765+
async def do_execute():
766+
await conn.execute("INSERT INTO t VALUES (1)")
767+
768+
task = asyncio.create_task(do_execute())
769+
await execute_entered.wait()
770+
771+
# close() should raise because execute is in progress
772+
with pytest.raises(InterfaceError, match="another operation is in progress"):
773+
await conn.close()
774+
775+
task.cancel()
776+
with pytest.raises(asyncio.CancelledError):
777+
await task

0 commit comments

Comments
 (0)