A truncated roundtrip is not an encoder ceiling, and the probe waits for video #426

Merged
enricobuehler merged 1 commits from worktree-abr-phantom-ceiling into main 2026-08-28 12:31:20 +00:00
Owner

Root-causes the 0.32.0 field reports of "frame losses and skips whenever the stream moves" ending in permanently soft video — two paired client+host log sets (Windows client over ZeroTier, Android over Wi-Fi) plus a local repro all show the same signature.

The phantom encoder ceiling (0.32.0 regression)

Phase 4 (#408) made the bitrate a wire budget: the encoder is handed enc_kbps(budget) and the applied read-back maps through budget_kbps(). That roundtrip deflates 1–2 kbps by design (budget_kbps_for_encoder never inflates), so the applied < requested compare in the stream loop recorded a phantom encoder_ceiling_kbps on every successful retarget (14000→13998, 9798→9797, …). The control task then clamps every later SetBitrate to it, so every climb is acked short at the current rate; the client controller learns it as a host cap ("host cap learned — climbs stop here", reprobe backoff 16→32 windows), and each descent lowers the "ceiling" further. Result: one loss episode ratchets an Automatic session 20 → 2.3 Mbps with no way back until reconnect. 0.31.4 was immune — its read-back stayed in encoder units, so an in-range apply compared equal.

Fix: EncDerive::applied_budget_kbps reads back in the request's own truncated terms — only an apply short of what the request itself derives to (a genuine driver clamp, the ABR-overdrive case) records a ceiling. Both the in-place and rebuild arms use it. The regression test was proven against the backed-out fix (red without, green with).

The startup capacity probe collides with bring-up

The client armed the probe on a flat 2 s timer, but host bring-up can take 6–8 s (IDD display acquisition), so the 940/464 Mbps × 800 ms burst landed exactly on the first IDR: black video for 5–11 s, the decoder refusing AUs ("slice references PPS 0, which has not been seen" ×21), 2101 audio underruns — and the result missed the 6 s timeout anyway (bring-up plus the burst's own queue drain and QUIC loss-recovery backoff, 8.6 s measured), so it was discarded as "old host?": all disturbance, zero measurement. The probe now arms only once a frame has completed, and the timeout is 15 s so a slow answer still teaches the ceiling.

Verification

  • punktfunk-core --features quic: 512 tests green (natively, non-vacuous — Checking punktfunk-core present).
  • punktfunk-host: compiles in the Linux container gate (its only compile signal); new test native::tests::full_apply_readback_is_the_request_not_the_deflated_roundtrip passes, and fails with the fix backed out.
  • cargo fmt --check clean.

Not addressed here (link-side, not ours): the reporter's host streams over its Wi-Fi uplink, and the Windows client picked its ZeroTier address although both machines share the LAN — motion-onset burst loss is real on that path; with this PR the controller rides it instead of collapsing.

Root-causes the 0.32.0 field reports of "frame losses and skips whenever the stream moves" ending in permanently soft video — two paired client+host log sets (Windows client over ZeroTier, Android over Wi-Fi) plus a local repro all show the same signature. ## The phantom encoder ceiling (0.32.0 regression) Phase 4 (#408) made the bitrate a wire budget: the encoder is handed `enc_kbps(budget)` and the applied read-back maps through `budget_kbps()`. That roundtrip **deflates 1–2 kbps by design** (`budget_kbps_for_encoder` never inflates), so the `applied < requested` compare in the stream loop recorded a phantom `encoder_ceiling_kbps` on **every** successful retarget (14000→13998, 9798→9797, …). The control task then clamps every later `SetBitrate` to it, so every climb is acked short at the current rate; the client controller learns it as a host cap ("host cap learned — climbs stop here", reprobe backoff 16→32 windows), and each descent lowers the "ceiling" further. Result: one loss episode ratchets an Automatic session 20 → 2.3 Mbps with no way back until reconnect. 0.31.4 was immune — its read-back stayed in encoder units, so an in-range apply compared equal. Fix: `EncDerive::applied_budget_kbps` reads back in the request's own truncated terms — only an apply short of what the request itself derives to (a genuine driver clamp, the ABR-overdrive case) records a ceiling. Both the in-place and rebuild arms use it. The regression test was proven against the backed-out fix (red without, green with). ## The startup capacity probe collides with bring-up The client armed the probe on a flat 2 s timer, but host bring-up can take 6–8 s (IDD display acquisition), so the 940/464 Mbps × 800 ms burst landed exactly on the first IDR: black video for 5–11 s, the decoder refusing AUs ("slice references PPS 0, which has not been seen" ×21), 2101 audio underruns — and the result missed the 6 s timeout anyway (bring-up plus the burst's own queue drain and QUIC loss-recovery backoff, 8.6 s measured), so it was discarded as "old host?": all disturbance, zero measurement. The probe now arms only once a frame has completed, and the timeout is 15 s so a slow answer still teaches the ceiling. ## Verification - `punktfunk-core` `--features quic`: 512 tests green (natively, non-vacuous — `Checking punktfunk-core` present). - `punktfunk-host`: compiles in the Linux container gate (its only compile signal); new test `native::tests::full_apply_readback_is_the_request_not_the_deflated_roundtrip` passes, and fails with the fix backed out. - `cargo fmt --check` clean. Not addressed here (link-side, not ours): the reporter's host streams over its Wi-Fi uplink, and the Windows client picked its ZeroTier address although both machines share the LAN — motion-onset burst loss is real on that path; with this PR the controller rides it instead of collapsing.
enricobuehler added 1 commit 2026-08-28 10:49:30 +00:00
A truncated roundtrip is not an encoder ceiling, and the probe waits for video
ci / bun-nix (pull_request) Successful in 32s
ci / docs-drift (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m46s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m18s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 7m23s
ci / rust (pull_request) Successful in 8m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m3s
224ed86fbd
Two field sessions on 0.32.0 (Windows client over ZeroTier, Android over
Wi-Fi) plus a local repro showed every Automatic session ratcheting
20 -> 2.3 Mbps on the first loss episode and never climbing back.

Phase 4 made the bitrate a wire budget: the encoder is handed
enc_kbps(budget) and the read-back maps through budget_kbps(). That
roundtrip deflates 1-2 kbps by design (the inverse never inflates), and
the applied < requested compare then recorded a phantom
encoder_ceiling_kbps on EVERY successful apply. The control task clamps
each later SetBitrate to it, so every climb was acked short at the
current rate, the client controller learned it as a host cap
(reprobe backoff 16 -> 32 windows), and each descent lowered the
"ceiling" further: a one-way ratchet to the floor. 0.31.4 was immune -
its read-back stayed in encoder units, so an in-range apply compared
equal. EncDerive::applied_budget_kbps now reads back in the request's
own truncated terms: only an apply short of what the request itself
derives to - a genuine driver clamp - records a ceiling. Regression
test proven against the backed-out fix.

The startup link-capacity probe fired on a flat 2 s timer, but host
bring-up can take 6-8 s (IDD display acquisition), so the 940/464 Mbps
burst landed exactly on the first IDR - black video for 5-11 s, the
decoder refusing AUs ("PPS 0 not seen"), 2101 audio underruns - and the
result missed the 6 s timeout anyway (bring-up plus the burst's own
queue drain and QUIC loss-recovery backoff: 8.6 s measured), so the
ceiling stayed negotiated: all disturbance, no measurement. The probe
now arms only once a frame has completed, and the timeout is 15 s so a
slow answer still teaches the ceiling.
enricobuehler merged commit abac2a6802 into main 2026-08-28 12:31:20 +00:00
enricobuehler deleted branch worktree-abr-phantom-ceiling 2026-08-28 12:31:23 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#426