From 6f82c0390dcb9716b93fb19ed7116f4c75e991dc Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Mon, 21 Sep 2026 01:10:37 -0400 Subject: [PATCH] Fix busy loop when a peer disconnects in the middle of a message header JsonIOStream._read_line() checks for EOF by testing the accumulated line rather than the bytes it just read. Once any part of a header line has arrived, that accumulator is never empty again, so the `if not line` check can only ever fire on the first read. A stream that ends mid-line keeps returning b"" from readline(), the line never grows and never ends with CRLF, and the loop spins with no sleep and no blocking call. Sockets here are always blocking (from_socket() does settimeout(None)), so b"" from readline() means EOF and nothing else. Test the chunk instead, and treat a truncated header the same way the body loop below already treats a truncated body: no more messages. The clean disconnect path is unchanged, since the first read then returns b"" with the accumulator still empty. This is reachable on any adapter started with --listen, from a peer that writes a few bytes without a CRLF and closes, and from a client or debuggee that dies while a header is partially flushed. The message loop thread then pins a core instead of shutting the session down. --- src/debugpy/common/messaging.py | 9 ++++-- tests/debugpy/common/test_messaging.py | 44 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) 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")