Skip to content

Fix busy loop when a peer disconnects in the middle of a message header - #2073

Merged
Rich Chiodo (rchiodo) merged 1 commit into
microsoft:mainfrom
Om-singhaI:fix/json-io-stream-eof-busy-loop
Sep 21, 2026
Merged

Rich Chiodo (rchiodo) merged 1 commit into
microsoft:mainfrom
Om-singhaI:fix/json-io-stream-eof-busy-loop

Conversation

@Om-singhaI

Copy link
Copy Markdown
Contributor

Fixes #2072

JsonIOStream._read_line() decides it has hit EOF by testing line, the accumulator it is building up, rather than the bytes it just read. Once any part of a header line has arrived, line is never empty again, so if not line can only ever fire on the very first read. When a stream ends part way through a header, readline() returns b"" from then on, line never grows and never ends with CRLF, and the loop spins. No sleep, nothing to block on, no bound.

readline() returning b"" here means EOF and only EOF. from_socket() does sock.settimeout(None), and the only other settimeout calls on a DAP stream outside _vendored also pass None. The one real timeout, in sockets.py, is on the listening socket and not on a stream. So the fix is to test the chunk, and to treat a truncated header the way the body loop a few lines further down already treats a truncated body: there are no more messages. The clean disconnect path is untouched, since the first readline() then returns b"" while the accumulator is still empty.

What changed:

  • _read_line() reads into chunk, raises NoMoreMessages when chunk is empty, then appends it to line.
  • A comment on that check saying why it is on the chunk, so it does not get folded back.
  • TestJsonIOStream.test_read_truncated_header feeds a reader that yields b"Content-Length: 24" and then EOF forever, and asserts NoMoreMessages plus exactly one read past EOF.
  • That reader raises ReaderSpinning after ten reads past EOF instead of hanging, so on the old code the test fails in under a second with a message that names the problem, rather than sitting there until the suite timeout fires.
  • The reader derives from io.RawIOBase, so it satisfies the reader annotation on JsonIOStream.__init__, and its readline takes size: int | None = -1 to match the IOBase signature.

Testing, on macOS 26.6.2 with CPython 3.10.6, each run with -o addopts= to keep it off the 8 worker default:

  • pytest tests/debugpy/common/test_messaging.py -k "not fuzz": 13 passed, 1 deselected in 2.32s.
  • pytest tests/debugpy/common/test_messaging.py -k "fuzz": 1 passed, 13 deselected in 0.42s.
  • pytest tests/debugpy/common/test_json.py tests/debugpy/common/test_socket.py: 10 passed in 1.76s.
  • Negative control. With src/debugpy/common/messaging.py put back to e220805 and the new test kept: 1 failed, 12 passed, 1 deselected in 2.23s. The failure is ReaderSpinning: readline() was called 11 times at EOF, raised out of messaging.py:182.
  • The repro from the issue, over a real socket.socketpair(). Before, the truncated case reported finished=False CPU=2.93s. After, finished=True CPU=0.00s. The case where the peer closes without sending anything reads finished=True CPU=0.00s both before and after.
  • python -m ruff check ., which is what the Lint stage runs: all checks passed, on ruff 0.16.8.
  • Pyright 1.1.411 in standard mode, on tests/debugpy/common/test_messaging.py: 5 errors, the same 5 that are on it at e220805, all reportOptionalMemberAccess in tests this PR does not touch. The new helper adds none.
  • python -m flake8 src/debugpy/common/messaging.py tests/debugpy/common/test_messaging.py: the same 5 findings as on e220805, all of them in code this PR does not touch. Nothing new. The test file is clean.
  • Black 26.5.1 leaves every added line as written. Both files already differ from Black on e220805 in untouched code, so I left that alone instead of mixing a reformat into this.

I left the same unbounded shape in write_json() as it is. I could not build a writer that reaches it, so there would be nothing behind the change.

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.
@Om-singhaI
om singhal (Om-singhaI) requested a review from a team as a code owner September 21, 2026 06:39
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@bschnurr

Bill Schnurr (bschnurr) commented Sep 21, 2026

Copy link
Copy Markdown
Member

🔒 Automated review in progress — Bill Schnurr (@bschnurr) is auto-reviewing this PR.

@bschnurr

Copy link
Copy Markdown
Member

Result: ⚠️ partially-verified

Verification details

Verification: Isolated verification observed failures that were not classified as caused by this PR: Offline editable dependency bootstrap. The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Summary: The new truncated-header regression test passed, and the complete messaging, JSON, and socket test files passed with 25 total test executions. The initial test invocation could not import `debugpy`, and editable installation failed because the sandbox lacked `setuptools.build_meta`; running directly from `src` provided full targeted coverage. Verification is partial because the standard installation path could not be established, though no test assertion failed.

Test runs: 4 passed, 1 failed, 1 not run

  • ⚠️ Not run | Messaging tests using default environment | python -m pytest tests/debugpy/common/test_messaging.py -k "not fuzz" -o addopts= -q
  • Failed | unrelated to this PR | Offline editable dependency bootstrap | python -m venv --system-site-packages .venv && .venv/bin/python -m pip install --no-index --no-build-isolation -e .
  • Passed | Truncated header regression test | PYTHONPATH=src python -m pytest tests/debugpy/common/test_messaging.py::TestJsonIOStream::test_read_truncated_header -o addopts= -q
  • Passed | Complete messaging tests | PYTHONPATH=src python -m pytest tests/debugpy/common/test_messaging.py -o addopts= -q
  • Passed | JSON and socket tests | PYTHONPATH=src python -m pytest tests/debugpy/common/test_json.py tests/debugpy/common/test_socket.py -o addopts= -q
  • Passed | Sandbox and dependency discovery | printf 'AUTOMATION_SANDBOX_PROFILE=%s\n' "${AUTOMATION_SANDBOX_PROFILE:-}"; git diff --name-status HEAD^ HEAD; python --version; python - <<'PY'
    mods = ['pytest', 'pytest_timeout']
    for mod in mods:
    try:
    m = import(mod)
    print(f'{mod}: available {getattr(m, "version", "")}'.rstrip())
    except Exception as exc:
    print(f'{mod}: unavailable: {exc}')
    PY
    printf '\nDependency manifests:\n'; ls -1 pyproject.toml pytest.ini setup.cfg requirements*.txt 2>/dev/null || true
⚠️ Messaging tests using default environment diagnostic output
ImportError while loading conftest '/workspace/tests/conftest.py'.
tests/__init__.py:54: in <module>
    from debugpy.common import json, log
E   ModuleNotFoundError: No module named 'debugpy'
Offline editable dependency bootstrap diagnostic output
Obtaining file:///workspace
Checking if build backend supports build_editable: finished with status 'done'
pip._vendor.pyproject_hooks._impl.BackendUnavailable: Cannot import 'setuptools.build_meta'

@bschnurr Bill Schnurr (bschnurr) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@bschnurr Bill Schnurr (bschnurr) added the review-auto:approved Automated review: no blocking findings (approval posted). label Sep 21, 2026

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@rchiodo

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@rchiodo
Rich Chiodo (rchiodo) merged commit bff9402 into microsoft:main Sep 21, 2026
24 of 26 checks passed
@rchiodo

Copy link
Copy Markdown
Contributor

Thanks for the PR.

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

Labels

review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants