Skip to content

Commit cc049cb

Browse files
committed
fix(tokenizer): preserve is_escaped across the SIMD string scan in streaming mode
findStringEnd's SIMD scanners (AVX2/SSE2/NEON) take is_escaped by reference and mutate it across the whole remaining buffer. When they do not find the closing quote (consumed == remaining) the function falls through to the scalar scan, which restarts from cursor_index -- but is_escaped now held the SIMD end-of-buffer state, so re-running the escape state machine flipped escape parity for a string crossing a streaming buffer boundary. A string whose escaped quote (\") straddled a buffer split then had the escaped quote treated as the terminator (wrong value / ExpectedDelimiter). Snapshot is_escaped before the SIMD scan and restore it before the scalar fallback, so the fallback re-derives escape state from the correct starting value. The snapshot/restore is compiled only when a SIMD path exists (no unused-variable warning under -Werror), and the common path (quote found -> early return) is unaffected. Rejected the originally proposed "return NeedMoreData" fix, which would wrongly report a complete string whose closing quote is the final buffer byte as incomplete. Added a streaming regression test (verified: fails without the fix with ExpectedDelimiter, passes with it). All 1648 unit-test assertions pass.
1 parent cd5d7ee commit cc049cb

2 files changed

Lines changed: 50 additions & 0 deletions

File tree

‎include/json_struct/json_struct.h‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,14 @@ JSON_STRUCT_FORCE_INLINE void Tokenizer::resetForNewValue()
19221922

19231923
JSON_STRUCT_FORCE_INLINE Error Tokenizer::findStringEnd(const DataRef &json_data, size_t *chars_ahead)
19241924
{
1925+
#if defined(JSON_STRUCT_HAS_AVX2) || defined(JSON_STRUCT_HAS_NEON) || defined(JSON_STRUCT_HAS_SSE2)
1926+
// The SIMD scan below mutates is_escaped across the whole remaining buffer. When
1927+
// it does not find the closing quote we fall through to the scalar scan starting
1928+
// again from cursor_index, which must re-derive escape state from the ORIGINAL
1929+
// value -- re-running the state machine from the SIMD end-state would flip escape
1930+
// parity for a string crossing a streaming buffer boundary. Snapshot and restore.
1931+
const bool escaped_before_simd = is_escaped;
1932+
#endif
19251933
#ifdef JSON_STRUCT_HAS_AVX2
19261934
if (JSON_STRUCT_LIKELY(json_data.size - cursor_index >= 32))
19271935
{
@@ -1958,6 +1966,12 @@ JSON_STRUCT_FORCE_INLINE Error Tokenizer::findStringEnd(const DataRef &json_data
19581966
}
19591967
#endif
19601968

1969+
#if defined(JSON_STRUCT_HAS_AVX2) || defined(JSON_STRUCT_HAS_NEON) || defined(JSON_STRUCT_HAS_SSE2)
1970+
// Restore the escape state to what it was before the SIMD scan re-mutated it, so
1971+
// the scalar fallback re-derives it correctly from cursor_index.
1972+
is_escaped = escaped_before_simd;
1973+
#endif
1974+
19611975
size_t end = cursor_index;
19621976
JSON_STRUCT_PREFETCH(json_data.data + end + 64);
19631977

‎tests/json-struct-optimization-fixes.cpp‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,42 @@ struct MultiInt
134134
JS_OBJ(a, b, c);
135135
};
136136

137+
TEST_CASE("streaming_escape_parity_across_buffer_boundary", "[tokenizer]")
138+
{
139+
// A string value whose escaped quote (\") straddles a buffer boundary: the
140+
// first buffer ends on the backslash (is_escaped carried over), the second
141+
// buffer starts with the escaped quote and is long enough (>=16 bytes) to hit
142+
// the SIMD scan with no real closing quote. Previously the SIMD scan left
143+
// is_escaped in its end-of-buffer state and the scalar fallback re-scanned from
144+
// the start with the wrong parity, treating the escaped quote as the terminator.
145+
const char BS = static_cast<char>(0x5c); // backslash
146+
147+
std::string b1 = std::string("[\"aaaaaaaa") + BS; // '[' '"' 8*a '\'
148+
// >= 32 bytes so both the AVX2 (>=32) and SSE2 (>=16) SIMD scans engage.
149+
std::string b2 = std::string("\"") + std::string(40, 'b'); // escaped quote + 40*b, no real quote
150+
std::string b3 = std::string("\"]"); // real closing quote + ']'
151+
152+
JS::Tokenizer tok;
153+
tok.addData(b1.data(), b1.size());
154+
tok.addData(b2.data(), b2.size());
155+
tok.addData(b3.data(), b3.size());
156+
157+
JS::Token token;
158+
REQUIRE(tok.nextToken(token) == JS::Error::NoError);
159+
REQUIRE(token.value_type == JS::Type::ArrayStart);
160+
161+
JS::Error e = tok.nextToken(token);
162+
REQUIRE(e == JS::Error::NoError);
163+
REQUIRE(token.value_type == JS::Type::String);
164+
// The raw token value keeps the escape sequences; the escaped quote must be
165+
// part of the string, not a premature terminator.
166+
std::string expected = std::string("aaaaaaaa") + BS + '"' + std::string(40, 'b');
167+
REQUIRE(std::string(token.value.data, token.value.size) == expected);
168+
169+
REQUIRE(tok.nextToken(token) == JS::Error::NoError);
170+
REQUIRE(token.value_type == JS::Type::ArrayEnd);
171+
}
172+
137173
struct NestChild
138174
{
139175
int a = 0;

0 commit comments

Comments
 (0)