You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Parser callback is still called inside a discarded container, and that container's keys are kept in memory #5643
When a parser callback returns false for an object_start or array_start event, the parser still calls it for the content of that container. It receives every key event, the start events of nested containers, and the value events of elements of nested containers. The documentation says the opposite:
Discarding it at the start event also means the callback is called neither for the content of the value nor for its matching end event.
The same root cause also has a memory effect. Since #5457 it keeps a copy of every key inside a discarded container until the parse ends. Filtering out a large subtree is the main reason to use a callback, and the parser now keeps a large part of that subtree anyway. For a discarded object with 200,000 members, peak heap use during the parse went from 49 KB to 13.7 MB. This applies to both ways of discarding an object: rejecting its object_start event or rejecting its key.
Root cause (json_sax_dom_callback_parser in json_sax.hpp):
start_object(), start_array() and key() call the callback unconditionally (L586, L707, L625). Only handle_value() checks whether the enclosing container is discarded (L1012). As a result, the value events of the discarded container's direct members are suppressed, but everything below them is not: nested containers get a fresh keep_stack entry of true.
key() always pushes onto key_keep_stack and key_stack (L626-L629). The matching pop in handle_value() is skipped by its early returns for values that are not stored (L1012-L1015, L1056-L1059). Each key inside a container that is not stored therefore stays on both stacks until the parse ends.
The extra events reproduce on every release from v3.2.0 on, when the callback parser was reimplemented on top of the SAX interface (Add a SAX parser #971).
v3.1.2 did not call the callback for the content of a discarded container.
Possible fix (tested). Do not call the callback and do not touch the key stacks inside a container that is not stored:
--- a/include/nlohmann/detail/input/json_sax.hpp+++ b/include/nlohmann/detail/input/json_sax.hpp@@ -582,8 +582,8 @@
bool start_object(std::size_t len)
{
- // check callback for object start- const bool keep = callback(static_cast<int>(ref_stack.size()), parse_event_t::object_start, discarded);+ // check callback for object start; not called inside a discarded container+ const bool keep = keep_stack.back() && callback(static_cast<int>(ref_stack.size()), parse_event_t::object_start, discarded);
keep_stack.push_back(keep);
// the key this object will be stored under, read before handle_value()
@@ -619,6 +619,18 @@
bool key(string_t& val)
{
+ if (!keep_stack.back() || !ref_stack.back())+ {+ // the object is not stored: the value of this key is dropped in+ // handle_value() without touching the key stacks+ if (keep_stack.back())+ {+ BasicJsonType k = BasicJsonType(val);+ static_cast<void>(callback(static_cast<int>(ref_stack.size()), parse_event_t::key, k));+ }+ return true;+ }+
BasicJsonType k = BasicJsonType(val);
// check callback for the key
@@ -704,7 +716,7 @@
bool start_array(std::size_t len)
{
- const bool keep = callback(static_cast<int>(ref_stack.size()), parse_event_t::array_start, discarded);+ const bool keep = keep_stack.back() && callback(static_cast<int>(ref_stack.size()), parse_event_t::array_start, discarded);
keep_stack.push_back(keep);
// see start_object()
The key callback is still called for a member of an object whose own key was rejected, because the documentation promises that: "The callback is still called for the associated value, but its return value has no further effect". Only the stacks are left alone.
Tests of the sketch:
The program below prints the documented event sequence, and peak heap drops to 304 bytes (object_start) and 280 bytes (key).
400,000 random documents with duplicate keys and random stateless callbacks, for both json and ordered_json, give byte-identical results before and after under ASan/UBSan.
These unit tests pass: unit-class_parser, unit-regression2, unit-regression3, unit-comparison, unit-diagnostic-positions, unit-locale-cpp.
A regression test would need to check the callback's event sequence (for example, that no key event occurs inside a discarded object) and that key_stack does not grow. The second part can only be checked indirectly, for example with a counting allocator.
Reproduction steps
Save the program below as callback.cpp.
clang++ -std=c++11 -O2 -I include callback.cpp -o callback && ./callback
Part (1): the callback discards the value of "skip" at its object_start event, yet it still receives events from inside that value.
Part (2): discarding a 200,000-member object keeps 13.7 MB alive during the parse.
Expected vs. actual results
Expected (part 1): after the discarded depth 1 object_start, the next event is depth 1 key "keep".
Actual (part 1): the callback also receives key "k1", key "k2", array_start, value 2, object_start, key "k3" and value 3 from inside the discarded object. The parse result itself is correct.
Description
When a parser callback returns
falsefor anobject_startorarray_startevent, the parser still calls it for the content of that container. It receives everykeyevent, the start events of nested containers, and thevalueevents of elements of nested containers. The documentation says the opposite:(parser_callback_t.md)
The same root cause also has a memory effect. Since #5457 it keeps a copy of every key inside a discarded container until the parse ends. Filtering out a large subtree is the main reason to use a callback, and the parser now keeps a large part of that subtree anyway. For a discarded object with 200,000 members, peak heap use during the parse went from 49 KB to 13.7 MB. This applies to both ways of discarding an object: rejecting its
object_startevent or rejecting itskey.Root cause (
json_sax_dom_callback_parserin json_sax.hpp):start_object(),start_array()andkey()call the callback unconditionally (L586, L707, L625). Onlyhandle_value()checks whether the enclosing container is discarded (L1012). As a result, the value events of the discarded container's direct members are suppressed, but everything below them is not: nested containers get a freshkeep_stackentry oftrue.key()always pushes ontokey_keep_stackandkey_stack(L626-L629). The matching pop inhandle_value()is skipped by its early returns for values that are not stored (L1012-L1015, L1056-L1059). Each key inside a container that is not stored therefore stays on both stacks until the parse ends.std::vector<bool> key_keep_stack).std::vector<string_t> key_stack(L1109), so each key is now also kept as a full string copy.Since when:
Possible fix (tested). Do not call the callback and do not touch the key stacks inside a container that is not stored:
The key callback is still called for a member of an object whose own key was rejected, because the documentation promises that: "The callback is still called for the associated value, but its return value has no further effect". Only the stacks are left alone.
Tests of the sketch:
jsonandordered_json, give byte-identical results before and after under ASan/UBSan.unit-class_parser,unit-regression2,unit-regression3,unit-comparison,unit-diagnostic-positions,unit-locale-cpp.A regression test would need to check the callback's event sequence (for example, that no
keyevent occurs inside a discarded object) and thatkey_stackdoes not grow. The second part can only be checked indirectly, for example with a counting allocator.Reproduction steps
callback.cpp.clang++ -std=c++11 -O2 -I include callback.cpp -o callback && ./callback"skip"at itsobject_startevent, yet it still receives events from inside that value.Expected vs. actual results
depth 1 object_start, the next event isdepth 1 key "keep".key "k1",key "k2",array_start,value 2,object_start,key "k3"andvalue 3from inside the discarded object. The parse result itself is correct.Minimal code example
Error messages
With the headers of 1dc1d09^ (before #5457), part (2) prints:
Compiler and operating system
Apple clang 21.0.0 (clang-2100.3.34.2), macOS 27.0 (arm64)
Library version
develop@ 633de8e. The extra events also occur on v3.2.0 and v3.12.0, but not on v3.1.2.Validation
developbranch is used.This issue was written by Claude Code on behalf of @nlohmann.