Skip to content

Report truncated non-null-terminated input as unexpected_end - #2735

Merged
stephenberry merged 2 commits into
mainfrom
fix/end-reached-leak
Jul 31, 2026
Merged

Report truncated non-null-terminated input as unexpected_end#2735
stephenberry merged 2 commits into
mainfrom
fix/end-reached-leak

Conversation

@stephenberry

Copy link
Copy Markdown
Owner

error_code::end_reached is documented in the enum as a non-error code. It is meant to be internal to a non-null-terminated read, saying only "the buffer ran out here" — which is a completed read or a truncated one depending on where it happened. Nothing settled which, so it escaped to callers.

Any non-null-terminated read of truncated JSON returned it:

static constexpr glz::opts options{.null_terminated = false};
glz::generic j{};
glz::read<options>(j, "[1,2");  // end_reached, a documented non-error

Since #2732 every registry read runs non-null-terminated, so this reached the wire: a truncated REPE body answered its client with end_reached in the response header, telling it the request parsed and merely stopped early. The existing truncated_body_is_an_error test asserted only != none, so it passed either way.

The fix

ctx.depth already carries the answer. A value that closed cleanly leaves it at zero, so the buffer ended exactly where the value did and the read succeeded. Any other depth means the buffer ran out with containers still open, which is truncated input.

settle_end_reached applies that at the top level, called from finalize_read_context so buffered and streaming reads settle identically and no call site can forget. end_reached no longer escapes a read at all; json_stream_reader, which raises it itself to signal end of stream, is unaffected.

The enum comment now says what the code is for and who is allowed to see it.

Tests

  • json_test: truncated non-null-terminated input reports unexpected_end across six shapes ([1,2, [[1,2],[3, {"a":1, ...), and six complete documents still report none.
  • istream_buffer_test: a truncated stream, and a truncation only discovered after several refills.
  • registry_view_test: truncated_body_is_an_error now names unexpected_end instead of accepting any non-none code.

Full suite: 108/108.

A non-null-terminated read raises end_reached to say "the buffer ran out
here". Whether that is a completed read or a truncated one is settled at the
top level from ctx.depth: zero means the value closed cleanly and the buffer
ended exactly where it did, anything else means containers were still open.
finalize_read_context cleared the first case and let the second escape, so a
truncated parse returned end_reached to the caller -- a code the enum
documents as a non-error, which reads as a success that stopped early:

    glz::read<{.null_terminated = false}>(v, "[1,2")   -> end_reached
    glz::read_json(v, istream_buffer)  on "[[1,2],[3"  -> end_reached

Both halves now settle in one place and it returns unexpected_end.

This reaches the wire. Since #2732 every registry read runs without a
terminator, so a truncated REPE body answered its client with end_reached in
the response header. The registry test that covered truncation only asserted
"not none" and so passed either way; it names the code now.

end_reached is left in the enum and keeps its value -- the readers use it
internally and REPE puts these codes on the wire. Its comment now says it is
internal, and names json_stream_reader as the one place that still surfaces
it, as its own end-of-stream signal rather than a parse result.
Three gaps around the fix, none in settle_end_reached itself.

A completed partial read left ctx.depth standing. A partial read stops
once it has the fields it wants and unwinds on an error code, so its
enclosing containers never decrement, and finalize_read_context cleared
the error without clearing the depth. Since this change makes depth the
sole arbiter of truncation, a context reused after a partial read then
settled the next well-formed buffer to unexpected_end -- a valid read
reported as failing. Cleared where the error is cleared. Resetting depth
at read entry instead would have been wrong: a custom reader may call
glz::read on an in-flight context, and that would erase its accounting.

The enum comment claimed a read never returns end_reached. read_jmespath
and get_view_json return ctx.error without finalizing, so they still do.
They navigate by matching braces rather than by entering depth, so their
ctx.depth is zero throughout and settle_end_reached cannot simply be
dropped in -- it would settle their truncated reads to none, which is
worse than leaking. The comment now says which entry points are covered
and why those two are not.

read_json_stream had no test, and it is where this mattered most: a
stream whose last record is cut off stops on end_reached, which the
reader treats as end of stream, so the caller got a short vector and a
success. Silent data loss in a public API. Covered now, along with a
stream that ends between records, which must still succeed.
@stephenberry
stephenberry merged commit 072b23b into main Jul 31, 2026
60 checks passed
@stephenberry
stephenberry deleted the fix/end-reached-leak branch July 31, 2026 22:22
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