@@ -221,6 +221,110 @@ def test_decode_handshake_rejects_unknown_version(self) -> None:
221221 with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
222222 decoder .decode_handshake ()
223223
224+ def test_decode_handshake_failure_does_not_consume_bytes (self ) -> None :
225+ """On an unsupported version, decode_handshake() must NOT consume the
226+ handshake bytes.
227+
228+ Previously the method read 8 bytes BEFORE validating the version, so
229+ a failed handshake left the buffer advanced by 8 with ``_handshake_done``
230+ still False. A retry consumed the next 8 bytes as a "version", which
231+ was almost always the header of a real message — silently desynchronizing
232+ the stream. Peek-before-consume means the bytes stay in the buffer and
233+ a retry is deterministic: same bytes, same error.
234+ """
235+ from dqlitewire .exceptions import ProtocolError
236+
237+ decoder = MessageDecoder (is_request = True )
238+ bogus = b"\x42 " * 8
239+ decoder .feed (bogus )
240+
241+ with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
242+ decoder .decode_handshake ()
243+
244+ # The 8 handshake bytes must still be in the buffer.
245+ assert decoder ._buffer .available () == 8
246+ assert not decoder ._handshake_done
247+
248+ # A retry on the same bytes gets the same error, deterministically.
249+ with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
250+ decoder .decode_handshake ()
251+ assert decoder ._buffer .available () == 8
252+
253+ def test_decode_handshake_partial_data_leaves_bytes_intact (self ) -> None :
254+ """With fewer than 8 bytes buffered, decode_handshake() returns None
255+ and leaves the partial data untouched so a subsequent feed() can
256+ complete the handshake.
257+ """
258+ decoder = MessageDecoder (is_request = True )
259+ version_bytes = PROTOCOL_VERSION_LEGACY .to_bytes (8 , "little" )
260+ decoder .feed (version_bytes [:4 ])
261+ assert decoder .decode_handshake () is None
262+ assert decoder ._buffer .available () == 4
263+
264+ decoder .feed (version_bytes [4 :])
265+ assert decoder .decode_handshake () == PROTOCOL_VERSION_LEGACY
266+
267+ def test_decode_handshake_failure_preserves_following_bytes (self ) -> None :
268+ """Direct reproducer of the original bug: a buffer containing a bogus
269+ version followed by a real valid version. The pre-fix code consumed
270+ both 8-byte chunks across two retries; the fix must consume neither
271+ on the first failure.
272+ """
273+ from dqlitewire .exceptions import ProtocolError
274+
275+ decoder = MessageDecoder (is_request = True )
276+ bogus = b"\x42 " * 8
277+ valid = PROTOCOL_VERSION_LEGACY .to_bytes (8 , "little" )
278+ decoder .feed (bogus + valid )
279+
280+ with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
281+ decoder .decode_handshake ()
282+ # All 16 bytes still in the buffer — neither chunk has been consumed.
283+ assert decoder ._buffer .available () == 16
284+
285+ # Even after a retry, the valid bytes behind are untouched.
286+ with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
287+ decoder .decode_handshake ()
288+ assert decoder ._buffer .available () == 16
289+
290+ def test_decode_handshake_recoverable_via_reset (self ) -> None :
291+ """After a handshake failure, reset() clears the buffer and the
292+ decoder accepts a fresh handshake on a reconnect.
293+ """
294+ from dqlitewire .exceptions import ProtocolError
295+
296+ decoder = MessageDecoder (is_request = True )
297+ decoder .feed (b"\x42 " * 8 )
298+ with pytest .raises (ProtocolError , match = "[Uu]nsupported protocol version" ):
299+ decoder .decode_handshake ()
300+
301+ decoder .reset ()
302+ assert decoder ._buffer .available () == 0
303+ assert not decoder ._handshake_done
304+
305+ # Fresh valid handshake works.
306+ decoder .feed (PROTOCOL_VERSION .to_bytes (8 , "little" ))
307+ assert decoder .decode_handshake () == PROTOCOL_VERSION
308+ assert decoder ._handshake_done
309+
310+ def test_peek_bytes_does_not_advance_position (self ) -> None :
311+ """Sanity: ReadBuffer.peek_bytes() must return the requested bytes
312+ without advancing _pos. This is the primitive decode_handshake()
313+ depends on.
314+ """
315+ from dqlitewire .buffer import ReadBuffer
316+
317+ buf = ReadBuffer ()
318+ buf .feed (b"abcdefghij" )
319+ assert buf .peek_bytes (4 ) == b"abcd"
320+ assert buf .available () == 10
321+ # Repeated peeks return the same bytes.
322+ assert buf .peek_bytes (4 ) == b"abcd"
323+ assert buf .available () == 10
324+ # Asking for more than available returns None.
325+ assert buf .peek_bytes (20 ) is None
326+ assert buf .available () == 10
327+
224328 def test_decode_handshake_rejects_zero_version (self ) -> None :
225329 """Version 0 is not a valid protocol version."""
226330 from dqlitewire .exceptions import ProtocolError
0 commit comments