spsc-fat: backpressure-preserving pool-exhaustion policy (default) — keep the hardware-ARQ contract under consumer stalls - #366
Conversation
…keep the hardware-ARQ contract under consumer stalls The spsc-fat ring's drop-at-exhaustion converts receiver congestion into ACKed-but-undelivered loss the TX peer logs as delivered (bench: 5,468-5,739 per escalation run). The new PoolExhaust policy decides which contract survives overload: - backpressure (default): at exhaustion the completed URB PARKS bufferless — its payload still reaches the consumer queue, the ring shrinks, the chip FIFO fills and the chip declines further ACKs, so overload loss happens chip-side where the ARQ loop sees and retries it. The consumer's buffer-return path re-arms parked URBs, with the submit held under pool_mu so teardown's parking_closed flip strictly excludes a late launch of an uncancellable infinite-timeout URB; teardown retires the parked set before its cancel pass (they are not in flight and would hang the active-drain). Parked URBs stay counted in `active` — the pump loop must keep running while the whole ring is parked. - drop: the previous behaviour, kept for latency-over-integrity consumers, still counted as pool_dropped. Config: DeviceConfig::Rx::pool_exhaust / DEVOURER_RX_POOL_EXHAUST (backpressure|drop). Telemetry: rx.ring gains pool_stalls (cumulative park events — nothing is lost host-side on that path); docs/logging.md schema row updated. Harness: DUT_POOL_EXHAUST passthrough in arq_e2e_delivery.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAdd backpressure-preserving pool-exhaust policy for spsc-fat RX ring
AI Description
Diagram
High-Level Assessment
Files changed (8)
|
Code Review by Qodo
1. Teardown frees parked callback
|
…or parked URBs Review round on #366, both real: - The park path queued the payload and ticked counters AFTER inserting the URB into the parked list — but the moment it is parked, teardown may retire it, finish, and destroy the stack-owned shared state, leaving the callback epilogue (queue mutex, cv, counters) racing a destroyed object when it runs on a co-running TX event pump. The park insertion is now the callback's final shared-state access, mirroring the retire paths' trailing active-- that teardown already blocks on. - Plain async self-terminates on unplug (every URB error-retires, active hits 0, rx_loop returns); parked URBs never complete, so a dead device left the loop spinning until Stop(). A `dead` latch — set from a NO_DEVICE transfer status or submit error — now exits the loop, and the parked set retires through the normal teardown path. Validated: 48/48 ctest; fourth consecutive backpressure escalation run with acked_undelivered=0 (pool_stalls 1,445, pool_dropped 0); and an AddressSanitizer-instrumented escalation run (DEVOURER_SANITIZE=address, same stall/park/teardown load) with zero sanitizer reports across DUT, drone-sim and witness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
) Steps 1 and 4 of #362 (issue stays open for the rate-fallback and deadline research, steps 2–3). ## What's added - **`tests/arq_retry_sweep.sh`** — runs the #359 arq_e2e bench once per `DEVOURER_TX_RETRY_LIMIT`, tabulates delivered% / drops / mean retries (the airtime proxy), and hands each run to the residual analyzer. - **`tests/arq_fec_dimension.py`** — post-ARQ residual gap-length distribution per run: the quantity a wfb-style (K,N) block FEC must cover. Header states the caveats: single-gap-per-window mapping, near-field scope, and unreported-frames-count-as-lost (conservative — the safe direction for dimensioning). - **`docs/scheduled-mac.md`** — the measured curve + recommendation next to the existing retry-knob paragraph. ## Measured (8812CU retrying TX → 8812EU duplex ground airing PixelPilot-shaped feedback bursts, ~1 k fps collision regime, near-field) | limit | delivered% | drops | mean retries | residual | gap P99.9 | FEC to cover | |---|---|---|---|---|---|---| | 3 | 99.72 | 352 | 0.035 | 0.26% | 62¹ | — | | 8 | 99.97 | 34 | 0.039 | 0.03% | 3 | **K=8 → N=11** | | 16 | **100.00** | 0 | 0.054 | 0.00% | — | residual gap-free | | 32 | 100.00 | 0 | 0.063 | — | — | +17% retries over 16 for nothing | ¹ single 62-frame gap in one run — one occurrence, P99 is still 3; not treated as a distribution feature. Queue-time p99 is flat (~10–11 fw units) across all limits; only the rare worst case doubles (18 → 35) and then stops growing — deep retries are rare enough that latency does not enter the trade. **Recommendation carried in the docs:** `DEVOURER_TX_RETRY_LIMIT=16` on an ARQ link (zero bench loss at +5.4% retry airtime), or `8` plus a light FEC floor (K8/N11-class) where airtime is precious. Retries are backoff-spaced, so the field default of 3 can burn entirely inside one 2–3 ms ground-station feedback burst — that is where its 0.26% residual comes from. Also re-derived from the recorded #359/#366 ledgers (posted to #362): the stall-regime contrast — plain-async stalls produce ~110-frame contiguous gaps no sane FEC rate covers (rate 0.07), while the #366 backpressure policy bounds the same stalls to ≤13-frame gaps. FEC should be sized to the collision residual; stall regimes are a ring-policy problem. ## Validation Four full on-air bench runs (one per limit, ~129 k frames each, frame-exact three-ledger accounting from #359); analyzer parity-checked against the exploratory version on the recorded baseline. `ctest` 48/48 (no library changes in this PR). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes #361.
Problem
The spsc-fat ring keeps the USB ring armed by discarding the payload at pool exhaustion — after the chip already admitted and ACKed the frame. Under a hardware-ARQ peer that converts receiver congestion into silent, unrecoverable loss the TX side logs as delivered: the exact field failure from the PixelPilot ACK/retry deployment, reproduced on the bench in #359 (5,468–5,739 ACKed-but-undelivered per escalation run).
Change
PoolExhaustpolicy (DeviceConfig::Rx::pool_exhaust,DEVOURER_RX_POOL_EXHAUST=backpressure|drop, default backpressure):pool_muso teardown'sparking_closedflip strictly excludes a late launch of an uncancellable infinite-timeout URB, and teardown retires the parked set before its cancel pass (parked URBs are not in flight — the active-drain would otherwise hang). Parked URBs stay counted inactive: the pump loop must keep running while the whole ring is parked.pool_dropped).Telemetry:
rx.ringgainspool_stalls(cumulative park events). Docs:docs/logging.mdschema row + the CLAUDE.md ACK-horizon paragraph. Harness:DUT_POOL_EXHAUSTpassthrough intests/arq_e2e_delivery.sh.Validation (on-air, 8812EU DUT / 8812CU drone-sim / 8814AU witness, per-seq three-ledger accounting)
Escalation conditions throughout: 40 µs/frame consumer cost + 150 ms consumer freeze every 3000 frames vs a ~2.3 k fps retrying unicast flood — the conditions that reliably produce ~5.5 k contract violations on the drop policy.
Bonus measured property: burst-phase ok% under backpressure is ~98.6 vs ~93 for plain async under identical stalls — the pool still absorbs most of each stall and only the tail converts to chip backpressure, so the mode keeps spsc-fat's smoothness while restoring async's honesty. Visible (retryable) drops per burst phase: ~150 vs async's ~700–900.
All five runs exited cleanly through the parked-retire teardown path;
ctest48/48. Residual noted for the record: an ASan-instrumented run of the parked-URB lifecycle (per #361's teardown checklist) has not been done — the path was exercised by five hardware teardowns, four of them with parked URBs plausible at kill time.🤖 Generated with Claude Code