Skip to content

PushParser: scope the input chunk's lifetime to write() instead of the struct - #98

Open
dgrantpete wants to merge 2 commits into
kaidokert:mainfrom
dgrantpete:push-parser-per-call-input
Open

PushParser: scope the input chunk's lifetime to write() instead of the struct#98
dgrantpete wants to merge 2 commits into
kaidokert:mainfrom
dgrantpete:push-parser-per-call-input

Conversation

@dgrantpete

@dgrantpete dgrantpete commented Aug 17, 2026

Copy link
Copy Markdown

Fixes #97.

What

PushParser loses its 'input struct lifetime; write() now takes the
chunk with a per-call borrow. Callers can feed chunks from a reused receive
buffer — the embedded network-loop shape — which previously failed to
borrow-check.

How

PushContentBuilder keeps only the state that must survive across write()
calls and no longer stores the chunk slice. write() pairs it with the
current chunk in a new per-call PushChunkExtractor view (crate-internal),
which implements ContentExtractor and DataSource for the duration of the
call. Since write() already copied any partial token into the scratch
buffer before returning (copy_partial_content_to_scratch) and then reset
the stored slice, no behavior changes — set_chunk/reset_input simply
disappear, along with the possibility of the chunk outliving the call.

Tests

  • New push_parser_buffer_reuse.rs: parses through a single fixed buffer
    that is overwritten (and scrambled past the chunk length) between write
    calls, at chunk sizes 1–32, with tokens deliberately split across reuse
    boundaries: strings, \u escapes, a surrogate pair, multi-byte UTF-8,
    numbers.
  • Full existing suite passes unchanged (234 unit + all integration + doc
    tests). The only test edit is push_parser_stress_test.rs dropping the
    now-gone lifetime parameter.
  • Downstream validation: we also ran a ~900 KB real-world JSON corpus
    through a reused 4 KB buffer at chunk sizes 1 / 7 / 1379 / 4096 and
    compared event streams against whole-buffer parses — identical.

Semver note

Breaking for code that names the lifetimes explicitly
(PushParser<'input, 'scratch, H, C>PushParser<'scratch, H, C>);
construction through new() with inference is unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_014XVVgKWoTRowDeeVySv5Sj

Summary by Sourcery

Scope PushParser input chunks to each write call via a new per-call extractor, removing the long-lived input lifetime from the parser while preserving behavior.

Enhancements:

  • Refactor PushParser to use PushChunkExtractor so input chunks are only borrowed for the duration of write calls and internal state persists separately.
  • Simplify PushParser type signature by dropping the input lifetime parameter, relying on scratch-buffer lifetime and per-call chunk borrowing.
  • Adjust content extraction and data source implementations to work through the new per-call extractor and expose parser state for finish().

Tests:

  • Add push_parser_buffer_reuse test to verify correct parsing when chunks are fed from a reused receive buffer across various chunk sizes.
  • Update push_parser_stress_test to use the revised PushParser lifetimes and chunk type.

…e struct

Fixes kaidokert#97.

PushContentBuilder no longer stores the chunk slice (and so loses its
'input parameter); write() pairs the persistent builder state with the
chunk in a per-call PushChunkExtractor view, which implements
ContentExtractor and DataSource for the duration of the call. Since any
partial token is already copied into the scratch buffer before write()
returns, no behavior changes — set_chunk/reset_input simply disappear
along with the possibility of the chunk outliving the call.

This lets callers feed chunks from a reused receive buffer (the embedded
network-loop shape), which previously failed to borrow-check. New
regression test covers that pattern, including tokens split across
reuse boundaries (strings, \u escapes, surrogate pairs, multi-byte
UTF-8, numbers).

Note: breaking for code that names PushParser's lifetimes explicitly
(PushParser<'input, 'scratch, H, C> -> PushParser<'scratch, H, C>);
construction through new() with inference is unaffected.
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review available on request

  • 🔍 Trigger review

Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment @coderabbitai review to review the latest changes. For a full review, comment @coderabbitai full review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b2265711-7446-4b9c-bf62-7c8e0cd2fbf7


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The DataSource impl for PushChunkExtractor introduces four lifetime parameters ('i, 's, 'a, 'chunk, 'scratch) with a single where 'chunk: 'i constraint; consider simplifying this by tying 'i/'s directly to 'chunk/'scratch (or using fewer named lifetimes) to make the lifetime relationships easier to understand and maintain.
  • Several PushChunkExtractor methods (e.g. handle_byte_accumulation, apply_unescaped_reset_if_queued) are thin delegations to PushContentBuilder; you might consider whether some of these can be called directly on the builder from the call sites to keep the wrapper surface smaller and reduce indirection.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `DataSource` impl for `PushChunkExtractor` introduces four lifetime parameters (`'i, 's, 'a, 'chunk, 'scratch`) with a single `where 'chunk: 'i` constraint; consider simplifying this by tying `'i`/`'s` directly to `'chunk`/`'scratch` (or using fewer named lifetimes) to make the lifetime relationships easier to understand and maintain.
- Several `PushChunkExtractor` methods (e.g. `handle_byte_accumulation`, `apply_unescaped_reset_if_queued`) are thin delegations to `PushContentBuilder`; you might consider whether some of these can be called directly on the builder from the call sites to keep the wrapper surface smaller and reduce indirection.

## Individual Comments

### Comment 1
<location path="picojson/src/push_content_builder.rs" line_range="259-268" />
<code_context>
+    pub(crate) fn copy_partial_content_to_scratch(&mut self) -> Result<(), ParseError> {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The number token start-position invariant is subtle; consider making it more explicit or guarded.

The `State::Number(start_pos)` branch assumes `start_pos` always refers to the character *before* the first digit, so `content_start = start_pos + 1` is correct only under that invariant. If the parser ever changes `State::Number` to point at the first digit instead, this will silently become off‑by‑one. Adding an assertion for this invariant or a helper to derive `content_start` from `State` would make the code more robust and maintainable.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +259 to +268
pub(crate) fn copy_partial_content_to_scratch(&mut self) -> Result<(), ParseError> {
// Determine the start of the current token content based on parser state
let content_start = match self.builder.parser_state {
State::String(start_pos) | State::Key(start_pos) => {
// For strings and keys, content starts after the opening quote
start_pos + 1
}
State::Number(start_pos) => {
// For numbers, start_pos points to the character before the first digit
// so we need to add 1 to get to the actual number content

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The number token start-position invariant is subtle; consider making it more explicit or guarded.

The State::Number(start_pos) branch assumes start_pos always refers to the character before the first digit, so content_start = start_pos + 1 is correct only under that invariant. If the parser ever changes State::Number to point at the first digit instead, this will silently become off‑by‑one. Adding an assertion for this invariant or a helper to derive content_start from State would make the code more robust and maintainable.

… trait lifetimes

The receiver's implied bounds already provide 'chunk: 'i / 'scratch: 's,
and a comment now explains why DataSource's lifetimes are not tied to the
struct's: call sites take short reborrows of the view, which lives
shorter than the chunk.
@dgrantpete

Copy link
Copy Markdown
Author

Went through the Sourcery feedback — one item taken, two declined with reasons:

Lifetime parameters on the DataSource impl — partially taken, in 4fd6eea.
The where 'chunk: 'i clause was redundant (the &'i self receiver's implied
bounds already provide it), so it's gone, and the impl now carries a comment
explaining why the trait lifetimes are independent of the struct's. The
suggested alternative — tying 'i/'s to 'chunk/'scratch — doesn't
compile: get_content_piece takes source: &'input D, so a
DataSource<'chunk, 'scratch> impl would require a &'chunk-long borrow of
the extractor, and the extractor (which holds &'a mut on the builder) lives
shorter than the chunk. Independent parameters let call sites infer short
reborrows, with variance shrinking &'chunk [u8] to &'i [u8].

Thin delegation methods — declined. The call sites in write() cannot
reach the builder directly: the PushChunkExtractor holds the exclusive
borrow of it for the duration of the call — that exclusivity is the point of
the design. The alternative (a pub(crate) field access like
extractor.builder.…) trades two one-line methods for exposed internals;
the delegations seemed the smaller surface.

Number start-position invariant (start_pos + 1) — agreed it's subtle,
but declined here to keep this PR move-only: that branch (and its twin in
extract_number) is pre-existing logic relocated verbatim from the old
PushContentBuilder::copy_partial_content_to_scratch, and the same + 1
convention is shared with SliceParser/StreamParser via their content
builders. Guarding it properly (a helper on State, or an assertion) touches
all three parsers and deserves its own change rather than riding a lifetime
refactor. Happy to send that as a follow-up if there's interest.

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.

PushParser: 'input as a struct lifetime prevents feeding chunks from a reused buffer

1 participant