Skip to content

Commit e968286

Browse files
Tighten dbapi sync Connection's GC-finalize callback
``Connection._cleanup_loop_thread`` is the safety net for users who forget to call ``close()``. The explicit-close paths (``Connection.close`` / ``Connection.force_close_transport``) disarm the inner ``DqliteConnection``'s ResourceWarning finalizer and reap any pending ``_invalidate`` drain task before stopping the loop; the GC-finalize callback did neither and was vulnerable to interpreter- shutdown module-globals teardown. - Disarm the inner finalizer + flip its closed flag so one leaked outer ``Connection`` no longer surfaces as two ``ResourceWarning`` stderr lines (outer + inner) for the same socket. - Bounded-resnapshot reap of ``inner._pending_drain`` before ``loop.stop`` so the Task is cancelled / observed before ``loop.close()`` would drop it (CPython ``BaseEventLoop.close`` does NOT cancel pending tasks; ``Task.__del__`` in PENDING state writes "Task was destroyed but it is pending" to stderr via the default exception handler — bypassing ``warnings.catch_warnings``). - Late-publish the inner via a ``self._inner_finalize_handle`` box (mutated when ``_get_async_connection`` builds the inner) so the finalize, which is registered in ``_ensure_loop`` before the inner exists, can reach it via ``weakref.ref(inner)`` without strong- pinning it (avoids an outer→inner→outer reference cycle through the outer's ``__dict__``). - Kwarg-default capture for ``warnings`` / ``logger`` / ``contextlib`` so ``Py_FinalizeEx`` phase-3 ``PyImport_Cleanup`` setting module globals to ``None`` cannot raise an unraisable-hook traceback from the finalize body. ``get_current_pid`` is intentionally read at call time (the fork-test fixture patches it) but wrapped in a broad except so the shutdown-time ``None`` short-circuits silently. Mirrors the discipline already in ``Connection.force_close_transport`` and the dbapi-aio sibling. Bundles all three GC-finalize findings as one cohesive change so the finalize signature evolves once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e78deb commit e968286

3 files changed

Lines changed: 524 additions & 27 deletions

File tree

src/dqlitedbapi/connection.py

Lines changed: 211 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,32 @@ def _cleanup_loop_thread(
902902
address: str,
903903
creator_pid: int,
904904
close_timeout: float = _LOOP_THREAD_JOIN_FALLBACK_SECONDS,
905+
inner_handle: list[Any] | None = None,
906+
*,
907+
# Bind PURE-MODULE globals (``warnings`` / ``logger`` /
908+
# ``contextlib``) as keyword-only default args so the
909+
# ``Py_FinalizeEx`` phase-3 module-globals-None-set teardown
910+
# (documented in ``Lib/weakref.py::_exitfunc`` /
911+
# ``Python/pylifecycle.c::Py_FinalizeEx``) cannot replace the
912+
# names this body dereferences with ``None`` between function
913+
# definition and finalizer invocation. Stdlib precedent:
914+
# ``Lib/tempfile.py::_TemporaryFileWrapper.close`` captures
915+
# ``closer`` the same way; ``multiprocessing.util.Finalize`` is
916+
# the same pattern. Names captured at definition time — if the
917+
# module re-binds any of them after definition (none do today;
918+
# not even test fixtures should), the captured value is stale.
919+
#
920+
# ``get_current_pid`` is INTENTIONALLY NOT captured: tests
921+
# (``test_cleanup_loop_thread_finalizer_fork_safe.py``) patch
922+
# the module-level name via ``unittest.mock.patch`` to simulate
923+
# a forked child, and a kwarg-default capture would freeze the
924+
# production value past the patch. The runtime dereference
925+
# below is wrapped in a ``try`` block that catches the
926+
# shutdown-time ``TypeError`` ('NoneType' is not callable) so
927+
# the shutdown-safety goal is still met for ``get_current_pid``.
928+
_warnings: Any = warnings,
929+
_logger: Any = logger,
930+
_contextlib: Any = contextlib,
905931
) -> None:
906932
"""Stop the background event loop and join its thread.
907933
@@ -911,6 +937,18 @@ def _cleanup_loop_thread(
911937
rather than a direct reference to self to decide whether to emit
912938
a ``ResourceWarning``.
913939
940+
``inner_handle`` is a 0-or-1-element list mutated by
941+
``Connection._publish_inner_finalize_handle`` once
942+
``self._async_conn`` is built. When populated, the single element
943+
is a ``weakref.ref`` to the inner ``DqliteConnection``. The box
944+
indirection is the canonical idiom for late-publishing a value
945+
into a ``weakref.finalize``'s captured args (the finalize captures
946+
args by reference at registration time; mutating a captured list
947+
is observed at call time). A ``weakref.ref`` avoids strong-pinning
948+
the inner from the finalize's args (which would create a
949+
reference cycle: outer → ``_async_conn`` → inner; finalize args →
950+
inner directly; cycle through the outer's ``__dict__``).
951+
914952
``close_timeout`` mirrors the operator's ``Connection._close_timeout``
915953
so the finalizer's join budget matches the graceful ``close()`` and
916954
``force_close_transport()`` paths. Captured positionally at finalize
@@ -934,12 +972,54 @@ def _cleanup_loop_thread(
934972
fork). Mirror the discipline of ``Connection._check_thread`` /
935973
``DqliteConnection.close`` / ``Pool.close``: pid-mismatch →
936974
no-op.
975+
976+
Shutdown-safety: when CPython's ``Py_FinalizeEx`` reaches phase 3
977+
(cycle-collect after ``atexit``), ``PyImport_Cleanup`` walks
978+
``sys.modules`` and sets every module's globals to ``None``. A
979+
finalize that dereferences imported names by NAME would then see
980+
``None`` for ``get_current_pid`` / ``warnings`` / ``logger`` /
981+
``contextlib`` and raise ``TypeError`` / ``AttributeError`` —
982+
emitting an unraisable-hook traceback that buries whatever
983+
actually caused the shutdown. Names are captured as kwarg
984+
defaults at definition time to dodge this teardown phase. If
985+
any of the captured names ends up ``None`` at call time anyway
986+
(exotic reload paths), the body short-circuits silently.
937987
"""
938-
if get_current_pid() != creator_pid:
988+
# Read ``get_current_pid`` from module globals at call time so
989+
# the test fixture's ``patch("dqlitedbapi.connection."
990+
# "get_current_pid", ...)`` is observed. Wrap in a broad except
991+
# so the ``Py_FinalizeEx`` phase-3 ``get_current_pid = None``
992+
# teardown surfaces as a silent no-op (not an unraisable-hook
993+
# ``TypeError: 'NoneType' object is not callable`` traceback).
994+
try:
995+
current_pid = get_current_pid()
996+
except Exception:
997+
# Module global ``get_current_pid`` may be ``None`` under
998+
# interpreter shutdown; return silently rather than emit an
999+
# unraisable-hook traceback that buries whatever caused the
1000+
# shutdown.
1001+
return
1002+
if current_pid != creator_pid:
9391003
# Forked child. The captured loop/thread/closed_flag belong
9401004
# to the parent process. Skip cleanup entirely — both the
9411005
# warning emission and the loop/thread teardown.
9421006
return
1007+
# Resolve the inner ``DqliteConnection`` if the late-publish box
1008+
# has been populated. Use a weakref to avoid strong-pinning. If
1009+
# the inner has already been GC'd (e.g. the outer's
1010+
# ``self._async_conn = None`` arm ran before the outer itself
1011+
# was reclaimed), ``inner_ref()`` returns ``None`` and we skip
1012+
# the disarm / drain reap entirely.
1013+
inner: Any = None
1014+
if inner_handle:
1015+
inner_ref = inner_handle[0]
1016+
if inner_ref is not None:
1017+
inner_obj = inner_ref() if callable(inner_ref) else None
1018+
# Skip the inner-targeted disarm if the inner is already
1019+
# closed (``_closed_flag[0] is True``): no false-positive
1020+
# warning to suppress and no pending drain to reap.
1021+
if inner_obj is not None:
1022+
inner = inner_obj
9431023
# Wrap the entire body in try/finally so the loop/thread teardown
9441024
# ALWAYS runs, regardless of whether the warning emission raises.
9451025
# Under ``pytest -W error::ResourceWarning`` the
@@ -952,25 +1032,95 @@ def _cleanup_loop_thread(
9521032
# open socket — ironically *amplifying* the leak the warning was
9531033
# supposed to surface.
9541034
try:
955-
if closed_flag[0] is False:
956-
# User never called close() → leak warning (matches stdlib
957-
# sqlite3). The narrow ``RuntimeError`` suppression here is
958-
# for the specific interpreter-shutdown race where the
959-
# warnings module's own finalization is mid-teardown; any
960-
# other exception (including ResourceWarning being
961-
# converted to a raise under -W error) is allowed to
962-
# propagate through the surrounding finally so the
963-
# finalizer's reporter (sys.unraisablehook) still surfaces
964-
# it while the cleanup completes.
965-
with contextlib.suppress(RuntimeError):
966-
warnings.warn(
1035+
# User never called close() → leak warning (matches stdlib
1036+
# sqlite3). The narrow ``RuntimeError`` suppression here is
1037+
# for the specific interpreter-shutdown race where the
1038+
# warnings module's own finalization is mid-teardown; any
1039+
# other exception (including ResourceWarning being
1040+
# converted to a raise under -W error) is allowed to
1041+
# propagate through the surrounding finally so the
1042+
# finalizer's reporter (sys.unraisablehook) still surfaces
1043+
# it while the cleanup completes. The
1044+
# ``_warnings is not None and _contextlib is not None``
1045+
# guard handles the rare interpreter-reload path where the
1046+
# kwarg-default capture itself sees ``None`` mid-shutdown.
1047+
if closed_flag[0] is False and _warnings is not None and _contextlib is not None:
1048+
with _contextlib.suppress(RuntimeError):
1049+
_warnings.warn(
9671050
f"Connection(address={address!r}) was garbage-collected "
9681051
f"without close(); cleaning up event-loop thread. Call "
9691052
f"Connection.close() explicitly to avoid this warning.",
9701053
ResourceWarning,
9711054
stacklevel=2,
9721055
)
9731056
finally:
1057+
# Disarm the inner client's ``_connection_unclosed_warning``
1058+
# finalizer BEFORE the loop teardown, mirroring the discipline
1059+
# at ``force_close_transport`` lines 2148-2155. Without this,
1060+
# the same GC sweep that fired this finalize would also
1061+
# eventually fire the inner's finalizer, emitting a misleading
1062+
# second ResourceWarning ("DqliteConnection ... was garbage-
1063+
# collected without await close()") for the SAME socket — one
1064+
# leak surfacing as two stderr lines. Mirrors what the explicit
1065+
# close paths already do at close.py / force_close_transport.
1066+
if inner is not None and _contextlib is not None:
1067+
inner_closed_flag = getattr(inner, "_closed_flag", None)
1068+
if isinstance(inner_closed_flag, list) and inner_closed_flag:
1069+
inner_closed_flag[0] = True
1070+
inner_finalizer = getattr(inner, "_finalizer", None)
1071+
if inner_finalizer is not None:
1072+
with _contextlib.suppress(Exception):
1073+
inner_finalizer.detach()
1074+
with _contextlib.suppress(Exception):
1075+
inner._finalizer = None
1076+
# Reap any pending invalidation-drain task on the inner
1077+
# BEFORE ``loop.stop`` lands, mirroring the bounded-
1078+
# resnapshot block in ``force_close_transport`` at
1079+
# ``connection.py:2156-2219``. Without this reap, the
1080+
# task survives ``loop.close()`` (CPython
1081+
# ``BaseEventLoop.close`` does NOT cancel pending tasks),
1082+
# ``Task.__del__`` fires with state PENDING, and asyncio
1083+
# writes "Task was destroyed but it is pending" to stderr
1084+
# via its default exception handler — bypassing
1085+
# ``warnings.catch_warnings`` and surfacing as a third
1086+
# stderr line per GC-leaked sync ``Connection``. FIFO of
1087+
# the ``call_soon_threadsafe`` ready queue ensures the
1088+
# cancel callbacks run before the queued ``loop.stop``.
1089+
if not loop.is_closed():
1090+
resnapshot_cap = 3
1091+
for _attempt in range(resnapshot_cap):
1092+
pending = getattr(inner, "_pending_drain", None)
1093+
with _contextlib.suppress(Exception):
1094+
inner._pending_drain = None
1095+
if pending is None or pending.done():
1096+
break
1097+
1098+
def _cancel_and_observe(target: asyncio.Task[Any]) -> None:
1099+
target.cancel()
1100+
1101+
def _observe(t: asyncio.Task[Any]) -> None:
1102+
if not t.cancelled():
1103+
with _contextlib.suppress(BaseException):
1104+
t.exception()
1105+
1106+
target.add_done_callback(_observe)
1107+
1108+
with _contextlib.suppress(RuntimeError):
1109+
loop.call_soon_threadsafe(_cancel_and_observe, pending)
1110+
else:
1111+
# Cap exhausted: final defensive null-out. Mirrors
1112+
# the ``force_close_transport`` cap-exhausted
1113+
# branch. Operator-visible warning only on the
1114+
# pathological feedback-loop case.
1115+
with _contextlib.suppress(Exception):
1116+
inner._pending_drain = None
1117+
if _logger is not None:
1118+
_logger.warning(
1119+
"Connection._cleanup_loop_thread: inner._pending_drain still "
1120+
"set after %d re-snapshot iterations; cancelling residual task "
1121+
"to avoid 'Task was destroyed but it is pending' at GC.",
1122+
resnapshot_cap,
1123+
)
9741124
# Narrow suppression to the specific exceptions loop/thread
9751125
# teardown can legitimately raise during finalization. Wider
9761126
# ``except Exception: pass`` would hide programmer bugs like a
@@ -984,24 +1134,27 @@ def _cleanup_loop_thread(
9841134
# operators triaging finalize-time anomalies; the
9851135
# ``pragma: no cover`` stays because the path is genuinely
9861136
# racy and not reproducible in tests.
987-
logger.debug(
988-
"Connection._cleanup_loop_thread: loop.call_soon_threadsafe "
989-
"raised RuntimeError (loop likely closed mid-call)",
990-
exc_info=True,
991-
)
992-
with contextlib.suppress(RuntimeError):
993-
thread.join(timeout=max(close_timeout, _LOOP_THREAD_JOIN_MIN_SECONDS))
1137+
if _logger is not None:
1138+
_logger.debug(
1139+
"Connection._cleanup_loop_thread: loop.call_soon_threadsafe "
1140+
"raised RuntimeError (loop likely closed mid-call)",
1141+
exc_info=True,
1142+
)
1143+
if _contextlib is not None:
1144+
with _contextlib.suppress(RuntimeError):
1145+
thread.join(timeout=max(close_timeout, _LOOP_THREAD_JOIN_MIN_SECONDS))
9941146
try:
9951147
if not loop.is_closed():
9961148
loop.close()
9971149
except RuntimeError: # pragma: no cover - race: loop restarted mid-finalize
9981150
# Raised if the loop was somehow restarted mid-finalization.
9991151
# Same operator-visibility rationale as above.
1000-
logger.debug(
1001-
"Connection._cleanup_loop_thread: loop.close() raised "
1002-
"RuntimeError (loop likely restarted mid-finalize)",
1003-
exc_info=True,
1004-
)
1152+
if _logger is not None:
1153+
_logger.debug(
1154+
"Connection._cleanup_loop_thread: loop.close() raised "
1155+
"RuntimeError (loop likely restarted mid-finalize)",
1156+
exc_info=True,
1157+
)
10051158

10061159

10071160
class Connection:
@@ -1211,6 +1364,17 @@ def __init__(
12111364
# close() flips to True. Using a list avoids the finalizer
12121365
# closing over ``self`` and preventing GC.
12131366
self._closed_flag: list[bool] = [False]
1367+
# Box for late-publishing the inner ``DqliteConnection``
1368+
# handle into ``_cleanup_loop_thread``'s captured args. The
1369+
# finalize captures THIS list by reference at registration
1370+
# time (inside ``_ensure_loop``, before the inner is built);
1371+
# ``_publish_inner_finalize_handle`` mutates the slot to a
1372+
# ``weakref.ref(inner)`` once the inner is built so the
1373+
# finalize body can reach it without strong-pinning. Cleared
1374+
# back to ``[]`` when the inner is nulled on every explicit
1375+
# close path so the finalize does not observe a dead-weakref-
1376+
# to-already-disarmed-inner state.
1377+
self._inner_finalize_handle: list[Any] = []
12141378
self._finalizer: weakref.finalize[Any, Any] | None = None
12151379
# Track outstanding cursors weakly so Connection.close() can
12161380
# scrub their state (stdlib sqlite3 cascades; buffered fetches
@@ -1284,6 +1448,14 @@ def _ensure_loop(self) -> asyncio.AbstractEventLoop:
12841448
# Connection alive. Capture primitives only. The
12851449
# closed-flag list is mutated by close() so the
12861450
# finalizer knows whether to emit a leak warning.
1451+
# ``_inner_finalize_handle`` is a list captured by
1452+
# reference; ``_publish_inner_finalize_handle`` will
1453+
# populate it with ``weakref.ref(inner)`` once
1454+
# ``self._async_conn`` is built so the finalize can
1455+
# disarm the inner's ResourceWarning finalizer and
1456+
# reap any pending ``_invalidate`` drain task BEFORE
1457+
# ``loop.stop`` lands. See ``_cleanup_loop_thread``'s
1458+
# docstring for the boxed-handle rationale.
12871459
self._finalizer = weakref.finalize(
12881460
self,
12891461
_cleanup_loop_thread,
@@ -1293,6 +1465,7 @@ def _ensure_loop(self) -> asyncio.AbstractEventLoop:
12931465
self._address,
12941466
self._creator_pid,
12951467
self._close_timeout,
1468+
self._inner_finalize_handle,
12961469
)
12971470
return self._loop
12981471

@@ -1777,6 +1950,19 @@ async def _get_async_connection(self) -> DqliteConnection:
17771950
attempt_timeout=getattr(self, "_attempt_timeout", None),
17781951
dial_func=getattr(self, "_dial_func", None),
17791952
)
1953+
# Late-publish the inner handle into the
1954+
# ``_cleanup_loop_thread`` finalize's captured args. The
1955+
# finalize was registered at ``_ensure_loop`` time before
1956+
# the inner existed; mutating the captured list slot is
1957+
# the canonical late-publish idiom for ``weakref.finalize``
1958+
# (the finalize captures args by reference at registration
1959+
# time). ``weakref.ref(inner)`` avoids strong-pinning the
1960+
# inner from the finalize args — without the weakref, the
1961+
# finalize args would form an outer→inner→outer reference
1962+
# cycle through the outer's ``__dict__`` that prevented
1963+
# the outer from being GC'd.
1964+
with contextlib.suppress(Exception):
1965+
self._inner_finalize_handle[:] = [weakref.ref(self._async_conn)]
17801966

17811967
return self._async_conn
17821968

0 commit comments

Comments
 (0)