Skip to content

Commit 27f7aa3

Browse files
Narrow the suppression in _socket_looks_dead
The helper caught bare Exception around both `transport.is_closing()` and `reader.at_eof()`, which silently masked programmer bugs like a misnamed attribute or an unexpected type — the helper always reported "probably alive" and ROLLBACK proceeded against a broken protocol. Narrow both clauses to (AttributeError, RuntimeError): the legitimate failure modes for a mocked / partially torn-down transport, matching the `_cleanup_loop_thread` precedent in the dbapi package. Anything else now propagates so the underlying bug surfaces to the operator. Add a regression test for each branch of the helper, including the contract-change check: a `ValueError` from a broken mock now escapes instead of being swallowed as "probably alive". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04d5364 commit 27f7aa3

2 files changed

Lines changed: 94 additions & 2 deletions

File tree

src/dqliteclient/pool.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,18 @@ def _socket_looks_dead(conn: DqliteConnection) -> bool:
2929
writer = getattr(protocol, "_writer", None)
3030
reader = getattr(protocol, "_reader", None)
3131
transport = getattr(writer, "transport", None) if writer is not None else None
32+
# Narrow suppression to the specific categories a mock / partially
33+
# torn-down transport can legitimately raise. Wider `except Exception`
34+
# would mask programmer bugs (e.g. a misnamed attribute introduced by a
35+
# refactor). Mirrors the precedent set by ``_cleanup_loop_thread`` in
36+
# ``dqlitedbapi.connection``.
3237
try:
3338
closing = transport.is_closing() if transport is not None else False
34-
except Exception:
39+
except (AttributeError, RuntimeError):
3540
closing = False
3641
try:
3742
eof = reader.at_eof() if reader is not None else False
38-
except Exception:
43+
except (AttributeError, RuntimeError):
3944
eof = False
4045
return (isinstance(closing, bool) and closing) or (isinstance(eof, bool) and eof)
4146

tests/test_pool.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,3 +1156,90 @@ async def test_fetchval_through_pool(self, mock_connection: MagicMock) -> None:
11561156
assert result == 42
11571157

11581158
await pool.close()
1159+
1160+
1161+
class TestSocketLooksDead:
1162+
"""Contract tests for the pool's ``_socket_looks_dead`` heuristic.
1163+
1164+
The helper must tolerate mocked / missing transport attributes (returning
1165+
``False`` so ROLLBACK still gets attempted) but must NOT silently swallow
1166+
arbitrary exceptions — an unexpected error is a programmer bug that should
1167+
surface, not a signal of a dead socket.
1168+
"""
1169+
1170+
def _make_conn(
1171+
self,
1172+
*,
1173+
transport: object | None = None,
1174+
reader: object | None = None,
1175+
) -> MagicMock:
1176+
writer = MagicMock()
1177+
writer.transport = transport
1178+
protocol = MagicMock()
1179+
protocol._writer = writer
1180+
protocol._reader = reader
1181+
conn = MagicMock()
1182+
conn._protocol = protocol
1183+
return conn
1184+
1185+
def test_returns_true_when_protocol_is_none(self) -> None:
1186+
from dqliteclient.pool import _socket_looks_dead
1187+
1188+
conn = MagicMock()
1189+
conn._protocol = None
1190+
assert _socket_looks_dead(conn) is True
1191+
1192+
def test_returns_false_when_transport_and_reader_both_alive(self) -> None:
1193+
from dqliteclient.pool import _socket_looks_dead
1194+
1195+
transport = MagicMock()
1196+
transport.is_closing = MagicMock(return_value=False)
1197+
reader = MagicMock()
1198+
reader.at_eof = MagicMock(return_value=False)
1199+
1200+
conn = self._make_conn(transport=transport, reader=reader)
1201+
assert _socket_looks_dead(conn) is False
1202+
1203+
def test_returns_true_when_transport_is_closing(self) -> None:
1204+
from dqliteclient.pool import _socket_looks_dead
1205+
1206+
transport = MagicMock()
1207+
transport.is_closing = MagicMock(return_value=True)
1208+
reader = MagicMock()
1209+
reader.at_eof = MagicMock(return_value=False)
1210+
1211+
conn = self._make_conn(transport=transport, reader=reader)
1212+
assert _socket_looks_dead(conn) is True
1213+
1214+
def test_returns_true_when_reader_at_eof(self) -> None:
1215+
from dqliteclient.pool import _socket_looks_dead
1216+
1217+
transport = MagicMock()
1218+
transport.is_closing = MagicMock(return_value=False)
1219+
reader = MagicMock()
1220+
reader.at_eof = MagicMock(return_value=True)
1221+
1222+
conn = self._make_conn(transport=transport, reader=reader)
1223+
assert _socket_looks_dead(conn) is True
1224+
1225+
def test_attribute_error_falls_through_as_alive(self) -> None:
1226+
"""Mocks missing ``is_closing`` / ``at_eof`` default to "assume alive"."""
1227+
from dqliteclient.pool import _socket_looks_dead
1228+
1229+
transport = object() # no is_closing attribute
1230+
reader = object() # no at_eof attribute
1231+
conn = self._make_conn(transport=transport, reader=reader)
1232+
assert _socket_looks_dead(conn) is False
1233+
1234+
def test_unexpected_exception_is_not_swallowed(self) -> None:
1235+
"""A ``ValueError`` from a broken mock must propagate, not be suppressed."""
1236+
from dqliteclient.pool import _socket_looks_dead
1237+
1238+
transport = MagicMock()
1239+
transport.is_closing = MagicMock(side_effect=ValueError("broken mock"))
1240+
reader = MagicMock()
1241+
reader.at_eof = MagicMock(return_value=False)
1242+
1243+
conn = self._make_conn(transport=transport, reader=reader)
1244+
with pytest.raises(ValueError, match="broken mock"):
1245+
_socket_looks_dead(conn)

0 commit comments

Comments
 (0)