Skip to content

fix(repeater): the receive window could not contain a frame (#1297) - #1313

Merged
dc0sk merged 1 commit into
mainfrom
fix/1297-repeater-accumulating-rx
Sep 8, 2026
Merged

fix(repeater): the receive window could not contain a frame (#1297)#1313
dc0sk merged 1 commit into
mainfrom
fix/1297-repeater-accumulating-rx

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #1297.

What was wrong

relay_one_frame_at called engine_rx.receive(...), which opens an input stream, reads once, and drops it. On a callback backend each attempt therefore saw a fresh buffer covering one poll interval — tens of milliseconds against a seconds-long frame. The cross-band repeater could not receive on real audio however long it ran.

This is the #1118 shape on a shipping surface, and capture_burst's own comment already recorded the mechanism: a fresh cpal stream needs tens of ms to start delivering, so reopening per tick never warms up.

Why a green suite never saw it

LoopbackBackend::read drains the whole buffer, so the buffer IS the frame and one receive call gets all of it. Every existing test in the crate feeds it that way.

The new gate defeats exactly that: it delivers one frame's audio one chunk per read via push_frame. Sabotage-verified — reverting the RX to engine_rx.receive(...) fails it with "no frame relayed within 16 ticks" while the rest of the suite stays green.

A shared helper, not a second copy

server.rs's rx ticker already does held-stream + accumulate_capture by hand. Open-coding it here would have made the repeater the second copy, with ARDOP and KISS to follow — and my assumption that their TCP-driven shape made a per-call window correct is false: both poll continuously (ardop/src/bridge.rs:430, kiss/src/bridge.rs:237).

So it is openpulse_modem::capture_ticker::CaptureTicker. Adoption by the other three is #1310, deliberately not in this PR: refactoring a working receive path inside a fix for a broken one trades risk for tidiness.

Two things the implementation taught me that the design did not

The stream cannot live on the struct. Box<dyn AudioInputStream> is not Send (a cpal Stream is not, on most hosts) and the daemon moves the repeater into a thread, so a field made CrossBandRepeater unspawnable. Caught by the workspace build, not by design. It is a loop local, which is what server.rs does for the same reason.

Twelve existing tests needed a relay_until helper, because one call is now one capture tick rather than one receive attempt — the burst flushes on the first empty read after the frame. That is how the daemon's loop experiences it, so the tests are more faithful, but it is a real contract change on a public method.

Also fixed: a second defect #1300 introduced

That PR's Err => Ok(None) arm swallowed ModemError::Audio from open_input alongside the demodulation errors it was written for. A repeater whose RX device could not be opened — no default input, or ALSA EBUSY because the daemon already holds that card — was indistinguishable from a quiet band, forever, at DEBUG. The ticker reports the first fault at WARN and retries.

What this does NOT deliver

It does not make the repeater work on a station, and the ledger says so:

  • The cross-band repeater has no audio device configuration — both its engines use the OS default input #1308 — the repeater cannot be pointed at a second sound card. Now scoped: the RX device is a one-line bug (cfg.audio.device exists and is never passed), only the TX device is a genuine config gap, and fixing the first creates a capture collision with the daemon's own rx_ticker. Blocked on a maintainer ruling.
  • The relay is payload-for-payload, not wire-for-wire — receive_from_samples returns frame.payload and the tx engine re-frames with its own sequence counter.
  • decode_burst decodes with FecMode::None, so FEC-coded traffic is not relayable at all — under hpx_hf that is everything surviving a fade.

The honest claim for the record is "cannot relay on cpal at HEAD by construction, and no on-air run is recorded" — not "has never relayed", which was inference presented as fact in my original filing and has been corrected on the issue.

GATE: PASS e7c04953dbf946a227f2cf87cceef3e9f0bdaab0 clean — 326 suites, 2503 passed, 0 failed.

Consumer

daemon/src/lib.rs:2631 EnableRepeater spawns run_full_duplex, the sole production consumer; it owns the CaptureTicker as a loop local.

Prior art

server.rs:886-1030 is the working open-coded version of this pattern, added because reopening per tick "never warms up on real hardware" — its own words, describing the repeater's behaviour precisely.

Twins

Checked rather than assumed, and the answer produced #1310: openpulse-ardop and openpulse-kiss call receive in free-running loops and have the same structural defect; server.rs has the correct pattern open-coded. CaptureTicker's module doc names all three so the next reader does not re-derive it.

Implements: REQ-DEV-01

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

`relay_one_frame_at` called `engine_rx.receive(...)`, which opens an input
stream, reads once and drops it. On a callback backend each attempt therefore
saw a fresh buffer covering one poll interval — tens of milliseconds against a
seconds-long frame — so the cross-band repeater could not receive on real audio
however long it ran.

`LoopbackBackend` hides this completely: its read drains the whole buffer, so
the buffer IS the frame and `receive` works. That is why a green suite never
saw it, and it is what the new gate had to defeat: the frame is delivered one
chunk per read via `push_frame`, and reverting to `receive()` fails it.

Extracted as `CaptureTicker` in `openpulse-modem` rather than open-coded here.
`server.rs`'s rx ticker already does this by hand, and ARDOP and KISS both call
`receive` in free-running loops with the same defect — the assumption that their
TCP-driven shape makes a per-call window correct is false. Their adoption is
#1310, kept out of this PR: refactoring a working receive path inside a fix for
a broken one trades risk for tidiness.

The stream is a loop local, not a struct field: `Box<dyn AudioInputStream>` is
not `Send` and the daemon moves the repeater into a thread, so a field would
make `CrossBandRepeater` unspawnable.

Also closes a second defect in the arm #1300 added: its `Err => Ok(None)`
swallowed `ModemError::Audio` from `open_input`, so a repeater whose RX device
could not be opened was indistinguishable from a quiet band, forever, at DEBUG.

This does not make the repeater work on a station. #1308 — no config field
anywhere can name a second sound card — is still open, and the relay is
payload-for-payload with `FecMode::None`, so FEC-coded traffic is not relayable
at all.

Implements: REQ-DEV-01

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
@dc0sk
dc0sk merged commit c4e0549 into main Sep 8, 2026
8 of 9 checks passed
@dc0sk
dc0sk deleted the fix/1297-repeater-accumulating-rx branch September 8, 2026 16:35
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.

The repeater's RX window is one 10 ms poll of a fresh buffer, so on hardware it can never contain a frame

1 participant