Skip to content

Commit 082454a

Browse files
fix: handle oversized continuation frames by clearing flag and poisoning
decode_continuation() now catches DecodeError from read_message() for oversized continuation frames, clears _continuation_expected, and poisons the buffer. Previously the decoder got stuck in a deadlock where decode(), skip_message(), and decode_continuation() were all blocked, with reset() as the only escape. Closes #123 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30ac37c commit 082454a

2 files changed

Lines changed: 47 additions & 1 deletion

File tree

src/dqlitewire/codec.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,17 @@ def decode_continuation(self) -> RowsResponse | None:
276276
"decode_continuation() called but no ROWS continuation "
277277
"is in progress. Use decode() for the initial message."
278278
)
279-
data = self._buffer.read_message()
279+
try:
280+
data = self._buffer.read_message()
281+
except DecodeError:
282+
# Oversized continuation frame — no skip_message() recovery
283+
# available during continuation mode. Clear the flag and
284+
# poison so the caller knows reset() is required.
285+
self._continuation_expected = False
286+
self._buffer.poison(
287+
DecodeError("Oversized ROWS continuation frame; call reset() to recover")
288+
)
289+
raise
280290
if data is None:
281291
return None
282292

tests/test_codec.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,3 +1726,39 @@ def test_decode_continuation_rejects_unsupported_schema(self) -> None:
17261726

17271727
with pytest.raises(DecodeError, match="Unsupported schema version"):
17281728
decoder.decode_continuation()
1729+
1730+
def test_oversized_continuation_frame_clears_flag_and_poisons(self) -> None:
1731+
"""123: oversized continuation frame must clear flag and poison."""
1732+
import struct
1733+
1734+
from dqlitewire.constants import ValueType
1735+
from dqlitewire.exceptions import ProtocolError
1736+
from dqlitewire.messages.responses import RowsResponse
1737+
1738+
initial = RowsResponse(
1739+
column_names=["id"],
1740+
column_types=[ValueType.INTEGER],
1741+
rows=[[1]],
1742+
has_more=True,
1743+
)
1744+
1745+
decoder = MessageDecoder(is_request=False)
1746+
decoder._buffer._max_message_size = 128
1747+
1748+
# Oversized continuation header: 200 words = 1600 bytes > 128
1749+
oversized_header = struct.pack("<IBBH", 200, 7, 0, 0) # type 7 = ROWS
1750+
1751+
decoder.feed(initial.encode() + oversized_header)
1752+
1753+
msg = decoder.decode()
1754+
assert isinstance(msg, RowsResponse) and msg.has_more
1755+
1756+
with pytest.raises(DecodeError, match="exceeds maximum"):
1757+
decoder.decode_continuation()
1758+
1759+
# Flag must be cleared and buffer poisoned
1760+
assert not decoder._continuation_expected
1761+
assert decoder.is_poisoned
1762+
1763+
with pytest.raises(ProtocolError, match="poisoned"):
1764+
decoder.decode()

0 commit comments

Comments
 (0)