Route streaming buffers through read_streaming in glz::read - #2734
Merged
Conversation
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
force-pushed
the
streaming-buffer-dispatch
branch
from
July 31, 2026 18:09
2e94197 to
b48289b
Compare
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A streaming buffer satisfies
contiguous, so it bound to the bufferedglz::readand was parsed as a flat span: one window, withnull_terminatedleft 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
mainat 310f270:read_jsonhasis_input_streamingoverloads — both the out-parameter form and theexpected-returning one — and is safe. It is the only helper in the library that does.read_jsonc,validate_json,validate_jsonc,read_beveand directglz::readcalls 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 withnull_terminatedoff and refills as it goes.Doing it at
readrather 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 existingread_jsonoverloads 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_contextand reports the outcome back through the caller's context.The buffered overloads change from
contiguous auto&& bufferto a namedcontiguous Bufso 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_streamingforcesnull_terminatedoff, 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_refillappears only injson/read.hppandjson/skip.hpp, so the JSON reader is the only one with refill points.read_jsoncandglz::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_streamingremoves 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:nonenonestreaming_unsupportedunexpected_endstreaming_unsupportedThe 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 inforward.hppso it is visible where formats are. It describes the reader, not the grammar: NDJSON isfalsebecause 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_streamingthen reportserror_code::streaming_unsupportedwhen a non-refilling reader was not shown the whole document. The check asks whether the source is exhausted, not whether the window is drained — hencestreaming_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_codeso no existing value shifts; REPE puts these on the wire.Tests
For the dispatch: a 400-element array through
read_jsonc, genericread, and genericreadwith a caller-supplied context — the success path, plus an error path checking the error reaches the caller's context — with a guard thatread_jsonstill behaves. They trip ASAN againstmainas 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. Plusstatic_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) andjson_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 = trueover 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::readwas two additive hunks. Re-verified on the new base: the repro above still reproduces againstmainat 310f270, so #2732 did not incidentally fix this.One interaction the rebase creates: the helpers this PR newly routes to
read_streamingnow inherit #2732's absent-value check, soread_jsoncon a stream holding only whitespace reportsno_read_inputinstead 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_streamingignoresvalidate_trailing_whitespace; that block lives only in the bufferedread.read_streamingsurfacesend_reached, whichcontext.hppdocuments as "a non-error code". It reaches the caller as one because the depth is non-zero, sofinalize_read_contextdoes 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.