Skip to content

Commit 5d314f8

Browse files
committed
fix(asyncio): preserve post-delimiter bytes in _read_until
The asyncio backend's _read_until previously called streamreader.read(1024) in a loop and, when the delimiter appeared mid-chunk, kept only the bytes up to and including the delimiter, silently discarding any trailing bytes from the same chunk. The original author flagged the limitation in an inline comment ("for production use, consider asyncio.StreamReader replacement"). Under load this surfaced as ReqlDriverError "Connection interrupted during handshake". The V1.0 SCRAM-SHA-256 handshake sends the version banner and the first JSON message in one initial write, then expects to read two null-terminated server replies back-to-back without an intermediate write (state 1 returns ""). When many connections handshake concurrently, the typical pattern when warming up a connection pool, both server replies can land in a single TCP frame. The legacy code returned the first reply and dropped the second; the next _read_until call then blocked on EOF or raised the misleading "interrupted" error. The fix delegates to asyncio.StreamReader.readuntil, which leaves trailing bytes in the reader's internal buffer for the next call. The EOF contract "return whatever was read" is preserved by mapping asyncio.IncompleteReadError to its .partial bytes. Tests in tests/test_asyncio_read_until.py are rewritten to drive a real asyncio.StreamReader (via feed_data / feed_eof) so they exercise the buffering path that production hits, and two regression tests are added that fail on the legacy implementation: * test_post_delimiter_bytes_preserved_for_next_call directly pins the IsardVDI bug scenario (two pipelined handshake replies in one frame). * test_three_pipelined_messages_in_one_frame extends the contract to more than two messages so future protocol changes do not regress.
1 parent 45b0dbe commit 5d314f8

2 files changed

Lines changed: 114 additions & 79 deletions

File tree

rethinkdb/asyncio_net/net_asyncio.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -71,28 +71,42 @@ def __setitem__(self, key, value):
7171

7272

7373
async def _read_until(streamreader, delimiter):
74-
"""Optimized implementation of reading until a delimiter"""
75-
buffer = bytearray()
76-
77-
# Read in chunks for better performance
78-
chunk_size = 1024
79-
80-
while True:
81-
chunk = await streamreader.read(chunk_size)
82-
if not chunk:
83-
break # EOF
84-
85-
delimiter_pos = chunk.find(delimiter)
86-
if delimiter_pos >= 0:
87-
# Found delimiter, add up to and including delimiter
88-
buffer.extend(chunk[: delimiter_pos + len(delimiter)])
89-
# Put back remaining data (if any) - not directly possible with asyncio StreamReader
90-
# This is a limitation; for production use, consider asyncio.StreamReader replacement
91-
break
92-
else:
93-
buffer.extend(chunk)
94-
95-
return bytes(buffer)
74+
"""Read from an :class:`asyncio.StreamReader` up to and including
75+
``delimiter``.
76+
77+
Defers to :meth:`asyncio.StreamReader.readuntil` so any bytes that
78+
arrive **after** the delimiter in the same TCP frame stay in the
79+
reader's internal buffer and are available to the next
80+
``_read_until`` call. The previous implementation called
81+
``streamreader.read(1024)`` and silently discarded any post-delimiter
82+
bytes; the only hint that this was lossy was an inline comment
83+
saying "for production use, consider asyncio.StreamReader
84+
replacement". In practice it manifested as
85+
``ReqlDriverError: Connection interrupted during handshake`` when a
86+
consumer (typically a connection pool warming up) opened many
87+
asyncio connections concurrently. The V1.0 handshake's two-step
88+
pipelined JSON exchange can deliver both messages in the same TCP
89+
frame under load, after which the original chunked read consumed
90+
the first message and dropped the second on the floor — the next
91+
read would then see EOF or block forever waiting for bytes that
92+
had already been delivered.
93+
94+
Uses stdlib :meth:`StreamReader.readuntil`, which buffers correctly:
95+
only the delimited prefix is consumed; trailing bytes remain
96+
available for subsequent reads.
97+
98+
Behaviour preserved from the legacy implementation:
99+
100+
* Returns whatever bytes were read (including a trailing delimiter
101+
if one was found) as a :class:`bytes` instance.
102+
* On EOF before the delimiter is encountered, returns the partial
103+
bytes read so far rather than raising — the caller's protocol
104+
decoder is in charge of reporting "incomplete frame" errors.
105+
"""
106+
try:
107+
return await streamreader.readuntil(delimiter)
108+
except asyncio.IncompleteReadError as err:
109+
return err.partial
96110

97111

98112
def reusable_waiter(loop, timeout):

tests/test_asyncio_read_until.py

Lines changed: 78 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
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.
1013
"""
1114

1215
import asyncio
@@ -16,93 +19,111 @@
1619
from rethinkdb.asyncio_net.net_asyncio import _read_until
1720

1821

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.
2627
"""
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
3734

3835

3936
@pytest.mark.unit
4037
class TestReadUntil:
4138
@pytest.mark.asyncio
4239
async def test_delimiter_in_single_chunk(self):
43-
reader = FakeStreamReader([b"hello\0world", b""])
40+
reader = _make_reader(b"hello\0world")
4441
result = await _read_until(reader, b"\0")
4542
assert result == b"hello\0"
46-
# Should stop at the delimiter; only one read call needed.
47-
assert reader._read_calls == 1
4843

4944
@pytest.mark.asyncio
5045
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")
5449
result = await _read_until(reader, b"\0")
5550
assert result == b'{"success":true}\0'
5651

5752
@pytest.mark.asyncio
5853
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")
6255
result = await _read_until(reader, b"\0")
6356
assert result == b"ack\0"
6457

6558
@pytest.mark.asyncio
6659
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")
6962
result = await _read_until(reader, b"\0")
7063
assert result == b"partial"
7164

7265
@pytest.mark.asyncio
7366
async def test_immediate_eof(self):
74-
reader = FakeStreamReader([b""])
67+
reader = _make_reader()
7568
result = await _read_until(reader, b"\0")
7669
assert result == b""
7770

7871
@pytest.mark.asyncio
7972
async def test_multibyte_delimiter(self):
80-
reader = FakeStreamReader([b"prefix\r\nsuffix", b""])
73+
reader = _make_reader(b"prefix\r\nsuffix\r\n")
8174
result = await _read_until(reader, b"\r\n")
8275
assert result == b"prefix\r\n"
8376

8477
@pytest.mark.asyncio
8578
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+
)
87100

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.
91111
"""
112+
reader = _make_reader(b"first-message\0second-message\0")
92113

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")
96116

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"
103119

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

Comments
 (0)