Fix stack overflow converting deep values between specializations - #5723
Merged
Merged
Conversation
Constructing a basic_json from another specialization (json to ordered_json or back, also via get<ordered_json>()) converted every container with its range constructor, which calls the converting constructor for each element. The call stack therefore grew with every nesting level, and a value nested some 30,000 levels deep overflowed it. The conversion now bounds its descent the way the copy constructor does since #5387: the first 128 levels are converted exactly as before, and below that convert_iteratively() finishes the value with an explicit stack. It builds each container bottom-up from its converted elements with the container's range constructor, so member order and keys that become equal are handled as before, and it gives a value its type only once its container exists, so an exception leaves nothing behind that cannot be destroyed. Parents (JSON_DIAGNOSTICS) and positions (JSON_DIAGNOSTIC_POSITIONS) are set for every value. Converting a null value no longer resets its positions: the constructor assigned null to a value that already was null, which swapped in the positions of the temporary. Fixes #5650. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
1 of 2 tasks
gregmarr
reviewed
Sep 30, 2026
Review feedback on #5723 (gregmarr): clarify in comments that the converting constructor has already copied the positions of val, which the null case keeps like every other case, and that next must be a reference into pending so that ++next advances the stored iterator. Comments only; no code change. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The comment still named nesting_depth_limit, which #5637 removed on develop in favor of detail::recursion_depth_limit(). Signed-off-by: Niels Lohmann <mail@nlohmann.me>
gregmarr
reviewed
Sep 30, 2026
…ull comment Signed-off-by: Niels Lohmann <mail@nlohmann.me>
gregmarr
approved these changes
Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Constructing a
basic_jsonfrom another specialization (nlohmann::ordered_json o = j;, the reverse direction, orj.get<nlohmann::ordered_json>()) converted every container with its range constructor, which calls the converting constructor for each element. The stack grew with every nesting level, so a value nested some 30,000 levels deep crashed with a stack overflow. Parsing, copying, anddump()handle such a value fine.The conversion now bounds its descent the way the copy constructor does since #5387. The first 128 levels are converted exactly as before. Below that, the value is finished with an explicit stack.
Changes
basic_json(const BasicJsonType&)now handles leaves withconvert_leaf(), which holds the leaf cases of the oldswitchand is shared by both paths, and handles objects and arrays withconvert_structured().convert_structured()takes anesting_depth_guard. Within the bound it callsJSONSerializer<other_object_t/other_array_t>::to_json()as before, so shallow conversions run the same code as before. Past the bound, or always withJSON_NO_THREAD_LOCAL(the same as for copying), it callsconvert_iteratively().convert_iteratively()converts post-order with an explicit stack. Each pending container holds the source container and its position. The converted elements go into shared scratch vectors:basic_jsonfor arrays, andstd::pair<key_type, basic_json>for objects, with the key converted the way the range constructor converts it. Once a container is complete,convert_level()creates it from move iterators withcreate<array_t>/create<object_t>and hands it to its parent. I did not pair elements by position, ascopy_object_level()does, for two reasons.std::mapandordered_mapcan enumerate the same members in different orders. Building from a range also keeps the range constructor's handling of keys that become equal on conversion.set_parents(). UnderJSON_DIAGNOSTIC_POSITIONS, every element gets its source's positions.nullvalue lost its positions underJSON_DIAGNOSTIC_POSITIONS, on both paths. The old constructor ran*this = nullptr, which swapped in the positions of the temporary. The value is already null at that point, so the assignment is gone.nesting_depth(): conversion now uses the same thread-local count as copying and comparing.Stack and speed (Apple clang, arm64):
-O2, converting a 100,000-level array or object uses about 32-38 KB of stack in total. Before, it used about 240-290 bytes per level (240-288 KB at 1,000 levels) and overflowed the 8 MB stack at 30,000-40,000 levels. Under ASan the conversion now needs about 190 KB at 100,000 levels.develop. A round trip json → ordered_json → json of a wide 20,000-element document takes the same time within noise (7.6 ms before and after).Tests
unit-large_json.cpp, new section "issue Converting between basic_json specializations (e.g. json to ordered_json) overflows the stack on deep input #5650": nested arrays, nested objects, and mixed nesting at 100,000 levels, for json → ordered_json, ordered_json → json, andget<ordered_json>(). Each result is checked by comparing itsdump()with the source text; all objects have a single member, so both object types list members in the same order. The section also has:dump()and by==.Against
develop's headers this section crashes with an ASan stack overflow in the json → ordered_json conversion.unit-diagnostics.cpp: after a 300-level mixed conversion, the JSON Pointer in a diagnostic still reaches the innermost value (parents set by the iterative path).unit-diagnostic-positions.cpp: start and end positions of every value on the path, and of its sibling, at depths 1, 127, 128, 129, and 300. The innermost value isnull. Againstdevelopthis fails at depth 1 (the null lost its positions). With the positions copy removed from the iterative path, it fails at level 129.unit-allocator.cpp: a newcountdown_allocatorfails the 1st, 2nd, 3rd, … construction while a 300-level value is converted to a specialization that uses it, until the conversion succeeds. Every failure must reach the caller asstd::bad_alloc. Checked by mutation: setting the type beforecreate<array_t>()makes this test fail onassert_invariant().unit-large_json,unit-allocator,unit-diagnostics, andunit-diagnostic-positionswith clang and-fsanitize=address,undefinedin these configurations: C++11, C++17,-DJSON_DIAGNOSTICS=1,-DJSON_DIAGNOSTIC_POSITIONS=1, and-DJSON_NO_THREAD_LOCAL. The last one runs every structured conversion through the iterative path. With-DJSON_NO_THREAD_LOCAL,unit-ordered_json,unit-ordered_json2,unit-udt,unit-regression2,unit-comparison, andunit-constructor1also pass. The four changed test files produce no warnings with clang-Weverything(CI exclusions, C++11 and C++20) or with GCC 16 and the CI flags (including-Weffc++).Public API
No breaking changes. The signature of the converting constructor is unchanged, and all new members are private. Two behavior changes:
JSON_DIAGNOSTIC_POSITIONS, a convertednullkeeps its positions.For levels past the bound, and for all levels with
JSON_NO_THREAD_LOCAL, the containers are built directly instead of throughJSONSerializer<other_array_t>/JSONSerializer<other_object_t>. Leaves still go throughJSONSerializer. This only matters for a customJSONSerializerthat specializes the other specialization's container types. Copying already takes the same approach past the bound.Fixes #5650
This PR was written by Claude Code on behalf of @nlohmann.
🤖 Generated with Claude Code