fix(stall): T2 amplification kill — resume-edge pacing + ABR starved-window guard #53

Merged
enricobuehler merged 3 commits from worktree-stall-ride-through into main 2026-08-05 06:35:32 +00:00
Owner

Stall program T2 (planning: design/vdisplay-disturbance-immunity.md §9). A 300–750 ms capture stall must degrade the stream by its own duration, not by seconds of crater.

The mapping (what turns a hole into a crater): a pure capture stall creates no frame-index gap and no loss — the recovery machinery correctly stays silent. The damage is all at the resume edge: the first frame after the hole carries seconds of scene delta, and the native pace budget min(0.9 × time-to-deadline, overflow @ 3× rate) let the deadline term clamp a multi-interval overflow into the remainder of one interval — a line-rate blast that overruns the socket tx-buffer (the field WSAENOBUFS(10055) + loss_ppm edge-spike fingerprint across all three RDNA4 standby-sink cases). The self-inflicted loss then costs recovery-IDR round trips (each another oversized blast), and the ABR reads dropped/flushed/kf-storms as SEVERE — ×0.7 per event — and since #36 could even latch a phantom decode cap from a pair of stall-shaped windows at the same rate.

Commit 1 — fix(host/send): send_pacing::native_budget() (pure, unit-tested). With the rate cap active, the overflow's wire time at the proven ~3× stream rate is the budget, bounded by a 100 ms ceiling; the deadline never under-cuts it. Steady-state frames produce byte-identical schedules (the rate term already decided there); PUNKTFUNK_PACE_FACTOR=0 keeps the legacy deadline-only spread; the GameStream plane's Moonlight-pinned schedule is untouched.

Commit 2 — fix(core/abr): a deciding window that delivered under ¼ of target (capture stall, outage) still backs off, but takes the same "not a knee sample either way" arm as a draining backoff — it neither latches a decode cap nor erases the reference a genuine choke set. Two new tests model the 5 s stall cycle (no-latch) and reference-preservation around a starved interruption.

Verified no-change-needed: keyframe-storm capping already ships (IDR cooldown 750 ms/2 s + RFI-echo swallowing); in-place reconfigure_bitrate (no IDR) already exists for NVENC/AMF, so the SetBitrate-rebuild cost is confined to fallback backends.

Gates: core 346 lib tests green (--features quic; 44 abr, 2 new) · host clippy --all-targets -D warnings + 9 send_pacing tests green (linux/amd64 container) · full CI board green on the PR head (the earlier windows reds were main's pad-audio audio_wasapi breakage, fixed in #57).

Post-merge follow-up: field A/B with the case-#3 reporter (LG UltraGear / RX 9070 XT) — expected: freeze duration ≈ raw stall duration, no edge loss, no bitrate crater.

Stall program T2 (planning: `design/vdisplay-disturbance-immunity.md` §9). A 300–750 ms capture stall must degrade the stream by its own duration, not by seconds of crater. **The mapping** (what turns a hole into a crater): a pure capture stall creates no frame-index gap and no loss — the recovery machinery correctly stays silent. The damage is all at the **resume edge**: the first frame after the hole carries seconds of scene delta, and the native pace budget `min(0.9 × time-to-deadline, overflow @ 3× rate)` let the *deadline* term clamp a multi-interval overflow into the remainder of one interval — a line-rate blast that overruns the socket tx-buffer (the field WSAENOBUFS(10055) + `loss_ppm` edge-spike fingerprint across all three RDNA4 standby-sink cases). The self-inflicted loss then costs recovery-IDR round trips (each another oversized blast), and the ABR reads `dropped`/`flushed`/kf-storms as SEVERE — ×0.7 per event — and since #36 could even **latch a phantom decode cap** from a pair of stall-shaped windows at the same rate. **Commit 1 — `fix(host/send)`**: `send_pacing::native_budget()` (pure, unit-tested). With the rate cap active, the overflow's wire time at the proven ~3× stream rate *is* the budget, bounded by a 100 ms ceiling; the deadline never under-cuts it. Steady-state frames produce byte-identical schedules (the rate term already decided there); `PUNKTFUNK_PACE_FACTOR=0` keeps the legacy deadline-only spread; the GameStream plane's Moonlight-pinned schedule is untouched. **Commit 2 — `fix(core/abr)`**: a deciding window that delivered under ¼ of target (capture stall, outage) still backs off, but takes the same "not a knee sample either way" arm as a draining backoff — it neither latches a decode cap nor erases the reference a genuine choke set. Two new tests model the 5 s stall cycle (no-latch) and reference-preservation around a starved interruption. **Verified no-change-needed**: keyframe-storm capping already ships (IDR cooldown 750 ms/2 s + RFI-echo swallowing); in-place `reconfigure_bitrate` (no IDR) already exists for NVENC/AMF, so the SetBitrate-rebuild cost is confined to fallback backends. **Gates**: core 346 lib tests green (`--features quic`; 44 abr, 2 new) · host `clippy --all-targets -D warnings` + 9 send_pacing tests green (linux/amd64 container) · full CI board green on the PR head (the earlier windows reds were main's pad-audio `audio_wasapi` breakage, fixed in #57). **Post-merge follow-up**: field A/B with the case-#3 reporter (LG UltraGear / RX 9070 XT) — expected: freeze duration ≈ raw stall duration, no edge loss, no bitrate crater.
enricobuehler added 2 commits 2026-08-05 05:47:35 +00:00
Stall program T2 (amplification kill), the resume-burst half. The native
pace budget was min(0.9 × time-to-deadline, overflow at ~3× stream rate) —
for steady-state frames the rate term is smaller and decides, but for an
OVERSIZED frame (a capture-stall resume carrying seconds of scene delta, a
cold IDR) the deadline term clamped a multi-interval overflow into the
remainder of ONE: an instantaneous many-×-stream-rate blast that overruns
the socket tx-buffer and loses the very frame that would have ended the
freeze. Field fingerprint across three RDNA4 standby-sink cases:
WSAENOBUFS(10055) + loss_ppm spikes at stall edges, then a recovery-IDR
round trip per retry while the client shows 'current bitrate 0.1'.

The budget is now the overflow's wire time at the pace rate itself
(send_pacing::native_budget, pure + unit-tested), bounded by an absolute
100 ms ceiling so a pathological frame can't park the send thread; the
deadline stays a target, never a license to blast. Steady-state frames
produce byte-identical schedules (the rate term already decided);
PUNKTFUNK_PACE_FACTOR=0 keeps the legacy deadline-only spread; the
GameStream plane's Moonlight-pinned schedule is untouched.

Gates: host clippy --all-targets -D warnings + 9 send_pacing tests green
(linux/amd64 container), fmt clean.
fix(core/abr): a starved window is never a decode-knee sample
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 6m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m57s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 5m30s
ci / rust (pull_request) Successful in 9m50s
e5ca213339
Stall program T2 (amplification kill), the phantom-latch half. A deciding
window that delivered under a quarter of the target rate (a host-side
capture stall, an outage, a mid-window pause) carries starvation-shaped
distress — a jump-to-live flush, a keyframe-ask burst — that the decode-cap
latch read as decoder evidence: under a periodic capture stall (the RDNA4
standby-sink field cases, one stall every ~5 s) every edge offers another
'backoff' at the SAME rate, and one pair latches a phantom decoder knee at
whatever rate the display driver happened to interrupt. The session then
fights the cap's re-probe ladder (+12.5% per 16-128 clean windows) for
minutes on a decoder that was never the problem.

Starved windows still back off (real damage deserves the safe response) but
take the same 'not a knee sample either way' arm as a draining backoff:
they neither latch a decode cap nor erase the reference a genuine choke
set, so a real knee's pair still finds itself around the interruption. The
¼ bar sits deliberately far under the ×¾ utilization bar climbs require.

Gates: 44 abr tests green (2 new: the stall-cycle no-latch scenario and the
reference-preservation scenario), full core lib suite 346 green
(--features quic), fmt + clippy clean.
enricobuehler added 1 commit 2026-08-05 06:22:48 +00:00
Merge branch 'main' into worktree-stall-ride-through
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m7s
android / android (pull_request) Successful in 3m10s
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 6m32s
1d6f4760f3
enricobuehler marked the pull request as ready for review 2026-08-05 06:35:22 +00:00
enricobuehler merged commit 110ac9b663 into main 2026-08-05 06:35:32 +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#53