Skip to content

Windowed RX receipts: app-layer delivery truth, frame-exact against the rx.seq ledger (#363 step 3) - #370

Merged
josephnef merged 2 commits into
masterfrom
rx-receipts
Aug 4, 2026
Merged

Windowed RX receipts: app-layer delivery truth, frame-exact against the rx.seq ledger (#363 step 3)#370
josephnef merged 2 commits into
masterfrom
rx-receipts

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Closes #363.

The tier this completes

The ARQ campaign established the two lower tiers' limits: the hardware ACK's horizon is chip-FIFO admission (it can never confirm host delivery — #359), and per-frame CCX reports collapse against the fw's ~1.3 k reports/s emission ceiling (#368, sampled in #369). This PR adds the tier neither can reach: the receiving application counts what it consumed and mails it back.

  • src/cell/RxReceipt.hReceiptWindow (receiver: sliding ring bitmap over the last W frame indices) + ReceiptLedger (transmitter: idempotent merge of overlapping, versioned receipt TLVs; strict-prefix parse; absorbs only receipts naming its own TA). Same caller-side contract as UeRxAttribution: demos feed frames, RX loops untouched, no payload convention assumed.
  • duplex: notes on the existing rx.seq path, injects a receipt frame every DEVOURER_RX_RECEIPT_MS on the feedback path (802.11 data at 6M; concurrent send_packet callers serialize on a demo mutex). DEVOURER_RX_RECEIPT_WINDOW sizes coverage (default 8192).
  • txdemo: DEVOURER_TX_RECEIPTS absorbs and emits every receipt as tx.receipt with the raw TLV hex.
  • tests/receipt_verify.py: replays the TLV merge and demands set-equality with the receiver's own rx.seq ledger — the issue's acceptance bar, literally.
  • receipt_roundtrip ctest selftest; RECEIPT_MS knob in the arq harness; docs (logging.md rows, scheduled-mac.md tier paragraph).

Measured (on air)

regime frames verified receipts verdict
clean, 5 cycles 126,594 1,671 FRAME-EXACT
150 ms consumer stalls @ 2.4 k fps, spsc-fat parking ring 349,455 1,330 FRAME-EXACT

Two bench-taught lessons, in the code

  1. Received bodies carry the trailing FCS — the first live run absorbed zero receipts because the TLV parse demanded exact length; an ACK frame logging len:14 (10 + FCS) was the tell. The parse is strict-prefix now, with the TA-match keeping arbitrary payloads out.
  2. The window must exceed the worst backlog drain in frames — with a 2,048-bit window, a stalled spsc-fat pool draining ~3 k frames inside one receipt interval evicted 2,846 delivered frames before any receipt covered them (MISSING in the verifier, late=0 — eviction, not reordering). The sizing rule is at the class comment: window_bits > pool_bytes / min_frame_bytes + one encode interval of arrivals; the 8192 default clears this bench's worst case ~2.7×.

Validation

ctest 49/49 (new selftest included); four on-air runs (first-light smoke, full-scale clean, failed 2048-window stress — kept as the sizing lesson — and the passing 8192-window stress). The verifier's NO-RECEIPTS and MISMATCH paths were both exercised for real during bring-up.

🤖 Generated with Claude Code

…he rx.seq ledger

Neither the hardware ACK (horizon = chip-FIFO admission) nor CCX reports
(fw emission ceiling ~1.3k/s) can state what the receiving APPLICATION got.
src/cell/RxReceipt.h closes that: the receiver notes every consumed frame
index in a sliding ring bitmap (ReceiptWindow) and mails overlapping,
versioned receipt TLVs on its feedback path; the transmitter merges them
into a delivered-set (ReceiptLedger — idempotent, strict-prefix parse,
absorbs only receipts naming its own TA). Same caller-side contract as
UeRxAttribution: demos feed frames in, the RX loops are untouched, the
library never assumes a payload convention.

Wiring: duplex notes on the existing rx.seq path and injects a receipt
frame every DEVOURER_RX_RECEIPT_MS (802.11 data at 6M, RA = the receipted
transmitter; concurrent send_packet callers serialize on a demo mutex);
txdemo absorbs under DEVOURER_TX_RECEIPTS and emits every receipt as a
tx.receipt event WITH the raw TLV hex, so tests/receipt_verify.py replays
the merge and compares SET-EXACTLY against the receiver's own rx.seq
ledger. tests/arq_e2e_delivery.sh grows the RECEIPT_MS knob; ctest grows
the receipt_roundtrip selftest.

Measured on air, both regimes FRAME-EXACT: 126,594 frames clean (1,671
receipts) and 349,455 frames under 150 ms consumer stalls at 2.4k fps with
the spsc-fat parking ring (1,330 receipts). Two bench-taught lessons are in
the code: received bodies carry the trailing FCS, so the TLV parse is
strict-prefix rather than exact-length (an ACK logging len 14 = 10+FCS was
the tell); and the window must exceed the worst backlog drain in frames — a
2,048-bit window leaked 2,846 delivered frames out of coverage when a
stalled pool drained ~3k frames inside one receipt interval, hence the 8192
default, the DEVOURER_RX_RECEIPT_WINDOW knob, and the sizing rule at the
class comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Windowed RX receipts for app-layer delivery truth (frame-exact vs rx.seq)

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add windowed RX receipt TLV (bitmap window) and TX-side merge ledger for app-layer delivery truth.
• Wire duplex to emit periodic receipt frames and txdemo to absorb/emit receipts for verification.
• Add selftests, an end-to-end verifier, and docs/env knobs for running the receipt tier.
Diagram

graph TD
  Duplex["duplex (RX app)"] --> Window["ReceiptWindow"] --> Wifi["802.11 receipt frame"] --> TxDemo["txdemo (RX thread)"] --> Ledger["ReceiptLedger"]
  Duplex --> RxSeq[("rx.seq log")]
  Ledger --> TxReceipt[("tx.receipt log")]
  RxSeq --> Verify("receipt_verify.py") --> TxReceipt

  subgraph Legend
    direction LR
    _app["Component"] ~~~ _db[("Log / ledger")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Sparse receipt list (ranges/varints) instead of bitmap window
  • ➕ More bandwidth-efficient when delivery is sparse or highly lossy
  • ➕ Avoids needing large windows for extreme backlog drains
  • ➖ More complex on-wire format and merge logic
  • ➖ Harder to make idempotent with simple set-union semantics
  • ➖ Worse CPU/cache behavior than bitmap scan for dense delivery
2. Leverage 802.11 Block Ack (BA) / reorder buffer state
  • ➕ Uses a standardized ACK/bitmap concept at MAC layer
  • ➕ Could be lower overhead than injecting data frames for receipts
  • ➖ Still not application-consumption truth (host delivery and app consume remain above MAC)
  • ➖ Hardware/firmware support and observability are device-specific; harder to make portable and testable
3. Out-of-band receipts over a host control channel (e.g., UDP/TCP)
  • ➕ Avoids RF airtime for control-plane receipts
  • ➕ Easier payload framing; no FCS/trailing bytes ambiguity
  • ➖ Breaks the “same feedback path” property; requires additional transport and configuration
  • ➖ Harder to correlate with RF-only deployments where the feedback path is the radio link

Recommendation: Keep the current overlapping bitmap-window TLV approach: it provides application-layer truth, is naturally loss-tolerant (overlapping windows + idempotent merge), and stays on the existing RF feedback path. The strict-prefix parse plus TA match is a pragmatic robustness measure for real MPDU bodies (including trailing FCS) without imposing payload conventions on the rest of the system.

Files changed (9) +613 / -1

Enhancement (3) +380 / -1
main.cppEmit periodic receipt frames and track consumed indices +97/-1

Emit periodic receipt frames and track consumed indices

• Wires ReceiptWindow into the existing rx.seq path: SA-matched pctr frames are noted and a background thread periodically encodes/sends receipt TLVs as 802.11 data frames at a fixed 6M rate. Adds env knobs (DEVOURER_RX_RECEIPT_MS/_SA/_WINDOW) and serializes send_packet calls via a mutex to support concurrent TX and receipt injection.

examples/duplex/main.cpp

main.cppAbsorb receipt TLVs and emit tx.receipt events +34/-0

Absorb receipt TLVs and emit tx.receipt events

• Adds transmitter-side ReceiptLedger gated by DEVOURER_TX_RECEIPTS; parses incoming plain data frames for receipt TLVs and absorbs only those naming the transmitter TA. Emits tx.receipt events with freshness/coverage counters and the raw TLV hex for offline verification.

examples/tx/main.cpp

RxReceipt.hIntroduce ReceiptWindow + ReceiptLedger and receipt TLV codec +249/-0

Introduce ReceiptWindow + ReceiptLedger and receipt TLV codec

• Adds a new header implementing the receipt TLV format, receiver-side sliding ring bitmap (note/encode/late), strict-prefix TLV decoding tolerant of trailing FCS bytes, and a transmitter-side idempotent merge ledger tracking delivered_total/highest_covered/receipts. Includes sizing guidance for the window to avoid eviction during backlog drains.

src/cell/RxReceipt.h

Tests (3) +214 / -0
CMakeLists.txtAdd ReceiptSelftest executable and ctest +11/-0

Add ReceiptSelftest executable and ctest

• Introduces a new headless selftest target (ReceiptSelftest) covering receipt TLV round-trip, ring eviction, late accounting, strict parsing, and ledger merge idempotence. Registers it as the receipt_roundtrip ctest.

CMakeLists.txt

receipt_selftest.cppAdd unit-style selftest for receipt primitives +109/-0

Add unit-style selftest for receipt primitives

• Adds a headless C++ selftest validating encode/decode round-trip, ring eviction behavior on large index jumps, below-window late accounting, ledger idempotent merging over overlapping receipts, and strict parse/TA rejection semantics (including acceptance of trailing bytes).

tests/receipt_selftest.cpp

receipt_verify.pyAdd offline verifier: receipt set-equality vs rx.seq +94/-0

Add offline verifier: receipt set-equality vs rx.seq

• Adds a Python script that replays every tx.receipt TLV (raw hex) into a merged delivered-set and compares it to the receiver’s rx.seq ledger within the covered domain. Produces explicit NO-RECEIPTS and MISMATCH diagnostics with missing/phantom examples.

tests/receipt_verify.py

Documentation (2) +16 / -0
logging.mdDocument tx.receipt and receipt.tx event emitters/fields +2/-0

Document tx.receipt and receipt.tx event emitters/fields

• Adds logging table entries for txdemo’s tx.receipt events (including raw TLV hex) and duplex’s receipt.tx cadence/late metrics. Clarifies that receipt events must not be decimated because the verifier replays every TLV.

docs/logging.md

scheduled-mac.mdDescribe windowed RX receipts as the app-layer truth tier +14/-0

Describe windowed RX receipts as the app-layer truth tier

• Adds a new paragraph describing the receipt tier above hardware ACK and CCX reports, including measured frame-exact behavior and the window sizing rule learned from stall/backlog-drain tests. Documents default window size and the consequences of undersizing.

docs/scheduled-mac.md

Other (1) +3 / -0
arq_e2e_delivery.shAdd RECEIPT_MS knob to enable receipts in arq_e2e runs +3/-0

Add RECEIPT_MS knob to enable receipts in arq_e2e runs

• Introduces a RECEIPT_MS parameter that, when set, enables duplex receipt emission (DEVOURER_RX_RECEIPT_MS) and arms txdemo receipt absorption (DEVOURER_TX_RECEIPTS=1 with DEVOURER_TX_WITH_RX=thread). Keeps receipts disabled by default.

tests/arq_e2e_delivery.sh

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Ring bit addressing wrong ✓ Resolved 🐞 Bug ≡ Correctness
Description
ReceiptWindow::set_bit/clear_bit/get_bit compute the ring word index from (idx % _w) but compute the
bit offset from (idx % 64), which corrupts the bitmap when window_bits is not a multiple of 64. This
can produce incorrect receipt TLVs under supported configurations (DEVOURER_RX_RECEIPT_WINDOW is not
rounded to a 64-bit multiple).
Code

src/cell/RxReceipt.h[R129-132]

+  void set_bit(uint32_t idx) { _bits[(idx % _w) / 64] |= 1ull << (idx % 64); }
+  void clear_bit(uint32_t idx) {
+    _bits[(idx % _w) / 64] &= ~(1ull << (idx % 64));
+  }
Evidence
The helper methods use (idx % _w) for selecting the word but (idx % 64) for selecting the bit,
which only works when _w is a multiple of 64. The duplex demo explicitly allows arbitrary window
sizes (64..65535), so this incorrect addressing is reachable and will corrupt encoded receipts for
non-aligned values.

src/cell/RxReceipt.h[126-135]
examples/duplex/main.cpp[171-178]

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

## Issue description
`ReceiptWindow` uses inconsistent modulo bases for its ring-slot addressing: the word index uses `(idx % _w) / 64` but the bit position uses `(idx % 64)`. When `_w` is not a multiple of 64, different ring slots can alias to the same physical bit or map to the wrong bit, corrupting receipt encoding.
### Issue Context
`examples/duplex/main.cpp` allows `DEVOURER_RX_RECEIPT_WINDOW` to be any value in `[64, 65535]` (not forced to a 64-bit multiple), so non-aligned values are reachable.
### Fix Focus Areas
- src/cell/RxReceipt.h[126-135]
- examples/duplex/main.cpp[171-178]
### Implementation notes
- Compute a ring slot first: `slot = idx % _w`.
- Use `slot / 64` for the word index and `slot % 64` for the bit index in `set_bit`, `clear_bit`, and `get_bit`.
- Add a selftest case with a non-64-multiple window (e.g. 65 or 100) to prevent regression.

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



Remediation recommended

2. Ledger resize DoS risk ✓ Resolved 🐞 Bug ⛨ Security
Description
ReceiptLedger::absorb resizes its bit-vector to cover (base + nbits) with no sanity bound, so a
single crafted receipt TLV with a huge base can force a very large allocation and potentially
terminate the process. This is externally triggerable in the tx demo when DEVOURER_TX_RECEIPTS is
enabled because receipt bodies from received 802.11 data frames are passed directly into absorb().
Code

src/cell/RxReceipt.h[R197-200]

+    const uint64_t top = static_cast<uint64_t>(v.base) + v.nbits;
+    if (top > _bits.size() * 64)
+      _bits.resize((top + 63) / 64, 0);
+    long fresh = 0;
Evidence
The ledger’s absorb() grows a contiguous vector based solely on (v.base + v.nbits) from the
decoded TLV, without any bounds. The tx demo’s RX callback passes untrusted packet bodies into
absorb() when receipts are enabled, making that resize attacker-controlled and therefore a DoS
risk.

src/cell/RxReceipt.h[192-200]
examples/tx/main.cpp[321-343]

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

## Issue description
`ReceiptLedger::absorb()` trusts the decoded `base`/`nbits` and resizes `_bits` to `(base + nbits)` with no bounds. A receipt with a very large `base` (even with small `nbits`) can cause a massive allocation (or `std::bad_alloc`) and DoS the process.
### Issue Context
In `examples/tx/main.cpp`, when `DEVOURER_TX_RECEIPTS` is set, the RX callback attempts to parse any plain data frame body as a receipt TLV and calls `g_receipt_ledger.absorb(...)`, making the allocation input attacker-controlled over the air (given a syntactically valid TLV and TA match).
### Fix Focus Areas
- src/cell/RxReceipt.h[192-216]
- examples/tx/main.cpp[321-343]
### Implementation notes
Pick one (or combine):
1) **Hard cap / horizon validation**: Reject receipts whose `(base+nbits)` exceeds a configured maximum or is implausibly far ahead of current coverage (e.g., beyond `_highest_covered + K*nbits`, once initialized).
2) **Sparse storage**: Replace the contiguous `std::vector<uint64_t> _bits` with a sparse structure keyed by word index (e.g., `std::unordered_map<uint64_t,uint64_t>`), so large `base` values don’t force contiguous allocation.
3) **Graceful failure**: If you keep `vector::resize`, catch allocation failures and treat the receipt as invalid (return -1) rather than terminating.
Also consider emitting a diagnostic counter/event for rejected receipts when enabled in demos, so bench runs can distinguish loss vs. rejection.

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/cell/RxReceipt.h Outdated
Comment thread src/cell/RxReceipt.h
…n cap

- ReceiptWindow bit addressing derived the word from idx % _w but the bit
  from idx % 64 — equivalent only when the window is a multiple of 64, and
  the size is caller-chosen. Both now derive from the slot; the selftest
  gains a 100-bit window crossing the ring seam.
- ReceiptLedger::absorb bounds the bitmap (default cap 2^26 indices): base
  arrives over the air, and an unbounded resize would let one crafted TLV
  allocate half a gigabyte. Crafted-base rejection covered in the selftest,
  and the reject leaves the ledger untouched.

Validated: 49/49 ctest incl. the new cases; on-air receipts smoke stays
FRAME-EXACT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef
josephnef merged commit a4cd84f into master Aug 4, 2026
24 checks passed
@josephnef
josephnef deleted the rx-receipts branch August 4, 2026 04:08
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.

tx.report at rate: attribute the coverage collapse (53% @ ~2.3k fps), deterministic SPE_RPT sampling, windowed RX receipts

1 participant