Skip to content

Adapter spins at 100% CPU when a peer disconnects in the middle of a message header #2072

Description

Environment data

  • debugpy version: 1.8.22, running from source at e220805
  • OS and version: macOS 26.6.2 (arm64)
  • Python version (& distribution if applicable, e.g. Anaconda): 3.10.6, CPython from python.org
  • Using VS Code or Visual Studio: neither. The repro below is plain Python.

Actual behavior

JsonIOStream.read_json() never returns and never raises when the stream hits EOF part way through a header line. It burns a full core while it does that. _read_line() keeps calling reader.readline(), which returns b"" right away on a socket that is already at EOF, so the loop has nothing to block on and no bound.

Anything listening for a DAP client is exposed to this. python -m debugpy --listen 0.0.0.0:5678 --wait-for-client app.py opens a port, and any peer that writes a few bytes with no CRLF and then closes pins a core: a port scanner, a health check, an HTTPS client aimed at the wrong port. It also happens with no stranger involved. If the client or the debuggee is killed while a header is half flushed, the side that survives spins instead of ending the session. That thread is a daemon, so nothing reaps it, and every connection that does this leaves another one spinning.

This is near #1308 but it is not the same thing. A peer that closes without sending anything is handled correctly today. Only a partial line spins, which the first line of output below shows.

Expected behavior

NoMoreMessages, same as when the peer closes before sending anything. That is what JsonMessageChannel._parse_incoming_messages() is waiting for so it can fail the outstanding requests, queue the disconnect and close the channel.

Steps to reproduce:

  1. Check out e220805 and run this with src on PYTHONPATH. Stdlib only. No debug session and no client.
import socket
import threading
import time

from debugpy.common.messaging import JsonIOStream


def read_after(payload):
    ours, theirs = socket.socketpair()
    stream = JsonIOStream.from_socket(ours)
    theirs.sendall(payload)
    theirs.close()

    done = threading.Event()

    def run():
        try:
            stream.read_json()
        except Exception:
            pass
        finally:
            done.set()

    cpu = time.process_time()
    threading.Thread(target=run, daemon=True).start()
    finished = done.wait(3)
    print(
        "payload %-22r finished=%-5s CPU=%.2fs"
        % (payload, finished, time.process_time() - cpu)
    )


read_after(b"")  # peer closes without sending anything
read_after(b"Content-Length: 24")  # peer closes part way through a header line
  1. Look at the second line of output.

What I get:

payload b''                    finished=True  CPU=0.00s
payload b'Content-Length: 24'  finished=False CPU=2.93s

The second read is still going when the script stops waiting after 3 seconds, and almost the whole wait was CPU time. That is one core pinned, and it does not stop. Across four runs the number sits between 2.93 and 3.00. I expected both lines to read finished=True CPU=0.00s.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions