Skip to content

feat(ios): estimate-aware startup/recovery variant-cap — probe-low when the throughput estimate is absent or stale #961

Description

@jonathaneoliver

Summary

Make the iOS startup/recovery variant cap estimate-aware: trust AVPlayer's throughput estimate only when it is actually trustworthy, and probe low + re-measure whenever it is absent (true cold start) or stale (post-failure recovery). Today the cap logic either over-trusts a possibly-wrong number (#814 caps recovery to pre-failure observed × 1.1) or applies no cap at all on the cheap live-resync seek path — both of which can re-open the very over-select wedge they exist to prevent.

This is the AVPlayer-side cap-policy layer. It is complementary to, and shares its core insight with:

Core principle — the estimate-availability gate

The right cap is a function of one thing: do we have a trustworthy throughput estimate?

State Estimate trustworthy? Cap policy
Warm / channel-change (recent observedBitrate exists) yes trust it — no artificial cap; AVPlayer picks correctly
Cold, no history (first play after install / reboot / long idle / network change) no — absent probe low (conservative cap or startsOnFirstEligibleVariant), release on first frame
Recovery after a stall/wedge no — stale/suspect re-probe: conservative cap, let fresh network-limited delivery re-authorize the ramp

The unifying rule: when the estimate is absent or suspect, it is a prior to re-test, not the cap. Probe low, measure on the fresh connection, and let real delivery re-earn the bitrate.

Why "trust the old observed" is wrong on recovery — two failure modes, opposite remedies

A recovery is entered because playback failed, so the pre-failure throughput is suspect. There are two failure modes that look identical from "we got no data for a while" but demand opposite caps (this mirrors #711's proxy-side table, one layer up):

Mode What happened Is pre-failure bitrate valid? Correct recovery cap
Link collapsed capacity really dropped to ~0 No — stale over-estimate low — old value re-over-selects → re-wedge
Server black-holed the request link is fine, one request lost Yes — link unchanged old value fine, but the fix is a new request, not a lower bitrate

You cannot tell them apart from throughput history alone. The resolution is to treat recovery as a re-probe on a fresh connection: if the fresh request delivers at ~the old rate → link was fine (ramp back up, cheap ~2s dip); if it still starves → link collapsed (correctly stay capped). The cost asymmetry makes "probe low when uncertain" correct: guessing low costs a brief quality dip; guessing high costs a ~90s re-wedge.

Discriminators (byte-level, not wall-clock)

A bare timer cannot separate "wedged" from "slow but healthy" — on 2 Mbps / 6s segments a legitimate full-segment fetch takes ~4.6–7.2s, so a short flat-buffer timeout false-fires. Use byte-level signals instead:

  1. Byte-progress on the in-flight request — wedge = ~0 bytes for N s; slow-healthy = bytes still flowing. Signal already exists: RequestTracker.shared.snapshotwireLastChunkMsAgo / wireInflightCount (per LocalProxy wedge-prevention: classify segment stalls by byte-progress → transparent re-request vs preferredPeakBitRate downshift (never variant removal) #711), and LocalHTTPProxy per-task onUpstreamChunk.
  2. Over-selection ratio = fetched-rung bitrate ÷ recent measured throughput. Wedge ≫ 1 (e.g. 4K/33.8 over 2 Mbps ≈ 17×); slow-healthy ≈ 1. Threshold-free.
  3. app-limited vs network-limited ([[reference_tcpinfo_applimited_bitfield]], the feat(delivery-rate): gate chart dots on kernel app_limited flag + independent marker toggles #909 delivery-rate gate) — so a low re-probe reading is only trusted as "the link is genuinely this slow" when the transfer was network-limited, not app-limited.

What exists today (code map)

  • captureRecoveryThroughputCap()PlayerViewModel.swift:1584-1632. Caps recovery to max(observed×1.1, startupClamp, priorCap). Over-trusts the pre-failure number (only floors, never treats it as suspect for the link-collapse case).
  • applyStartupCaps(to:):1665-1707. isRecovery gate at :1666; startsOnFirstEligibleVariant is skipped on recovery (:1705). Cold cap (is.peak_bitrate_mbpsstartupPeakBitrateBps) is applied unconditionally, not gated on estimate-presence.
  • attemptLiveResyncSeek():1832-1876. The cheap method-3 seek carries NO cap — only the escalated full restart does. So a seek can re-select the same over-pick and re-wedge → forced escalation. Also gates on isPlaybackLikelyToKeepUp (:1843), which is exactly the signal a cold over-select corrupts.
  • liveResyncDue trigger — PlaybackDiagnostics.swift:1278-1285; liveResyncStallSeconds = 45 (:214, tunable via is.flag.live_resync_stall_s). Wall-clock driven via bitrateSampleTimer (:419-427, the feat(iOS): application wedge detector — auto-restart playback on -12880 hard wedge #703/fix(703): drive freeze/wedge detection from wall-clock timer, not playback observer #706 fix — NOT the periodic observer, which goes silent on a freeze).
  • wedgeDetected:1289; wedgeConfirmSeconds = 120 (:229), armed only after a -12880 — which a cold over-select may never emit. Demoted to observability-only by #703a's "pure-.failed model" (PlayerViewModel.swift:2224): the time-based detectors (frozen / segment-stall / zero-buffer / wedge) emit telemetry + HAR but drive no recovery.
  • observedBitrate / indicatedBitrate read from AVPlayerItemAccessLog:1306-1324, :435. Empty until the first fetchnil at the cold-start instant. That nil is the "no estimate" signal. Note: the access log is per-item and resets per play (:743, :768); cross-play carryover today is only the 30s rolling history in recentBitrateBps.

Proposed implementation (checklist)

Data captured this investigation

Recovery mechanism of the 48 wedged plays (report ABERRANT, video-start ≥ 10s):

  • 43 live-resync seek · 2 player restart (both also seek-jumped — the two worst, 76.5s / 71.4s) · 3 true self-heal (no seek/jump/restart: 10.0 / 14.3 / 16.6s).
  • 45/48 (94%) needed an explicit external kick. Only 6% self-healed, and only the mild cases. The 43 resync escapes depend on live content — they would not exist on VOD.

Wedge deep-dive (s6 / 20 Mbps / uncapped, cold + channel-change, this session):

  • cold n=24: 0 hard wedges; median 3.4s, max 8.4s.
  • By rep: rep0 (first play post-relaunch) 6.5–8.4s (n=8) vs rep1–4 2.9–3.5s → confirms in-process estimate carryover.
  • channel-change n=20: median 3.1s, 0 wedges.
  • Conclusions: (a) refutes s6/20 as reliably wedge-prone — that was an n=1 over-generalization from the single 89.7s play; (b) app-relaunch "cold" ≠ true no-estimate cold — even rep0 only reached ~8s, not the 89s hard wedge; (c) the hard wedge needs a true-cold system (fresh boot / simctl erase / network-change), i.e. "first play after the system knows nothing." (Whether app-relaunch retains a cross-launch estimate — a "system store" — vs the hard wedge simply being a rare stochastic tail is unresolved / needs-test.)

Prior startup findings (tests/characterization/STARTUP-FINDINGS.md + this session):

  • Cold over-select is AVPlayer-specific; bimodal, ~13% hard-wedge tail.
  • channel-change collapses the tail: 12% → 3%, worst 71s → 14s, CoV 127% → 33%.
  • start-on-first kills the wedge: s6/20 → 0.76s vs 89.7s.
  • ExoPlayer (androidtv) never wedges — starts low (~768×432 / 1.54 Mbps), ~2.5s. Full 75-cell androidtv grid now in the report.

fc8e3f46 triage (the 89.7s wedge): cold 4K over-select on a 20 Mbps link; only 1.9 MB delivered in 90s; playhead frozen at 96.096; live-resync seek at ~85s (position 96 → 178); video-start 89.7s. Video segment 56 never completes (2160p 13% partial, 1296p never fetched) — an A/V-56 alignment stall. Root cause reframed: not the over-selection itself but the recovery failure afterward.

Content ladder insane_newer_p200_h264: 0.988→640×360 … 33.808→3840×2160 (4K); master lists variants ascending (360p first) — confirmed in master.m3u8 and master_6s.m3u8.

Open questions / needs-test

  • Store vs stochastic: run first-ever-boot (erase/boot between plays) vs rep0 at the s1/2/16 cell, enough samples to catch the ~13% tail. Identical → stochastic (drop the "store" idea); first-ever worse → real cross-launch carryover.
  • Recovery-time baseline: only a true-cold repro reliably produces the 89s wedge, so it is the only way to measure how long the built-in ladder actually takes (45s arm + escalation/verify ≈ observed ~85s) — the number that quantifies how much an estimate-gated fast path claws back.
  • Cap value: low fixed (e.g. 2 Mbps → 432p) vs startsOnFirstEligibleVariant (bottom rung). The 0.76s start-on-first datum favors first-variant for the no-estimate case.
  • Is the cold conditional cap even worth it given app-relaunch rarely wedges? Depends on the store-vs-stochastic answer above.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions