Skip to content

Route streaming buffers through read_streaming in glz::read - #2734

Merged
stephenberry merged 2 commits into
mainfrom
streaming-buffer-dispatch
Jul 31, 2026
Merged

Route streaming buffers through read_streaming in glz::read#2734
stephenberry merged 2 commits into
mainfrom
streaming-buffer-dispatch

Conversation

@stephenberry

@stephenberry stephenberry commented Jul 31, 2026

Copy link
Copy Markdown
Owner

A streaming buffer satisfies contiguous, so it bound to the buffered glz::read and was parsed as a flat span: one window, with null_terminated left on, over memory that carries no terminator and ends wherever the last fill stopped. The parser drops its end checks in that mode, so it reads past the window.

std::istringstream iss{json};                    // 400-element array, larger than the window
glz::basic_istream_buffer<std::istringstream, 512> buf{iss};
std::vector<int> v;
glz::read_jsonc(v, buf);

Against main at 310f270:

ERROR: AddressSanitizer: heap-buffer-overflow on address 0x615000000500
READ of size 1 at 0x615000000500 thread T0
    #0 glz::from<JSON, std::vector<int>>::op<glz::opts{10u, true, true, true, true}>   read.hpp:2441
    #1 glz::read_jsonc<std::vector<int>, glz::basic_istream_buffer<..., 512>&>         read.hpp:5292
    #2 main

0x615000000500 is located 0 bytes after 512-byte region [0x615000000300,0x615000000500)

read_json has is_input_streaming overloads — both the out-parameter form and the expected-returning one — and is safe. It is the only helper in the library that does. read_jsonc, validate_json, validate_jsonc, read_beve and direct glz::read calls all funnel into the same buffered overload, so each of them had to remember a rule that none of them stated. glz::read<opts{}>(v, buf) overruns identically.

The fix

Fixed where they meet rather than one helper at a time. The buffered overloads exclude streaming buffers, and a pair of dispatching overloads forwards them to read_streaming, which reads with null_terminated off and refills as it goes.

Doing it at read rather than per format is the point: every helper that funnels through it is covered by the one change, and a format added later inherits the behavior instead of having to opt in. The existing read_json overloads become redundant but are left in place — they route to the same function.

The context-taking overload has nowhere to hold the streaming state the parsers refill through, so it runs the read on a streaming_context and reports the outcome back through the caller's context.

The buffered overloads change from contiguous auto&& buffer to a named contiguous Buf so the constraint can name it. The invented parameter already sat in that position, so the template parameter order is unchanged.

Effect

Two separate things, worth not conflating.

The out-of-bounds read ends for every format. read_streaming forces null_terminated off, so nothing runs the sentinel-trusting parser over an unterminated window any more.

Streaming to completion is JSON-only, and stays that way here. consume_and_refill appears only in json/read.hpp and json/skip.hpp, so the JSON reader is the only one with refill points. read_jsonc and glz::read<opts{}> on JSON now parse a document larger than the window, which they could not do at all before. Every other format still sees one window — the second commit is about saying so.

Buffered reads are untouched: they take the same overloads they always did, and the dispatching pair is unreachable for them.

Second commit: a short read now says it is one

Routing a format to read_streaming removes the overrun. It does not give a reader refill points it never had, and what the non-refilling readers reported when a document outran the window blamed the document rather than the window. Measured, 400 elements through a 512-byte window:

format before after
JSON 400 elements, none unchanged
NDJSON 156 elements, none streaming_unsupported
BEVE unexpected_end streaming_unsupported

The NDJSON row is the one that matters: silent data loss, reported as success. It is not new — it is what reading NDJSON from a streaming buffer has always done — but this PR is what routes those calls here, so it is the right place to stop it.

glz::format_supports_streaming<Format> names which readers can refill, and sits next to the format identifiers in forward.hpp so it is visible where formats are. It describes the reader, not the grammar: NDJSON is false because its line loop cannot refill between lines, even though the per-line values it delegates to the JSON reader can. A user-defined format specializes it.

read_streaming then reports error_code::streaming_unsupported when a non-refilling reader was not shown the whole document. The check asks whether the source is exhausted, not whether the window is drained — hence streaming_state::source_at_eof. at_eof() also demands a drained window, which would call a value that legitimately left trailing bytes a truncated read. Once the source is exhausted the window holds everything there will ever be, so whatever the reader concluded, it concluded on the full input and stands.

The new code is appended to error_code so no existing value shifts; REPE puts these on the wire.

Tests

For the dispatch: a 400-element array through read_jsonc, generic read, and generic read with a caller-supplied context — the success path, plus an error path checking the error reaches the caller's context — with a guard that read_json still behaves. They trip ASAN against main as written and pass here.

For the reporting: both short reads, and the three legitimate cases the check must not touch — a document that fits, a value followed by trailing bytes in the same window, and genuinely malformed input that keeps its own unexpected_end. Plus static_asserts on the trait and a guard that JSON still streams to completion.

Full suite is 108/108. istream_buffer_test (222 tests, 11,727 asserts) and json_test (717 tests, 61,620 asserts) are clean under -fsanitize=address,undefined -fno-sanitize-recover=all.

Notes

Found while working on #2732, which bounded the same defect class — a JSON parser running with null_terminated = true over a buffer that has no terminator — for the RPC registry. This one is reached through a different door.

#2732 has since merged and this branch is rebased onto it; the overlap in glz::read was two additive hunks. Re-verified on the new base: the repro above still reproduces against main at 310f270, so #2732 did not incidentally fix this.

One interaction the rebase creates: the helpers this PR newly routes to read_streaming now inherit #2732's absent-value check, so read_jsonc on a stream holding only whitespace reports no_read_input instead of succeeding with the destination untouched. That is #2732's intended behavior reaching the callers this PR connects to it.

Three limitations are left alone:

  • read_streaming ignores validate_trailing_whitespace; that block lives only in the buffered read.
  • Leading whitespace wider than the window stops the parse short of a value that is really there, because nothing refills during the top-level whitespace skip.
  • Truncated JSON read through read_streaming surfaces end_reached, which context.hpp documents as "a non-error code". It reaches the caller as one because the depth is non-zero, so finalize_read_context does not clear it. Pre-existing and unrelated to this change.

Giving the other readers real refill points is the follow-up this makes possible, and is deliberately not attempted here.

A streaming buffer satisfies `contiguous`, so it bound to the buffered
`glz::read` and was parsed as a flat span: one window, with `null_terminated`
on, over memory that carries no terminator and ends wherever the last fill
stopped. That reads past the window.

    std::istringstream iss{json};                    // larger than the window
    glz::basic_istream_buffer<std::istringstream, 512> buf{iss};
    std::vector<int> v;
    glz::read_jsonc(v, buf);

    ERROR: AddressSanitizer: heap-buffer-overflow
    READ of size 1 ... 0 bytes after 512-byte region
      #0 glz::parse_int<int>                          atoi.hpp:488
      #7 glz::read_jsonc<std::vector<int>, glz::basic_istream_buffer<...,512>>

`read_json` was given its own `is_input_streaming` overloads and was safe.
Nothing else was: `read_jsonc`, `read_beve`, the other per-format helpers and
direct `glz::read` calls all funnel into the same buffered overload, so each
had to remember a rule none of them stated.

Fixed where they meet rather than one helper at a time. The buffered overloads
now exclude streaming buffers and a pair of dispatching overloads forwards them
to `read_streaming`, which reads with `null_terminated` off and refills as it
goes. Every helper that funnels through `read` is fixed by that, and a format
added later inherits it instead of having to opt in.

The context-taking overload has nowhere to hold the streaming state the parsers
refill through, so it runs the read on a `streaming_context` and reports the
outcome back through the caller's context.
@stephenberry
stephenberry force-pushed the streaming-buffer-dispatch branch from 2e94197 to b48289b Compare July 31, 2026 18:09
Routing streaming buffers to read_streaming removed the overrun, but it did
not give a reader refill points it never had. Only the JSON reader has them,
so every other format parsed whatever the first window held and stopped at its
edge -- and reported that in terms that blamed the document:

  - NDJSON returned success carrying only the elements that fit. A 400 element
    document read back 156, with error_code::none. Silent data loss.
  - BEVE returned unexpected_end, which reads as malformed input.

Neither told the caller the window was the cause, and nothing in the API said
which formats could stream at all.

format_supports_streaming names that, next to the format identifiers so it is
visible where formats are. It is a property of the reader rather than of the
grammar: NDJSON is false because its line loop cannot refill between lines,
even though the per-line values it delegates to the JSON reader can.

read_streaming reports error_code::streaming_unsupported when a non-refilling
reader was not shown the whole document. The check asks whether the *source*
is exhausted, not whether the window is drained, which is why streaming_state
gains source_at_eof: at_eof also demands a drained window, and would call a
value that legitimately left trailing bytes a truncated read. Once the source
is exhausted the window holds everything there will ever be, so whatever the
reader concluded it concluded on the full input and stands -- genuinely
malformed input keeps its own error.

The new error code is appended to the enum so no existing value shifts; REPE
puts these on the wire.

Tests cover both short reads, the three legitimate cases they must not touch
(a document that fits, a value with trailing bytes, genuinely malformed
input), and that JSON still streams to completion.
@stephenberry
stephenberry merged commit 1b01f5b into main Jul 31, 2026
60 checks passed
@stephenberry
stephenberry deleted the streaming-buffer-dispatch branch July 31, 2026 19:12
stephenberry added a commit that referenced this pull request Jul 31, 2026
* Give the NDJSON reader refill points

NDJSON records are independent, so the gap between two of them is a natural
place for a streaming window to release what has been parsed and pull in
more. The reader had no such point: it walked one window and stopped at its
edge, which #2734 had to report as streaming_unsupported because the
alternative was returning the records that happened to fit and calling that
a complete document. 400 records through a 512 byte window now read as 400
records rather than as an error.

The three loops over records -- filling a presized container, growing one,
and filling a fixed-arity tuple -- each had their own copy of the separator
scan and their own idea of when input had run out. They now share
ndjson_has_next_record and read_ndjson_record, so all three refill.

Two things the split makes explicit:

A run of separators can be wider than the window, so finding the next record
loops rather than refilling once. Otherwise a window ending in newlines
looks like the start of a record and a blank-line-only document reads as one
empty record.

parse<Format>::op takes `end` by value, so a reader that refills partway
through leaves its caller holding the edge of the window the parse started
in. The JSON reader does exactly that inside an object or array, and `it`
moves with the refill, so the two stop bounding the same span and offsets
taken from them run past the buffer. resync_window_end re-derives it.

Refill points sit between values and never inside one, so a single string or
number still has to fit in the window. One that does not now reports
streaming_unsupported naming the buffer, instead of unexpected_end blaming a
document that is not malformed. A record cut off at the end of the source is
still a truncated document: the source is exhausted, so the window is not
what ran out.

format_supports_streaming<NDJSON> is true accordingly, and the streaming doc
now states which readers can refill and what still has to fit in a window.

* Refill NDJSON by record delimiter, not by byte count

Review found that enabling streaming for NDJSON on a byte-count refill
policy corrupts data silently. Two defects, both measured against a
buffered read of the same document.

A record cut by the window edge was accepted as complete. Refilling only
once the window was half spent left records straddling the edge, and a
bare token has no closing delimiter to miss: the JSON reader parses it
cleanly up to the window end and reports end_reached at depth zero,
which is exactly what a record ending with the buffer looks like. The
tail then read as the next record. "1\n" + 510 spaces + "2\n" through a
512 byte window returned [1, 0, 2] with a success code; two wide numbers
returned a fabricated third value.

Records that fit the window were rejected. A record had to fit in
whatever the previous record left over, not in the window, so a 400 byte
record in a 512 byte window failed with streaming_unsupported and a
message that said it was wider than the window. Across randomized
mixed-width documents 426 of 3000 disagreed with the buffered read.

Both come from asking a byte count a question it cannot answer. Records
are newline delimited, so "does the window hold a whole record" is
answerable before parsing: look for the delimiter, and refill until it
shows up or the source runs dry. The limit becomes one a user can reason
about -- a record and its newline must fit in the window -- rather than
one that depends on where the previous record happened to end. The same
sweep now reports 0 of 3000.

Two further fixes fell out of it. A CR whose LF had not arrived yet was
read as a stray carriage return, so a CRLF split by the window edge
rejected a well-formed document; skip_record_separators now leaves that
undecided for the caller, which refills. It also handles both line
endings in one loop, so a CRLF following a bare LF is a separator rather
than the start of a record -- that one predates this branch.

refill_window clamps the consumed count instead of trusting the
iterator. A reader can leave it past the window end on an error path and
a variant resolved by shape rewinds it, either of which underflowed the
count and left the next refill memmoving a garbage length.

Tests: a differential suite against the buffered read over randomized
mixed-width records, which is what catches this class; separators and
CRLF spanning the window edge; a record wider than the window's
remainder. Fixed-width records pass almost anything, which is why the
first round of tests missed all of it.

Docs: the window section stated a limit that was never the real one, and
now also warns that reading into std::string_view or raw_json_view under
streaming silently yields dangling views. That one is not new and is not
NDJSON specific -- plain JSON streaming corrupts 28 of 40 elements the
same way -- so it is documented here rather than fixed here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant