Skip to content

fix: make deeply-nested-body test platform-independent (fixes #3146) - #3147

Open
g0rdonL wants to merge 1 commit into
modelcontextprotocol:mainfrom
g0rdonL:repro-3146-macos
Open

fix: make deeply-nested-body test platform-independent (fixes #3146)#3147
g0rdonL wants to merge 1 commit into
modelcontextprotocol:mainfrom
g0rdonL:repro-3146-macos

Conversation

@g0rdonL

@g0rdonL g0rdonL commented Jul 22, 2026

Copy link
Copy Markdown

Fixes #3146.

Root cause

The test assumed a 100k-deep body always makes json.loads raise RecursionError. Since CPython 3.12 the C json scanner guards recursion by remaining C-stack headroom, not a fixed depth — so the outcome depends on the thread's stack size. Measured on Apple Silicon macOS, CPython 3.14:

  • main thread: flips to RecursionError at depth ~74k → deep body fails to parse → PARSE_ERROR
  • thread with a 64 MB stack: depth 100k parses fine → giant list fails request validation → INVALID_REQUEST

macOS runner threads get a smaller default stack than the main thread, which is why macos-latest sees -32600 while Ubuntu/Windows see -32700. Both are correct 400 rejections of the same hostile body; the old assertion was pinning a platform accident.

Change (test-only)

  • test_modern_post_with_deeply_nested_body_is_rejected_not_a_crash: asserts 400 with either PARSE_ERROR or INVALID_REQUEST — the invariant that actually matters is "rejected, not crashed".
  • New test_modern_post_recursion_error_during_parse_is_parse_error: monkeypatches the body parse to raise RecursionError, pinning the RecursionError → PARSE_ERROR mapping deterministically on every platform (this is what the old test was really trying to cover).

Verified locally on macOS arm64: both tests pass on CPython 3.13 and 3.14, full test_streamable_http_modern.py 52/52, ruff + pyright clean.

AI Disclaimer

…rotocol#3146)

Since CPython 3.12 the C json scanner guards recursion by remaining
C-stack headroom rather than a fixed depth, so whether a 100k-deep
body raises RecursionError (-> PARSE_ERROR) or parses into a giant
list that fails request validation (-> INVALID_REQUEST) depends on
the thread's stack size. macOS threads run with a smaller default
stack, which is why the test failed there while passing on Ubuntu
and Windows.

Split the test in two: the nested-body test now asserts a 400 with
either rejection code (both are correct; neither is a crash), and a
new monkeypatch-based test pins the RecursionError -> PARSE_ERROR
mapping deterministically on every platform.

Fixes modelcontextprotocol#3146

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 1 file

Re-trigger cubic

@opensource-joe opensource-joe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not a maintainer, just a contributor who went looking for the mechanism here because the reasoning was interesting. The fix looks right to me, and the monkeypatched test is the part I would keep regardless: relaxing the assertion alone would have quietly dropped all coverage of the RecursionError to PARSE_ERROR mapping, and pinning it separately avoids that. Two corrections to the docstring, both from measurements rather than reading.

I measured json.loads("[" * N + "]" * N) on Linux x86_64, each run in its own subprocess so a hard crash is observable, with thread stack sizes set via threading.stack_size().

Python main thread 1 MB 8 MB 64 MB 256 MB tracks stack?
3.11.16 RecursionError from depth 1,000 same same same same no
3.12.3 parses to 5,000, raises from 10,000 SIGSEGV same as main same as main same as main no
3.13.15 parses to 5,000, raises from 10,000 SIGSEGV same as main same as main same as main no
3.14.7 parses to 50,000, raises from 75,000 raises from 10,000 flips ~64,901 parses past 400,000 parses past 400,000 yes

1. The stack-headroom behaviour starts in 3.14, not 3.12

On 3.12 and 3.13 the flip depth is identical at 8 MB, 64 MB and 256 MB, so the guard there ignores the stack completely. Only 3.14 moves with it, and on 3.14 it is cleanly linear: roughly 8,100 levels per MB, about 129 bytes of C stack per level. Depth 100,000 needs somewhere between 12 MB and 16 MB on this platform.

This matters a little beyond wording, because the CI matrix runs 3.10 through 3.14. On everything below 3.14 the 100,000-deep body always raises and the response is always PARSE_ERROR, deterministically.

2. The macOS explanation seems to point the wrong way

The docstring attributes the macOS result to runner threads getting a smaller default stack. Following that through: a smaller stack means less headroom, so json.loads raises sooner, which gives PARSE_ERROR. That is what the original test asserted, so it would have passed.

The failure in #3146 was INVALID_REQUEST, which means the body parsed. That needs more headroom than 100,000 levels requires, not less.

My guess is that the per-level C-stack cost differs by architecture, and arm64 macOS fits 100,000 levels in a stack where x86_64 Linux needs about 16 MB. I cannot confirm that, since I only have Linux to measure on, so please treat it as a hypothesis rather than a correction to yours. Either way the conclusion is the same, and it is the one your fix already encodes: which of the two codes you get is not something the test should pin.

Suggested docstring

Something like this, if the measurements above hold up on your side:

Which JSON-RPC code it gets is platform-dependent. From CPython 3.14 the C json scanner guards recursion by actual C-stack headroom rather than a fixed limit, so whether a 100k-deep body parses depends on how much stack the parsing thread has and on the per-level cost on that architecture. It either fails to parse (RecursionError, so PARSE_ERROR) or parses into a giant list that then fails request validation (INVALID_REQUEST). Both are correct rejections; the deterministic PARSE_ERROR mapping is covered by the monkeypatch test below.

One optional idea

Since the outcome is deterministic below 3.14, you could keep the stronger assertion where it still holds:

if sys.version_info >= (3, 14):
    assert response.json()["error"]["code"] in (PARSE_ERROR, INVALID_REQUEST)
else:
    assert response.json()["error"]["code"] == PARSE_ERROR

That keeps four of the five supported versions asserting the exact code. It also adds a version branch to a test, which is its own kind of cost, so I would understand leaving it as it is.

Unrelated, and not yours to fix

On 3.12 and 3.13, a thread with a 1 MB stack segfaults parsing a deeply nested body rather than raising, because the fixed limit does not account for the actual stack available. That is CPython's, not this PR's, and the server is unlikely to parse on such a thread. Mentioning it only because the test being replaced was named ..._not_a_crash, so it seemed worth someone knowing.

Disclosure: I used AI assistance for the measurements and for writing this up. I ran them myself and can go through any of it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deeply-nested-body test fails on macOS: rejected as INVALID_REQUEST (-32600) instead of PARSE_ERROR (-32700)

2 participants