parser3: keep the stackSize needed by a document independent of chunking (reserve transient token slots) #72

Merged
andrew merged 3 commits from weaselbot/weaseljson:weaselbot/issue-71 into main 2026-10-30 13:05:59 +00:00
Member

Closes #71.

Problem

WeaselJson_OVERFLOW is documented as "json is too deeply nested" and stackSize as a nesting-depth budget, but the pushdown stack also held tokenizer progress tokens. When a true/false/null literal or a \uXXXX escape did not fit entirely in the current chunk, the slow path pushed the not-yet-matched symbols (T_R T_U N_TRUE, T_A T_L T_S N_FALSE, T_U T_L N_NULL, N_STRING_FOLLOWING_ESCAPE, four T_HEX, or six symbols for a surrogate pair) onto the same fixed-size stack, while the whole-buffer path consumed those characters inline and pushed nothing.

The minimum stackSize therefore depended on the chunking: true was accepted at stackSize 3 when fed whole and returned the sticky WeaselJson_OVERFLOW when fed one byte at a time. RootBuilder, testStreaming() and the corpus only ever used large stacks, so nothing caught it.

Fix (option 2 from the issue)

Keep the existing encoding, but reserve the transient symbols in addition to the caller's budget:

  • kTransientSymbols = 6 in src/parser3.h, with the upper bound derived and documented there (next to the constant, so it can be kept in sync with the pushes): the largest single push of transient symbols is t_hex2()'s six symbols for a UTF-16 surrogate pair, and none of those continuations pushes while another's symbols are pending, so at most six are on the stack at once. The bound is tight: with 5, "\ud801\udc37" streamed byte by byte still overflows at stackSize 3 while the whole-buffer parse succeeds.
  • WeaselJsonParser_create() allocates stackSize + kTransientSymbols slots (Parser3::allocationSize()).
  • Nesting pushes still go through push() and are bounded by stackEnd = stack() + stackSize, so WeaselJson_OVERFLOW keeps meaning exactly "the document's nesting exceeds stackSize"; the new pushTransient() writes in the reserve past stackEnd and so cannot fail.
  • include/weaseljson.h documents only the guarantee callers can rely on - that the accept/reject/overflow decision depends on the document's bytes and on stackSize, not on how the bytes are split across parse() calls. How that is achieved is left to the implementation (per review feedback).

Tests

  • New doctest acceptance is independent of the chunking: for literals, escapes, surrogate pairs, nested containers and a few invalid documents, sweeps stackSize 3..16, splits each document in two at every offset and also feeds it at strides 1..12, and requires the status to equal the whole-buffer status. Verified that it fails on the pre-fix parser (true, stackSize 3, cut 1: whole buffer accepts, two chunks return WeaselJson_OVERFLOW).
  • src/fuzz.cpp: testStreaming() now sweeps stack sizes {3,4,5,6,7,8,12,1024} instead of only 1024, and requires the status of every streaming pattern (strides 1..15 and every single split point) to match the whole-buffer status exactly - previously a REJECT-vs-OVERFLOW difference was tolerated. Verified that the fuzz_corpus replay aborts on the pre-fix code (the corpus contains a small null document) and passes with the fix.

All existing tests pass (ctest, Release/gcc including overflow state is sticky, create rejects too-small stack, python bindings and the schemagen tests), the chunking tests are clean under valgrind, and an additional out-of-tree randomized check (200k random documents, random stack sizes 3..12, random split points) found 49k whole-vs-split status mismatches on the pre-fix parser and none with the fix.

Closes #71. ## Problem `WeaselJson_OVERFLOW` is documented as "json is too deeply nested" and `stackSize` as a nesting-depth budget, but the pushdown stack also held *tokenizer progress* tokens. When a `true`/`false`/`null` literal or a `\uXXXX` escape did not fit entirely in the current chunk, the slow path pushed the not-yet-matched symbols (`T_R T_U N_TRUE`, `T_A T_L T_S N_FALSE`, `T_U T_L N_NULL`, `N_STRING_FOLLOWING_ESCAPE`, four `T_HEX`, or six symbols for a surrogate pair) onto the same fixed-size stack, while the whole-buffer path consumed those characters inline and pushed nothing. The minimum `stackSize` therefore depended on the chunking: `true` was accepted at `stackSize` 3 when fed whole and returned the sticky `WeaselJson_OVERFLOW` when fed one byte at a time. `RootBuilder`, `testStreaming()` and the corpus only ever used large stacks, so nothing caught it. ## Fix (option 2 from the issue) Keep the existing encoding, but reserve the transient symbols in addition to the caller's budget: * `kTransientSymbols = 6` in `src/parser3.h`, with the upper bound derived and documented there (next to the constant, so it can be kept in sync with the pushes): the largest single push of transient symbols is `t_hex2()`'s six symbols for a UTF-16 surrogate pair, and none of those continuations pushes while another's symbols are pending, so at most six are on the stack at once. The bound is tight: with 5, `"\ud801\udc37"` streamed byte by byte still overflows at `stackSize` 3 while the whole-buffer parse succeeds. * `WeaselJsonParser_create()` allocates `stackSize + kTransientSymbols` slots (`Parser3::allocationSize()`). * Nesting pushes still go through `push()` and are bounded by `stackEnd = stack() + stackSize`, so `WeaselJson_OVERFLOW` keeps meaning exactly "the document's nesting exceeds `stackSize`"; the new `pushTransient()` writes in the reserve past `stackEnd` and so cannot fail. * `include/weaseljson.h` documents only the guarantee callers can rely on - that the accept/reject/overflow decision depends on the document's bytes and on `stackSize`, not on how the bytes are split across `parse()` calls. How that is achieved is left to the implementation (per review feedback). ## Tests * New doctest `acceptance is independent of the chunking`: for literals, escapes, surrogate pairs, nested containers and a few invalid documents, sweeps `stackSize` 3..16, splits each document in two at *every* offset and also feeds it at strides 1..12, and requires the status to equal the whole-buffer status. Verified that it fails on the pre-fix parser (`true`, `stackSize` 3, cut 1: whole buffer accepts, two chunks return `WeaselJson_OVERFLOW`). * `src/fuzz.cpp`: `testStreaming()` now sweeps stack sizes `{3,4,5,6,7,8,12,1024}` instead of only 1024, and requires the status of every streaming pattern (strides 1..15 and every single split point) to match the whole-buffer status *exactly* - previously a REJECT-vs-OVERFLOW difference was tolerated. Verified that the `fuzz_corpus` replay aborts on the pre-fix code (the corpus contains a small `null` document) and passes with the fix. All existing tests pass (`ctest`, Release/gcc including `overflow state is sticky`, `create rejects too-small stack`, python bindings and the schemagen tests), the chunking tests are clean under valgrind, and an additional out-of-tree randomized check (200k random documents, random stack sizes 3..12, random split points) found 49k whole-vs-split status mismatches on the pre-fix parser and none with the fix.
weaselbot added 1 commit 2026-10-27 20:20:19 +00:00
parser3: reserve stack slots for chunk-split literals and escapes
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64, true) (pull_request) Successful in 1m3s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64, false) (pull_request) Successful in 54s
CI / pre-commit (pull_request) Failing after 49s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64, true) (pull_request) Successful in 1m46s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64, false) (pull_request) Successful in 1m36s
ac619af24d
WeaselJson_OVERFLOW is documented as "json is too deeply nested", and
stackSize as a nesting-depth budget. In practice the pushdown stack also
held tokenizer progress for a `true`/`false`/`null` literal or a \uXXXX
escape that straddled the end of a chunk: the whole-buffer path consumes
those characters inline and pushes nothing, while the slow path pushed up
to six symbols (a surrogate pair) onto the same stack. So the stackSize a
document needed depended on where the parse() boundaries fell: e.g. "true"
was accepted at stackSize 3 when fed whole but returned the sticky
WeaselJson_OVERFLOW when fed one byte at a time.

Reserve slots for those symbols in addition to the supplied stackSize,
as a fixed upper bound rather than by changing the encoding: the largest
single push of transient symbols is t_hex2()'s six symbols for a surrogate
pair, and no transient symbol is ever pushed while another one is on the
stack, so kTransientSymbols = 6 is enough. Nesting pushes are still bounded
by stackSize, so OVERFLOW now means exactly what the header says and the
accept/reject/overflow decision is a function of the input bytes and
stackSize only.

Document the reserve in weaseljson.h and cover the property with tests:
a doctest sweeping stackSize 3..16 over documents whose literals, escapes
and surrogate pairs land on every chunk boundary, and a fuzz harness that
now sweeps small stack sizes and requires the status of every chunking
(stride 1..15 plus every single split point) to match the whole-buffer
status exactly.
@@ -57,0 +53,4 @@
* increases the depth of nested json accepted. `stackSize` is an exact budget:
* whether a document is accepted, rejected or reported as too deeply nested
* depends only on its bytes and on `stackSize`, not on how the bytes are split
* across WeaselJsonParser_parse calls. To get that guarantee, the parser
Owner

How we achieve this is an implementation detail and doesn't belong in the public header

How we achieve this is an implementation detail and doesn't belong in the public header
andrew marked this conversation as resolved
weaselbot added 1 commit 2026-10-27 21:31:51 +00:00
Address review: keep the transient-token reserve out of the public header
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64, true) (pull_request) Successful in 1m12s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64, false) (pull_request) Successful in 53s
CI / pre-commit (pull_request) Failing after 47s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64, true) (pull_request) Successful in 1m44s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64, false) (pull_request) Successful in 1m42s
507be6f0a2
The create() documentation described how the reserve is implemented (six
extra slots for literals and \uXXXX escapes that straddle a chunk
boundary). That is an implementation detail; the header should only
promise the behaviour callers can rely on, i.e. that the accept/reject/
overflow decision depends on the document's bytes and on stackSize, not
on how they are split across parse() calls. The derivation of the upper
bound lives next to kTransientSymbols in src/parser3.h, where it can be
kept in sync with the pushes.

Also make the chunking test cover every split point, not just fixed
strides: each document is split in two at every possible offset, so every
literal, escape and surrogate pair in the fixture straddles a chunk
boundary. Verified that this fails on the pre-fix parser ("true",
stackSize 3, cut 1: whole buffer accepts, two chunks overflow).
weaselbot force-pushed weaselbot/issue-71 from 5785912b6c to 507be6f0a2 2026-10-27 21:31:51 +00:00 Compare
weaselbot added 1 commit 2026-10-27 22:53:14 +00:00
ci: don't hardcode ~/.local/bin when running pre-commit
CI / pre-commit (pull_request) Successful in 54s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64, true) (pull_request) Successful in 59s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64, false) (pull_request) Successful in 52s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64, true) (pull_request) Successful in 1m44s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64, false) (pull_request) Successful in 1m50s
bce971fbd2
The pre-commit job failed with

    /root/.local/bin/pre-commit: No such file or directory

even though `pipx install pre-commit` succeeded: `pipx install` writes the
app to PIPX_BIN_DIR, and the runner image no longer uses ~/.local/bin for
it. Run the binary from PATH when pipx put it there, and fall back to
`pipx run pre-commit` otherwise, so the hook still runs regardless of
where pipx exposes its apps.
andrew merged commit 426b2641ff into main 2026-10-30 13:05:59 +00:00
andrew deleted branch weaselbot/issue-71 2026-10-30 13:06:04 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/weaseljson#72