Skip to content

Commit d777f65

Browse files
Re-check closed state under op_lock in async commit/rollback
AsyncConnection.commit / rollback checked _closed and _async_conn before taking op_lock and never re-checked under it. A concurrent close() that won the lock first would null _async_conn and release, after which the parked commit/rollback dereferenced NoneType.execute and raised AttributeError instead of the correct InterfaceError. Add a post-lock re-check so a lost race surfaces a clean "Connection is closed" error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent add75e5 commit d777f65

File tree

2 files changed

+62
-1
lines changed

2 files changed

+62
-1
lines changed

src/dqlitedbapi/aio/connection.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ async def close(self) -> None:
169169
# re-check before it touches the now-None primitive.
170170
self._connect_lock = None
171171
self._op_lock = None
172-
self._loop_id = None
172+
self._loop_ref = None
173173

174174
async def commit(self) -> None:
175175
"""Commit any pending transaction.
@@ -184,6 +184,12 @@ async def commit(self) -> None:
184184
return
185185
_, op_lock = self._ensure_locks()
186186
async with op_lock:
187+
# Re-check under the lock: a concurrent close() may have
188+
# acquired op_lock before us, closed the connection, and
189+
# released. Without this second check we would dereference
190+
# ``self._async_conn.execute`` on ``None``.
191+
if self._closed or self._async_conn is None:
192+
raise InterfaceError("Connection is closed")
187193
try:
188194
await self._async_conn.execute("COMMIT")
189195
except (OperationalError, _client_exc.OperationalError) as e:
@@ -198,6 +204,9 @@ async def rollback(self) -> None:
198204
return
199205
_, op_lock = self._ensure_locks()
200206
async with op_lock:
207+
# Re-check under the lock for the same race as commit().
208+
if self._closed or self._async_conn is None:
209+
raise InterfaceError("Connection is closed")
201210
try:
202211
await self._async_conn.execute("ROLLBACK")
203212
except (OperationalError, _client_exc.OperationalError) as e:

tests/test_async_close_race.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,55 @@ async def run_close() -> None:
6363
"close:start",
6464
"close:end",
6565
], f"actual order: {order}"
66+
67+
68+
class TestCommitRollbackCloseRace:
69+
"""A race where ``close()`` wins the op_lock first must make the
70+
parked ``commit()`` / ``rollback()`` see ``_closed=True`` and raise
71+
``InterfaceError`` instead of dereferencing ``_async_conn=None``.
72+
"""
73+
74+
async def test_commit_parked_on_lock_sees_close_first(self) -> None:
75+
import asyncio
76+
from unittest.mock import AsyncMock, MagicMock
77+
78+
import pytest
79+
80+
from dqlitedbapi.aio.connection import AsyncConnection
81+
from dqlitedbapi.exceptions import InterfaceError
82+
83+
conn = AsyncConnection("localhost:19001", database="x")
84+
85+
# Prime _async_conn and the locks from within the running loop
86+
# so close() / commit() both take the lock path.
87+
conn._ensure_locks()
88+
inner = MagicMock()
89+
inner.close = AsyncMock()
90+
inner.execute = AsyncMock()
91+
conn._async_conn = inner
92+
93+
# Gate close() inside the lock so commit() can park.
94+
assert conn._op_lock is not None
95+
close_release = asyncio.Event()
96+
97+
real_close = inner.close
98+
99+
async def slow_close(*args: object, **kwargs: object) -> None:
100+
await close_release.wait()
101+
await real_close(*args, **kwargs)
102+
103+
inner.close = slow_close # type: ignore[assignment]
104+
105+
close_task = asyncio.create_task(conn.close())
106+
# Yield so close() acquires op_lock.
107+
await asyncio.sleep(0)
108+
await asyncio.sleep(0)
109+
110+
commit_task = asyncio.create_task(conn.commit())
111+
await asyncio.sleep(0)
112+
# Let close complete.
113+
close_release.set()
114+
115+
await close_task
116+
with pytest.raises(InterfaceError, match="Connection is closed"):
117+
await commit_task

0 commit comments

Comments
 (0)