Skip to content

Commit 6e09702

Browse files
fix: make DqliteConnection.close() safely idempotent
close() previously called _check_in_use() up front, which raised InterfaceError for pool-released connections — breaking __aexit__ and try/finally cleanup patterns that hold the connection across release. A concurrent second close could also race on nulling _protocol after wait_closed(). Short-circuit when _pool_released or _protocol is already None. Null _protocol before awaiting wait_closed so a concurrent second call sees already-closed state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 851ac27 commit 6e09702

2 files changed

Lines changed: 34 additions & 6 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,21 @@ async def connect(self) -> None:
156156
self._in_use = False
157157

158158
async def close(self) -> None:
159-
"""Close the connection."""
159+
"""Close the connection.
160+
161+
Idempotent: safe to call on an already-closed or pool-released
162+
connection. Null ``_protocol`` before awaiting ``wait_closed`` so
163+
a concurrent second close cannot re-enter the socket-close path.
164+
"""
165+
# Pool-released or already-closed: nothing to do.
166+
if self._pool_released or self._protocol is None:
167+
return
160168
self._check_in_use()
161-
if self._protocol is not None:
162-
self._protocol.close()
163-
await self._protocol.wait_closed()
164-
self._protocol = None
165-
self._db_id = None
169+
protocol = self._protocol
170+
self._protocol = None
171+
self._db_id = None
172+
protocol.close()
173+
await protocol.wait_closed()
166174

167175
async def __aenter__(self) -> "DqliteConnection":
168176
await self.connect()

tests/test_connection.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,26 @@ async def test_not_connected_after_invalidation_chains_cause(
801801
assert exc_info.value.__cause__ is not None
802802
assert isinstance(exc_info.value.__cause__, OperationalError)
803803

804+
async def test_close_is_idempotent_on_second_call(self, connected_connection) -> None:
805+
"""close() must be safe to call twice; a second call must no-op
806+
rather than re-enter _check_in_use and raise.
807+
"""
808+
conn, _, _ = connected_connection
809+
await conn.close()
810+
# Second close should not raise.
811+
await conn.close()
812+
assert not conn.is_connected
813+
814+
async def test_close_on_pool_released_connection_is_noop(self, connected_connection) -> None:
815+
"""A pool-released connection's close() must no-op rather than raise
816+
InterfaceError. __aexit__ and try/finally cleanup patterns rely on
817+
this.
818+
"""
819+
conn, _, _ = connected_connection
820+
conn._pool_released = True
821+
# Would previously raise InterfaceError("returned to the pool").
822+
await conn.close()
823+
804824
async def test_cross_event_loop_raises_interface_error(self) -> None:
805825
"""Using a connection from a different event loop must raise InterfaceError."""
806826
import asyncio

0 commit comments

Comments
 (0)