P3: incremental parser chunk-invariance - #7
Merged
Conversation
The streaming parser produced different event streams depending on how
the input was chunked, so a consumer reacting to events could observe a
different document based purely on byte arrival timing. Several bugs:
- Close-token stall: in stateAfterValue, a closing '}'/']'/')' only
bounced to the parent state and returned 0, which Feed treated as
EventNeedMore — so e.g. `{a=1}` stalled at '}' and never finished.
- Implicit-separator stall: a whitespace-separated next item likewise
returned 0 after a state change, stalling (`{a:1 b:2}` stopped at 'b').
- Early emission: `t`/`f`/`true`/`null` and numbers were emitted as soon
as a prefix was seen, so feeding `true` byte-by-byte yielded Bool(true)
+ bare "rue", and `30` yielded 3 then 0.
- Path accumulation: object keys were never popped between sibling
fields, and nested list indices collapsed, so paths were wrong.
- Sum close ')' was never handled at all (no EndSum, frame never popped).
Rework:
- process() treats a state/stack/path change as progress, so zero-width
transitions never stall; genuine no-progress means need-more.
- Extensible tokens (number, identifier/keyword, ref, bare key) only emit
once terminated by a non-token byte or at end-of-input. An `atEnd` flag,
set by End(), flushes the final pending token.
- closeContainer() consumes the matching close token, unwinds the path to
the container's entry depth (tracked per frame), pops, and emits the
right End event — including EventEndSum for ')'.
- updatePathIndex() is frame-relative, fixing sibling/nested list paths;
object keys are popped on each field transition.
Adds incremental_chunk_test.go: the property
events(feed_all) == events(feed_one_byte) == events(split_at_every_byte)
over scalars, containers, nested structs, sums and malformed input, plus
direct "container finishes" and "sibling paths don't accumulate" tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
phenomenon0
force-pushed
the
feat/p3-incremental-chunks
branch
from
June 20, 2026 01:49
940cd6b to
285ba23
Compare
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.
Stage 4 — P3 incremental / streaming chunk-invariance
The streaming
IncrementalParseremitted different event streams depending on how the input was chunked, so a consumer reacting to events could observe a different document purely because of byte-arrival timing.Bugs fixed (
incremental.go)stateAfterValue, a closing}/]/)only bounced to the parent state and returned0, whichFeedtreated asEventNeedMore— so{a=1}stalled at}and never emittedEndObject.0after a state change → stall ({a:1 b:2}stopped atb).t/f/true/false/nulland numbers were emitted as soon as a prefix was seen: feedingtruebyte-by-byte producedBool(true)then bare"rue";30produced3then0.{a:1 b:2}→ paths.a,.a.b…), and nested list indices collapsed.)unhandled. No state consumed), soEventEndSumwas never emitted and sum frames never popped.Rework
process()treats a state / stack / path change as progress, so zero-width transitions (closing a container, an implicit separator) never stall; genuine no-progress = need-more.atEndflag, set byEnd(), flushes the final pending token. (Keyword/bool/null detection moved into the unifiedparseIdentifier.)closeContainer()consumes the matching close token, unwinds the path to the container's entry depth (tracked per frame aspathLen), pops the frame, and emits the correct End event — includingEventEndSumfor).updatePathIndex()is now frame-relative, fixing sibling and nested-list paths; object keys are popped on each field transition.Tests (
incremental_chunk_test.go)events(feed_all(x)) == events(feed_one_byte_at_a_time(x)) == events(split_at_every_single_byte(x)), over scalars, refs, containers, nested structs/lists, sums, multi-byte∅/UTF-8, whitespace, and malformed input. (EventNeedMoreis excluded from the comparison — its frequency is inherently chunk-dependent.)TestIncrementalParserFinishesContainers— directly asserts the stall is gone ({a=1},[1 2 3],Tag(5), etc. finish with their End event and no error).TestIncrementalSiblingPathsDoNotAccumulate—{a:1 b:2 c:3}yields key paths.a .b .c.All pre-existing
incremental_test.gotests still pass.Verification
ulimit -v 6000000; go test -C go ./glyph/ -skip 'Industrial|Cliff|Perf|Savings|Benchmark' -count=1→ PASS.go vetclean. Heavy memory-bomb tests skipped locally for memory safety — CI runs them.Note
EventNeedMoreis deliberately excluded from the invariant (a byte-by-byte feed naturally raises far more need-more signals than a single feed). The structural event sequence is what must be — and now is — identical.Do not merge — for maintainer review.