Skip to content

Phase 5 exit: every injected fault and every hostile manifest recovers or ends typed - #198

Merged
ramesh130 merged 3 commits into
mainfrom
issue-184-phase-exit
Sep 16, 2026
Merged

ramesh130 merged 3 commits into
mainfrom
issue-184-phase-exit

Conversation

@ramesh130

@ramesh130 ramesh130 commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #184

PRD.md Part 4's Phase 5 criterion — every injected fault either recovers or produces a typed,
actionable error. Zero unclassified errors
— as three tests and one added count, plus the honest
list of what still cannot recover.

1. The fault sweep — superplayer-resilience/…/FaultSweepTest.kt

Every FaultScript fault kind, under HLS and DASH, on a player a consumer builds (a profile and
Resilience.standard(), one source, no HeaderProvider). The kinds are read off
FaultScript.Builder by reflection, so a kind added later is visibly missing rather than
silently unswept. Twenty sessions, all twenty recovered or ended named, none unclassified, none
stalled:

fault kind HLS DASH
addLatencyMs RECOVERED RECOVERED
capThroughputBps RECOVERED RECOVERED
failWithHttpStatus (500) typed Transient.Network typed Transient.Network
truncateAfterBytes typed Transient.Network RECOVERED
failDnsResolution typed Transient.Network typed Transient.Network
failTlsHandshake typed Transient.Network typed Transient.Network
resetConnection typed Transient.Network typed Transient.Network
timeOutConnection typed Transient.Network typed Transient.Network
expireTokenAtSegment (not refreshable) typed Transient.CdnEdge typed Transient.CdnEdge
serveThroughCache (VOD manifest) RECOVERED RECOVERED

The one asymmetry is Media3's answer rather than the ladder's: a truncated HLS segment is a failed
load, while the fragmented-MP4 extractor reads the truncated DASH segment's opening boxes, finds no
samples and moves on to the next one. Recorded rather than evened out — the criterion asks that each
end recovered or named, and each does.

2. The hostile corpus, re-recorded — and why the diff is empty

Where it had to go. HostileManifestCorpusTest is superplayer-testkit's, and a phase 2 module
may not depend on a phase 5 one (docs/modules.md), so the ladder cannot be installed in that file
at all. The ladder record is therefore a second table, HostileManifestLadderTest.WITH_LADDER in
superplayer-resilience, and both tables are taken by one observer —
HostileObservation.observe, moved into testkit's main sources — so the only difference between
them is the PlaybackResilience argument.

Row by row: nothing moved. All 15 rows of WITH_LADDER equal RECORDED, and RECORDED and
GRADED are unchanged row for row. That is a finding, not a shortfall: every corpus entry is a
manifest that lies about media the origin then serves perfectly, so no load fails and the ladder is
never asked a question. The two named rows are core's own detection (ADR-0011 rule 4) reaching rung 6
with the likely cause still on them — hls-cached-live-playlist (StaleLivePlaylistException,
intermediary cache) and dash-short-time-shift-buffer-depth (LiveWindowTooShortException). The
corpus that does move the ladder is the injected one, above; PRD.md Part 4 asks for both because
they are different kinds of defect.

isTyped did need SuperPlayerError. #183 made it rung 6's cause, and a session named that way
is named exactly as the criterion asks, so it joins StaleLivePlaylistException and
LiveWindowTooShortException in FAILS_TYPED. It moves no core-only row — a player without
resilience produces none — which is one more count of rule 14.

3. What still cannot recover

One entry, recorded in CANNOT_RECOVER with its reason rather than hidden:

  • dash-availability-start-time-skew — NEVER_STARTS. An availabilityStartTime an hour ahead
    of the clock means every segment the manifest names is legitimately not published yet, so the
    player waits, correctly, for ever. No load fails: there is nothing to retry, no other host to ask,
    and no failure to classify. Diagnosing it needs a doctor reading a manifest against a clock
    (PRD.md §3.6's MediaSourceDoctor, Phase 9), not a rung reading a failure.

nothingListedAsBeyondTheLadderIsQuietlyPassing keeps that list honest in the other direction: an
entry that starts recovering must leave the list. And noEntryEndsOnAFailureNothingNamed holds for
every entry without exception — a player with the module never ends a session on an unclassified
PlaybackException.

Proposed follow-up issue (not filed): Diagnose a manifest whose media is not published yet —
give Phase 9's doctor the availability-window check, so a session that will never start says so
instead of spinning. Also worth a line in that issue: today the library has no way to report
"waiting, and here is why", since rung 6 exists only for failures.

4. Every rung has a forcing test, named in one place

FallbackRungCoverageTest names, per FallbackRung, the test that forces that rung and the test
that counts what a player built without resilience pays for it:

rung forced by rule 14 counted by
RETRY_SAME_URL RetryPlaybackTest.aFaultThatRelentsIsRetriedAndPlaybackGoesOn …KeepsMedia3SOwnLoadErrorHandling
NEXT_HOST FallbackPlaybackTest.aLocationThatFailsEveryAttemptIsLeftForTheOneTheManifestAlsoNames …IsLeftToMedia3SOwnFallbackTable
EXCLUDE_VARIANT FallbackPlaybackTest.aRenditionThatKeepsFailingIsExcludedAndPlaybackGoesOnAtAnotherBitrate …IsLeftToMedia3SOwnFallbackTable
NEXT_SOURCE NextSourcePlaybackTest.aSourceThatFailsEveryRungBelowIsLeftForTheNextOneInTheRequest …OpensTheFirstSourceAndNothingElse
RECREATE_DECODER core's SuperPlayerDecoderRecreationTest.aFailureTheLadderAnswersWithARecreatedDecoderIsRePreparedAndPlaysOn …RePreparesNothing
TYPED_ERROR TypedErrorPlaybackTest.aSessionNothingRescuedEndsOnATypedError …EndsOnMedia3sOwnErrorUnchanged

They are names rather than code because rungs 4 and 5 are forced in superplayer-core, whose test
classes are not on this module's test classpath. The names are checked against the source tree (the
file exists; the method is declared with a @Test above it and no declaration in between), following
superplayer-abr's NoDeviceModelStringTest. A rename, a method demoted from @Test, or a seventh
rung added with no entry fails there rather than leaving a rung silently unforced.

5. Rule 14, counted

  • Third slot — SuperPlayerResilienceSeamTest now asserts EngineConfiguration.playerStateRungs
    is null on a core-only player and non-null on one with resilience. That is the slot whose emptiness
    rule 14 rests on since Fallback ladder rung 4: the next source in MediaRequest.sources, at the position already reached #181: core registers its player-error listener only where it is filled, so
    an empty slot is a listener that does not exist. The test's KDoc, written before the slot existed,
    claimed there was nothing to register; it now says what is registered and where the behaviour half
    is counted.
  • Load errors / header refresh — both already asserted empty there;
    DefaultLoadErrorHandlingPolicy in force is counted as behaviour in
    RetryPlaybackTest.aPlayerWithoutResilienceKeepsMedia3SOwnLoadErrorHandling.
  • PlaybackFailure.classification null — TypedErrorPlaybackTest
    onAPlayerWithoutResilienceTelemetryReportsExactlyWhatItReportedBefore (Rung 6: surface a typed, actionable error, and let telemetry report the classification #183).
  • Core-only golden traces — the four traces in superplayer-telemetry/src/test/golden/ are
    unchanged; ./gradlew updateGoldenTraces was not run, and nothing in the diff touches them.

Review

Standards. Clean-room: no algorithm added; the one heuristic (finding a @Test above a
declaration) is documented where it lives. ADR-0011: the sweep and both corpus tables drive the
library through its public API only, and the new testkit API names no Media3 type — updateApiSurface
run, diff committed (HostileObservation, HostileOutcome). docs/modules.md's direction is
respected, and is the reason the ladder record is where it is. Smells considered: the shared observer
exists precisely to avoid Duplicated Code across two modules; FallbackRungCoverageTest's
string-named tests are a deliberate trade (a reflective form is impossible across module test
classpaths) and are validated rather than trusted; HostileObservation.STEP_MS and READY_WINDOW_MS
are public though only the enum's KDoc and the corpus's bound read them — kept public because a
reader of either table needs the window's width to read it.

Spec. All six acceptance criteria are met, with one deviation stated rather than glossed: "…
recorded tables are regenerated with the ladder in place"
could not be done inside
HostileManifestCorpusTest for the module-phase reason above; the tables were re-run (unchanged) and
the ladder's record is the sibling table, with docs/testing.md updated to say a new pathology now
gets a row in three tables rather than two.

What was run, and what was skipped

./gradlew assemble check green (≈40 s cold). The three new test classes cost about 5 s together —
the harness's fake clock makes 20 swept sessions and 15 corpus entries cheap, so check can afford
them. Emulator and demo runs skipped (host memory), as the batch instructed; nothing here touches
runtime code, only tests, one test-support file and docs.

CI: https://github.com/ramesh130/superplayer/actions/runs/35069057888 — success (dispatched on issue-184-phase-exit;
.github/workflows/ci.yml is workflow_dispatch: only, so nothing runs on the PR itself).

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS

ramesh130 and others added 3 commits September 16, 2026 17:30
…ompared

The corpus record is about to be taken twice: once on the core-only player it
has always described, and once on a player built with ADR-0011's ladder. The
second cannot live beside the first — `HostileManifestCorpusTest` is
`superplayer-testkit`'s, and a phase 2 module may not depend on a phase 5 one
(`docs/modules.md`) — so the loop that plays an entry and says how far it got
moves into testkit's main sources as `HostileObservation.observe`, with the
outcome vocabulary as the public `HostileOutcome`. One observer rather than two
copies, because otherwise a difference between the two tables would be a
difference between two observers as much as between two players.

`isTyped` now also recognises `SuperPlayerError`, which #183 made the cause a
failure nothing rescued ends on: a session named that way is named exactly as
`PRD.md` Part 4 asks, so it belongs in `FAILS_TYPED` with core's own two
detections. It moves no row here — a player with no resilience has nobody to ask
and produces none — which is itself one more count of ADR-0011 rule 14.

RECORDED and GRADED are unchanged, row for row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
…ne nothing rescues

Phase 5's exit criterion as a test rather than a claim (`PRD.md` Part 4: every
injected fault either recovers or produces a typed, actionable error; zero
unclassified errors). Four parts.

`FaultSweepTest` plays every `FaultScript` fault kind under HLS and under DASH —
twenty sessions on a player a consumer builds, a profile and
`Resilience.standard()` and nothing else — and records how each ended in SWEPT.
The kinds are read off `FaultScript.Builder` by reflection, so a kind added later
is visibly missing rather than silently unswept. Every cell recovered or ended in
a named class: latency, a throughput cap and a cached on-demand manifest play on;
a 500, a DNS failure, a TLS failure, a reset and a timeout end as
`Transient.Network`; an unrefreshable token ends as `Transient.CdnEdge`. One kind
answers differently by protocol, and it is Media3's answer rather than the
ladder's: a truncated HLS segment is a failed load, while the fragmented-MP4
extractor reads the truncated DASH segment's boxes, finds no samples and moves
on. Recorded rather than evened out.

`HostileManifestLadderTest` is the hostile corpus on a player with the ladder,
beside `HostileManifestCorpusTest`'s core-only tables and taken by the same
observer. **No row moves**, and that is the finding rather than a
disappointment: every entry there is a manifest that lies about media the origin
then serves perfectly, so no load fails and the ladder is never asked a question.
The two typed rows are core's own detections (rule 4) reaching rung 6 with the
likely cause still on them. One entry neither recovers nor ends named —
`dash-availability-start-time-skew`, whose `availabilityStartTime` an hour ahead
means every segment is legitimately not published yet, so the player waits
correctly and for ever — and it is recorded in CANNOT_RECOVER with that reason.
Diagnosing it needs `PRD.md` §3.6's doctor reading a manifest against a clock,
which is Phase 9's, not a rung reading a failure.

`FallbackRungCoverageTest` is the register `PRD.md` Part 5 asks for: per
`FallbackRung`, the test that forces that rung and the test that counts what a
player without resilience pays for it. They are names rather than code because
rungs 4 and 5 are forced in `superplayer-core`, whose test classes are not on
this module's test classpath; the names are checked against the source tree, so a
rename, a method that is no longer a `@Test`, or a seventh rung with no entry
fails here.

Rule 14's count grows the third slot: `SuperPlayerResilienceSeamTest` now asserts
`playerStateRungs` is empty on a core-only player and filled on one with
resilience. That slot is the one whose emptiness is a *registration* that does
not happen (rule 13's addendum), and the test's KDoc, written before the slot
existed, said there was nothing to register. The golden traces in
`superplayer-telemetry` are unchanged, which is the rule's other half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
A synthetic segment is 2 s, so 20 s of injected latency is ten of them rather
than the four the comment claimed, and the stream the sweep plays is 12 s of
media. Comments only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
@ramesh130
ramesh130 merged commit 2cc342a into main Sep 16, 2026
1 check passed
@ramesh130
ramesh130 deleted the issue-184-phase-exit branch September 18, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 5 exit: every injected fault and every hostile manifest recovers or ends typed

1 participant