Skip to content

Commit 45a67a1

Browse files
Add public force_close_transport hook for non-greenlet finalize paths
GC sweeps and atexit handlers can run outside any event loop or greenlet context. The async close machinery requires both, so a last-resort cleanup hook needs to bypass it and tear down the underlying transport synchronously. Without one, downstream adapters (the SA dialect) reach into private internals across package boundaries — and silently get the wrong attribute chain. Expose a public force_close_transport on AsyncConnection that walks self._async_conn._protocol._writer.close() with full null-checks. Idempotent. Never raises (writer.close exceptions absorbed at debug). Five tests cover the normal path plus four degenerate cases (no inner conn, no protocol, no writer, writer.close raises). Bump to 0.1.4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 477321e commit 45a67a1

4 files changed

Lines changed: 123 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "dqlite-dbapi"
7-
version = "0.1.3"
7+
version = "0.1.4"
88
description = "PEP 249 (DB-API 2.0) compliant interface for dqlite"
99
readme = "README.md"
1010
requires-python = ">=3.13"

src/dqlitedbapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
sqlite_version_info = (3, 35, 0)
7777
sqlite_version = ".".join(str(v) for v in sqlite_version_info)
7878

79-
__version__ = "0.1.3"
79+
__version__ = "0.1.4"
8080

8181
__all__ = [ # noqa: RUF022 - grouped by PEP 249 section, not alphabetical
8282
# Module attributes

src/dqlitedbapi/aio/connection.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,45 @@ async def close(self) -> None:
358358
self._op_lock = None
359359
self._loop_ref = None
360360

361+
def force_close_transport(self) -> None:
362+
"""Synchronously tear down the underlying socket transport.
363+
364+
Last-resort cleanup for finalize paths running outside any
365+
event loop (GC sweep with no greenlet, atexit handler with the
366+
loop already torn down). Walks the inner client-layer
367+
connection's protocol writer and calls
368+
``writer.close()`` directly — the writer's ``close()`` is
369+
synchronous and safe to invoke without a running loop.
370+
371+
Idempotent. Never raises. A missing inner connection / missing
372+
protocol / missing writer is silently absorbed (the connection
373+
was never opened, or the regular async ``close()`` already ran
374+
and nulled the references).
375+
376+
Used by SQLAlchemy's async adapter when SA's finalize path
377+
executes outside a greenlet context — without this hook the
378+
adapter would have to walk private attributes of two
379+
underlying packages, which broke silently when the chain
380+
changed shape.
381+
"""
382+
inner = self._async_conn
383+
if inner is None:
384+
return
385+
proto = getattr(inner, "_protocol", None)
386+
if proto is None:
387+
return
388+
writer = getattr(proto, "_writer", None)
389+
if writer is None:
390+
return
391+
try:
392+
writer.close()
393+
except Exception: # noqa: BLE001 - last-resort cleanup
394+
logger.debug(
395+
"AsyncConnection.force_close_transport (id=%s): writer.close() raised; ignoring",
396+
id(self),
397+
exc_info=True,
398+
)
399+
361400
@property
362401
def in_transaction(self) -> bool:
363402
"""Whether the connection currently has an open transaction.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Pin: ``AsyncConnection.force_close_transport`` is a public,
2+
synchronous, idempotent, never-raising last-resort cleanup hook.
3+
4+
The SA dialect's async adapter calls this from its non-greenlet
5+
finalize path (GC sweep with no event loop). Walking the
6+
underlying client connection's private ``_protocol._writer``
7+
chain from outside this package broke silently when the chain
8+
shape changed; this hook is the single supported access boundary.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from unittest.mock import MagicMock
14+
15+
from dqlitedbapi.aio.connection import AsyncConnection
16+
17+
18+
def test_force_close_transport_calls_writer_close() -> None:
19+
"""The hook walks _async_conn → _protocol → _writer and calls
20+
writer.close()."""
21+
conn = AsyncConnection("localhost:9001", database="x")
22+
inner = MagicMock()
23+
proto = MagicMock()
24+
writer = MagicMock()
25+
proto._writer = writer
26+
inner._protocol = proto
27+
conn._async_conn = inner
28+
29+
conn.force_close_transport()
30+
31+
writer.close.assert_called_once_with()
32+
33+
34+
def test_force_close_transport_is_idempotent() -> None:
35+
"""Multiple invocations are safe; the writer's close() may be
36+
called repeatedly."""
37+
conn = AsyncConnection("localhost:9001", database="x")
38+
inner = MagicMock()
39+
proto = MagicMock()
40+
writer = MagicMock()
41+
proto._writer = writer
42+
inner._protocol = proto
43+
conn._async_conn = inner
44+
45+
conn.force_close_transport()
46+
conn.force_close_transport()
47+
conn.force_close_transport()
48+
49+
assert writer.close.call_count == 3
50+
51+
52+
def test_force_close_transport_handles_missing_async_conn() -> None:
53+
"""A connection that was never opened (or already closed and
54+
nulled) absorbs the call without raising."""
55+
conn = AsyncConnection("localhost:9001", database="x")
56+
assert conn._async_conn is None # never connected
57+
conn.force_close_transport() # must not raise
58+
59+
60+
def test_force_close_transport_handles_missing_protocol() -> None:
61+
"""An inner connection without ``_protocol`` (mid-construction
62+
or already torn down) absorbs the call."""
63+
conn = AsyncConnection("localhost:9001", database="x")
64+
inner = MagicMock(spec=[]) # no attributes
65+
conn._async_conn = inner
66+
conn.force_close_transport() # must not raise
67+
68+
69+
def test_force_close_transport_swallows_writer_close_exception() -> None:
70+
"""``writer.close()`` raising must not propagate — last-resort
71+
cleanup must always finish."""
72+
conn = AsyncConnection("localhost:9001", database="x")
73+
inner = MagicMock()
74+
proto = MagicMock()
75+
writer = MagicMock()
76+
writer.close.side_effect = OSError("transport already closed")
77+
proto._writer = writer
78+
inner._protocol = proto
79+
conn._async_conn = inner
80+
81+
conn.force_close_transport() # must not raise
82+
writer.close.assert_called_once_with()

0 commit comments

Comments
 (0)