Repository: aio-libs/aiohttp

Issue:
Error in StreamReader#readuntil
### Describe the bug

Hi there, it looks like StreamReader#readuntil function [here](https://github.com/aio-libs/aiohttp/blob/3.8/aiohttp/streams.py#L328) has an error.

My original use case was to read CSV files directly from ClientResponse line-by-line when the line terminator is `\r\n`.
The example I've provided is very similar.

When the separator has more than one symbol, it splits lines in a strange way.
For instance, when separator is `b'ab'`, it splits as follows:
```
b'11a'
b'b\n22a'
b'b\n33a'
b'b'
```
e.g. at the middle of the separator.

I think that this part wasn't updated at the time when it was `readline` function with only `\n` (one symbol length) separator available.

Probably this line should be:
```python
ichar = self._buffer[0].find(separator, offset) + len(separator) 
```

so that it will contain the whole separator instead of 'splitting' it.

### To Reproduce

Run this thing https://gist.github.com/vitaliihonta/b9be77deb0dfea1e9a411e695e6f15c9

### Expected behavior

Each line should contain the whole separator
```
b'11ab'
b'\n22ab'
b'\n33ab'
```

### Logs/tracebacks

```python-traceback
None
```


### Python Version

```console
Python 3.8.9
```


### aiohttp Version

```console
3.8.1
```


### multidict Version

```console
Name: multidict
Version: 6.0.2
```


### yarl Version

```console
Name: yarl
Version: 1.7.2
```


### OS

macOS

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
Looks reasonable to me. If you can write a test and create a PR, that would be great.

Resolving patch:
diff --git a/aiohttp/streams.py b/aiohttp/streams.py
--- a/aiohttp/streams.py
+++ b/aiohttp/streams.py
@@ -325,7 +325,9 @@ async def readuntil(self, separator: bytes = b"\n") -> bytes:
                 offset = self._buffer_offset
                 ichar = self._buffer[0].find(separator, offset) + 1
                 # Read from current offset to found separator or to the end.
-                data = self._read_nowait_chunk(ichar - offset if ichar else -1)
+                data = self._read_nowait_chunk(
+                    ichar - offset + seplen - 1 if ichar else -1
+                )
                 chunk += data
                 chunk_size += len(data)
                 if ichar:


Test patch:
diff --git a/tests/test_streams.py b/tests/test_streams.py
--- a/tests/test_streams.py
+++ b/tests/test_streams.py
@@ -380,44 +380,47 @@ async def test_readline_exception(self) -> None:
         with pytest.raises(ValueError):
             await stream.readline()
 
-    async def test_readuntil(self) -> None:
+    @pytest.mark.parametrize("separator", [b"*", b"**"])
+    async def test_readuntil(self, separator: bytes) -> None:
         loop = asyncio.get_event_loop()
         # Read one chunk. 'readuntil' will need to wait for the data
         # to come from 'cb'
         stream = self._make_one()
         stream.feed_data(b"chunk1 ")
-        read_task = loop.create_task(stream.readuntil(b"*"))
+        read_task = loop.create_task(stream.readuntil(separator))
 
         def cb():
             stream.feed_data(b"chunk2 ")
             stream.feed_data(b"chunk3 ")
-            stream.feed_data(b"* chunk4")
+            stream.feed_data(separator + b" chunk4")
 
         loop.call_soon(cb)
 
         line = await read_task
-        assert b"chunk1 chunk2 chunk3 *" == line
+        assert b"chunk1 chunk2 chunk3 " + separator == line
 
         stream.feed_eof()
         data = await stream.read()
         assert b" chunk4" == data
 
-    async def test_readuntil_limit_with_existing_data(self) -> None:
+    @pytest.mark.parametrize("separator", [b"&", b"&&"])
+    async def test_readuntil_limit_with_existing_data(self, separator: bytes) -> None:
         # Read one chunk. The data is in StreamReader's buffer
         # before the event loop is run.
 
         stream = self._make_one(limit=2)
         stream.feed_data(b"li")
-        stream.feed_data(b"ne1&line2&")
+        stream.feed_data(b"ne1" + separator + b"line2" + separator)
 
         with pytest.raises(ValueError):
-            await stream.readuntil(b"&")
+            await stream.readuntil(separator)
         # The buffer should contain the remaining data after exception
         stream.feed_eof()
         data = await stream.read()
-        assert b"line2&" == data
+        assert b"line2" + separator == data
 
-    async def test_readuntil_limit(self) -> None:
+    @pytest.mark.parametrize("separator", [b"$", b"$$"])
+    async def test_readuntil_limit(self, separator: bytes) -> None:
         loop = asyncio.get_event_loop()
         # Read one chunk. StreamReaders are fed with data after
         # their 'readuntil' methods are called.
@@ -425,71 +428,79 @@ async def test_readuntil_limit(self) -> None:
 
         def cb():
             stream.feed_data(b"chunk1")
-            stream.feed_data(b"chunk2$")
+            stream.feed_data(b"chunk2" + separator)
             stream.feed_data(b"chunk3#")
             stream.feed_eof()
 
         loop.call_soon(cb)
 
-        with pytest.raises(ValueError):
-            await stream.readuntil(b"$")
+        with pytest.raises(ValueError, match="Chunk too big"):
+            await stream.readuntil(separator)
         data = await stream.read()
         assert b"chunk3#" == data
 
-    async def test_readuntil_nolimit_nowait(self) -> None:
+    @pytest.mark.parametrize("separator", [b"!", b"!!"])
+    async def test_readuntil_nolimit_nowait(self, separator: bytes) -> None:
         # All needed data for the first 'readuntil' call will be
         # in the buffer.
+        seplen = len(separator)
         stream = self._make_one()
-        data = b"line1!line2!line3!"
-        stream.feed_data(data[:6])
-        stream.feed_data(data[6:])
+        data = b"line1" + separator + b"line2" + separator + b"line3" + separator
+        stream.feed_data(data[: 5 + seplen])
+        stream.feed_data(data[5 + seplen :])
 
-        line = await stream.readuntil(b"!")
-        assert b"line1!" == line
+        line = await stream.readuntil(separator)
+        assert b"line1" + separator == line
 
         stream.feed_eof()
         data = await stream.read()
-        assert b"line2!line3!" == data
+        assert b"line2" + separator + b"line3" + separator == data
 
-    async def test_readuntil_eof(self) -> None:
+    @pytest.mark.parametrize("separator", [b"@", b"@@"])
+    async def test_readuntil_eof(self, separator: bytes) -> None:
         stream = self._make_one()
         stream.feed_data(b"some data")
         stream.feed_eof()
 
-        line = await stream.readuntil(b"@")
+        line = await stream.readuntil(separator)
         assert b"some data" == line
 
-    async def test_readuntil_empty_eof(self) -> None:
+    @pytest.mark.parametrize("separator", [b"@", b"@@"])
+    async def test_readuntil_empty_eof(self, separator: bytes) -> None:
         stream = self._make_one()
         stream.feed_eof()
 
-        line = await stream.readuntil(b"@")
+        line = await stream.readuntil(separator)
         assert b"" == line
 
-    async def test_readuntil_read_byte_count(self) -> None:
+    @pytest.mark.parametrize("separator", [b"!", b"!!"])
+    async def test_readuntil_read_byte_count(self, separator: bytes) -> None:
+        seplen = len(separator)
         stream = self._make_one()
-        data = b"line1!line2!line3!"
-        stream.feed_data(data)
+        stream.feed_data(
+            b"line1" + separator + b"line2" + separator + b"line3" + separator
+        )
 
-        await stream.readuntil(b"!")
+        await stream.readuntil(separator)
 
-        data = await stream.read(7)
-        assert b"line2!l" == data
+        data = await stream.read(6 + seplen)
+        assert b"line2" + separator + b"l" == data
 
         stream.feed_eof()
         data = await stream.read()
-        assert b"ine3!" == data
+        assert b"ine3" + separator == data
 
-    async def test_readuntil_exception(self) -> None:
+    @pytest.mark.parametrize("separator", [b"#", b"##"])
+    async def test_readuntil_exception(self, separator: bytes) -> None:
         stream = self._make_one()
-        stream.feed_data(b"line#")
+        stream.feed_data(b"line" + separator)
 
-        data = await stream.readuntil(b"#")
-        assert b"line#" == data
+        data = await stream.readuntil(separator)
+        assert b"line" + separator == data
 
-        stream.set_exception(ValueError())
-        with pytest.raises(ValueError):
-            await stream.readuntil(b"#")
+        stream.set_exception(ValueError("Another exception"))
+        with pytest.raises(ValueError, match="Another exception"):
+            await stream.readuntil(separator)
 
     async def test_readexactly_zero_or_less(self) -> None:
         # Read exact number of bytes (zero or less).