Skip to content

Commit 83dc218

Browse files
Pin force_close_transport concurrent-safety alongside in-flight async close
The hook's docstring promises that calling the sync force-close hook while an async close() is mid-flight on the same connection is safe: both paths converge on writer.close() (asyncio's StreamWriter.close is idempotent). Existing tests covered idempotence on a single caller, missing-attrs absorption, and writer.close() raising — none exercised the documented concurrent path. Add a test that parks an in-flight async close on its first await, invokes the sync hook from the same coroutine, then resumes. Asserts writer.close was called and the close task completed without raising. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1fbf128 commit 83dc218

1 file changed

Lines changed: 61 additions & 1 deletion

File tree

tests/test_async_force_close_transport.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
from __future__ import annotations
1212

13-
from unittest.mock import MagicMock
13+
from unittest.mock import AsyncMock, MagicMock
14+
15+
import pytest
1416

1517
from dqlitedbapi.aio.connection import AsyncConnection
1618

@@ -80,3 +82,61 @@ def test_force_close_transport_swallows_writer_close_exception() -> None:
8082

8183
conn.force_close_transport() # must not raise
8284
writer.close.assert_called_once_with()
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_force_close_transport_concurrent_with_async_close() -> None:
89+
"""Pin the docstring's concurrent-safety contract: invoking the
90+
sync hook while an async ``close()`` is in flight on the same
91+
connection must not raise. Both paths converge on
92+
``writer.close()`` (idempotent on asyncio's StreamWriter).
93+
94+
Setup:
95+
* Build an AsyncConnection in the post-_ensure_locks state.
96+
* The inner client conn's ``close()`` yields once via
97+
``asyncio.sleep(0)`` so the async path reaches its first
98+
await before completing.
99+
* Start ``conn.close()`` as a task; let it park.
100+
* Invoke ``conn.force_close_transport()`` synchronously from
101+
the parent coroutine.
102+
* Resume the close_task; assert it finished cleanly.
103+
"""
104+
import asyncio
105+
106+
conn = AsyncConnection("localhost:9001", database="x")
107+
# Reproduce the post-_ensure_locks state without going through a
108+
# real connect.
109+
import weakref
110+
111+
loop = asyncio.get_running_loop()
112+
conn._loop_ref = weakref.ref(loop)
113+
conn._connect_lock = asyncio.Lock()
114+
conn._op_lock = asyncio.Lock()
115+
116+
inner = MagicMock()
117+
proto = MagicMock()
118+
writer = MagicMock()
119+
proto._writer = writer
120+
inner._protocol = proto
121+
inner.in_transaction = False
122+
123+
async def slow_close() -> None:
124+
await asyncio.sleep(0)
125+
126+
inner.close = AsyncMock(side_effect=slow_close)
127+
conn._async_conn = inner
128+
129+
# Park the async close inside its first await.
130+
close_task = asyncio.create_task(conn.close())
131+
await asyncio.sleep(0)
132+
133+
# Synchronous hook from the same coroutine — must not raise.
134+
conn.force_close_transport()
135+
136+
await close_task
137+
138+
assert writer.close.call_count >= 1, (
139+
"writer.close must be called at least once across the two "
140+
"convergent paths; idempotence ensures multiple calls are safe"
141+
)
142+
assert close_task.done() and close_task.exception() is None

0 commit comments

Comments
 (0)