A slice block's lying base can no longer ship as a complete frame #398

Merged
enricobuehler merged 1 commits from worktree-slice-tiling-guard into main 2026-08-26 14:48:15 +00:00
Owner

The field report

AMD Strix Halo Windows client streaming from a 4090 host: black rectangular blocks "moving like an equalizer from top to bottom", identical with Vulkan, DXVA, and CPU decoders, while an Android client on the same host streams clean (both sides 0.31.4).

Identical across all three decoders means the AU bytes are wrong before any decoder sees them — the three lanes take three different presenter paths, so the presenter is exonerated too. And the one negotiated difference from Android is VIDEO_CAP_MULTI_SLICE (unconditional on desktop, gated on multiSliceTolerant() on Android), which arms the slice-granularity streamed wire: NVENC sub-frame readback, per-slice FEC blocks at sender-declared base offsets.

The gap

A slice sentinel's wire base was bounds-checked (shard-aligned, in range, below the final block's base — plus the retro-validation when the final block pins the totals) but nothing ever proved the blocks tile the AU. A base that lies within bounds leaves a zero gap at the honest offset and an overlap at the claimed one — and the reassembler still stamped the frame complete: true. The decoder paints the wrong-offset bytes as garbage rectangles, no loss counter moves, so the freeze gate never arms, no recovery keyframe is ever requested, and the intra-refresh wave marches the corruption down the picture indefinitely. The old comment said a lying base "can only corrupt this frame's own pixels" — true for memory safety, but that corrupt frame then shipped as complete.

The fix

At frame completion, verify the slice blocks tile [0..total_data] contiguously. A frame that doesn't is killed exactly like a lying final header already is: index remembered against stragglers, counted into frames_dropped — which is the signal the session pump already treats as loss, arming the freeze gate and requesting recovery. The user sees a ~100 ms freeze-and-heal instead of persistent black bands. The slice-progressive prefix walk gets the same one-line contiguity guard so a lying base can't extend a "contiguous" prefix either.

The new test forges a base that passes every pre-fix check (block 1 shifted one shard: still aligned, still below the final base) and asserts the frame is refused, counted lost, releases its budget, and can't be resurrected. Verified by mutation: without the guard, that frame is delivered as complete — today's shipping behavior.

What this does and doesn't cover

This makes the client refuse to render the corruption and recover instead, whichever host bug produces mis-based blocks. The host-side cause on the 4090 still wants its own fix — the leading suspect is the NVENC sub-frame chunk reader, whose emitted-prefix consistency check is #[cfg(debug_assertions)] only (nvenc.rs:2251). Host-side bisect knobs for the reporter, in order: PUNKTFUNK_NVENC_SUBFRAME=0PUNKTFUNK_SLICE_STREAM=0PUNKTFUNK_NVENC_SLICES=1, plus PUNKTFUNK_DUMP_VIDEO=<dir> on the client for ground truth.

## The field report AMD Strix Halo Windows client streaming from a 4090 host: black rectangular blocks "moving like an equalizer from top to bottom", **identical with Vulkan, DXVA, and CPU decoders**, while an Android client on the same host streams clean (both sides 0.31.4). Identical across all three decoders means the AU bytes are wrong before any decoder sees them — the three lanes take three different presenter paths, so the presenter is exonerated too. And the one negotiated difference from Android is `VIDEO_CAP_MULTI_SLICE` (unconditional on desktop, gated on `multiSliceTolerant()` on Android), which arms the slice-granularity streamed wire: NVENC sub-frame readback, per-slice FEC blocks at sender-declared base offsets. ## The gap A slice sentinel's wire base was bounds-checked (shard-aligned, in range, below the final block's base — plus the retro-validation when the final block pins the totals) but nothing ever proved the blocks **tile** the AU. A base that lies *within* bounds leaves a zero gap at the honest offset and an overlap at the claimed one — and the reassembler still stamped the frame `complete: true`. The decoder paints the wrong-offset bytes as garbage rectangles, **no loss counter moves**, so the freeze gate never arms, no recovery keyframe is ever requested, and the intra-refresh wave marches the corruption down the picture indefinitely. The old comment said a lying base "can only corrupt this frame's own pixels" — true for memory safety, but that corrupt frame then shipped as complete. ## The fix At frame completion, verify the slice blocks tile `[0..total_data]` contiguously. A frame that doesn't is killed exactly like a lying final header already is: index remembered against stragglers, counted into `frames_dropped` — which is the signal the session pump already treats as loss, arming the freeze gate and requesting recovery. The user sees a ~100 ms freeze-and-heal instead of persistent black bands. The slice-progressive prefix walk gets the same one-line contiguity guard so a lying base can't extend a "contiguous" prefix either. The new test forges a base that passes every pre-fix check (block 1 shifted one shard: still aligned, still below the final base) and asserts the frame is refused, counted lost, releases its budget, and can't be resurrected. Verified by mutation: without the guard, that frame is delivered as `complete` — today's shipping behavior. ## What this does and doesn't cover This makes the client refuse to render the corruption and recover instead, whichever host bug produces mis-based blocks. The *host-side* cause on the 4090 still wants its own fix — the leading suspect is the NVENC sub-frame chunk reader, whose emitted-prefix consistency check is `#[cfg(debug_assertions)]` only (`nvenc.rs:2251`). Host-side bisect knobs for the reporter, in order: `PUNKTFUNK_NVENC_SUBFRAME=0` → `PUNKTFUNK_SLICE_STREAM=0` → `PUNKTFUNK_NVENC_SLICES=1`, plus `PUNKTFUNK_DUMP_VIDEO=<dir>` on the client for ground truth.
enricobuehler added 1 commit 2026-08-26 14:28:54 +00:00
fix(core): a slice block's lying base can no longer ship as a complete frame
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m10s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / rust (pull_request) Successful in 5m29s
android / android (pull_request) Successful in 6m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m5s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m21s
e684b3e4bd
A slice-streamed sentinel's wire base was bounds-checked (in range, below
the final block) but never validated to TILE the AU: a base that lied
within bounds left a zero gap and an overlap, and the reassembler still
stamped the frame `complete`. The decoder then painted the wrong-offset
bytes as garbage rectangles while no loss counter moved — so the recovery
machinery never fired and the corruption marched on indefinitely (the
field report: black bars 'moving like an equalizer', identical across
Vulkan/DXVA/CPU decoders, Android immune because it never negotiates the
slice wire).

At completion, verify the blocks tile [0..total_data] contiguously; a
frame that doesn't is killed exactly like a lying final header — index
remembered against stragglers, counted into frames_dropped, which is the
signal that arms the freeze gate and requests recovery. The
slice-progressive prefix walk gets the same contiguity guard so a lying
base can't extend a 'contiguous' prefix either.
enricobuehler merged commit 1198522931 into main 2026-08-26 14:48:15 +00:00
enricobuehler deleted branch worktree-slice-tiling-guard 2026-08-26 14:48:18 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#398