|
1 | | -"""Edge-case tests for the asyncio backend's chunked _read_until() helper. |
2 | | -
|
3 | | -The fork rewrote _read_until to do 1 KB chunked reads instead of upstream's |
4 | | -byte-by-byte loop. The optimisation is correct on a normal network but has a |
5 | | -documented edge case: if the delimiter is the last byte of one TCP frame |
6 | | -and the next bytes belong to a different message, the chunked read could |
7 | | -overshoot. RethinkDB's handshake protocol uses null-terminated framing |
8 | | -where the delimiter only appears at message ends, so in practice this is |
9 | | -safe — but we want a regression net for it. |
| 1 | +"""Edge-case tests for the asyncio backend's ``_read_until`` helper. |
| 2 | +
|
| 3 | +The asyncio handshake reads null-terminated JSON messages with this helper. |
| 4 | +The legacy implementation did its own chunked reads and silently discarded |
| 5 | +any bytes that arrived after the delimiter in the same TCP frame; under |
| 6 | +connection-pool warm-up the V1.0 handshake's pipelined replies could land |
| 7 | +in one frame, the post-delimiter half got dropped, and the next read |
| 8 | +surfaced as ``ReqlDriverError: Connection interrupted during handshake``. |
| 9 | +
|
| 10 | +These tests pin the corrected behaviour: the helper now defers to |
| 11 | +``StreamReader.readuntil`` so trailing bytes remain in the reader's |
| 12 | +internal buffer for the next call. |
10 | 13 | """ |
11 | 14 |
|
12 | 15 | import asyncio |
|
16 | 19 | from rethinkdb.asyncio_net.net_asyncio import _read_until |
17 | 20 |
|
18 | 21 |
|
19 | | -class FakeStreamReader: |
20 | | - """StreamReader stand-in that hands out pre-staged byte chunks. |
21 | | -
|
22 | | - Each call to ``read(n)`` returns the next staged chunk verbatim, |
23 | | - regardless of ``n`` — the implementation should still find the |
24 | | - delimiter and stop, even if a chunk is split across what would be |
25 | | - multiple TCP frames. |
| 22 | +def _make_reader(*chunks: bytes, eof: bool = True) -> asyncio.StreamReader: |
| 23 | + """Build a fully-buffered :class:`asyncio.StreamReader` from the given |
| 24 | + chunks. Equivalent to feeding ``data_received`` callbacks in the |
| 25 | + transport layer — but with the data already buffered, the reader |
| 26 | + behaves exactly like a real socket that received the same frames. |
26 | 27 | """ |
27 | | - |
28 | | - def __init__(self, chunks): |
29 | | - self._chunks = list(chunks) |
30 | | - self._read_calls = 0 |
31 | | - |
32 | | - async def read(self, n): |
33 | | - self._read_calls += 1 |
34 | | - if not self._chunks: |
35 | | - return b"" |
36 | | - return self._chunks.pop(0) |
| 28 | + reader = asyncio.StreamReader() |
| 29 | + for chunk in chunks: |
| 30 | + reader.feed_data(chunk) |
| 31 | + if eof: |
| 32 | + reader.feed_eof() |
| 33 | + return reader |
37 | 34 |
|
38 | 35 |
|
39 | 36 | @pytest.mark.unit |
40 | 37 | class TestReadUntil: |
41 | 38 | @pytest.mark.asyncio |
42 | 39 | async def test_delimiter_in_single_chunk(self): |
43 | | - reader = FakeStreamReader([b"hello\0world", b""]) |
| 40 | + reader = _make_reader(b"hello\0world") |
44 | 41 | result = await _read_until(reader, b"\0") |
45 | 42 | assert result == b"hello\0" |
46 | | - # Should stop at the delimiter; only one read call needed. |
47 | | - assert reader._read_calls == 1 |
48 | 43 |
|
49 | 44 | @pytest.mark.asyncio |
50 | 45 | async def test_delimiter_split_across_chunks(self): |
51 | | - # The handshake response is delivered in two halves, with the |
52 | | - # delimiter at the start of the second chunk. |
53 | | - reader = FakeStreamReader([b'{"success":true}', b"\0", b""]) |
| 46 | + # Handshake response delivered in two halves with the delimiter |
| 47 | + # at the start of the second chunk. |
| 48 | + reader = _make_reader(b'{"success":true}', b"\0") |
54 | 49 | result = await _read_until(reader, b"\0") |
55 | 50 | assert result == b'{"success":true}\0' |
56 | 51 |
|
57 | 52 | @pytest.mark.asyncio |
58 | 53 | async def test_delimiter_at_frame_boundary(self): |
59 | | - # Delimiter is the last byte of the chunk — common case for short |
60 | | - # ack messages on a slow network. |
61 | | - reader = FakeStreamReader([b"ack\0", b""]) |
| 54 | + reader = _make_reader(b"ack\0") |
62 | 55 | result = await _read_until(reader, b"\0") |
63 | 56 | assert result == b"ack\0" |
64 | 57 |
|
65 | 58 | @pytest.mark.asyncio |
66 | 59 | async def test_eof_before_delimiter(self): |
67 | | - # Connection died mid-message; we get back what we read, no infinite loop. |
68 | | - reader = FakeStreamReader([b"partial", b""]) |
| 60 | + # Connection died mid-message — return what we have, no exception. |
| 61 | + reader = _make_reader(b"partial") |
69 | 62 | result = await _read_until(reader, b"\0") |
70 | 63 | assert result == b"partial" |
71 | 64 |
|
72 | 65 | @pytest.mark.asyncio |
73 | 66 | async def test_immediate_eof(self): |
74 | | - reader = FakeStreamReader([b""]) |
| 67 | + reader = _make_reader() |
75 | 68 | result = await _read_until(reader, b"\0") |
76 | 69 | assert result == b"" |
77 | 70 |
|
78 | 71 | @pytest.mark.asyncio |
79 | 72 | async def test_multibyte_delimiter(self): |
80 | | - reader = FakeStreamReader([b"prefix\r\nsuffix", b""]) |
| 73 | + reader = _make_reader(b"prefix\r\nsuffix\r\n") |
81 | 74 | result = await _read_until(reader, b"\r\n") |
82 | 75 | assert result == b"prefix\r\n" |
83 | 76 |
|
84 | 77 | @pytest.mark.asyncio |
85 | 78 | async def test_handshake_split_with_concurrent_yields(self): |
86 | | - """Simulate slow network: each chunk arrives after an event-loop tick. |
| 79 | + """Bytes arriving after event-loop ticks must be reassembled.""" |
| 80 | + reader = asyncio.StreamReader() |
| 81 | + |
| 82 | + async def feeder() -> None: |
| 83 | + msg = ( |
| 84 | + b'{"success":true,"min_protocol_version":0,' |
| 85 | + b'"max_protocol_version":1}\0' |
| 86 | + ) |
| 87 | + for i in range(0, len(msg), 12): |
| 88 | + # Yield to the loop between feeds — simulates separate |
| 89 | + # ``data_received`` callbacks on the transport. |
| 90 | + await asyncio.sleep(0) |
| 91 | + reader.feed_data(msg[i : i + 12]) |
| 92 | + reader.feed_eof() |
| 93 | + |
| 94 | + feed_task = asyncio.create_task(feeder()) |
| 95 | + result = await _read_until(reader, b"\0") |
| 96 | + await feed_task |
| 97 | + assert result == ( |
| 98 | + b'{"success":true,"min_protocol_version":0,' b'"max_protocol_version":1}\0' |
| 99 | + ) |
87 | 100 |
|
88 | | - This is the scenario the fork's IsardVDI evaluation flagged — handshake |
89 | | - bytes split across multiple data_received callbacks. The chunked read |
90 | | - must correctly accumulate them and find the terminating null. |
| 101 | + @pytest.mark.asyncio |
| 102 | + async def test_post_delimiter_bytes_preserved_for_next_call(self): |
| 103 | + """Regression for the IsardVDI handshake-race bug. |
| 104 | +
|
| 105 | + When two null-terminated messages arrive in one TCP frame (the |
| 106 | + V1.0 handshake's pipelined replies under pool warm-up), the |
| 107 | + bytes after the first delimiter MUST remain in the stream for |
| 108 | + the next ``_read_until`` call. The previous chunked |
| 109 | + implementation silently discarded them and the second |
| 110 | + handshake step would block on EOF. |
91 | 111 | """ |
| 112 | + reader = _make_reader(b"first-message\0second-message\0") |
92 | 113 |
|
93 | | - class SlowReader: |
94 | | - def __init__(self, chunks): |
95 | | - self._chunks = list(chunks) |
| 114 | + first = await _read_until(reader, b"\0") |
| 115 | + second = await _read_until(reader, b"\0") |
96 | 116 |
|
97 | | - async def read(self, n): |
98 | | - if not self._chunks: |
99 | | - return b"" |
100 | | - # Yield to the event loop between chunks so other tasks run. |
101 | | - await asyncio.sleep(0) |
102 | | - return self._chunks.pop(0) |
| 117 | + assert first == b"first-message\0" |
| 118 | + assert second == b"second-message\0" |
103 | 119 |
|
104 | | - # Mimic a 60-byte JSON handshake response delivered in 5 frames. |
105 | | - msg = b'{"success":true,"min_protocol_version":0,"max_protocol_version":1}\0' |
106 | | - reader = SlowReader([msg[i : i + 12] for i in range(0, len(msg), 12)]) |
107 | | - result = await _read_until(reader, b"\0") |
108 | | - assert result == msg |
| 120 | + @pytest.mark.asyncio |
| 121 | + async def test_three_pipelined_messages_in_one_frame(self): |
| 122 | + """Stretch case: SCRAM-SHA-256 doesn't pipeline three replies, |
| 123 | + but the helper's contract is "consume exactly one delimited |
| 124 | + message per call" so verify it scales beyond two.""" |
| 125 | + reader = _make_reader(b"a\0bb\0ccc\0") |
| 126 | + |
| 127 | + assert (await _read_until(reader, b"\0")) == b"a\0" |
| 128 | + assert (await _read_until(reader, b"\0")) == b"bb\0" |
| 129 | + assert (await _read_until(reader, b"\0")) == b"ccc\0" |
0 commit comments