Skip to content

Commit cdd7b74

Browse files
Distinguish drain-completed from first-caller-exited in pool.close sibling-signalling
``ConnectionPool.close()`` set ``_close_done`` unconditionally in the finally so siblings parked on ``_close_done.wait()`` did not deadlock under cancel. But the same event is consumed by the second-caller arm as "drain completed" — when an outer cancel (``asyncio.timeout(pool.close())`` under SIGTERM-with- budget) interrupted the first caller's ``_drain_idle``, the second caller observed the event set and returned "successfully" against a pool whose idle queue was still non-empty. Add a ``_drain_complete`` flag set after ``_drain_idle()`` returns normally (NOT in the finally). The second-caller arm re-checks the flag after ``_close_done.wait()`` returns and runs a best-effort ``asyncio.shield(_drain_remaining_after_cancel())`` sweep when the first caller bailed mid-drain — so the documented "queue drained on close" promise is upheld even when the first caller is interrupted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b69f8d4 commit cdd7b74

1 file changed

Lines changed: 26 additions & 0 deletions

File tree

src/dqliteclient/pool.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,16 @@ def __init__(
279279
self._closed = False
280280
self._closed_event: asyncio.Event | None = None
281281
self._close_done: asyncio.Event | None = None
282+
# Distinguish "first caller exited the drain phase" (signalled
283+
# via ``_close_done.set()`` in the close finally, so siblings
284+
# do not deadlock under cancel) from "drain ran to completion".
285+
# A second caller parking on ``_close_done.wait()`` and observing
286+
# the event set must NOT assume the queue was drained — the
287+
# first caller may have been interrupted mid-drain by an outer
288+
# cancel. Re-checking this flag after wait() returns lets the
289+
# second caller run a best-effort sweep so the documented
290+
# "queue drained" promise is upheld.
291+
self._drain_complete: bool = False
282292
self._initialized = False
283293
# Fork-after-init is unsupported: pooled connections hold
284294
# shared TCP sockets and asyncio primitives bound to the
@@ -1436,6 +1446,17 @@ async def close(self) -> None:
14361446
if self._closed:
14371447
if self._close_done is not None:
14381448
await self._close_done.wait()
1449+
# If the FIRST caller's drain was interrupted mid-flight
1450+
# by an outer cancel (``asyncio.timeout(pool.close())``
1451+
# under SIGTERM-with-budget), ``_close_done`` was set in
1452+
# the finally even though the queue is still under-
1453+
# drained. Run a best-effort sweep so the second caller's
1454+
# ``await pool.close()`` returns against an empty queue
1455+
# rather than the documented "drain completed" contract
1456+
# being silently broken.
1457+
if not self._drain_complete:
1458+
with contextlib.suppress(asyncio.CancelledError):
1459+
await asyncio.shield(self._drain_remaining_after_cancel())
14391460
return
14401461
# Publish the drain-done event BEFORE flipping the closed flag
14411462
# so any second caller observing ``_closed=True`` is guaranteed
@@ -1456,6 +1477,11 @@ async def close(self) -> None:
14561477
if self._closed_event is not None:
14571478
self._closed_event.set()
14581479
await self._drain_idle()
1480+
# Drain completed normally. Set BEFORE the finally so a
1481+
# cancel landing between the drain return and the flag
1482+
# assignment leaves ``_drain_complete=False`` — siblings
1483+
# then run the best-effort sweep above.
1484+
self._drain_complete = True
14591485
finally:
14601486
self._close_done.set()
14611487
# Drop the signalled-and-now-useless wakeup event

0 commit comments

Comments
 (0)