Repository navigation
Keep waiting for repairs while no complete frame is blocked - #128
Merged
Merged
Conversation
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.
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
marked this pull request as ready for review
October 29, 2026 23:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
FrameAssembler.swifttick(at:), mirrored inreassembly.rstick): when the oldest frame's deadline plus stall allowance passes, the frame is held if no pending frame is complete and it is less thanmaxHoldUspast its first gap. Before this change the condition waspending.count == 1.dropByinFrameAssembler.swift,drop_byinreassembly.rs).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.swiftwas changed first as the specification.proto/src/reassembly.rsmirrors it, and the shared traces inproto/testdata/repair_vectors.txtcover 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.
KEYFRAME_REQUEST_MIN_INTERVAL). This change sends fewer keyframe requests and repeats NACKs the PC already accepts.scripts/e2e_stream_ui.shpassed in both mixed directions againstv0.3.3on 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: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:host/tests/usb_link_sim.rsis identical at all three commits. USB doesn't negotiate repair, and every change is inside the repair path:host/tests/usb_link_differential.rspassed 7 of 7, with identical output at all three commits:e2e:
cargo test -p eternal-host --test e2e_synthetic lossy_stream_recovers_and_adaptspassed (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):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: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.