Skip to content

Commit a7912ea

Browse files
fix: bail out when ROWS continuation makes no progress
A continuation frame with zero rows and has_more=True would otherwise spin the client forever. This can happen on the server when the column header alone exceeds the per-batch page buffer (query__batch emits a partial frame with no encoded rows). Detect and raise ProtocolError rather than livelock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 54a0045 commit a7912ea

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

src/dqliteclient/protocol.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ async def query_sql(
201201
all_rows = list(response.rows)
202202
while response.has_more:
203203
next_response = await self._read_continuation()
204+
if not next_response.rows and next_response.has_more:
205+
# Server claimed "more coming" but delivered zero rows in a
206+
# continuation frame. That would spin forever (known
207+
# pathological case: column header larger than the server's
208+
# page buffer). Bail out instead of livelocking.
209+
raise ProtocolError(
210+
"ROWS continuation made no progress: frame had 0 rows and has_more=True"
211+
)
204212
all_rows.extend(next_response.rows)
205213
response = next_response
206214

tests/test_protocol.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,36 @@ async def test_query_sql_reads_single_response(
215215
# Stray response must remain in the buffer, not silently consumed.
216216
assert protocol._decoder.has_message()
217217

218+
async def test_query_sql_raises_if_continuation_has_no_progress(
219+
self,
220+
protocol: DqliteProtocol,
221+
mock_reader: AsyncMock,
222+
) -> None:
223+
"""A ROWS continuation that sets has_more=True but delivers 0 rows
224+
must raise rather than loop forever. Known pathological server case:
225+
column header alone exceeds the page buffer, so query__batch sends a
226+
frame with no rows encoded but still marks it as partial.
227+
"""
228+
from dqlitewire.constants import ValueType
229+
from dqlitewire.messages import RowsResponse
230+
231+
first = RowsResponse(
232+
column_names=["x"],
233+
column_types=[ValueType.INTEGER],
234+
rows=[],
235+
has_more=True,
236+
)
237+
stuck = RowsResponse(
238+
column_names=["x"],
239+
column_types=[ValueType.INTEGER],
240+
rows=[],
241+
has_more=True,
242+
)
243+
mock_reader.read.return_value = first.encode() + stuck.encode()
244+
245+
with pytest.raises(ProtocolError, match="no progress|no rows"):
246+
await protocol.query_sql(1, "SELECT x FROM wide_table")
247+
218248
async def test_query_sql(
219249
self,
220250
protocol: DqliteProtocol,

0 commit comments

Comments
 (0)