Skip to content

Commit cc1c21b

Browse files
Clear _closed_event in pool.close's finally so the signalled wakeup event is GC'd alongside the pool's other once-used loop primitives
The pool's close path nulls every other once-used loop-bound field after use (``_async_conn``, ``_protocol``, ``_connect_lock``, ``_op_lock``, ``_loop_ref``); ``_closed_event`` was the lone holdout. Once ``set()`` runs in the close finally, the at-capacity wakeup event has no remaining waiters (the pool is ``_closed=True``; new acquirers short-circuit before parking) — yet the reference stayed pinned for the lifetime of the pool object, keeping the event's captured loop reachable through any SA engine diagnostic accessor / module-level cache that outlives ``close()``. ``_close_done`` deliberately stays — the second-caller ``if self._closed:`` arm awaits it, and clearing in that arm would be TOCTOU. Clearing only ``_closed_event`` in the first caller's finally matches the project's "null every once-used loop-bound field" discipline without touching the second-caller drain visibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8a74ac commit cc1c21b

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/dqliteclient/pool.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,19 @@ async def close(self) -> None:
14321432
await self._drain_idle()
14331433
finally:
14341434
self._close_done.set()
1435+
# Drop the signalled-and-now-useless wakeup event
1436+
# so it can be GC'd alongside the pool's other
1437+
# once-used loop-bound primitives (``_async_conn``,
1438+
# ``_protocol``, ``_connect_lock``, ``_op_lock``,
1439+
# ``_loop_ref`` are all nulled at the end of their
1440+
# close paths). ``_close_done`` stays — the
1441+
# second-caller arm above awaits it; clearing in
1442+
# the second-caller arm would be TOCTOU and clearing
1443+
# here while siblings might still be parked is wrong.
1444+
# ``_closed_event`` has no remaining waiters once
1445+
# ``set()`` runs (the pool is closed; new acquirers
1446+
# short-circuit before parking) so it is safe to drop.
1447+
self._closed_event = None
14351448

14361449
# In-use connections are closed by acquire()'s cleanup when they
14371450
# return — the else branch checks _closed and closes instead of
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Pin: ``ConnectionPool.close()`` clears the at-capacity wakeup
2+
event ``self._closed_event`` after the drain completes.
3+
4+
The pool's close path nulls every other once-used loop-bound
5+
field after use (``_async_conn``, ``_protocol``, ``_connect_lock``,
6+
``_op_lock``, ``_loop_ref``); ``_closed_event`` is the lone
7+
holdout. A signalled-and-now-useless ``asyncio.Event`` bound to
8+
the pool's loop is held until the pool object itself is GC'd,
9+
keeping the loop reference alive past the user's intended
10+
lifetime when the pool object outlives ``close()`` (SA engine
11+
diagnostic accessors, module-level state, etc.).
12+
13+
``_close_done`` is deliberately NOT cleared — see the issue's
14+
"Subtle" paragraph: clearing in the second-caller branch is
15+
TOCTOU; clearing only in the first caller's finally is what's
16+
needed, but the second-caller arm awaits the same event so we
17+
keep its lifetime tied to the pool's own.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from unittest.mock import MagicMock
23+
24+
import pytest
25+
26+
from dqliteclient.cluster import ClusterClient
27+
from dqliteclient.pool import ConnectionPool
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_pool_close_clears_closed_event_after_drain() -> None:
32+
cluster = MagicMock(spec=ClusterClient)
33+
pool = ConnectionPool(
34+
addresses=["localhost:9001"],
35+
min_size=0,
36+
max_size=2,
37+
timeout=1.0,
38+
cluster=cluster,
39+
)
40+
# Force the wakeup event to exist (acquire normally lazy-creates
41+
# it via ``_get_closed_event`` when a parker is going to sleep).
42+
pool._get_closed_event()
43+
assert pool._closed_event is not None
44+
45+
await pool.close()
46+
47+
# Mirror the discipline applied to every other once-used
48+
# loop-bound field on the close path: drop the now-useless
49+
# event reference so it can be GC'd alongside the pool's
50+
# other loop primitives. ``_close_done`` stays — the
51+
# second-caller arm awaits it.
52+
assert pool._closed_event is None, (
53+
"ConnectionPool.close() must clear _closed_event after "
54+
"set() — every other loop-bound field on the close path "
55+
"is nulled; this is the lone exception, leaving a "
56+
"signalled Event pinned to the pool's loop."
57+
)

0 commit comments

Comments
 (0)