@@ -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
10071160class 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