fix: make deeply-nested-body test platform-independent (fixes #3146) - #3147
fix: make deeply-nested-body test platform-independent (fixes #3146)#3147g0rdonL wants to merge 1 commit into
Conversation
…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
opensource-joe
left a comment
There was a problem hiding this comment.
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_ERRORThat 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.
Fixes #3146.
Root cause
The test assumed a 100k-deep body always makes
json.loadsraiseRecursionError. 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:RecursionErrorat depth ~74k → deep body fails to parse →PARSE_ERRORINVALID_REQUESTmacOS runner threads get a smaller default stack than the main thread, which is why
macos-latestsees-32600while 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 eitherPARSE_ERRORorINVALID_REQUEST— the invariant that actually matters is "rejected, not crashed".test_modern_post_recursion_error_during_parse_is_parse_error: monkeypatches the body parse to raiseRecursionError, pinning theRecursionError → PARSE_ERRORmapping 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.py52/52, ruff + pyright clean.AI Disclaimer