Skip to content

Commit 7c8188e

Browse files
Reorder DqliteConnection.close fork branch ahead of pool-released short-circuit
Move the fork-detection branch (``if _current_pid != self._creator_pid:``) to run BEFORE the ``if self._pool_released: return`` short-circuit. Previously the short-circuit pre-empted the fork-cleanup branch for connections inherited across a fork while marked pool-released (the SA pool checkin path), leaving the inherited ``_pending_drain`` asyncio.Task — bound to the parent's event loop — alive in the child. Child GC then tripped "Task was destroyed but it is pending" and the coroutine frame retained the inherited writer transport (and FD) indefinitely. The reorder applies the same field-nulling already documented in the fork branch (per the original fix) to the pool-released-cross-fork case, while preserving the same-process idempotency contract (``_pool_released=True`` in the same process still no-ops).
1 parent 0ca0eae commit 7c8188e

2 files changed

Lines changed: 154 additions & 16 deletions

File tree

‎src/dqliteclient/connection.py‎

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,22 +1687,6 @@ async def close(self) -> None:
16871687
is already closed after ``writer.close()`` — the remaining
16881688
wait is best-effort cleanup, not correctness-critical.
16891689
"""
1690-
# Pool-released connections are never in_use for close(); their
1691-
# close path has already run under pool ownership.
1692-
if self._pool_released:
1693-
return
1694-
# Flip the public predicate even if we early-return below
1695-
# (fork branch / already-disconnected path). ``closed`` is
1696-
# the "close() has been called" marker; ``is_connected``
1697-
# remains the "transport alive" marker.
1698-
self._closed = True
1699-
# Detach the unclosed-warning finalizer — orderly shutdown
1700-
# owns the cleanup; firing the warning now would be a false
1701-
# positive.
1702-
self._closed_flag[0] = True
1703-
if self._finalizer is not None:
1704-
self._finalizer.detach()
1705-
self._finalizer = None
17061690
# Fork-after-init: the inherited socket FD is shared with the
17071691
# parent. ``writer.close()`` would send a FIN that the parent
17081692
# still depends on. Flip the local state to closed without
@@ -1712,6 +1696,16 @@ async def close(self) -> None:
17121696
# silent on already-closed inputs — silently no-oping in the
17131697
# child preserves that contract for the GC / __del__ path that
17141698
# commonly drives close in a forked worker.
1699+
#
1700+
# NB: this branch runs BEFORE the ``_pool_released`` short-
1701+
# circuit below. The previous ordering pre-empted the fork
1702+
# cleanup for pool-released connections inherited across a
1703+
# fork (SA pool checkin path), leaving the inherited
1704+
# ``_pending_drain`` Task — bound to the parent's loop —
1705+
# alive in the child until GC, where ``Task.__del__`` later
1706+
# tripped "Task was destroyed but it is pending" and the
1707+
# coroutine frame retained the inherited writer transport
1708+
# (and FD) indefinitely.
17151709
if _current_pid != self._creator_pid:
17161710
# Drop every reference that crosses the fork boundary so
17171711
# GC in the child doesn't keep parent-loop primitives or
@@ -1724,6 +1718,11 @@ async def close(self) -> None:
17241718
# transaction / savepoint / loop bookkeeping also keeps
17251719
# the child's view of the connection self-consistent —
17261720
# ``in_transaction`` reads False for a closed connection.
1721+
self._closed = True
1722+
self._closed_flag[0] = True
1723+
if self._finalizer is not None:
1724+
self._finalizer.detach()
1725+
self._finalizer = None
17271726
self._protocol = None
17281727
self._db_id = None
17291728
self._pending_drain = None
@@ -1734,6 +1733,23 @@ async def close(self) -> None:
17341733
self._has_untracked_savepoint = False
17351734
self._bound_loop_ref = None
17361735
return
1736+
# Pool-released connections are never in_use for close(); their
1737+
# close path has already run under pool ownership. The same-
1738+
# process idempotency contract: subsequent calls no-op.
1739+
if self._pool_released:
1740+
return
1741+
# Flip the public predicate even if we early-return below
1742+
# (already-disconnected path). ``closed`` is the "close() has
1743+
# been called" marker; ``is_connected`` remains the
1744+
# "transport alive" marker.
1745+
self._closed = True
1746+
# Detach the unclosed-warning finalizer — orderly shutdown
1747+
# owns the cleanup; firing the warning now would be a false
1748+
# positive.
1749+
self._closed_flag[0] = True
1750+
if self._finalizer is not None:
1751+
self._finalizer.detach()
1752+
self._finalizer = None
17371753
# Run the in-use guard BEFORE the ``_protocol is None``
17381754
# early-return so a concurrent ``connect()`` racing with
17391755
# ``close()`` surfaces as ``InterfaceError`` instead of a silent
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Pin: ``DqliteConnection.close()`` on a pool-released connection
2+
in a forked child runs the fork-cleanup branch (nulling
3+
``_pending_drain`` and the rest of the parent-loop-bound state)
4+
rather than taking the ``_pool_released`` short-circuit.
5+
6+
Background: the SA pool's checkin path leaves a conn marked
7+
``_pool_released=True`` so a follow-up ``close()`` is a same-
8+
process no-op. The fork branch (per ISSUE-944) nulls inherited
9+
parent-loop state (``_pending_drain`` Task is the canonical leak
10+
shape). Previously the ``_pool_released`` short-circuit ran
11+
BEFORE the fork branch, pre-empting the cleanup — pool-released
12+
conns inherited across fork kept their parent-loop ``_pending_drain``
13+
references alive in the child, tripping
14+
"Task was destroyed but it is pending" warnings at child GC and
15+
retaining the inherited writer transport (and FD) indefinitely.
16+
17+
The fix reorders ``close()`` so the fork detection runs first,
18+
regardless of ``_pool_released``. Same-process idempotency
19+
(``_pool_released=True`` -> no-op) is preserved.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
import contextlib
26+
import os
27+
28+
import pytest
29+
30+
import dqliteclient.connection as _conn_mod
31+
from dqliteclient.connection import DqliteConnection
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_pool_released_close_in_forked_child_nulls_pending_drain(
36+
monkeypatch: pytest.MonkeyPatch,
37+
) -> None:
38+
"""Set ``_pool_released=True`` and a sentinel
39+
``_pending_drain`` Task, simulate fork by monkeypatching
40+
``_current_pid``, then call ``close()``. The fork branch MUST
41+
null ``_pending_drain`` even though the connection is pool-
42+
released — pre-empting that with the short-circuit was the
43+
documented leak shape.
44+
"""
45+
conn = DqliteConnection("leader:9001")
46+
conn._pool_released = True
47+
sentinel_task = asyncio.create_task(asyncio.sleep(60))
48+
conn._pending_drain = sentinel_task
49+
conn._in_transaction = True
50+
conn._tx_owner = asyncio.current_task()
51+
52+
monkeypatch.setattr(_conn_mod, "_current_pid", os.getpid() + 1)
53+
try:
54+
await conn.close()
55+
finally:
56+
sentinel_task.cancel()
57+
with contextlib.suppress(asyncio.CancelledError):
58+
await sentinel_task
59+
60+
# The fork branch must null _pending_drain (matching the symmetric
61+
# fork branch nullification). The same set of fields is nulled.
62+
assert conn._pending_drain is None, (
63+
"DqliteConnection.close() in a forked child with _pool_released=True "
64+
"must null _pending_drain — the prior ordering pre-empted the fork "
65+
"branch and left the parent-loop Task referenced through child GC."
66+
)
67+
assert conn._in_transaction is False
68+
assert conn._tx_owner is None
69+
assert conn._bound_loop_ref is None
70+
assert conn._protocol is None
71+
assert conn._db_id is None
72+
assert conn._closed is True
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_pool_released_close_same_process_is_still_no_op() -> None:
77+
"""Regression guard: same-process ``close()`` on a pool-released
78+
conn must still no-op (the SA pool checkin idempotency contract).
79+
The reorder must NOT scrub state when same-process +
80+
``_pool_released=True``.
81+
"""
82+
conn = DqliteConnection("leader:9001")
83+
conn._pool_released = True
84+
# Plant a sentinel _pending_drain to detect any scrubbing.
85+
sentinel_task = asyncio.create_task(asyncio.sleep(60))
86+
conn._pending_drain = sentinel_task
87+
conn._in_transaction = True
88+
try:
89+
await conn.close()
90+
91+
# Same-process + pool-released: nothing must be scrubbed.
92+
assert conn._pending_drain is sentinel_task
93+
assert conn._in_transaction is True
94+
finally:
95+
sentinel_task.cancel()
96+
with contextlib.suppress(asyncio.CancelledError):
97+
await sentinel_task
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_non_pool_released_fork_branch_still_works(
102+
monkeypatch: pytest.MonkeyPatch,
103+
) -> None:
104+
"""Regression guard: the fork branch must continue to fire for
105+
connections that are NOT pool-released (the original
106+
ISSUE-944 path)."""
107+
conn = DqliteConnection("leader:9001")
108+
sentinel_task = asyncio.create_task(asyncio.sleep(60))
109+
conn._pending_drain = sentinel_task
110+
conn._in_transaction = True
111+
112+
monkeypatch.setattr(_conn_mod, "_current_pid", os.getpid() + 1)
113+
try:
114+
await conn.close()
115+
finally:
116+
sentinel_task.cancel()
117+
with contextlib.suppress(asyncio.CancelledError):
118+
await sentinel_task
119+
120+
assert conn._pending_drain is None
121+
assert conn._in_transaction is False
122+
assert conn._closed is True

0 commit comments

Comments
 (0)