The grey native-vulkan stream: a host re-anchor claim the client could not check #262

Merged
enricobuehler merged 3 commits from worktree-grey-frame-reanchor-fixes into main 2026-08-16 07:57:21 +00:00
3 Commits
Author SHA1 Message Date
enricobuehler 89fd4c6f87 fix(client): teach the VAAPI AV1 sizing fixture about the clean bit
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 5m43s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m7s
ci / rust (pull_request) Failing after 5m8s
ci / rust-arm64 (pull_request) Successful in 2m21s
ci / docs-site (pull_request) Successful in 1m31s
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m34s
Adding a public field breaks LITERAL constructors, and the only one outside
pf-bitstream lives in a `cfg(test)` fixture in a crate that consumes the plan types
through pf-vaadec's re-exports — so nothing on the macOS side and no single-crate
test run could see it. The Linux `--all-targets` gate did.

Vacuously `true`: the fixture codes a key frame, which predicts from nothing. It
exists to exercise the sizing path (sequence max vs coded vs render), so the clean
bit is incidental here — but it still has to state the honest value, because `false`
is the answer that withholds a re-anchor.
2026-08-16 01:30:53 +02:00
enricobuehler 93c1ed0723 fix(host): an RFI anchor could be picked over damage the client had already reported
The slot-family RFI backends choose a recovery anchor over `slot_wire`, which answers
"did the client RECEIVE this frame" when the question is "did the client DECODE it
intact". The taint sweep exists precisely to bridge that gap -- rfi.rs says so -- but
it only ever runs inside invalidate_ref_frames, reachable from exactly ONE of the
client's five damage signals (the frame-index gap). The other four send a plain
keyframe request, which sets force_kf and taints nothing.

That is self-healing while the IDR is actually emitted. It is not when the request is
coalesced away by the 750 ms IDR cooldown: the client's damage then goes unrepaired
AND unrecorded, and those references stay anchor candidates for the next loss -- so
the host serves an anchor over damage the client already told it about, tagged as the
client's definitive clean re-anchor. The client-side half of this fix now refuses such
an anchor; this is the other half, which stops it being offered.

Adds Encoder::distrust_references (defaulted no-op, forwarded through TrackedEncoder
-- unforwarded it would have been a silent no-op for every session), implemented by
the three slot-family backends through their own persistence markers, which rfi.rs
explicitly says not to harmonize: Vulkan Video blanks slot_wire ONLY and leaves
slot_poc naming every resident, or build_h265_rps_s0 stops retaining them and a
conforming decoder evicts pictures the encoder still references -- a separate grey
bug that file already documents. AMF clears its mirror slot; QSV raises ltr_tainted
rather than clearing its mirror, because the RejectedRefList only names Some slots and
a cleared entry would skip the very reference being distrusted.

Called on the IDR-cooldown branch, where the client is still reporting damage and
nothing in the table is honestly known-good until the in-flight IDR lands. Deliberately
NOT on the RFI-echo branch while its budget holds: that branch's premise is that the
request echoes the loss the RFI just repaired, and distrusting on the first echo would
poison the table after EVERY successful recovery, so RFI could never fire twice running
and a sustained-loss session would fall back to the IDR path this block exists to keep
it off. RFI_ECHO_MAX_SWALLOWED is already the hedge for that premise being wrong: when
the client keeps asking past the budget, the anchor demonstrably did not heal it, and
the escalation arm withdraws trust then -- on evidence rather than on suspicion.

Distrust never touches prediction (that runs off slot indices, not the wire domain) and
all three markers self-correct within a few frames, so the suppression is brief by
construction and never spans a session.
2026-08-16 01:14:47 +02:00
enricobuehler 26d06195f5 fix(client): a host recovery anchor lifted the freeze onto a grey picture, unchecked
Field report: the native-vulkan HEVC stream goes grey with moving artifacts after a
host context change (starting a game), and recovers by itself in 0.5-2 s.

Of the three signals that lift the post-loss freeze, two are self-evident to the
client and one is pure hearsay. An IDR predicts from nothing. A recovery mark is
half a re-anchor and the gate says so by requiring two. But USER_FLAG_RECOVERY_ANCHOR
is the HOST asserting a fact about the CLIENT's decoder -- "the picture I coded this
P-frame against is one you still hold, intact" -- and the gate took it on faith, on
the first occurrence, with no scrutiny at all.

The host derives that claim from bookkeeping that tracks what the client RECEIVED,
not what it managed to DECODE. Those diverge exactly when the client had to conceal,
and then the anchor lifts the freeze onto a picture predicted from damage AND LEAVES
IT LIFTED -- so the grey plate reaches the screen and keeps reaching it until some
later signal re-arms and the 500 ms backstop extracts a real IDR. That is the
observed 0.5-2 s, and it is the worst-shaped failure in the module: a re-anchor claim
the client can refute is worse than no claim, because no claim merely holds.

So corroborate it. pf-bitstream's planners now carry a per-picture clean bit --
damage propagates down the prediction chain, because the descendants of a concealed
picture raise no warning of their own -- surfaced as PicturePlan::references_clean
and carried to the consumer on DecodedVkFrame. The gate gains AnchorEvidence and
on_decoded_corroborated, and refuses an anchor whose references the client can prove
were damaged. Refusing can only ever hold LONGER: the freeze stays up, the backstop
fires on its ORIGINAL deadline, and the client escalates to the IDR the anchor failed
to be. Lanes with no local parser pass Unavailable and are bit-for-bit unchanged --
all 29 pre-existing reanchor tests pass untouched.

Also brings the H.264 decoder to parity with its two siblings, found on the way. It
was the only one of the three that failed OPEN: a DPB slot with no bound image was
traced and decoded anyway (H.265 and AV1 return UnboundReferenceSlot), reference_count
was computed after the held-slot loop so a dropped reference silently took an
unrelated slot's picture in its place, and there was no RecoveryLatch, so a failure
left the planner DPB, the slot map and the image bindings disagreeing forever. None
of it raised a warning, so the frame was shipped, presented, and cleared the demotion
streak on its way past. Both latches landed with the HEVC and AV1 decoders in August;
H.264 predates them and was never retro-fitted.

The damage classification moves onto the warning enums so the planner's ledger and
the client's concealment test cannot drift apart -- still exactly one list, now in
the crate that owns the enum, with pf_vkdecode::is_integrity_warning* delegating.
Every copy stays an exhaustive match with no wildcard: a future variant must stop the
compiler, never default to clean.
2026-08-16 01:14:30 +02:00