diff --git a/src/debugpy/common/messaging.py b/src/debugpy/common/messaging.py index 8ac0f8d9e..fb9092cc1 100644 --- a/src/debugpy/common/messaging.py +++ b/src/debugpy/common/messaging.py @@ -179,11 +179,16 @@ def _read_line(self, reader: Union[io.RawIOBase, BinaryIO]) -> bytes: line: bytes = b"" while True: try: - line += reader.readline() + chunk = reader.readline() except Exception as exc: raise NoMoreMessages(str(exc), stream=self) - if not line: + if not chunk: + # EOF. The check has to be on the chunk that was just read, not on + # the accumulated line - once any bytes have arrived, the line is + # never empty again, and readline() on a stream that is already at + # EOF keeps returning b"" without blocking, so retrying spins. raise NoMoreMessages(stream=self) + line += chunk if line.endswith(b"\r\n"): line = line[0:-2] return line diff --git a/tests/debugpy/common/test_messaging.py b/tests/debugpy/common/test_messaging.py index c6054c47e..d5d61b603 100644 --- a/tests/debugpy/common/test_messaging.py +++ b/tests/debugpy/common/test_messaging.py @@ -65,6 +65,39 @@ def write_json(self, value, encoder=None): self.output.append(value) +class ReaderSpinning(BaseException): + """Raised by TruncatedReader when it is read past EOF too many times. + + Derived from BaseException rather than Exception on purpose: JsonIOStream turns + any Exception raised by the reader into NoMoreMessages, and that is the very + thing the test needs to tell apart from a real EOF. + """ + + +class TruncatedReader(io.RawIOBase): + """Yields data once, and then EOF forever, like a socket whose peer wrote part + of a message and disconnected. + """ + + EOF_READS_ALLOWED = 10 + + def __init__(self, data): + super().__init__() + self.data = data + self.eof_reads = 0 + + def readline(self, size: int | None = -1) -> bytes: + if self.data: + data, self.data = self.data, b"" + return data + self.eof_reads += 1 + if self.eof_reads > self.EOF_READS_ALLOWED: + raise ReaderSpinning( + "readline() was called %d times at EOF" % self.eof_reads + ) + return b"" + + class TestJsonIOStream(object): MESSAGE_BODY_TEMPLATE = '{"arguments": {"threadId": 3}, "command": "next", "seq": %d, "type": "request"}' MESSAGES = [] @@ -92,6 +125,17 @@ def test_read(self): stream.read_json() assert exc_info.value.stream is stream + def test_read_truncated_header(self): + # A peer that writes part of a header line and then disconnects. readline() + # returns the partial line once, and b"" from then on, same as a socket that + # is at EOF. The stream must report NoMoreMessages rather than keep reading. + reader = TruncatedReader(b"Content-Length: 24") + stream = messaging.JsonIOStream(reader, io.BytesIO(), "data") + with pytest.raises(messaging.NoMoreMessages) as exc_info: + stream.read_json() + assert exc_info.value.stream is stream + assert reader.eof_reads == 1 + def test_write(self): data = io.BytesIO() stream = messaging.JsonIOStream(data, data, "data")