Skip to content

Commit d0fe4fe

Browse files
committed
Test the copy constructor's iterative path in CI
The copy constructor descends into 128 levels before it finishes a value without the call stack, so the iterative path is otherwise only reached by the few tests that nest deeper than that. JSON_NO_THREAD_LOCAL switches the descent off, which sends every value down that path. Running the whole test suite that way covers it with every object type, string type, allocator, and base class the suite already exercises. The new ci_test_no_thread_local target does that; the macro had no build coverage at all before. Copying a nested value also has to carry over what the element-wise copy constructor would have copied: the parents that JSON_DIAGNOSTICS relies on, and the positions that JSON_DIAGNOSTIC_POSITIONS reports. Both are now checked on either side of the descent bound, for objects and arrays. Neither was tested before, and dropping either one makes the new tests fail. Also quantify what JSON_NO_THREAD_LOCAL costs a copy instead of calling it "measurably slower". Signed-off-by: Niels Lohmann <mail@nlohmann.me>
1 parent f8e4ffc commit d0fe4fe

5 files changed

Lines changed: 135 additions & 2 deletions

File tree

.github/workflows/ubuntu.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ jobs:
100100
container: ubuntu:focal
101101
strategy:
102102
matrix:
103-
target: [ci_cmake_flags, ci_test_diagnostics, ci_test_diagnostic_positions, ci_test_noexceptions, ci_test_noimplicitconversions, ci_test_legacycomparison, ci_test_noglobaludls]
103+
target: [ci_cmake_flags, ci_test_diagnostics, ci_test_diagnostic_positions, ci_test_noexceptions, ci_test_noimplicitconversions, ci_test_legacycomparison, ci_test_noglobaludls, ci_test_no_thread_local]
104104
steps:
105105
- name: Install build-essential
106106
run: apt-get update ; apt-get install -y build-essential unzip wget git libssl-dev

cmake/ci.cmake

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,25 @@ add_custom_target(ci_test_noglobaludls
242242
COMMENT "Compile and test with global UDLs disabled"
243243
)
244244

245+
###############################################################################
246+
# Disable thread-local storage.
247+
###############################################################################
248+
249+
# Without thread-local storage, the copy constructor cannot bound its descent
250+
# and copies every object and array without the call stack. That path is
251+
# otherwise only reached by values nested deeper than the bound, so this target
252+
# is what runs the whole test suite through it.
253+
add_custom_target(ci_test_no_thread_local
254+
COMMAND ${CMAKE_COMMAND}
255+
-DCMAKE_BUILD_TYPE=Debug -GNinja
256+
-DJSON_BuildTests=ON
257+
-DCMAKE_CXX_FLAGS=-DJSON_NO_THREAD_LOCAL
258+
-S${PROJECT_SOURCE_DIR} -B${PROJECT_BINARY_DIR}/build_no_thread_local
259+
COMMAND ${CMAKE_COMMAND} --build ${PROJECT_BINARY_DIR}/build_no_thread_local
260+
COMMAND cd ${PROJECT_BINARY_DIR}/build_no_thread_local && ${CMAKE_CTEST_COMMAND} --parallel ${N} --output-on-failure
261+
COMMENT "Compile and test without thread-local storage"
262+
)
263+
245264
###############################################################################
246265
# Coverage.
247266
###############################################################################

docs/mkdocs/docs/api/macros/json_no_thread_local.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ shared between threads would be raced.
1414

1515
Without that counter, no descent can be bounded safely, so objects and arrays are copied without the call stack right
1616
away. Copying keeps working exactly as it does otherwise - the same values come out, and deeply nested values are copied
17-
just as safely - but copying is measurably slower, as the containers no longer copy themselves.
17+
just as safely - but copying is slower, because the containers no longer copy themselves. Copying the benchmark
18+
documents takes 9% (`canada.json`) to 34% (`twitter.json`) longer; values built mostly from objects are affected the
19+
most.
1820

1921
## Default definition
2022

tests/src/unit-diagnostic-positions.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,61 @@ TEST_CASE("Better diagnostics with positions")
6868
CHECK(j.end_pos() == root.size());
6969
}
7070

71+
SECTION("copying keeps the positions of nested values (#5387)")
72+
{
73+
// Values nested deeper than the copy constructor's descent bound are
74+
// copied without the call stack, on a path that has to carry the
75+
// positions over itself; shallower ones copy their containers, which
76+
// bring the positions along. Both sides of the bound are checked here.
77+
const auto check_copy = [](std::size_t depth, bool objects)
78+
{
79+
CAPTURE(depth)
80+
CAPTURE(objects)
81+
82+
const std::string open = objects ? R"({"a":)" : "[";
83+
const std::string close = objects ? "}" : "]";
84+
85+
std::string text;
86+
for (std::size_t i = 0; i < depth; ++i)
87+
{
88+
text += open;
89+
}
90+
text += "12";
91+
for (std::size_t i = 0; i < depth; ++i)
92+
{
93+
text += close;
94+
}
95+
96+
const json original = json::parse(text);
97+
const json copy(original); // NOLINT(performance-unnecessary-copy-initialization)
98+
99+
const json* o = &original;
100+
const json* c = &copy;
101+
for (std::size_t level = 0; level <= depth; ++level)
102+
{
103+
CAPTURE(level)
104+
REQUIRE(c->start_pos() == o->start_pos());
105+
REQUIRE(c->end_pos() == o->end_pos());
106+
107+
if (level < depth)
108+
{
109+
o = objects ? &o->at("a") : &o->at(0);
110+
c = objects ? &c->at("a") : &c->at(0);
111+
}
112+
}
113+
};
114+
115+
const bool shapes[] = {false, true};
116+
for (const bool objects : shapes)
117+
{
118+
check_copy(1, objects);
119+
check_copy(127, objects);
120+
check_copy(128, objects);
121+
check_copy(129, objects);
122+
check_copy(300, objects);
123+
}
124+
}
125+
71126
SECTION("JSON patch add to primitive parent (#4292)")
72127
{
73128
// the JSON Patch "add" target /foo/bar/baz has a string parent

tests/src/unit-diagnostics.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,5 +273,62 @@ TEST_CASE("Regression tests for extended diagnostics")
273273
CHECK(j1["numbers"]["two"] == 2);
274274
CHECK(j1["string"] == "t");
275275
}
276+
277+
SECTION("Regression test for issue #5387 - copying keeps the parents of nested values")
278+
{
279+
// A value nested deeper than the copy constructor's descent bound is
280+
// copied without the call stack. Every container that path creates has
281+
// to have the parents of its children set, or the JSON Pointer in the
282+
// diagnostic is cut short.
283+
const std::size_t depth = 300;
284+
285+
SECTION("objects")
286+
{
287+
json j = "not a number";
288+
std::string pointer;
289+
for (std::size_t i = 0; i < depth; ++i)
290+
{
291+
j = json{{"a", j}};
292+
pointer += "/a";
293+
}
294+
295+
json const copy(j); // NOLINT(performance-unnecessary-copy-initialization)
296+
297+
const json* inner = &copy;
298+
for (std::size_t i = 0; i < depth; ++i)
299+
{
300+
inner = &inner->at("a");
301+
}
302+
303+
std::string const expected = "[json.exception.type_error.302] (" + pointer + ") type must be number, but is string";
304+
int i = 0;
305+
CHECK_THROWS_WITH_AS(i = inner->get<int>(), expected.c_str(), json::type_error);
306+
CHECK(i == 0);
307+
}
308+
309+
SECTION("arrays")
310+
{
311+
json j = "not a number";
312+
std::string pointer;
313+
for (std::size_t i = 0; i < depth; ++i)
314+
{
315+
j = json::array({j});
316+
pointer += "/0";
317+
}
318+
319+
json const copy(j); // NOLINT(performance-unnecessary-copy-initialization)
320+
321+
const json* inner = &copy;
322+
for (std::size_t i = 0; i < depth; ++i)
323+
{
324+
inner = &inner->at(0);
325+
}
326+
327+
std::string const expected = "[json.exception.type_error.302] (" + pointer + ") type must be number, but is string";
328+
int i = 0;
329+
CHECK_THROWS_WITH_AS(i = inner->get<int>(), expected.c_str(), json::type_error);
330+
CHECK(i == 0);
331+
}
332+
}
276333
}
277334

0 commit comments

Comments
 (0)