Repository navigation
Fallback ladder rungs 2 and 3: the next CDN location, then the failing rendition excluded - #194
Merged
Merged
Conversation
`FaultScript` addressed a fault by resource kind, index and attempt, every one of which is the same on every host — so "host A fails and host B serves" was inexpressible, and the rungs above a retry are precisely the ones that move a session somewhere else. `host` is now the fourth coordinate, optional and trailing, so every script written before it addresses every host exactly as it did. It is the one part of the address that names something from the URL, and `docs/testing.md` says why that is the rule applied rather than relaxed. Two stream shapes come with it, because a fault needs something to address. `SyntheticDashStream.resources(mirrorHost = …)` declares the same media at two `BaseURL`s (ISO/IEC 23009-1 §5.6.4) ordered by DVB-DASH's `dvb:priority`, which is written out rather than left to the profile's default because two locations with no priority at all are one location to Media3. `SyntheticHlsStream.resources(secondVariantHost = …)` puts the higher rendition's playlist and segments on a host of their own, which is the only thing about one of two renditions carrying identical bytes that a script could name. Both are off by default, and both single-host forms emit byte-identical bytes to what they always did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
Rungs 2 and 3 of ADR-0011 rule 7's ladder, reached the only way rule 7 allows: as `LadderRung`s inside `FallbackLadder`, so both of `RetryingLoadErrors`' answers still come out of one climb. Media3 asks for a fallback *before* it asks for a retry delay, and answering the first question directly would have skipped rung 1 while it still had budget — silently. Rung 2 is `NextHost`, a `FALLBACK_TYPE_LOCATION` selection: DASH `BaseURL` failover is in the specification (ISO/IEC 23009-1 §5.6.4) and Media3's own `BaseUrlExclusionList` performs it, so what is decided here is only *whether* to move. Media3 gives an HLS chunk source no location dimension at all, so an HLS failure escalates through this rung to rung 3 — the order rather than a gap, and ADR-0011 rule 13's reserved slot is still where a host substitution would go. The content is the same content, so the cache key is unchanged: `ContentKeys` keys on the content id and the URI path and never the host, and a failover is now asserted against a `ContentKeyedCache` so it cannot quietly become a miss. Rung 3 is `ExcludeVariant`, a `FALLBACK_TYPE_TRACK` selection — Media3's own track exclusion, asked for on the classification rather than on Media3's fixed table of statuses. Rule 8 holds by construction rather than by arithmetic: an exclusion only narrows what is available, while the ceiling is a `canSelectFormat` refusal under `superplayer-abr` and `TrackSelectionParameters.maxVideoBitrate` without it, so no exclusion can widen what the policy allows, and one that leaves nothing under the ceiling escalates to rung 4 on its own. The exclusion is time-bounded because Media3 offers no other kind, which is the opposite of `CEILING_EXCLUSION_MS`'s one millisecond and is argued where the minute is chosen. A ceiling caps the climb and does not route it: which rungs below a class's `rungCeiling` are worth offering is the ladder's, so a `Device.DecoderTransient` reaches rung 5 without trying a host, an unusable manifest is offered another host but no rendition of itself, and a `Fatal.Unsupported` — whose ceiling is the one that is not a permission to climb — is offered nothing at all. `FallbackPlaybackTest` forces both rungs through the harness with a 502, a status Media3's own table does not list, so a fallback that happens is the ladder's; the same file counts what a player without resilience does with that script, which is end the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
ramesh130
force-pushed
the
issue-180-host-and-variant
branch
from
September 16, 2026 04:28
a2182f0 to
358e475
Compare
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.
Closes #180
Rungs 2 and 3 of ADR-0011 rule 7's ladder, plus the harness addressing they need.
The two rungs
Both are
LadderRungs reached throughFallbackLadder.climb, never by answeringgetFallbackSelectionFordirectly — Media3 asks for a fallback before it asks for a retry delay,and answering the first question on its own would skip rung 1 while it still had budget.
RetryingLoadErrorsTest.noFallbackIsOfferedWhileRungOneStillHasBudgetstill pins that.FailedLoadgainsfallbackOptions, null when Media3 asked the question that carries none(
getRetryDelayMsFor). Null is "not asked", not "nothing available", and it costs nothing becauseevery source with somewhere to go asks the other question first.
Rung 2 —
NextHost, aFALLBACK_TYPE_LOCATIONselection. DASHBaseURLfailover is in thespecification (
// spec: ISO/IEC 23009-1 §5.6.4, ordered by DVB-DASH'sdvb:priority,// spec: ETSI TS 103 285 §10.8.2.1), and Media3'sBaseUrlExclusionListperforms it; what isdecided here is only whether to move. Media3 gives an HLS chunk source no location dimension at
all (it reports one location always), so an HLS failure escalates through rung 2 to rung 3 —
the order rather than a gap. ADR-0011 rule 13's header-refresh slot is still where an HLS host
substitution would go if one is ever wanted; nothing needed it here, and that slot is #179's.
Rung 3 —
ExcludeVariant, aFALLBACK_TYPE_TRACKselection.The exclusion mechanism, and "does not reappear"
Media3's own
excludeTrack, via aFallbackSelection, and it is time-bounded — Media3 offersno untimed form from inside a load. So "a blacklisted variant does not reappear on the next
evaluation" is met by the exclusion duration, not by construction:
TRACK_EXCLUSION_MSis oneminute, which is tens of evaluations (a selection is evaluated about once per chunk). A rendition
still broken when it lapses fails once more and is excluded again — self-correcting — and one that
has recovered is back in the ladder without the session ending to find out.
That is deliberately the opposite of
superplayer-abr'sCEILING_EXCLUSION_MS(1 ms): there therefusal in
canSelectFormatkeeps a rung out for as long as the ceiling stands, and the exclusiononly has to unseat the rung that is playing. Nothing refuses a rendition that merely failed to load,
so here the duration is the whole of the mechanism. Both durations match Media3's own figures for
the same decisions; this change is about which failures move, and moving the durations too would
be two changes argued as one.
Rule 8's ceiling constraint, honoured by construction
"Excluding a variant hands Media3 a narrower ladder, and the rung it continues at is still chosen
under the selection ceiling and pace
PlaybackPolicydecided." That holds without arithmetic inthis rung, in both engines a decision can be in force on:
superplayer-abr, the ceiling is a refusal inNetworkAwareTrackSelection.canSelectFormat,re-checked every evaluation;
TrackSelectionParameters.maxVideoBitrate, a constraintDefaultTrackSelectorapplies before a selection is built (EngineBinding.applyTo).An exclusion only ever removes a rung from what is available, and neither mechanism consults
exclusions, so no exclusion can widen what the policy allows. When the exclusion leaves nothing
under the ceiling, the selection has nothing to continue on and the failure escalates out of rung 3
— which is rule 8's "it moves to rung 4" arriving by the route the rule names, rather than by this
rung second-guessing a ceiling whose rungs it cannot see from inside a load.
A ceiling caps the climb; it does not route it
FallbackRung.TYPED_ERRORas arungCeilingis the one ceiling that is not a permission to climb —rung 6 is where the ladder has stopped.
climbnow says so, so aFatal.Unsupported"goes torung 6 at once" (rule 7) instead of being offered rungs 1–3 on the way past. The rest of the routing
is each rung's: rung 2 refuses every
Deviceclass (no host has anything to do with a decoder, whichis how
Device.DecoderTransientreaches rung 5 without trying one), and rung 3 refusesDevice.DecoderTransientandContent.ManifestInvalidwhile acceptingDevice.DecoderInit— eachasymmetry is the class's own documentation, quoted at the refusal.
FallbackLadderTestis thatrouting written down.
Harness work
FaultScriptgainshostas a fourth addressing coordinate — optional and trailing on everybuilder method, so every existing addressing form is unchanged and every script written before
it addresses every host as it did. It is the one part of the address that names something from the
URL, and
docs/testing.md's Forcing the faults section now says why that is the rule appliedrather than relaxed.
Two stream shapes come with it, because a fault needs something to address, and both are off by
default with the single-host forms byte-identical:
SyntheticDashStream.resources(mirrorHost = …)/TestContent.dash(mirrorHost = …)— the samemedia at two
BaseURLs with the MPD on the origin.dvb:priorityis written out rather than leftto the profile default because two
BaseURLs withPRIORITY_UNSETare one location to Media3'sgetPriorityCountAfterExclusion, i.e. nothing to fail over to.SyntheticHlsStream.resources(secondVariantHost = …)/TestContent.hls(secondVariantHost = …)—the higher rendition's playlist and segments on a host of their own. The stream's two renditions
carry identical bytes and differ only in what they declare, so a host is the only thing about one
of them a script could name. (This is why
servedFromalone could not do the job: it moves thewhole stream to a second host rather than giving one rendition an address.)
API surfaces for
superplayer-testkitandsuperplayer-testmediaregenerated and committed.Tests
FallbackPlaybackTest(new, Robolectric +PlaybackHarness, real DASH and real HLS):and the origin was asked exactly
1 + maxRetriestimes first (rung 1 before rung 2);never backwards (rule 9);
into one
ContentKeyedCache, and a later healthy-origin replay re-fetches zero segments;failing rendition asked exactly
1 + maxRetriestimes and never again;Every playback fault is a 502, chosen deliberately: Media3's own table is 403/404/410/416/500/503
(
DefaultLoadErrorHandlingPolicy.isEligibleForFallback), so a fallback forced with one of thosewould have happened on a stock player too and would prove nothing. The stock-player test is the
control that makes that argument checkable.
FallbackLadderTest(new) states the classifier-driven routing, including the acceptance criterion"a class that must not trigger it leaving the ladder intact":
Content.ManifestInvalidis offeredanother host but not a rendition of itself. It is stated at the ladder rather than through a
player because
LoadErrorInfocarries anIOException, so the decoder classes the ladder must routepast rungs 2 and 3 do not reach a player's load-error path at all — they arrive from the player-error
path #182/#183 are being built on.
RetryingLoadErrorsTestgains the same order through the questions Media3 actually asks: rung 1while the budget lasts, rung 2 once it is spent, rung 3 only once no location is left, and the
Content.ManifestInvalidsplit surviving the trip.Self-review
Standards. No ADR or
docs/rule breached that I can find. ADR-0001 rule 2: both rungs areinternal, the new testkit/testmedia surface isStrings andInts, no Media3 type escapes.ADR-0011 rules 7, 8, 9, 13 and 14 each argued at the code that discharges them. Clean-room:
// spec:citations on the DASH
BaseURLand DVB-DASH priority behaviour and on RFC 9110 §15.6.3 for the 502;// ref:on the two Media3 constants and onisEligibleForFallback.docs/api-surface.md: bothsurfaces regenerated in the same change.
docs/testing.md: updated for the new coordinate, since thedocument's "never by a URL" rule needed the exception argued rather than quietly taken. No new
third-party dependency, so no
THIRD_PARTY.mdrow;superplayer-cachebecomes a test-only projectdependency of
superplayer-resilience, which is phase 5 on phase 4 and so allowed bydocs/modules.md— the assertion cannot live insuperplayer-cache, which may not depend on aphase 5 module.
Smells considered: Data Clumps —
(kind, index, firstAttempts, host)is now a four-field clumprepeated across nine builder methods, and extracting an address value type is the obvious cleanup;
not taken, because it would change every existing call site and this ticket requires the existing
addressing forms to be unchanged. Flagging it rather than fixing it. Duplicated Code —
HOST/baseUriOnnow exist on both synthetic streams with the same host value; they are independentfixtures with different path roots and I judged a shared constant to be the worse coupling.
Speculative Generality —
NextHost/ExcludeVariant'sDevicerefusals are unreachable fromtoday's load-error path; kept because they discharge promises written in
FailureClass's own KDocand because
FallbackLadder.climbis the one place that routing can live for #182/#183, and testedat the ladder rather than through a fabricated
LoadErrorInfo.LadderRung.ESCALATINGremoved —it was #178's placeholder and has no callers left.
Spec (#180's acceptance criteria): all eight met. One note on the first — "a fault that fails
every attempt at one host" is written in two forms here: the DASH test narrows it to one resource at
that host so the failover happens mid-playback and there is a position to preserve, and the HLS
rung-3 test uses the unindexed "everything this host serves" form. The fault never relents in either.
What was skipped, and why
Emulator and demo runs, per the batch instruction about host memory. The Robolectric harness tests
are the e2e path here;
superplayer-cache's andsuperplayer-abr's suites were run alongside(
assemble checkgreen in full, twice — before and after mergingmain).Merge with #179
mainmoved under me while this was in flight (74de69d, the token refresh). I mergedorigin/main— the only conflict wasCLAUDE.md's resilience paragraph, and both sides arekept: #179's
Resilience.standard(headers = …)andTokenRefreshLayerprose intact, with itsclosing "rungs 2 and 3 stay empty" sentence replaced by this change's.
Resilience.ktitself mergedclean — this change never touched it, since the rungs are registered in
FallbackLadder.standard../gradlew assemble checkre-run green after the merge, andsuperplayer-resilience's suite forcedwith
--rerun-tasks: 8 classes, 57 tests, 0 failures.CI
Dispatched manually (
ci.ymlisworkflow_dispatch:only, so nothing runs on the PR itself).Green: https://github.com/ramesh130/superplayer/actions/runs/35054765246 — conclusion
success.🤖 Generated with Claude Code
https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS