Skip to content

Tests: Force Direct Play refuses a learned refusal before the Load - #1063

Merged
GLinnik21 merged 51 commits into
mainfrom
series/forced-refuses-learned
Oct 26, 2026
Merged

GLinnik21 merged 51 commits into
mainfrom
series/forced-refuses-learned

Conversation

@GLinnik21

Copy link
Copy Markdown
Owner

Fifth PR of the playback-refusal series (#918). It is stacked on #1062 (S2b), so the diff shown is S3 only.

No production change. Under Force Direct Play, audio this install has learned the TV refuses is already refused before any Load. The refusal names the codec and offers "Switch to Auto and play". This PR adds the tests that pin that behaviour, so a later change cannot quietly send the refused stream to the pipeline again.

Closes #1048

Why there is no code change

Tests

  • forced_direct_play_refuses_a_learned_refusal_before_the_load (route tier). Forced plus a stored named dca 6 row, planned from start_caps, gives:
    • ForcedFailure::Audio { codec: "DTS" };
    • an empty plan.url;
    • a read-out that names DTS.
  • forced_direct_play_plays_the_ac3_sibling_of_a_learned_dts (route tier). The same row with an AC3 sibling: there is no verdict, the part URL is planned, and every hasMDE=1 probe names audioStreamID=2.
  • the_play_start_value_lacks_an_applied_refusal (app crate). This is the first test of the app's start_caps() wrapper:
    • with no row, the boot Arc itself;
    • with a row, a new value that lacks DTS 5.1 and that Forced will not feed;
    • the boot value is untouched.
  • The route tests needed cold_audio_resolve_held, a split of cold_audio_resolve for a caller that already holds the test serial lock (TempSession and the firmware door must be built under it). The 15 existing callers are unchanged.

Red, observed by mutation. No landed tree fails these tests, because main since #1034 also refuses. Each mutation was reverted before the commit:

  • The app wrapper returns devcaps::caps(): the_play_start_value_lacks_an_applied_refusal fails with a row in force makes a new value.
  • The Forced pick reads devcaps::caps() instead of &env.caps: both route tests fail. The refusal test gets no verdict; the sibling test's probe names audioStreamID=1.
  • audio_direct_plays_with's Forced arm calls forced_feeds_audio instead of forced_feeds_audio_track. Both fail with:
    • a learned DTS refusal must refuse under Force Direct Play before the Load, got None
    • Forced must name the AC3 sibling (stream 2), not the learned DTS (stream 1)

There is no ui-sim capture: the read-out code is unchanged, and forced_audio_refusal_names_the_codec already pins its sentence.

Crate runs on the committed tree:

  • make test-crate C=plx_media T=forced_direct_play: test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 1863 filtered out
  • make test-crate C=plx_media T=forced: test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 1812 filtered out
  • make test-crate C=app T=the_play_start_value: test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1059 filtered out

The full gates are left to this PR's CI.

Docs

player/CLAUDE.md: one sentence saying that under Force Direct Play a row in force refuses before any Load, with the codec named.

🤖 Generated with Claude Code

GLinnik21 and others added 30 commits October 26, 2026 04:05
… media follows)

Refs #1044

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… recovery flight read it (wip, tests next)

PlaybackSession holds an Arc<Caps> read once where an attempt starts
(request_play_with_queue, and the native-audio conversion's dispatch);
ResolveEnv, the rebase/recovery/retranscode/ABR captures and TranscodeSpec
carry it, so the route's gates and the profile string PMS reads come from the
same value instead of two reads of the process-wide answer.

Refs #1044

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d refusal reaching its flight)

Caps A (a DTS row) and B (= A.without(Audio dts)) pick differently and profile
differently with no global installed; the native-audio recovery flight's
replacement /decision carries the ceiling learned mid-attempt.

Refs #1044

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ost-lesson caps

Refs #1044

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… none

The app's play start (request_movie / request_episode / the content and
queue paths) reads devcaps::caps() ONCE and hands it to the route's
request_play* entry points, which begin the attempt on it. The planner,
the resolve worker and every mid-film rebuild plan from that value
(PlaybackSession::caps, ResolveEnv::caps); route no longer reads the
process-wide answer to plan, and plan.rs holds no devcaps::caps( and no
zero-argument is_dp_audio( / is_dp_audio_track( (the cfg(test) pick
wrappers take the &Caps too).

The pump's ConvertNativeAudio no longer re-reads the global after the
lesson: dispatch_native_audio_conversion narrows the playing attempt by
what the demuxer fed (Caps::without), so the replacement /decision
carries the learned ceiling from the attempt's own value. The #1034
pin test (the refusal reaching its recovery flight) is unchanged.

ci/check-deps.sh: the `globals` rule is widened to devcaps::caps( and the
zero-argument is_dp_audio( / is_dp_audio_track( (qualified, or bare after
an import; the _with forms do not match). It is a coverage expansion, not
a shrink: no machine reads them, so zero ledger lines were seeded and
PINNED_LEDGER_TOTALS is unchanged. A self-test covers the shapes.

Refs #1044

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, not only the conversion's

The failed-open recovery chose the Original trial's rollback over the conversion when both
applied, and only the conversion narrowed the attempt's Caps. The refusal was learned (the
process-wide answer lost the audio), but the attempt kept the value the play started on, so the
rollback's rebase profile, a later retry and a quality change offered native Opus again. The
pipeline refused it again, `learn` returned false (known combination), and a manual Original ended
on the read-out where main converted.

recover_failed_source_route now narrows the attempt (PlaybackSession::narrow_attempt) before it
picks the recovery, once per learned refusal; dispatch_native_audio_conversion no longer repeats it.
The pump rig test a_native_audio_refusal_learned_during_the_original_trial_reaches_the_rollbacks_profile
failed on the rollback's /decision profile before this and passes after.

Also: the docs the change made false (learn_native_audio_refusal, devcaps::learn_refusal, the Caps
struct, plex/mod.rs's allow list, webos5-port.md's codec-capability bullet, sans-io.md), and
FlightRig now begins its attempt on the thread's devcaps::caps() so rig tests plan from the table
they install rather than the pre-probe fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… does without HEVC

The previous wording said "H.264 alone" for a set whose table lists no HEVC decoder, which drops
the VP9 / AV1 copy lanes a table may still list. The direct-play list omits hevc
(direct_play_video_codecs) and the transcode target's encode head is h264 (Caps::encode_vcodec).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very lands

Step 0 of the playback-refusal S1 lane. Two hostsim rig tests drive the real foreground actuator
and the pump -> poll_foreground_load order of playback_tick:

- an asynchronous type=18 after the Load call returned (route Started, then load_failed): the
  lifecycle reads Started and lets go (Idle); the pump's failed-open ladder fails the Engine.
  This is the case OpenRecovery will own.
- a synchronous refusal (StartFailed together with load_failed, as threads.rs publishes it): the
  pump fails the Engine (read-out) AND the lifecycle then settles the same refusal as #1018's
  retry (Prepared, Engine retired). Two machines act on one refusal today; the test names say so,
  and the split that fixes it (the lifecycle keeps the Load call's own result) follows.

launch_restore is the first half of refuse_restore, extracted unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng main's four arms

`open_failure_action` was a pure function over four booleans the pump gathered after it
had already run the install's learner and narrowed the attempt. It is now `OpenRecovery`
(`player/recovery.rs`): a tested `State + Event -> Effects` machine owned per request by
`PlaybackSession::recovery`, driven by `pump.rs` (`drive_open_recovery`) the way
`ForegroundLifecycle` is driven by `foreground.rs`.

Characterization only: the ladder is main's, in main's order (pending HLS->Original rollback,
the conversion of native audio the install just learned the pipeline refuses, a cold Auto
Original's HLS contingency, the read-out), and a native-audio STALL of a combination the
install already knew still only logs (that is the next change's bug, pinned by a test).

- Events `Refused{refusal, facts}`, `RefusalConfirmed{newly}`, `FlightLanded`, `FlightRefused`,
  `FirstPicture`; effects `ConfirmRefusal`, `Rollback`, `StartFlight`, `StartAutoHls`,
  `ShowError{refusal}`. The learner is IO, so the machine asks for it (`ConfirmRefusal`) and the
  pump answers with whether the combination was new, in the same drain; the rung is picked on
  that answer. `LoadRefusal` is the typed payload: `Refused{num}`, `TimedOut`, plus `Stalled` and
  `SourceFailed` for the watchdog and the demuxer/transport branches that share the ladder.
- The attempt's capabilities moved into the machine (`start` plus the audio facets excluded, the
  attempt always re-derived from `start`): `PlaybackSession::caps` and `narrow_attempt` are gone,
  `attempt_caps()` delegates, and the narrowing happens inside `step` on a newly learned refusal,
  on every arm, as `recover_failed_source_route` did.
- `FirstPicture` is stepped from the pump's decoded-frame branch; the machine is idempotent there.
- `ci/machines.ini` registers the machine; `player/recovery.rs` joins the workers/globals scope
  of `ci/check-deps.sh` with no ledger line. `open_failure_action`'s unit test moved into the
  machine's table tests with the same seven expectations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…w ends on the read-out, not on "Buffering..."

TV run G (2026-10-26): a stored {opus, 6ch} refusal and a transcode fixture serving 1600 B
6-channel Opus again. `native_audio_stall_fired` returned false for a combination the install
already knew ("only logs"), so the pump neither failed the Engine nor showed a read-out, the
watchdog was spent, and the viewer stayed on the buffering spinner for good.

The stall is a refusal of the same class as `type=18` (`LoadRefusal::Stalled`). OpenRecovery
now runs the ladder on the learner's answer whatever the refusal: a known combination is a
refusal with no conversion left, so it takes the next rung (a pending Original trial's rollback,
a cold Auto Original's HLS contingency) and else `ShowError`.

Red first: the rig test (a stored {opus,6ch}, both AU queues holding data, nothing accepted for
NATIVE_AUDIO_STALL_MS, the watchdog's STALL line asserted as its precondition) failed with
"a stall of a combination the install already knew left the viewer on Connecting" before the
change and passes after it. player/CLAUDE.md's "a known refusal fails as before" held only for
the `type=18` path; it now says what both paths do.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, owned by OpenRecovery

Comment-only. `PlaybackSession::caps` no longer exists (the value lives in the request's
`OpenRecovery`); three doc comments in plan.rs, devcaps.rs and session.rs still named the
field or the route as the narrower.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ecycle's alone; the ladder owns refusals observed after the call returned

The pump's failed-open ladder and the foreground lifecycle both acted on a synchronous
refusal of a foreground-resume Load (StartFailed + load_failed): the pump set Error, then the
lifecycle settled the same refusal as #1018's retry and retired the Engine.

Split by who observes the refusal. The foreground actuator marks the Engine whose exact Load
attempt it follows (Engine::foreground_follows_load, set where it starts or takes over following
an attempt). The pump decides before stepping the machine: a failed Load on a followed Engine
whose route_start_status is Failed is the Load call's own result, so it returns and the
lifecycle's Failed settlement owns it. A refusal seen after the call returned, and a fresh play's
synchronous refusal (no lifecycle behind it), stay the ladder's.

The step-0 SYNC test is flipped to the desired behaviour (red first: the pump left Error), the
ASYNC test is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-facets rung and a 2-flight cap

A pipeline refusal before the first picture now converts the suspect audio (a codec outside
aac/ac3/eac3, named by recovery::suspect from the stream the pipeline was fed) in one recovery
flight, then takes the HLS contingency of a cold Auto Original, then the read-out;
MAX_RECOVERY_FLIGHTS bounds the flights of one request. The learner's answer (newly) no longer
selects a rung: a facet the attempt's start already lacks narrows nothing and gets no flight.
Forced Direct Play ends on the read-out after a pending rollback. StartFlight is ConvertFacet and
dispatch_native_audio_conversion is dispatch_refusal_conversion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efusalRecovery

The Retranscode landing of a recovery flight notes the delivery (and the app's own recovery as the
open's origin) through report::note_refusal_recovery_delivery; the reason joins the closed list,
the consent legend and PRIVACY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y classes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… landing re-grades the Original candidate's audio; the #695 rig tests

plxnative-refuse601 (dev builds) publishes type=18 num=601 through sf_on_event once the Load
returned and before a picture, once per playback trace generation, so the converted reload of
the same request plays. The flag is read only inside that open window (a process-wide latch
cannot be armed per test).

The refusal-recovery landing calls regrade_original_audio, so the stored Original candidate's
audio is graded against the narrowed attempt and the ABR climb plans the remux, not the
refused audio's Part.

Hostsim tests: a DTS 601 converts in one flight (#695, with and without a sibling), an Auto
Original flies the conversion not HLS, a refused conversion ends on the read-out, the climb
after a rescue plans no DTS direct play, the landing notes its delivery reason and counts the
attempt once, refuse601 publishes once per request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1a ladder made false is rewritten

ConvertFacet carries the facet and the flight ordinal, so the driver logs 'converting {facet}
(flight n/2)'. The learner's doc, the pump's refusal comments, dispatch_refusal_conversion's doc,
the type=18 callback comment and the player guide describe the ladder as it is: the facet rung is
chosen by suspect() and the loop guard, not by the learner's answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the ladder did not ask for re-opens it; an unhandled refusal fails the Engine

The ladder lives per request but a picture is per Engine: the facet rung is gated on
the refusal's own Engine having no decoded frame (facts.no_frames), not on the
request's history, so a native-audio refusal on a reloaded Engine converts again.
RecoveryEvent::LoadStarted, raised by engine::start_bufferfeed_for (the funnel every
Engine start passes), takes a Flight or Failed machine back to Opening when a park and
restore re-Loaded the route without a landing; the recovery landing's own reload finds
the machine already in Opening and changes nothing. recover_failed_open fails the
Engine on a refusal the machine does not handle, as before the machine.

The stall branch gets the preview check the other failure branches have; the DTS rig
tests are renamed and tightened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ladder change made false is corrected

The pure table now walks the full cap as a fixpoint over (state, flights, excluded) with
FirstPicture, a free RefusalConfirmed, FlightRefused, LoadStarted and a Refused of an Engine that
did show a picture, and pins every (state, event) pair once. The CLAUDE.md ladder paragraph, the
Sans-IO scope notes, the check-deps header, the pump and decision docs and parity-gaps item 5
now describe the per-Engine facet rung, the LoadStarted re-entry, and the driver failing the
Engine for a refusal the machine does not handle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… proof (S2a, #1046)

A DTS 601 names no codec, so a refusal of it was never learned and a refusal
that does name one was applied from a single event. The OpenRecovery machine now
separates the two kinds of evidence:

- Named (num=200, the stall): ConfirmRefusal{Named} is emitted in the same step
  as the ladder's rung; there is no Confirming state and no RefusalConfirmed
  event. The row applies at once (no `proofs`).
- Proof: a facet flight whose /decision lanes are audio=transcode and
  video=copy|directplay (FlightLanded{lanes}) enters Proving; the first picture
  of the converted reload emits ConfirmRefusal{Proof}. Both-facets (HLS),
  rollback, timeout and source-failure rescues prove nothing.

The stored NativeAudioRefusal gains `proofs: Option<u32>` (absent = named or
pre-S2a row = applied). A counted row applies at LEARNED_REFUSAL_PROOFS (2);
named wins on merge, proofs add capped, a proof of unknown width is not stored.
The writers (record_named_refusal / record_refusal_proof) only read
devcaps::learning_firmware() and queue the merge on the storage worker; devcaps
no longer carries a write door (learn_refusal is gone), the row reaches the
capability answer through the record's projection of applied rows.

Lanes are parsed from /decision by route/plan.rs::decision_lanes and carried
PreparedEncode -> AppliedRetranscode -> RecoveryLanding -> FlightLanded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed proof, not devcaps::learn_refusal

Prose the S2a change made false: player/CLAUDE.md's learned-refusal section,
docs/sans-io.md and a test_harness docstring still named learn_refusal and
described a refusal as applying at once from the pump.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rves it

The non-hostsim test build compiled decision_test_support.rs without the rig,
so MDE_TRANSCODE_BOTH was dead code there (-D warnings). Defined where it is used.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e its picture, proves nothing (S2a, #1046)

A facet flight proved its conversion whenever PMS answered the lanes clean, even when the request had
already played the facet natively: the picture is per Engine, so the facet rung is reachable from
Playing (a Home, a return, a restore Load refused before its picture), and two such restores would
persist a wrong row. OpenRecovery now remembers that a picture showed (pictured) and enters Proving
only for a request that showed none.

A Load between the clean landing and the first picture (a track switch, a restore after Home, a seek
reload) read as the landing's own reload and kept the pending proof for a route whose lanes were never
read. FlightLanded moves the machine BEFORE the driver reloads, so Proving carries `loaded`: the first
LoadStarted is the landing's own reload, a second one drops to Opening and loses the proof.

Tests: the first_picture helper asserts its refusal flag arrived (5 s, not a silent 500 ms fall-through);
confirm_refusal's recorder test runs the real recorder under a temporary session and reads the answer
before the queued write lands; the proof and named-opus rig tests project the real record before their
capability assert; decision_lanes and Proving x named Refused get direct pins; the debug-flag test goes
through project_stored_refusals (nativeaudioforget applies NONE, so a row recorded during the run
reaches caps() on the next launch). Prose corrected where it described the old landing and the old
forget behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… assertion can fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow reads the start value

R1 fails on the base (the record's projection swaps the Arc: opus ceiling None -> Some(2)); M2
fails on the base (the preview reads the boot snapshot and promises Direct Play for a refused DTS 5.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d its test doors go

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d on Session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds the start value; pump tests read the record at the start edge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sals (start_caps), at every play-start site

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GLinnik21 and others added 3 commits October 26, 2026 10:00
…alue from the stored refusals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1048)

No production change. Since #1034 the Forced audio gate honours a learned ceiling
(native_ceiling), and S0/S2b moved it to the attempt's start value. These tests pin
that composition:

- route tier: a set that announces DTS 5.1 with an applied `dca 6` row is refused
  as ForcedFailure::Audio("DTS") with no part URL (no Load); with an AC3 sibling,
  Forced plays the sibling.
- app tier: the play-start wrapper `start_caps()` returns a value lacking the
  applied refusal while the write-once boot snapshot is untouched.
- cold_audio_resolve_held: cold_audio_resolve for a caller that already holds
  testlock::serial() and set up its own fixtures under it.

The guide gains one sentence under the start-value paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@GLinnik21 GLinnik21 added playback Playback, the player and its controls internal Tooling, CI, tests and code health; no user-facing change labels Oct 26, 2026
GLinnik21 and others added 15 commits October 26, 2026 11:51
…fusal of their Load before the next frame

The rig's Engine is never built by a real sf_load (the rig forces the host object ready), so its teardown cannot prove
the release and quarantines the host sink; every Load a test then starts (the foreground restore, the facet landing's
reload) is refused by the stub from the MEDIA thread (ok=0, no type=18 callback, so a refusal with num=0). The tests
assert "not Error" after a frame that follows the start; they pass only while that frame beats the media thread. When
the media thread wins, the frame reads a second refusal of an open whose facet is spent and the ladder ends on the
read-out ("no rung left for type=18 num=0"). The CI hostsim job failed on S3 (#1063) in two of these tests with no
S3 code involved; a loop of only the pump tests failed the same way (1 of 60 and 4 of 250 runs under load).

pipeline_takes_the_load awaits the stub's refusal of the Load and takes it down, and is called after each Load these
tests go on to grade: park_and_restore (Scenario B), Scenario A's restore, and refuse601's reload. The refusal is now
always absorbed before the frame, so every run takes the ordering that used to lose; the test's own
pipeline_refuses_601 still ends the open. No production change: on a set the restore's Load returns ok=1 and the
pipeline then refuses it with 601.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
first_picture takes the hostsim sink's refusal through pipeline_takes_the_load
(S1a's helper) instead of repeating its wait-and-clear body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	rust-modules/media/src/route/decision.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
audio_converted_by_server reads the attempt since S2b; the merged #1049 test still used the
three-argument form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from series/caps-write-once to main October 26, 2026 10:40
@GLinnik21
GLinnik21 merged commit 03534e3 into main Oct 26, 2026
19 checks passed
@GLinnik21
GLinnik21 deleted the series/forced-refuses-learned branch October 26, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Tooling, CI, tests and code health; no user-facing change playback Playback, the player and its controls

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Force Direct Play refuses a learned refusal before the Load

1 participant