Skip to content

fix: bound parser stall on oversized-length garbage prefixes - #173

Merged
tylerkron merged 2 commits into
mainfrom
fix/protobuf-parser-resync
Apr 24, 2026
Merged

tylerkron merged 2 commits into
mainfrom
fix/protobuf-parser-resync

Conversation

@tylerkron

Copy link
Copy Markdown
Contributor

Summary

  • Caps MaxMessageSizeBytes at 4 KB (down from 1 MB) so a garbage varint encoding tens of KB no longer parks the parser waiting for data that will never arrive.
  • Adds gap + resync gates to the partial-frame wait path so a plausible-under-the-cap bogus prefix can't stall indefinitely and doesn't lead to re-entering the same garbage on every call.
  • Regression test RecoversFromOversizedPrefix asserts a varint for 32768 is rejected and the parser recovers a valid frame sitting right behind it.

Context

v0.19.6 (#169) fixed the unparseable bytes case (advance 1 byte per parse failure, no retry cap). This addresses the other failure mode: bytes that varint-encode a plausible large length. Under the old 1 MB cap, the parser accepted the declared length and took the "wait for more data" branch. Buffer grew unbounded, zero MessageReceived events fired, Live Graph stayed empty despite the device LED blinking.

Diagnosed against a physical Nyquist1 on /dev/cu.usbmodem101 driven by daqifi-core-example-app --mimic-desktop. Core at HEAD streams cleanly at 1 Hz and 100 Hz with 16 analog + 16 digital channels both before and after this fix, so no regression on the happy path.

Test plan

  • dotnet test src/Daqifi.Core.Tests/Daqifi.Core.Tests.csproj — 820 passed (both net8.0 and net9.0)
  • New test ProtobufMessageParser_ParseMessages_RecoversFromOversizedPrefix fails on main, passes with this change
  • Live streaming @ 100 Hz, 32 channels, 5s → 992 stream messages
  • Live streaming @ 1 Hz, 32 channels + 300ms inter-command delays, 8s → 16 stream messages

🤖 Generated with Claude Code

Streaming stalled indefinitely against USB-Serial (Microchip CDC) when
the boot-time byte stream happened to varint-encode a large length. The
parser accepted any declared length up to 1 MB, then took the "wait for
more data" path because the buffer was nowhere close to that size. The
buffer grew unbounded, zero MessageReceived events fired, and the
desktop Live Graph stayed empty despite the device LED blinking.

The v0.19.6 leading-garbage fix handled *unparseable* bytes (1-byte
advance on parse failure) but not this case — the bytes parsed fine as
a length prefix; they just declared an impossibly large frame.

Two changes:

  * Cap MaxMessageSizeBytes at 4 KB. Real DaqifiOutMessage frames are
    well under 1 KB at 32 channels; initial-status frames that carry
    channel metadata are a few KB at most. 4 KB is generous for any
    legitimate frame and tight enough to reject the large-prefix
    garbage pattern immediately.

  * In the partial-frame wait path, add two escape gates:
      - Gap gate: declared length > MaxPartialFrameGapBytes (1 KB)
        beyond what's currently buffered is treated as garbage. Real
        partials leave gaps in hundreds of bytes, not kilobytes.
      - Resync gate: if we've already advanced in this call but not
        yet produced a message, keep advancing one byte at a time
        instead of stopping on the next plausible prefix. Without this,
        a bogus 3-byte prefix can reduce to a 2-byte bogus continuation
        after one advance and re-stall on every call.

Added regression test `RecoversFromOversizedPrefix` that prepends a
varint for 32768 and asserts the parser resyncs to the valid frame
that follows. Before the fix: 0 messages, 0 bytes consumed — exactly
the desktop symptom. After the fix: the valid frame is recovered.

All 820 existing Core tests still pass. Live repro against
/dev/cu.usbmodem101 (32-channel Nyquist1) still streams cleanly at
both 1 Hz and 100 Hz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tylerkron
tylerkron requested a review from a team as a code owner April 24, 2026 03:31
@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Fix parser stall on oversized-length garbage prefixes with resync gates

🐞 Bug fix 🧪 Tests

Grey Divider

Walkthroughs

Description
• Cap MaxMessageSizeBytes from 1 MB to 4 KB to reject oversized garbage prefixes immediately
• Add gap and resync gates to prevent indefinite stalls on plausible-but-bogus length prefixes
• Add regression test RecoversFromOversizedPrefix validating parser recovery from garbage
Diagram
flowchart LR
  A["Boot-time garbage<br/>on serial port"] -->|"varint encodes<br/>large length"| B["Parser receives<br/>plausible prefix"]
  B -->|"Old: 1 MB cap"| C["Wait indefinitely<br/>for data"]
  C -->|"Buffer grows<br/>unbounded"| D["Stall: 0 messages<br/>parsed"]
  B -->|"New: 4 KB cap +<br/>gap/resync gates"| E["Reject as garbage<br/>& resync"]
  E -->|"Advance 1 byte<br/>at a time"| F["Recover valid frame<br/>behind prefix"]
Loading

Grey Divider

File Changes

1. src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs 🐞 Bug fix +56/-6

Add size caps and resync gates to prevent parser stalls

• Reduced MaxMessageSizeBytes from 1 MB to 4 KB to immediately reject oversized prefixes
• Added MaxPartialFrameGapBytes constant (1 KB) to detect implausible gaps between declared and
 available bytes
• Implemented gap gate: treat declared lengths exceeding available data by more than 1 KB as garbage
• Implemented resync gate: continue advancing one byte at a time if already resyncing without having
 parsed a message yet

src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs


2. src/Daqifi.Core.Tests/Communication/Consumers/ProtobufMessageParserTests.cs 🧪 Tests +45/-0

Add regression test for oversized prefix recovery

• Added new regression test RecoversFromOversizedPrefix that reproduces the streaming stall
 scenario
• Test prepends a 3-byte varint encoding 32768 (0x80 0x80 0x02) before a valid DaqifiOutMessage
 frame
• Asserts parser correctly rejects the oversized prefix, resyncs, and recovers the valid frame
• Validates that all data is consumed and exactly one message is parsed

src/Daqifi.Core.Tests/Communication/Consumers/ProtobufMessageParserTests.cs


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Apr 24, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Resync gate drops partials ☑ 🐞 Bug ≡ Correctness
Description
In ParseMessages, the new wait-path “resync gate” advances currentIndex even when a valid length
prefix was decoded but the frame is merely incomplete, whenever any bytes were previously skipped in
the same call and no message has yet been produced. Because callers remove the first consumedBytes
from their buffers even when zero messages were parsed, this can permanently discard the first
byte(s) of a legitimate frame that spans reads (e.g., leading garbage + partial first real frame).
Code

src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[R106-112]

+                if (messageLength - remainingData.Length > MaxPartialFrameGapBytes
+                    || (consumedBytes > 0 && messages.Count == 0))
+                {
+                    currentIndex++;
+                    consumedBytes = currentIndex;
+                    continue;
+                }
Evidence
ParseMessages treats an incomplete frame as garbage and advances by 1 byte when `(consumedBytes > 0
&& messages.Count == 0)` (even if the length prefix was successfully read), which can move the
consumption point into the start of a real frame. StreamMessageConsumer then unconditionally removes
the first consumedBytes from _messageBuffer regardless of whether any messages were produced,
and SdCardFileParser similarly discards the first consumed bytes when carrying forward
leftovers—so advancing into a real-but-incomplete frame irreversibly drops needed prefix/payload
bytes.

src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[81-115]
src/Daqifi.Core/Communication/Consumers/StreamMessageConsumer.cs[198-207]
src/Daqifi.Core/Device/SdCard/SdCardFileParser.cs[171-189]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ProtobufMessageParser.ParseMessages` currently advances into the start of a frame when the length prefix is valid but the frame is incomplete, solely because `consumedBytes > 0 && messages.Count == 0`. Downstream consumers treat `consumedBytes` as “safe to drop from the buffer”, so this can permanently discard prefix/payload bytes of a real frame that spans reads after leading garbage.

### Issue Context
This happens in the partial-frame wait branch (`remainingData.Length < prefixBytes + messageLength`). The parser should only consume bytes that are known to be garbage. If a plausible prefix is found and the missing gap is within tolerance, the parser should stop and let the caller retain those bytes for completion.

### Fix Focus Areas
- src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[81-115]

### Suggested direction
- Remove/relax the `(consumedBytes > 0 && messages.Count == 0)` skip behavior for incomplete frames.
- If you need to ensure progress while resyncing, consider consuming only the bytes *before* the candidate prefix (e.g., set `consumedBytes = currentIndex; break;`) rather than `currentIndex++`.
- Add a regression test: leading garbage + start of a valid delimited frame with truncated payload should not consume into the valid prefix/payload (i.e., consumedBytes should only reflect the garbage).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

2. Gap gate miscomputes bytes ☑ 🐞 Bug ≡ Correctness
Description
The partial-frame “gap gate” compares messageLength - remainingData.Length, but remainingData
includes the length-prefix bytes; this makes the gap check inconsistent with the intent (“declared
length vs bytes available for the message”). While the magnitude is small (varint prefix is 1–5
bytes), it is still a correctness mismatch in the heuristic.
Code

src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[R106-107]

+                if (messageLength - remainingData.Length > MaxPartialFrameGapBytes
+                    || (consumedBytes > 0 && messages.Count == 0))
Evidence
The code is already in the branch where remainingData.Length < prefixBytes + messageLength, so the
relevant ‘available payload bytes’ are remainingData.Length - prefixBytes. Using
remainingData.Length underestimates the missing payload by prefixBytes.

src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[81-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The gap calculation uses `messageLength - remainingData.Length`, but `remainingData.Length` includes the varint length prefix. The intended gap should be based on payload availability (`remainingData.Length - prefixBytes`).

### Issue Context
This is a heuristic, but keeping it mathematically consistent makes behavior easier to reason about and avoids edge-threshold surprises.

### Fix Focus Areas
- src/Daqifi.Core/Communication/Consumers/ProtobufMessageParser.cs[81-112]

### Suggested change
Compute missing payload as:
- `var availablePayload = remainingData.Length - prefixBytes;`
- `var missingPayload = messageLength - availablePayload;`
and compare `missingPayload` to `MaxPartialFrameGapBytes`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Two fixes in response to qodo-code-review on #173:

1. Resync gate could discard real frame bytes (Bug, Correctness).
   The previous partial-frame wait path advanced `currentIndex` whenever
   any bytes had already been skipped in this call and no message had
   been produced yet. `StreamMessageConsumer` and `SdCardFileParser`
   both trim `consumedBytes` from their buffers regardless of whether
   messages were parsed, so this could permanently discard the first
   byte(s) of a legitimate frame that happened to arrive split across
   reads behind leading garbage.

   Replaced the resync gate with a protobuf field-tag plausibility
   check. When at least one body byte is available in the partial,
   peek at it: real DaqifiOutMessage frames always start with a valid
   field tag (wire_type in {0,1,2,5}, field_number >= 1). A byte that
   fails those checks isn't a real frame start, so it's treated as
   garbage. Multi-byte tags (continuation bit set) can't be fully
   validated from a single byte and are allowed through — the caller
   waits for more data rather than risk discarding a real frame.

   This preserves recovery from the oversized-prefix stall (the
   original motivation) without eating into real partial frames.

2. Gap gate used the wrong baseline (Bug, Correctness).
   `messageLength - remainingData.Length` counts the varint prefix
   bytes against the "missing payload" figure. The correct calculation
   is `messageLength - (remainingData.Length - prefixBytes)`, i.e.
   declared payload minus available payload. Small difference in
   magnitude (1–5 bytes) but inconsistent with the intent of the
   heuristic.

Also added a regression test `PreservesPartialFrameAfterGarbage` that
asserts the parser consumes only the garbage when followed by an
incomplete real frame — leaving the partial for the caller to
complete on the next read. All 823 tests pass (10 parser tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Addressed both Qodo findings in 31168cf:

1. Resync gate drops partials (Bug): Fixed. Removed the consumedBytes > 0 && messages.Count == 0 resync gate and replaced it with a protobuf field-tag plausibility check. When at least one body byte is available, the parser peeks at it — a real DaqifiOutMessage always starts with wire_type ∈ {0,1,2,5} and field_number ≥ 1; a byte that fails both is garbage, anything else is let through for the caller to complete. This keeps the oversized-prefix recovery working without eating real partial frames. Also added the regression test Qodo suggested (PreservesPartialFrameAfterGarbage): leading garbage + truncated real frame → consumedBytes stops exactly at the real frame boundary, zero messages parsed.

2. Gap gate miscomputes bytes (Bug): Fixed. Gap is now messageLength - (remainingData.Length - prefixBytes) — i.e., declared payload minus available payload. The magnitude was small (1–5 bytes) but this makes the heuristic consistent with its intent.

All 823 tests pass (10 parser tests).

@tylerkron
tylerkron merged commit 7d027b7 into main Apr 24, 2026
1 check passed
@tylerkron
tylerkron deleted the fix/protobuf-parser-resync branch April 24, 2026 04:04
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