Skip to content

Keep waiting for repairs while no complete frame is blocked - #128

Merged
whoisaldo merged 6 commits into
release/0.3.4-candidatefrom
fix/assembler-hold
Oct 29, 2026
Merged

whoisaldo merged 6 commits into
release/0.3.4-candidatefrom
fix/assembler-hold

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What changed

When the PC sends nothing for more than the 100 ms stall allowance, the iPad drops the oldest waiting frame and asks for a keyframe. Until now it did that even when every frame behind it was incomplete too, so the drop unblocked nothing. In a failed run on a starved PC, frames 352 and 353 were both incomplete. Their repairs arrived 348 ms later, after 352 had been dropped, so the PC had to encode an extra keyframe and the picture froze for longer.

The hold rule from #118 already covered a lone incomplete frame. It now covers the oldest incomplete frame whenever no complete frame waits behind it. Instead of being dropped, that frame is asked for again, less often each time (one repair window, then doubling up to 200 ms), and it gets no keyframe request.

  • Rule (FrameAssembler.swift tick(at:), mirrored in reassembly.rs tick): when the oldest frame's deadline plus stall allowance passes, the frame is held if no pending frame is complete and it is less than maxHoldUs past its first gap. Before this change the condition was pending.count == 1.
  • A complete frame behind it: the drop and its keyframe request never come later than before this change for the same input. A frame waiting with newer frames behind it is dropped at its deadline plus stall allowance and requests a keyframe, as before. A frame that is already held waits at most one more repair window, counted from the first fragment of the first newer frame, partial or complete, which is the same anchor as before. The cap only takes effect once a newer frame is complete, because only then is anything blocked. A frame held while newer frames already waited behind it would have been dropped then. When a complete frame arrives, it goes at once (dropBy in FrameAssembler.swift, drop_by in reassembly.rs).
  • Backstop: this reuses the existing 1 s hold bound (maxHoldUs / MAX_HOLD, from Stop the USB keyframe spiral and wait for late repairs on Wi-Fi #118), measured from the oldest frame's first gap. When it passes with nothing complete behind, the oldest frame is dropped. The incomplete frames behind it are dropped under the same keyframe request, up to but not including the first pending keyframe. A keyframe needs no earlier frame, so it keeps its own deadline and is shown if it completes. As with any drop that has only a partial keyframe behind it, the drop still requests a keyframe. With the hold's retry cadence, the backstop fires at the first hold deadline past 1 s (1.1 s in the traces).
  • FrameAssembler.swift was changed first as the specification. proto/src/reassembly.rs mirrors it, and the shared traces in proto/testdata/repair_vectors.txt cover both.

Why reuse the 1 s hold

An existing bound already describes how long a frame with nothing decodable behind it may wait: the hold limit added in #118 for a lone frame. Reusing it keeps one rule for "nothing is blocked", whether one incomplete frame is waiting or several. A frame's ordinary deadline is too short to serve: deadline plus stall allowance is about 125 ms, and that is exactly the drop this change removes. One second covers the observed 348 ms PC stall with room to spare, and a dead stream still recovers within about 1.1 s.

Compatibility

There is no wire change. Only the iPad's receive-side timing changes, and the Rust mirror is used only by host tests.

  • iPad 0.3.4 with a PC on 0.3.1–0.3.3: older PCs answer NACKs the same way and rate-limit keyframe requests (KEYFRAME_REQUEST_MIN_INTERVAL). This change sends fewer keyframe requests and repeats NACKs the PC already accepts.
  • PC 0.3.4 with an iPad on 0.3.2/0.3.3: host code is untouched.
  • scripts/e2e_stream_ui.sh passed in both mixed directions against v0.3.3 on an iPad simulator (results below).

Tests

New unit tests, Swift and Rust

  • Two incomplete frames, PC silent for 350 ms, then both repairs. No drop and no keyframe request, and both frames decode. Swift: testTwoIncompleteFramesWaitOutASilentPCWithoutAKeyframe. Rust: two_incomplete_frames_wait_out_a_silent_pc_without_a_keyframe.

  • Incomplete frames with a complete one behind them: today's behaviour. Drops at 125.000 ms and 141.667 ms, one keyframe request each, then the complete frame is shown. Swift: testACompleteFrameBehindIncompleteOnesIsNotHeldBack. Rust: a_complete_frame_behind_incomplete_ones_is_not_held_back.

  • Dead stream: the backstop fires once at 1.1 s, drops both frames and requests one keyframe. After that, a late repair is ignored, no more NACKs are sent, and the keyframe is shown. Swift: testADeadStreamGivesUpOnceWithOneKeyframeRequest. Rust: a_dead_stream_gives_up_once_with_one_keyframe_request.

  • Backstop with a keyframe arriving behind the held frame (352 held from 0 ms, keyframe 353 from 1,090 ms to 1,105 ms). 352 is dropped at 1.1 s with one keyframe request, and 353 is still shown. Swift: testTheHoldBackstopKeepsAKeyframeThatIsStillArriving. Rust: the_hold_backstop_keeps_a_keyframe_that_is_still_arriving.

  • Held 352, partial 353 at 160 ms, complete 354 at 170 ms. 352 is dropped with a keyframe request at 185 ms, one repair window after 353 began, as before this change. Swift: testAHeldFrameWaitsOneWindowFromTheFirstNewerFrame. Rust: a_held_frame_waits_one_window_from_the_first_newer_frame.

  • A frame held with a partial frame already behind it is dropped as soon as a complete frame arrives. Swift: testACompleteFrameEndsAHoldThatNewerFramesOverran. Rust: a_complete_frame_ends_a_hold_that_newer_frames_overran.

Shared traces (repair_vectors.txt), which both implementations pass:

  • New: the 352/353 case. This trace fails on the base at line 139, where 352 is dropped.
  • New: a complete frame behind incomplete ones.
  • New: a dead stream.
  • New: the backstop with a keyframe arriving behind the held frame. On the base it gives the same numbers.
  • New: the one-window cap counted from a partial newer frame (drop and keyframe request at 185 ms). On the base it gives the same numbers.
  • New: a frame held with a partial frame behind it, then a complete frame. The drops and keyframe requests match the base. There is one extra NACK from the hold.
  • Changed: "A partial keyframe cannot cover loss…". With only partial frames waiting, nothing is dropped any more; the keyframe retires both older frames when it completes, with zero keyframe requests. The inputs are unchanged.
  • Added: a variant of that trace with a complete delta frame behind. It keeps coverage that a partial keyframe does not suppress keyframe requests.
  • Every other existing trace is unchanged.

Simulations, base 920d228 → previous head f44e307 → head (debug, deterministic)

proto/tests/wifi_repair_sim.rs. With the base's drop timing restored, the head matches the base in every column except NACKs, which are up to 3 higher:

Scenario Decodable Broken Dropped Keyframe requests NACKs p90 ms
still desktop, 4 fps, contiguous 2285 → 2285 → 2285 99 → 99 → 99 9 → 9 → 9 7 → 7 → 7 3637 → 3637 → 3637 79.606 → 79.606 → 79.606
still desktop, 4 fps, capture 2283 → 2283 → 2283 97 → 97 → 97 14 → 14 → 14 9 → 9 → 9 3664 → 3664 → 3664 137.2 → 137.2 → 137.2
moving, 60 fps, contiguous 6520 → 6524 → 6520 581 → 578 → 581 99 → 98 → 99 62 → 62 → 62 18076 → 18079 → 18077 5.363 → 5.368 → 5.363
moving, 60 fps, capture 6535 → 6538 → 6535 562 → 561 → 562 103 → 101 → 103 61 → 61 → 61 18174 → 18182 → 18177 11.992 → 11.996 → 11.992
moving, 120 fps, contiguous 13083 → 13084 → 13083 1143 → 1143 → 1143 174 → 173 → 174 86 → 86 → 86 26353 → 26354 → 26354 4.844 → 4.847 → 4.844
moving, 120 fps, capture 12995 → 12996 → 12995 1199 → 1199 → 1199 206 → 205 → 206 91 → 91 → 91 26481 → 26482 → 26482 7.551 → 7.552 → 7.551

host/tests/usb_link_sim.rs is identical at all three commits. USB doesn't negotiate repair, and every change is inside the repair path:

Run Decodable Forced keyframes Host drops Longest freeze ms
healthy-batched 1201/1201 0 0 28
2.0 MB/s 1200/1200 0 0 44
1.0 MB/s 1200/1200 0 0 71
0.7 MB/s 1200/1200 0 0 95
0.55 MB/s 1200/1200 0 0 117
0.4 MB/s 735/1200 12 465 157
backlog 841/1200 12 347 69

host/tests/usb_link_differential.rs passed 7 of 7, with identical output at all three commits:

  • stress: 4000 schedules, 1571 base-clean, 0 differences on base-clean schedules;
  • realistic: 400 schedules, 23 base-clean, 0 differences on base-clean schedules.

e2e: cargo test -p eternal-host --test e2e_synthetic lossy_stream_recovers_and_adapts passed (load about 180). 899 of 899 frames decoded, 0 keyframe requests, 0 frames dropped, 681 fragments repaired, 857 NACKs.

Checks, with PKG_CONFIG_PATH=/opt/homebrew/opt/ffmpeg@7/lib/pkgconfig:

  • cargo fmt --check: clean.
  • cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace: 841 passed, 0 failed, 2 ignored.

iOS unit tests (scripts/test_ios.sh -only-testing:EternalMonitorTests, iPad Pro 11-inch M4):

  • iOS 26.3 (Xcode 26.3): 352 tests, 0 failures.
  • iOS 18.6 (Xcode 16.4): 349 tests, 0 failures, on f44e307. Not rerun for this change.

Mixed-version stream UI (scripts/e2e_stream_ui.sh, iPad Pro 11-inch M4, iOS 26.3). Run on f44e307. The wire is unchanged since then:

  • This branch's app against the v0.3.3 host: PASS, 6 of 6 (load about 870 at the start).
  • The v0.3.3 app and its UI tests against this branch's host: PASS, 6 of 6.
  • Both cover live audio, Wi-Fi repair diagnostics (the host drops 3% of datagrams), USB connect and disconnect, cable fallback and HUD hiding.

Release notes

On Wi-Fi, when the PC briefly stops sending, for example under heavy load, the iPad now waits for the missing pieces of a frame instead of discarding it and asking for a fresh keyframe. The picture resumes as soon as the PC catches up, without the extra freeze a new keyframe caused. If the PC stays silent for more than a second, the iPad still asks for a keyframe once.

When the PC sent nothing for more than the 100 ms stall allowance, the
iPad dropped the oldest waiting frame and asked for a keyframe even when
every frame behind it was incomplete too, so the drop unblocked nothing.
A starved PC went silent with frames 352 and 353 incomplete; their repairs
arrived 348 ms later, after 352 had been dropped, and the PC had to encode
an extra keyframe.

The hold that already covered a lone frame now covers any oldest frame
with no complete frame behind it: it is asked for again, less often each
time, instead of dropped. Once a newer frame completes, the held frame
waits at most one more repair window from that frame's first fragment, as
before. The existing 1 s hold bound is the backstop: when it passes, every
waiting frame (all incomplete, all needing the oldest) is retired under
one keyframe request.

FrameAssembler.swift changes first; the Rust mirror and the shared repair
traces follow, with traces for the 352/353 case, a complete frame behind
incomplete ones, and a dead stream.
@whoisaldo
whoisaldo requested a review from Alitleis123 October 29, 2026 19:52
@whoisaldo whoisaldo added the 0.3.4 Part of the 0.3.4 release label Oct 29, 2026
@whoisaldo whoisaldo mentioned this pull request Oct 29, 2026
whoisaldo and others added 2 commits October 29, 2026 17:57
When a frame held for a second is retired, only the incomplete frames
behind it up to the first pending keyframe go with it. The keyframe keeps
its own deadline and is shown when its last fragment arrives.

With a complete frame behind it, a held frame now goes one repair window
after the first newer frame began, as before this branch, rather than
after the complete frame began. A frame held with newer frames already
behind it goes as soon as a complete frame arrives.
@whoisaldo
whoisaldo marked this pull request as ready for review October 29, 2026 23:52
@whoisaldo
whoisaldo merged commit 5c4e2b6 into release/0.3.4-candidate Oct 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.3.4 Part of the 0.3.4 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants