Skip to content

Commit dfe24dd

Browse files
Absorb RuntimeError from shielded close so user's exception propagates
acquire()'s broken-conn cleanup wraps the underlying close in asyncio.shield. The inner except only catches OSError / DqliteConnectionError. A RuntimeError from inside the close — most commonly "Event loop is closed" during a racing engine.dispose() — would propagate out and SUPPLANT the user's original exception (preserved only on __context__). Add a narrow except RuntimeError that logs-and-absorbs. The bare ``raise`` further out then re-raises the user's original exception unchanged. Narrow to RuntimeError specifically so programming bugs (AttributeError, TypeError, etc.) still surface per done/ISSUE-198. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 251a078 commit dfe24dd

2 files changed

Lines changed: 27 additions & 5 deletions

File tree

‎src/dqliteclient/pool.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,23 @@ async def acquire(self) -> AsyncIterator[DqliteConnection]:
786786
getattr(conn, "_address", "?"),
787787
exc_info=True,
788788
)
789+
except RuntimeError:
790+
# ``RuntimeError`` from inside the shielded
791+
# close — typically ``"Event loop is closed"``
792+
# during a racing ``engine.dispose()`` — would
793+
# otherwise propagate out and SUPPLANT the
794+
# user's original exception (preserved only
795+
# via ``__context__``). Log it and absorb so
796+
# the bare ``raise`` further out re-raises
797+
# the user's original error. Narrow to
798+
# ``RuntimeError`` so programming bugs
799+
# (``AttributeError``, ``TypeError``, etc.)
800+
# still surface — see done/ISSUE-198.
801+
logger.debug(
802+
"pool.acquire cleanup: conn.close(%r) raised RuntimeError",
803+
getattr(conn, "_address", "?"),
804+
exc_info=True,
805+
)
789806
finally:
790807
# Always set ``_pool_released`` so a subsequent
791808
# close() short-circuits and the slot accounting

‎tests/test_pool_acquire_close_shielded_on_cancel.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,21 +108,26 @@ async def fake_drain_idle() -> None:
108108
async def test_acquire_cleanup_close_failure_still_sets_pool_released() -> None:
109109
"""If ``conn.close()`` raises an unhandled exception (not
110110
OSError/DqliteConnectionError), the inner ``finally`` must still
111-
set ``_pool_released=True`` so subsequent close() short-circuits."""
111+
set ``_pool_released=True`` so subsequent close() short-circuits.
112+
113+
Also pin: the user's ORIGINAL ValueError must propagate — the
114+
close-time RuntimeError must NOT supplant it. The widened
115+
``except Exception`` log-and-absorb pattern preserves the user's
116+
exception for the bare ``raise`` further out."""
112117
pool, conn = await _build_pool_with_breakable_conn()
113118

114119
async def fake_close() -> None:
115120
# Simulate an unrecognised close failure (not OSError /
116-
# DqliteConnectionError). Must propagate, but the finally
117-
# still flips _pool_released.
118-
raise RuntimeError("unexpected close failure")
121+
# DqliteConnectionError). Must NOT supplant the user's
122+
# original exception.
123+
raise RuntimeError("simulated close-time RuntimeError")
119124

120125
async def fake_drain_idle() -> None:
121126
return
122127

123128
pool._drain_idle = fake_drain_idle
124129

125-
with patch.object(conn, "close", new=fake_close), pytest.raises((ValueError, RuntimeError)):
130+
with patch.object(conn, "close", new=fake_close), pytest.raises(ValueError, match="user"):
126131
async with pool.acquire():
127132
_break_conn(conn)
128133
raise ValueError("user code error")

0 commit comments

Comments
 (0)