From 3e0dc01b41469acc5847c8d6df2407fa0aefe760 Mon Sep 17 00:00:00 2001 From: Gareth Lloyd Date: Sat, 11 Apr 2026 12:00:31 +0100 Subject: [PATCH] Fix ~basic_json causing std::terminate During ~basic_json, json_value::destroy() allocated a std::vector to iteratively flatten nested containers. If the allocator threw (e.g. quota violation), the exception propagated through the noexcept destructor boundary, calling std::terminate. Replace with a three-tier strategy. For arrays, children are first promoted into the parent array's spare vector capacity, no heap allocation since the move constructor is noexcept and we stay within capacity. Children that exceed the spare slots overflow to a lazily- allocated std::vector, which is drained iteratively to preserve O(1) call-stack depth. For objects, structured values are moved to the same overflow stack. A try-catch around both tiers ensures that if the overflow stack cannot be allocated, remaining elements are destroyed via RAII recursion instead of calling std::terminate. The overflow stack uses the default allocator to avoid interference from user allocators that may throw or not forward move semantics. Signed-off-by: Gareth Lloyd --- include/nlohmann/json.hpp | 145 +++++++++++++++++++++++-------- single_include/nlohmann/json.hpp | 141 +++++++++++++++++++++++------- tests/src/unit-allocator.cpp | 92 ++++++++++++++++++++ 3 files changed, 310 insertions(+), 68 deletions(-) diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 0b8f155ac5..8685841e26 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -569,51 +569,126 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec } if (t == value_t::array || t == value_t::object) { - // flatten the current json_value to a heap-allocated stack - std::vector stack; - - // move the top-level items to stack - if (t == value_t::array) - { - stack.reserve(array->size()); - std::move(array->begin(), array->end(), std::back_inserter(stack)); - } - else + // Iteratively flatten nested containers to prevent + // stack overflow from recursive destruction. Children + // are promoted into the parent array's spare capacity + // (no allocation), with overflow to a heap stack. If + // the heap stack cannot be allocated, the catch block + // lets RAII recurse instead of calling std::terminate. + +#if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) + try { - stack.reserve(object->size()); - for (auto&& it : *object) - { - stack.push_back(std::move(it.second)); - } - } - - while (!stack.empty()) - { - // move the last item to a local variable to be processed - basic_json current_item(std::move(stack.back())); - stack.pop_back(); +#endif + // Uses the default allocator, we must not be + // affected by a user allocator that throws or + // copies on move. + std::vector stack; - // if current_item is array/object, move - // its children to the stack to be processed later - if (current_item.is_array()) + if (t == value_t::array) { - std::move(current_item.m_data.m_value.array->begin(), current_item.m_data.m_value.array->end(), std::back_inserter(stack)); - - current_item.m_data.m_value.array->clear(); + // Move children from source into parent, + // leaving `remain` behind. Stays within + // spare capacity. Uses resize() rather than + // erase() to avoid instantiating move- + // assignment in MSVC module builds. + auto* parent = array; + auto promote = [parent](basic_json & source, size_type remain) + { + if (source.is_array()) + { + auto* src = source.m_data.m_value.array; + auto first = src->begin() + static_cast(remain); + std::move(first, src->end(), std::back_inserter(*parent)); + src->resize(remain); + } + else if (source.is_object()) + { + auto* src = source.m_data.m_value.object; + const auto to_move = src->size() - remain; + for (size_type i = 0; i < to_move; ++i) + { + parent->push_back(std::move(src->begin()->second)); + src->erase(src->begin()); + } + } + }; + + while (!array->empty()) + { + if (array->back().is_structured()) + { + const auto spare = parent->capacity() - parent->size(); + const auto n = array->back().size(); + + if (n <= spare + 1) + { + // All fit (+1 from freeing container slot) + basic_json nested(std::move(array->back())); + array->pop_back(); + promote(nested, 0); + } + else if (spare > 0) + { + // Partial: revisited once promoted + // children are processed + promote(array->back(), n - spare); + } + else + { + // No capacity: overflow to stack + stack.push_back(std::move(array->back())); + array->pop_back(); + } + } + else + { + array->pop_back(); + } + } } - else if (current_item.is_object()) + else { - for (auto&& it : *current_item.m_data.m_value.object) + // Move structured values to the stack so that + // the object's destruction only encounters leaves. + for (auto& it : *object) { - stack.push_back(std::move(it.second)); + if (it.second.is_structured()) + { + stack.push_back(std::move(it.second)); + } } - - current_item.m_data.m_value.object->clear(); } - // it's now safe that current_item gets destructed - // since it doesn't have any children + while (!stack.empty()) + { + basic_json current(std::move(stack.back())); // NOLINT(misc-const-correctness) + stack.pop_back(); + + if (current.is_array()) + { + auto* src = current.m_data.m_value.array; + std::move(src->begin(), src->end(), std::back_inserter(stack)); + src->clear(); + } + else if (current.is_object()) + { + auto* src = current.m_data.m_value.object; + for (auto& it : *src) + { + stack.push_back(std::move(it.second)); + } + src->clear(); + } + } +#if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) } + catch (...) // NOLINT(bugprone-empty-catch) + { + // Stack allocation failed, RAII cleans up; remaining + // elements are destroyed recursively below. + } +#endif } switch (t) diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index e2bb8517b6..bd609b887d 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -20806,51 +20806,126 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec } if (t == value_t::array || t == value_t::object) { - // flatten the current json_value to a heap-allocated stack - std::vector stack; + // Iteratively flatten nested containers to prevent + // stack overflow from recursive destruction. Children + // are promoted into the parent array's spare capacity + // (no allocation), with overflow to a heap stack. If + // the heap stack cannot be allocated, the catch block + // lets RAII recurse instead of calling std::terminate. - // move the top-level items to stack - if (t == value_t::array) +#if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) + try { - stack.reserve(array->size()); - std::move(array->begin(), array->end(), std::back_inserter(stack)); - } - else - { - stack.reserve(object->size()); - for (auto&& it : *object) - { - stack.push_back(std::move(it.second)); - } - } - - while (!stack.empty()) - { - // move the last item to a local variable to be processed - basic_json current_item(std::move(stack.back())); - stack.pop_back(); +#endif + // Uses the default allocator, we must not be + // affected by a user allocator that throws or + // copies on move. + std::vector stack; - // if current_item is array/object, move - // its children to the stack to be processed later - if (current_item.is_array()) + if (t == value_t::array) { - std::move(current_item.m_data.m_value.array->begin(), current_item.m_data.m_value.array->end(), std::back_inserter(stack)); + // Move children from source into parent, + // leaving `remain` behind. Stays within + // spare capacity. Uses resize() rather than + // erase() to avoid instantiating move- + // assignment in MSVC module builds. + auto* parent = array; + auto promote = [parent](basic_json & source, size_type remain) + { + if (source.is_array()) + { + auto* src = source.m_data.m_value.array; + auto first = src->begin() + static_cast(remain); + std::move(first, src->end(), std::back_inserter(*parent)); + src->resize(remain); + } + else if (source.is_object()) + { + auto* src = source.m_data.m_value.object; + const auto to_move = src->size() - remain; + for (size_type i = 0; i < to_move; ++i) + { + parent->push_back(std::move(src->begin()->second)); + src->erase(src->begin()); + } + } + }; + + while (!array->empty()) + { + if (array->back().is_structured()) + { + const auto spare = parent->capacity() - parent->size(); + const auto n = array->back().size(); - current_item.m_data.m_value.array->clear(); + if (n <= spare + 1) + { + // All fit (+1 from freeing container slot) + basic_json nested(std::move(array->back())); + array->pop_back(); + promote(nested, 0); + } + else if (spare > 0) + { + // Partial: revisited once promoted + // children are processed + promote(array->back(), n - spare); + } + else + { + // No capacity: overflow to stack + stack.push_back(std::move(array->back())); + array->pop_back(); + } + } + else + { + array->pop_back(); + } + } } - else if (current_item.is_object()) + else { - for (auto&& it : *current_item.m_data.m_value.object) + // Move structured values to the stack so that + // the object's destruction only encounters leaves. + for (auto& it : *object) { - stack.push_back(std::move(it.second)); + if (it.second.is_structured()) + { + stack.push_back(std::move(it.second)); + } } - - current_item.m_data.m_value.object->clear(); } - // it's now safe that current_item gets destructed - // since it doesn't have any children + while (!stack.empty()) + { + basic_json current(std::move(stack.back())); // NOLINT(misc-const-correctness) + stack.pop_back(); + + if (current.is_array()) + { + auto* src = current.m_data.m_value.array; + std::move(src->begin(), src->end(), std::back_inserter(stack)); + src->clear(); + } + else if (current.is_object()) + { + auto* src = current.m_data.m_value.object; + for (auto& it : *src) + { + stack.push_back(std::move(it.second)); + } + src->clear(); + } + } +#if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) } + catch (...) // NOLINT(bugprone-empty-catch) + { + // Stack allocation failed, RAII cleans up; remaining + // elements are destroyed recursively below. + } +#endif } switch (t) diff --git a/tests/src/unit-allocator.cpp b/tests/src/unit-allocator.cpp index 6f2c70148c..951b773f2e 100644 --- a/tests/src/unit-allocator.cpp +++ b/tests/src/unit-allocator.cpp @@ -261,3 +261,95 @@ TEST_CASE("bad my_allocator::construct") j["test"].push_back("should not leak"); } } + +#if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) +namespace +{ +struct QuotaReached : std::exception {}; + +template +struct allocator_controlled_throw : std::allocator +{ + static bool& should_throw() + { + static bool flag = false; + return flag; + } + + allocator_controlled_throw() = default; + template + allocator_controlled_throw(allocator_controlled_throw /*unused*/) {} + + template + struct rebind + { + using other = allocator_controlled_throw; + }; + + T* allocate(size_t n) + { + if (should_throw()) + { + throw QuotaReached{}; + } + return std::allocator::allocate(n); + } +}; +} // namespace + +TEST_CASE("~basic_json tolerates allocator exceptions") +{ + using my_alloc_json = nlohmann::basic_json; + + SECTION("flat array") + { + { + auto j = my_alloc_json{1, 2, 3, 4}; + allocator_controlled_throw::should_throw() = true; + } // should not std::terminate + allocator_controlled_throw::should_throw() = false; + } + + SECTION("nested array") + { + { + auto j = my_alloc_json::array(); + j.push_back(my_alloc_json{1, 2}); + j.push_back(my_alloc_json{3, 4}); + allocator_controlled_throw::should_throw() = true; + } + allocator_controlled_throw::should_throw() = false; + } + + SECTION("object with nested array") + { + { + auto j = my_alloc_json::object(); + j["arr"] = my_alloc_json{1, 2, 3}; + j["val"] = 42; + allocator_controlled_throw::should_throw() = true; + } + allocator_controlled_throw::should_throw() = false; + } + + SECTION("deeply nested array") + { + { + auto inner = my_alloc_json{1, 2}; + auto mid = my_alloc_json::array(); + mid.push_back(std::move(inner)); + auto outer = my_alloc_json::array(); + outer.push_back(std::move(mid)); + allocator_controlled_throw::should_throw() = true; + } + allocator_controlled_throw::should_throw() = false; + } +} +#endif