Skip to content

Commit 8519b83

Browse files
hjanuschkabaylesj
andauthored
fix: avoid quadratic re-scan of comments after a value (#1689)
* fix: avoid quadratic re-scan of comments after a value OurReader::readComment() decides whether a comment should be attached to the previous value (commentAfterOnSameLine) by scanning the input from the end of that value up to the comment with containsNewLine(). lastValueEnd_ only advances when a new value is read, so a long run of comments after a value (e.g. during error recovery, or a value followed by many comments) made every comment re-scan the same growing prefix, giving O(n^2) parse time. A jsoncpp_fuzzer testcase took ~18s for a 400KB input. A comment can only ever be on the same line as the last value if no newline separates them, and the gap to inspect only grows as further comments are consumed, so once the gap has been examined for the first comment it never needs to be examined again. Mark lastValueHasAComment_ after the first comment following a value so subsequent comments skip the scan. Parsing the testcase drops from ~18s to ~56ms with identical output. Add a regression test that parses a value followed by a large number of trailing comments and requires it to complete well under a generous time bound. * test: assert linear comment scanning deterministically Replace the wall-clock bound in the comment regression test with a direct, deterministic assertion on work done. The parse output is identical with and without the fix, so the only observable difference is how much the parser scans; a time bound is also flaky under valgrind/sanitizers/loaded CI. Add an instrumentation counter for the bytes examined by OurReader::containsNewLine, exposed via a JSON_API seam, and assert it stays linear in the input (scanned < 4 * doc.size()) rather than O(comments * gap). The counter is thread_local (no race during concurrent parsing) and the increment is negligible, running only while parsing comments. It is compiled unconditionally because the ABI compatibility job builds the test suite against a separately-installed Release library, so the symbol must exist there. Rename the test to parseCommentsAfterValueScansLinearly to describe what it checks, and link crbug.com/521541633. Verified: the test fails when the fix is reverted and passes with it, in Debug and Release, and the seam links against a Release-installed shared library (the ABI compatibility scenario). --------- Co-authored-by: Jordan Bayles <jophba@chromium.org>
1 parent d4d0721 commit 8519b83

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

src/lib_json/json_reader.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,8 +975,20 @@ class OurReader {
975975

976976
// complete copy of Read impl, for OurReader
977977

978+
// Test-only instrumentation: total bytes examined by
979+
// OurReader::containsNewLine, so unit tests can assert that comment handling
980+
// stays linear in the input rather than quadratic in the comment count (see
981+
// CharReaderTest/parseCommentsAfterValueScansLinearly). thread_local so it
982+
// never races during concurrent parsing; the increment is negligible and only
983+
// runs while parsing comments. Not part of the supported public API.
984+
JSON_API size_t& newlineScanByteCountForTesting() {
985+
static thread_local size_t count = 0;
986+
return count;
987+
}
988+
978989
bool OurReader::containsNewLine(OurReader::Location begin,
979990
OurReader::Location end) {
991+
newlineScanByteCountForTesting() += static_cast<size_t>(end - begin);
980992
return std::any_of(begin, end, [](char b) { return b == '\n' || b == '\r'; });
981993
}
982994

@@ -1296,9 +1308,13 @@ bool OurReader::readComment() {
12961308
if (lastValueEnd_ && !containsNewLine(lastValueEnd_, commentBegin)) {
12971309
if (isCppStyleComment || !cStyleWithEmbeddedNewline) {
12981310
placement = commentAfterOnSameLine;
1299-
lastValueHasAComment_ = true;
13001311
}
13011312
}
1313+
// The gap between the last value and this comment only grows as more
1314+
// comments are consumed, so a later comment can never be on the same
1315+
// line as that value. Mark it handled to avoid re-scanning the same
1316+
// growing prefix for every following comment (quadratic behavior).
1317+
lastValueHasAComment_ = true;
13021318
}
13031319

13041320
addComment(commentBegin, current_, placement);

src/test_lib_json/main.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929

3030
using CharReaderPtr = std::unique_ptr<Json::CharReader>;
3131

32+
namespace Json {
33+
// Defined in json_reader.cpp; test instrumentation seam.
34+
JSON_API size_t& newlineScanByteCountForTesting();
35+
} // namespace Json
36+
3237
// Make numeric limits more convenient to talk about.
3338
// Assumes int type in 32 bits.
3439
#define kint32max Json::Value::maxInt
@@ -3308,6 +3313,42 @@ JSONTEST_FIXTURE_LOCAL(CharReaderTest, parseComment) {
33083313
}
33093314
}
33103315

3316+
JSONTEST_FIXTURE_LOCAL(CharReaderTest, parseCommentsAfterValueScansLinearly) {
3317+
// A value, then a comment whose only newline is at its end, then many
3318+
// trailing comments. Comment handling should scan the value->comment gap a
3319+
// bounded number of times (linear in the input), not once per trailing
3320+
// comment (O(comments * gap)). Assert directly on bytes scanned
3321+
// (deterministic) rather than wall-clock time (flaky under valgrind/CI).
3322+
//
3323+
// Regression test for crbug.com/521541633 (jsoncpp_fuzzer timeout: a 400KB
3324+
// input scanned 2.24GB across 8384 containsNewLine calls, ~18s).
3325+
const int kFiller = 256;
3326+
const int kComments = 1000;
3327+
std::string doc = "[0 /*";
3328+
doc.append(kFiller, 'a');
3329+
doc += "\n*/";
3330+
for (int i = 0; i < kComments; ++i)
3331+
doc += "/*c*/";
3332+
doc += "]";
3333+
3334+
Json::CharReaderBuilder b;
3335+
CharReaderPtr reader(b.newCharReader());
3336+
Json::Value root;
3337+
Json::String errs;
3338+
3339+
Json::newlineScanByteCountForTesting() = 0;
3340+
const bool ok =
3341+
reader->parse(doc.data(), doc.data() + doc.size(), &root, &errs);
3342+
3343+
JSONTEST_ASSERT(ok);
3344+
JSONTEST_ASSERT(errs.empty());
3345+
JSONTEST_ASSERT_EQUAL(0, root[0]);
3346+
// Quadratic-regression guard. Linear scans ~O(input); the bug scanned
3347+
// ~kComments * kFiller (~2.7M here vs a few bytes fixed).
3348+
const size_t scanned = Json::newlineScanByteCountForTesting();
3349+
JSONTEST_ASSERT(scanned < 4 * doc.size());
3350+
}
3351+
33113352
JSONTEST_FIXTURE_LOCAL(CharReaderTest, parseObjectWithErrors) {
33123353
Json::CharReaderBuilder b;
33133354
CharReaderPtr reader(b.newCharReader());

0 commit comments

Comments
 (0)