Skip to content

Commit 397323b

Browse files
Shield pool cleanup against cancel and drain pending-close on release
Two cancel-hygiene gaps in the pool's lifecycle paths: - acquire's broken-conn cleanup awaited conn.close() bare. An outer cancel landing during the close skipped it and left the user's transport open until GC, plus left _pool_released=False so a later close() did not short-circuit. Wrap the close in asyncio.shield + suppress(CancelledError) and move _pool_released into a finally so the flag flips even when close raises an unrecognised exception. - _release set _pool_released=True before observing _pending_drain (scheduled by _invalidate's bounded wait_closed task on cancel mid-ROLLBACK). The next close() would short-circuit past the pending-drain await and the reader-task could outlive the connection. Drain the pending task in the finally before flipping _pool_released. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c34f553 commit 397323b

3 files changed

Lines changed: 358 additions & 8 deletions

File tree

src/dqliteclient/pool.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -764,15 +764,34 @@ async def acquire(self) -> AsyncIterator[DqliteConnection]:
764764
"pool.acquire cleanup: _drain_idle failed",
765765
exc_info=True,
766766
)
767+
# Shield ``conn.close()`` so an outer cancel landing
768+
# mid-cleanup does not skip it — the user's
769+
# checked-out connection's transport would otherwise
770+
# stay open until GC. The ``_close_timeout`` bound
771+
# on ``wait_closed`` keeps the shielded await
772+
# bounded; ``contextlib.suppress(CancelledError)``
773+
# absorbs any nested cancel delivered after the
774+
# shield released so the original cancel still
775+
# propagates via the surrounding ``raise``.
776+
# ``_drain_idle()`` above stays bare: it protects
777+
# siblings, not the leaked conn, and shielding it
778+
# could turn outer cancel into an unbounded wait.
767779
try:
768-
await conn.close()
769-
except (OSError, DqliteConnectionError):
770-
logger.debug(
771-
"pool.acquire cleanup: conn.close(%r) failed",
772-
getattr(conn, "_address", "?"),
773-
exc_info=True,
774-
)
775-
conn._pool_released = True
780+
try:
781+
with contextlib.suppress(asyncio.CancelledError):
782+
await asyncio.shield(conn.close())
783+
except (OSError, DqliteConnectionError):
784+
logger.debug(
785+
"pool.acquire cleanup: conn.close(%r) failed",
786+
getattr(conn, "_address", "?"),
787+
exc_info=True,
788+
)
789+
finally:
790+
# Always set ``_pool_released`` so a subsequent
791+
# close() short-circuits and the slot accounting
792+
# stays consistent — landed even if the shielded
793+
# close above raised an unrecognised exception.
794+
conn._pool_released = True
776795
finally:
777796
if not returned_to_queue:
778797
# ``asyncio.shield`` already prevents an outer cancel
@@ -941,6 +960,21 @@ async def _release(self, conn: DqliteConnection) -> None:
941960
# takes the early-return path instead of running a
942961
# redundant close against a protocol that's already
943962
# None.
963+
#
964+
# Drain ``_pending_drain`` BEFORE setting
965+
# ``_pool_released=True``: a cancel mid-ROLLBACK has
966+
# ``_invalidate`` schedule a bounded ``wait_closed``
967+
# drain task on the connection. ``close()`` would
968+
# normally await that task at ``connection.py``'s
969+
# pending-drain block, but the early-return on
970+
# ``_pool_released=True`` we set below short-circuits
971+
# past it. Snapshot and shield-await the drain here so
972+
# the reader-task doesn't outlive the connection (no
973+
# "Task was destroyed but it is pending" on shutdown).
974+
pending = getattr(conn, "_pending_drain", None)
975+
if pending is not None and not pending.done():
976+
with contextlib.suppress(BaseException):
977+
await asyncio.shield(pending)
944978
conn._pool_released = True
945979
with contextlib.suppress(asyncio.CancelledError):
946980
await asyncio.shield(self._release_reservation())
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Pin: pool ``acquire``'s broken-conn cleanup shields ``conn.close()``.
2+
3+
The exception-cleanup branch in ``acquire`` runs when the user's
4+
checked-out connection is broken (invalidated by execute/fetch error).
5+
It calls ``await conn.close()`` to release the transport. If an outer
6+
``asyncio.timeout`` or ``CancelScope`` fires during the cleanup, the
7+
unshielded close is interrupted and the user's transport leaks open
8+
until GC. The fix wraps the close in ``asyncio.shield`` and
9+
``contextlib.suppress(CancelledError)`` so the close completes
10+
(bounded by ``_close_timeout``) before the cancellation propagates;
11+
the inner ``finally`` then guarantees ``_pool_released = True``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import asyncio
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from dqliteclient import DqliteConnection
22+
from dqliteclient.exceptions import DqliteConnectionError
23+
from dqliteclient.pool import ConnectionPool
24+
25+
26+
async def _build_pool_with_breakable_conn() -> tuple[ConnectionPool, DqliteConnection]:
27+
"""Construct a pool whose ``_create_connection`` returns a conn
28+
that LOOKS connected at acquire time but transitions to broken
29+
once the user invalidates it (simulating leader flip mid-execute).
30+
"""
31+
pool = ConnectionPool(["localhost:9001"], min_size=0, max_size=1)
32+
conn = DqliteConnection("localhost:9001")
33+
# Pretend the conn is fully connected so ``acquire`` does not run
34+
# the entry-time ``_drain_idle`` (which would mask the cleanup
35+
# branch we are testing).
36+
conn._protocol = object() # type: ignore[assignment]
37+
conn._db_id = 1
38+
assert conn.is_connected is True
39+
40+
async def _fake_create_connection() -> DqliteConnection:
41+
return conn
42+
43+
pool._create_connection = _fake_create_connection # type: ignore[method-assign]
44+
return pool, conn
45+
46+
47+
def _break_conn(conn: DqliteConnection) -> None:
48+
"""Simulate a leader-flip-style invalidation: drop ``_protocol``
49+
so ``conn.is_connected`` flips to False, sending the cleanup down
50+
the broken-conn branch."""
51+
conn._protocol = None
52+
conn._db_id = None
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_acquire_cleanup_close_completes_under_outer_cancel() -> None:
57+
"""Cancel during the broken-conn cleanup must NOT skip
58+
``conn.close()``: shield + suppress(CancelledError) keeps the
59+
close running while the original cancel propagates via the
60+
surrounding ``raise``. Pin via ``_pool_released=True`` (set in the
61+
inner ``finally`` after the close) — without the fix it would
62+
stay False because cancel propagated past the bare close."""
63+
pool, conn = await _build_pool_with_breakable_conn()
64+
65+
close_completed = False
66+
67+
async def fake_close() -> None:
68+
nonlocal close_completed
69+
# Yield once so a concurrent cancel can land mid-close. The
70+
# shield must keep us running through the second statement.
71+
await asyncio.sleep(0)
72+
# Without the shield, the cancel queued via raise CancelledError
73+
# below would propagate through this await and skip the
74+
# assignment — the test would catch that as close_completed=False.
75+
close_completed = True
76+
77+
async def fake_drain_idle() -> None:
78+
return
79+
80+
pool._drain_idle = fake_drain_idle # type: ignore[method-assign]
81+
82+
with (
83+
patch.object(conn, "close", new=fake_close),
84+
pytest.raises((asyncio.CancelledError, ValueError)),
85+
):
86+
async with pool.acquire():
87+
_break_conn(conn)
88+
# Schedule a cancel of the current task so it lands
89+
# while ``conn.close`` is suspended on the
90+
# ``await asyncio.sleep(0)`` above. The shield must
91+
# absorb the cancel and let close finish.
92+
current = asyncio.current_task()
93+
assert current is not None
94+
asyncio.get_running_loop().call_soon(current.cancel)
95+
raise ValueError("user code error")
96+
97+
assert close_completed, (
98+
"conn.close() must run to completion despite cancel — shield "
99+
"around the close await keeps it running"
100+
)
101+
assert conn._pool_released is True, (
102+
"_pool_released must be set in inner finally — the shield+suppress "
103+
"around close ensures we reach the flag-set"
104+
)
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_acquire_cleanup_close_failure_still_sets_pool_released() -> None:
109+
"""If ``conn.close()`` raises an unhandled exception (not
110+
OSError/DqliteConnectionError), the inner ``finally`` must still
111+
set ``_pool_released=True`` so subsequent close() short-circuits."""
112+
pool, conn = await _build_pool_with_breakable_conn()
113+
114+
async def fake_close() -> None:
115+
# Simulate an unrecognised close failure (not OSError /
116+
# DqliteConnectionError). Must propagate, but the finally
117+
# still flips _pool_released.
118+
raise RuntimeError("unexpected close failure")
119+
120+
async def fake_drain_idle() -> None:
121+
return
122+
123+
pool._drain_idle = fake_drain_idle # type: ignore[method-assign]
124+
125+
with patch.object(conn, "close", new=fake_close), pytest.raises((ValueError, RuntimeError)):
126+
async with pool.acquire():
127+
_break_conn(conn)
128+
raise ValueError("user code error")
129+
130+
assert conn._pool_released is True
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_drain_idle_failure_does_not_skip_close() -> None:
135+
"""Pin existing behaviour: the bare ``_drain_idle()`` failure path
136+
is intentionally narrow (DqliteConnectionError / OSError swallowed
137+
via _POOL_CLEANUP_EXCEPTIONS); the close still runs after."""
138+
pool, conn = await _build_pool_with_breakable_conn()
139+
140+
close_call_count = 0
141+
142+
async def fake_close() -> None:
143+
nonlocal close_call_count
144+
close_call_count += 1
145+
146+
async def fake_drain_idle() -> None:
147+
raise DqliteConnectionError("simulated drain failure")
148+
149+
pool._drain_idle = fake_drain_idle # type: ignore[method-assign]
150+
151+
with patch.object(conn, "close", new=fake_close), pytest.raises(ValueError):
152+
async with pool.acquire():
153+
_break_conn(conn)
154+
raise ValueError("user code error")
155+
156+
assert close_call_count == 1
157+
assert conn._pool_released is True
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Pin: ``_release``'s finally drains ``_pending_drain`` before
2+
setting ``_pool_released=True``.
3+
4+
Cancel mid-ROLLBACK (during ``_release._reset_connection``) lands in
5+
``_run_protocol``'s cancellation branch, which schedules a bounded
6+
``wait_closed`` drain task on ``_pending_drain``. ``close()`` would
7+
normally await that drain at its pending-drain block, but the
8+
``_pool_released=True`` flag set by ``_release``'s finally short-
9+
circuits past it. The reader-task then outlives the connection,
10+
producing "Task was destroyed but it is pending" warnings on
11+
shutdown under cancel-heavy workloads.
12+
13+
The fix snapshots and shield-awaits the drain BEFORE setting
14+
``_pool_released=True``.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import asyncio
20+
from unittest.mock import patch
21+
22+
import pytest
23+
24+
from dqliteclient import DqliteConnection
25+
from dqliteclient.pool import ConnectionPool
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_release_drains_pending_before_setting_pool_released() -> None:
30+
"""Pin: the drain task completes BEFORE ``_pool_released`` flips.
31+
32+
Use a release_reservation stub that captures the drain's done-state
33+
at the moment it runs. ``_release_reservation`` is awaited AFTER
34+
``_pool_released = True`` in the finally clause, so capturing
35+
``drain.done()`` from inside the stub tells us whether the fix's
36+
pre-flag drain ran.
37+
"""
38+
pool = ConnectionPool(["localhost:9001"], min_size=0, max_size=1)
39+
pool._size = 1 # reservation already taken
40+
conn = DqliteConnection("localhost:9001")
41+
conn._protocol = object() # type: ignore[assignment]
42+
conn._db_id = 1
43+
conn._in_transaction = True # force the ROLLBACK branch in _reset_connection
44+
45+
# Drain task that requires multiple loop turns to complete — so a
46+
# single yield from _release_reservation cannot accidentally drain
47+
# it. The fix's explicit ``await asyncio.shield(pending)`` is the
48+
# only mechanism that drains it before _pool_released flips.
49+
async def slow_drain() -> None:
50+
for _ in range(5):
51+
await asyncio.sleep(0)
52+
53+
conn._pending_drain = asyncio.create_task(slow_drain())
54+
55+
async def fake_reset(c: DqliteConnection) -> bool:
56+
# Take the close-and-drop branch (mimics
57+
# cancel-during-ROLLBACK). The pending drain is what we are
58+
# pinning the order on.
59+
return False
60+
61+
pool._reset_connection = fake_reset # type: ignore[method-assign]
62+
63+
# Capture the drain task's done-state at the moment
64+
# _release_reservation runs. With the fix, the drain has been
65+
# explicitly awaited before this point. Without the fix, the
66+
# drain is still pending when this fires.
67+
drain_done_when_release_reservation_ran: list[bool] = []
68+
69+
async def observing_release_reservation() -> None:
70+
drain_done_when_release_reservation_ran.append(
71+
conn._pending_drain is not None and conn._pending_drain.done()
72+
)
73+
74+
pool._release_reservation = observing_release_reservation # type: ignore[method-assign]
75+
76+
async def fake_close() -> None:
77+
return
78+
79+
with patch.object(conn, "close", new=fake_close):
80+
await pool._release(conn)
81+
82+
assert drain_done_when_release_reservation_ran == [True], (
83+
"drain task must have completed before _release_reservation ran "
84+
"(i.e. before _pool_released was set in the finally)"
85+
)
86+
assert conn._pool_released is True
87+
assert conn._pending_drain.done()
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_release_with_no_pending_drain_path_unchanged() -> None:
92+
"""Negative pin: when there is no ``_pending_drain``, the existing
93+
behaviour is unchanged — the drain block is a no-op."""
94+
pool = ConnectionPool(["localhost:9001"], min_size=0, max_size=1)
95+
pool._size = 1
96+
conn = DqliteConnection("localhost:9001")
97+
conn._protocol = object() # type: ignore[assignment]
98+
conn._db_id = 1
99+
conn._in_transaction = True
100+
assert conn._pending_drain is None
101+
102+
async def fake_reset(c: DqliteConnection) -> bool:
103+
return False
104+
105+
pool._reset_connection = fake_reset # type: ignore[method-assign]
106+
107+
async def noop() -> None:
108+
return
109+
110+
pool._release_reservation = noop # type: ignore[method-assign]
111+
112+
async def fake_close() -> None:
113+
return
114+
115+
with patch.object(conn, "close", new=fake_close):
116+
await pool._release(conn)
117+
118+
assert conn._pool_released is True
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_release_pending_drain_failure_does_not_block_release() -> None:
123+
"""If the drain task itself raises, ``_release`` must still
124+
complete (set ``_pool_released``, release the reservation). The
125+
fix uses ``contextlib.suppress(BaseException)`` around the
126+
shielded await so the drain's exception is observed and discarded
127+
— ``_release`` is a cleanup path; the original cause is what the
128+
user cares about."""
129+
pool = ConnectionPool(["localhost:9001"], min_size=0, max_size=1)
130+
pool._size = 1
131+
conn = DqliteConnection("localhost:9001")
132+
conn._protocol = object() # type: ignore[assignment]
133+
conn._db_id = 1
134+
conn._in_transaction = True
135+
136+
async def failing_drain() -> None:
137+
raise RuntimeError("drain failed")
138+
139+
conn._pending_drain = asyncio.create_task(failing_drain())
140+
141+
async def fake_reset(c: DqliteConnection) -> bool:
142+
return False
143+
144+
pool._reset_connection = fake_reset # type: ignore[method-assign]
145+
146+
async def noop() -> None:
147+
return
148+
149+
pool._release_reservation = noop # type: ignore[method-assign]
150+
151+
async def fake_close() -> None:
152+
return
153+
154+
with patch.object(conn, "close", new=fake_close):
155+
await pool._release(conn)
156+
157+
assert conn._pool_released is True
158+
# The drain task is done — the suppression observed the exception.
159+
assert conn._pending_drain.done()

0 commit comments

Comments
 (0)