PEGO

026. Bounded Stream Text Snapshots

  • Status: Implemented
  • Author: @ornew
  • Date: 2026-10-11

Summary

Reuse an immutable snapshot of text that a stream parser has already loaded. A text value wholly inside that snapshot can share its backing string; cache misses may build a new bounded snapshot at the requested start. Empty ranges return empty strings; requests with too little loaded input and oversized ranges keep the existing copy behavior.

Motivation

Streaming input is held in mutable bytes or runes and consumed prefixes are compacted. Text returned to syntax nodes and actions must remain stable after compaction or buffer reuse. Copying every requested range preserves that contract, but nearby ranges can repeatedly copy already-loaded text.

Goals

  • Preserve returned text in byte and code-point position units.
  • Bound the backing source text retained by each cache-backed substring.
  • Avoid read-ahead and leave mutable input buffers and discard behavior unchanged.
  • Return empty ranges as empty strings; keep requests with too little loaded input and oversized requests on the existing copy path.
  • Demonstrate useful allocation reductions without material regressions in streaming or batch controls.

Non-Goals

  • Change stream chunking, UTF-8 decoding or input-buffer ownership.
  • Return unsafe views of mutable input storage.
  • Remove copies for oversized ranges, ranges with too little loaded input, or non-streaming ranges.
  • Change capture-frame, undo-trail or result-child-storage lifetimes.

Design

Public Behavior and Contracts

There is no public API change. Returned strings remain immutable and valid independently of later reads, parsing, compaction or parser reuse. An empty range returns an empty string. The cache may only use positions already loaded; requesting text must never read from the underlying reader.

Implementation and Invariants

Each parser input owns one immutable snapshot with a start position, end position and normalized source string. In code-point mode, a parser-owned offset table maps positions into the snapshot's UTF-8 bytes. The candidate bounds a snapshot to 1,024 positions and at most 4,096 bytes.

A request wholly within the current snapshot can share its backing string. On a cache miss, a request of at most 1,024 positions can create a new snapshot at its requested start when at least 64 positions are already loaded there. This also handles a request crossing the previous snapshot's edge. Larger requests and requests with fewer than 64 loaded positions use the existing detached-copy path. Cache construction is clamped to available input and does not call the reader. Position arithmetic must avoid overflow near MaxInt by limiting the loaded distance before adding it to the start.

Byte-mode snapshots preserve raw bytes. Code-point snapshots use the existing rune-to-string normalization, including replacement of malformed input with U+FFFD. Offsets are built from the normalized string. The candidate does not mutate or alias the parser's mutable input buffer. When compaction makes the cache obsolete, the cache owner is released; already returned strings may keep at most one bounded snapshot as their backing storage. Undo-trail and old live- root sibling references are separate retention concerns and are not changed by this design.

Compatibility and Backend Support

The mechanism applies to closure, recursive VM and iterative VM streaming in the engine. Generated Go, typed Go and TypeScript parsers do not expose this streaming path. The private pego_reference_stream_text build tag selects the copying reference implementation without adding a public option.

Alternatives Considered

  • Copy every range: simple ownership and the reference behavior; retained as fallback for requests with too little loaded input and oversized ranges.
  • Expose mutable input as a string: unsafe because later compaction or reuse could invalidate returned text; rejected.
  • Replace the stream buffer with immutable chunks: could share more ranges, but changes decoding, boundary and discard behavior; deferred as a broader design.
  • Cache without size or loaded-input thresholds: risks retaining much more source text for small requests and constructing snapshots unlikely to be reused; rejected for this candidate.

Testing

Compare optimized and reference behavior in both position units and all three engine runtimes. Cover malformed UTF-8, random ranges, exact and crossing snapshot boundaries, oversized requests, fewer than 64 loaded positions, one-byte reads, early EOF and positions near MaxInt. Verify text access does not trigger a read. Retain emitted node and action text through subsequent stream elements and compactions, and verify results match whole-input parsing. Test that obsolete cache owners are released while previously returned strings remain valid. Measure backing retention separately from cumulative allocation.

Performance and Results

Current focused measurements and tradeoffs are summarized in optimization 088. They use the bounded-snapshot source against parent revision b18832d.

Limitations and Open Questions

  • Retained-text tests cover emitted values across stream commits; stale sibling references still require a separate ownership investigation.
  • Undo-trail references and older live-root sibling references can retain storage independently of the snapshot owner; their cleanup is a separate concern.
  • Measurements should be repeated on the target environment if cache bounds or eligibility thresholds change.