You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make the iOS startup/recovery variant capestimate-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:
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:
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_mbps → startupPeakBitrateBps) 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.
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 fetch → nil at the cold-start instant. That nilis 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)
Recovery cap = re-probe, not trust-old-observed. Replace the blanket observed × 1.1 with a conservative re-probe cap; ramp only once fresh network-limited delivery confirms capacity. Demote the pre-failure reading to a prior.
Apply the recovery cap on the live-resync SEEK path (attemptLiveResyncSeek), not just the escalated restart — so method-3 can succeed without re-wedging.
Do not gate the seek/recovery on isPlaybackLikelyToKeepUp during the suspect window — it is the corrupted signal.
Gate the cold startup cap on estimate-availability — apply the low cap / startsOnFirstEligibleVariant on cold-no-estimate; skip it when a recent observedBitrate exists (channel-change), so warm starts aren't needlessly quality-capped.
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.
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.
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.
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 (
#814caps recovery topre-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:
recoveryVariantCapBps) — the thing this issue refines.attemptLiveResyncSeek→ escalate to restart).Core principle — the estimate-availability gate
The right cap is a function of one thing: do we have a trustworthy throughput estimate?
observedBitrateexists)startsOnFirstEligibleVariant), release on first frameThe 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):
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:
RequestTracker.shared.snapshot→wireLastChunkMsAgo/wireInflightCount(per LocalProxy wedge-prevention: classify segment stalls by byte-progress → transparent re-request vs preferredPeakBitRate downshift (never variant removal) #711), andLocalHTTPProxyper-taskonUpstreamChunk.What exists today (code map)
captureRecoveryThroughputCap()—PlayerViewModel.swift:1584-1632. Caps recovery tomax(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.isRecoverygate at:1666;startsOnFirstEligibleVariantis skipped on recovery (:1705). Cold cap (is.peak_bitrate_mbps→startupPeakBitrateBps) 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 onisPlaybackLikelyToKeepUp(:1843), which is exactly the signal a cold over-select corrupts.liveResyncDuetrigger —PlaybackDiagnostics.swift:1278-1285;liveResyncStallSeconds = 45(:214, tunable viais.flag.live_resync_stall_s). Wall-clock driven viabitrateSampleTimer(: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/indicatedBitrateread fromAVPlayerItemAccessLog—:1306-1324,:435. Empty until the first fetch →nilat the cold-start instant. Thatnilis 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 inrecentBitrateBps.Proposed implementation (checklist)
observed × 1.1with a conservative re-probe cap; ramp only once fresh network-limited delivery confirms capacity. Demote the pre-failure reading to a prior.attemptLiveResyncSeek), not just the escalated restart — so method-3 can succeed without re-wedging.isPlaybackLikelyToKeepUpduring the suspect window — it is the corrupted signal.startsOnFirstEligibleVarianton cold-no-estimate; skip it when a recentobservedBitrateexists (channel-change), so warm starts aren't needlessly quality-capped.RequestTrackerwireLastChunkMsAgo, shared with LocalProxy wedge-prevention: classify segment stalls by byte-progress → transparent re-request vs preferredPeakBitRate downshift (never variant removal) #711) instead of a bare flat-buffer timer, so a 2 Mbps/6s healthy startup does not false-trigger.scheduleStartupCapsRelease) so fat links aren't pinned low.Data captured this investigation
Recovery mechanism of the 48 wedged plays (report
ABERRANT, video-start ≥ 10s):Wedge deep-dive (s6 / 20 Mbps / uncapped, cold + channel-change, this session):
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):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 inmaster.m3u8andmaster_6s.m3u8.Open questions / needs-test
startsOnFirstEligibleVariant(bottom rung). The 0.76s start-on-first datum favors first-variant for the no-estimate case.References
.claude/findings/cold-start-rung-nondeterminism-2026-07-13.md,.claude/findings/channel-change-vs-cold-startup-2026-07-14.md,.claude/findings/a45a161d-progressive-stall-wedge-2026-05-20.md(iPhone progressive stall accumulation → non-recoverable wedge under sustained pyramid #481).tests/characterization/STARTUP-FINDINGS.md; startup-report tool undertests/characterization/tools/startup-report/.