Compare commits

...
Author SHA1 Message Date
enricobuehler 57703fe31c Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / rust-arm64 (pull_request) Failing after 58s
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m9s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m10s
ci / rust (pull_request) Failing after 5m14s
android / android (pull_request) Successful in 9m15s
2026-08-12 18:30:50 +02:00
enricobuehler 90a3304c2b Merge pull request 'The Windows host no longer blocks system sleep while idle' (#179) from worktree-win-sleep-blockers into main
deb / build-publish (push) Failing after 1m16s
ci / bun-nix (push) Successful in 27s
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 1m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Failing after 1m24s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
android / android (push) Canceled after 3m59s
apple / screenshots (push) Canceled after 2m19s
ci / rust-arm64 (push) Failing after 3m56s
ci / rust (push) Failing after 3m56s
arch / build-publish (push) Canceled after 4m1s
deb / build-publish-host (push) Canceled after 2m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m11s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 17s
windows-host / package (push) Canceled after 4m4s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #179
2026-08-12 16:30:11 +00:00
enricobuehler 017867f211 fix(ci): the gates the PR run reaches and the boxes could not
ci / rust-arm64 (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 1m52s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 3m36s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m52s
android / android (pull_request) Successful in 6m23s
- adl_emul.rs adl_malloc: panic-free (a reachable expect in an extern fn is an
  abort — gate B; the Err arm is unreachable, ADL treats null as failure)
- punktfunk-host main.rs: reword the carve-out comments so gate C's textual
  count stays at its baseline (comments count)
- clients/linux: forbid(unsafe_code) -> deny with two named allows — the SDL
  device-filter clear and the spawn test's HOME scoping are unsafe calls in
  edition 2024 (caught by the aarch64 leg, the only one with glib)
2026-08-12 17:44:15 +02:00
enricobuehler ef0af3b558 docs(changelog): the Windows host no longer blocks system sleep while idle
ci / rust (pull_request) Failing after 1m1s
ci / web (pull_request) Successful in 1m7s
ci / bun-nix (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 6m17s
ci / docs-site (pull_request) Successful in 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 9m51s
2026-08-12 17:41:08 +02:00
enricobuehler 535e95c4c0 fix(punktfunk-host): idle-stop the virtual-mic stream so a Windows host can sleep
The mic pump's WASAPI backend rendered silence into the virtual mic's
render endpoint (typically the Steam Streaming Microphone) for the whole
host lifetime. A RUNNING stream makes the Windows audio stack hold a
kernel power request ("An audio stream is currently in use", visible in
powercfg /requests), so every idle Punktfunk host blocked system sleep
forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since
installing punktfunk; powercfg shows the Steam Streaming Microphone").

Stop the render stream (IAudioClient::Stop — client stays initialized,
the mic endpoint keeps existing) after 10 s of silence-only output at an
unchanged queue length, and park the render thread on a condvar the
producer notifies on the empty->non-empty edge, so the next mic frame
resumes the stream within one device period — well under the jitter
buffer's prime depth, so nothing audible changes. Keying the idle window
on the queue LENGTH (not emptiness) also covers a sub-prime tail a
vanished client left behind, while any fresh burst moves the length and
resets the window instead of being dropped.

During a session the box stays awake through the session's own
DisplayWakeRequest (pf-frame), never through this silence.
PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case
a virtual audio driver misbehaves while its render side is paused.
2026-08-12 17:39:22 +02:00
enricobuehler b385f0a031 Merge pull request 'Rust edition 2024: the whole tree, with the env-mutation class made visible (WP20)' (#177) from worktree-edition-2024 into main
ci / rust (push) Failing after 56s
apple / swift (push) Successful in 1m36s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m15s
deb / build-publish-client-arm64 (push) Failing after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m28s
apple / screenshots (push) Successful in 5m59s
ci / rust-arm64 (push) Failing after 8m18s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m43s
windows-drivers / probe-and-proto (push) Successful in 23s
deb / build-publish-host (push) Successful in 7m58s
decky / build-publish (push) Successful in 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m50s
arch / build-publish (push) Failing after 11m24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / bun-nix (push) Failing after 10m37s
deb / build-publish (push) Failing after 3m38s
flatpak / build-publish (push) Failing after 8m49s
android / android (push) Successful in 16m29s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 35s
release / apple (push) Successful in 10m11s
nix / flake (push) Successful in 15m25s
windows-host / package (push) Successful in 13m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m16s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
Reviewed-on: #177
2026-08-12 15:38:22 +00:00
enricobuehler 7528fd48a9 Merge pull request 'A stats tier picked between streams reached nothing until the app was restarted' (#178) from worktree-console-stats-tier-relatch into main
android / android (push) Canceled after 21s
apple / swift (push) Canceled after 21s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 26s
ci / rust (push) Canceled after 32s
ci / rust-arm64 (push) Canceled after 12s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 1s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #178
2026-08-12 15:38:03 +00:00
enricobuehler c68e0be688 Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
2026-08-12 17:27:27 +02:00
enricobuehler 71c1970b93 fix(client/console): a stats tier picked between streams reached nothing until a restart
ci / bun-nix (pull_request) Successful in 31s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 59s
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m18s
ci / rust-arm64 (pull_request) Successful in 5m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m17s
ci / docs-site (pull_request) Successful in 4m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m15s
Field report: "no matter what I select the stats overlay is stuck showing as
detailed" on the Deck, cured by restarting the client app.

The console (Gaming Mode, and therefore Decky) builds its window and its run loop
ONCE and streams every session through them, and the loop took its stats tier from
the settings snapshot read at process start. Its own settings screen writes the
chosen tier to the file and redraws its row, so the choice looked taken while every
stream kept the tier the process happened to start on — Detailed for anyone who had
been on Detailed. Only a restart re-read it. The desktop shells were never affected:
they spawn a session process per stream, which resolves settings for itself.

The tier now rides `SessionParams` per launch, so browse mode adopts what THIS launch
resolved and the start-of-process value only seeds the loop until the first stream.
Two things fall out of resolving per launch rather than per process: a profile bound
to a host can finally move the tier in console mode (part of the documented P4 gap),
and the adoption sits in the `Start` arm rather than `StreamState::new`, so the
codec-fallback retry can't snap the overlay back and undo an in-stream cycle.

The `--stats` rule (a floor that lifts Off to Normal and demotes nothing) was written
out three times and is now one tested helper. The rest of the console's latched
presentation tier — touch and mouse model, shortcut inhibit, match-window, render
scale — is unchanged and still needs the models rebuilt per launch.

Gate: clippy --all-targets -D warnings, plain build, and tests for pf-client-core,
pf-presenter and punktfunk-client-session, all green in pf-lxcheck2 (linux/amd64);
clippy proven non-vacuous by touching the four edited files. cargo fmt --all --check
clean.
2026-08-12 17:26:33 +02:00
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00
enricobuehler 28b6633058 Merge pull request 'feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming' (#176) from worktree-edid-lock-toggle into main
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (web) (push) Successful in 23s
audit / docs-site-audit (push) Successful in 22s
audit / pnpm-audit (push) Successful in 13s
apple / swift (push) Successful in 1m36s
audit / license-gate (push) Successful in 4m52s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m50s
apple / screenshots (push) Successful in 5m56s
ci / web (push) Successful in 3m49s
ci / rust-arm64 (push) Successful in 4m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
ci / docs-site (push) Successful in 3m50s
audit / c-abi-asan (push) Successful in 5m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m54s
audit / cargo-audit (push) Successful in 36s
deb / build-publish-host (push) Successful in 8m38s
release / apple (push) Successful in 9m55s
deb / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14m53s
docker / builders-arm64cross (push) Skipped
android / android (push) Successful in 15m34s
ci / rust (push) Failing after 14m34s
flatpak / build-publish (push) Successful in 20m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
nix / flake (push) Failing after 20m46s
arch / build-publish (push) Successful in 21m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 38m25s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m20s
2026-08-12 13:12:11 +00:00
enricobuehler c58217e403 Merge pull request 'fix(client): stop the double-arm race re-freezing RFI-healed streams' (#174) from worktree-gate-double-arm-fix into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
audit / miri (push) Canceled after 0s
audit / c-abi-asan (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
nix / flake (push) Canceled after 0s
release / apple (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 13:11:51 +00:00
enricobuehler 23f9b1130e Merge pull request 'Black-stream fixes: CCD restore snapshot pruning, iOS silent-black recovery, devnode journal retention' (#175) from worktree-black-stream-fixes into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-12 13:11:29 +00:00
enricobuehler c2c71f0ac5 Merge pull request 'fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default' (#173) from worktree-rt-gpu-priority-defaults into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 24s
windows-drivers / driver-build (push) Successful in 1m54s
2026-08-12 13:11:02 +00:00
enricobuehler 6a506a8fa9 fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default
windows-drivers / probe-and-proto (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m31s
windows-drivers / driver-build (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 9m54s
apple / swift (pull_request) Failing after 13m27s
apple / screenshots (pull_request) Skipped
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:

- the vdisplay driver's IddCxSetRealtimeGPUPriority raise beat at ~1.75-1.78 s
  (PFVD_NO_RT_GPU=1 alone removed that metronome: ~0.35 stalls/s metronomic ->
  10 sparse aperiodic over 3.9 min);
- the host auto-gate's HIGH->REALTIME upgrade (pf-frame dxgi.rs, T2.3) beat at
  ~3.58 s in the AV1 sessions where it promoted (vram_pct=1, 12:59:26); pinning
  PUNKTFUNK_GPU_PRIORITY_CLASS=high removed that residual too (13:45 session:
  zero metronomic, stall rate at the clean-run baseline).

Neither period matches any punktfunk clock: the full periodic-actor census
(driver: event-paced drain + 16 ms E_PENDING wait, 33 ms cursor poll, 3 s
watchdog reap; host: 250 ms descriptor poll, 5/50/100 ms probes + ~2 s scanline
retarget, 2 s VRAM gate, 2 s exclusive re-assert, 3.33 s pinger, 1 s stats,
~1 Hz phase-lock, fps/2 LTR marks) has nothing in the 1.69-2.29 s band, and
every host-side actor ran unchanged in the A/B that killed the fast metronome.
The periodicity is emergent from holding an unreachable-priority queue against
the WDDM scheduler on this AMD family (the period even differs by which of our
processes holds REALTIME); it is not a punktfunk cadence being amplified, so
there is nothing punktfunk-periodic to fix - the fix is to stop holding
REALTIME by default, which is also canonical parity (no shipping IDD raises
it, and HIGH was the class that delivered the original Sunshine-parity encode
win).

- Driver: PFVD_NO_RT_GPU (default-ON, opt-OUT) becomes the PFVD_RT_GPU ladder,
  default OFF on every vendor: unset = no raise (canonical IDD behavior);
  =thread = SetGPUThreadPriority(+7), a graduated in-band middle rung for field
  A/B (not default: unmeasured here, and the host measured the same call as "no
  help" for its own starvation case); anything else = the old REALTIME DDI.
  PFVD_NO_RT_GPU stays recognized and WINS over the opt-in, so the field boxes
  that carry it through the default-ON era keep meaning OFF. Both directions
  remain A/B-able without a rebuild (machine env + device restart). The CPU
  half of the original branch-2 hardening (MMCSS / TIME_CRITICAL) is untouched
  - it addressed the delivery holes that were actually observed.
- Host: PUNKTFUNK_GPU_PRIORITY_CLASS default auto -> high. `auto` (the gated
  REALTIME upgrade) stays available as an explicit opt-in, `realtime` still
  pins; unrecognized values now land on the HIGH default instead of silently
  opting into the gate - a typo must not buy the hazard. The VRAM/HAGS gate
  machinery is unchanged for `auto`; it guards the NVENC-hang hazard but cannot
  see this one.
- stall.rs: the no-OS-event METRONOMIC warning now carries rt_gpu_driver /
  rt_gpu_host fields (the machine-env state of both levers) and names clearing
  them as the FIRST cure, ahead of the display-hardware suspects - a field log
  self-answers the triage question this program just spent a week on.

No console policy axis for the driver knob: the lever is default-safe now, the
driver reads config at WUDFHost scope where machine env already matches the
device-restart lifecycle, and a policy axis would need pf-driver-proto churn
(or a device-key registry write) for an experimental lever that only exists to
be A/B-ed. If the `thread` rung ever proves out as a default-worthy raise,
that is the moment to revisit.
2026-08-12 13:57:20 +02:00
enricobuehler 712ee935d6 fix(win-display): a failed devnode re-enable must keep its crash-journal entry
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 3m34s
ci / web (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 4m13s
ci / rust (pull_request) Successful in 5m56s
enable_instances cleared every requested id from the pnp-disabled-monitors
journal unconditionally — a mid-life re-enable failure erased its own
crash-recovery entry, so neither the running host nor the next start would
ever retry, leaving the operator's monitor disabled (invisible to Windows and
every display listing) until a manual Device Manager fix. Field 2026-08-12
(Android-client host running the pnp_disable_monitors axis): displays gone
from the client after 2-3 sessions, only a full host restart bringing them
back — the restart path only works while the journal still has the entry.

Only successfully re-enabled ids clear now; failures stay journaled (and are
logged with the consequence) so startup_recover retries them.
2026-08-12 13:30:04 +02:00
enricobuehler a2aa0a5f97 fix(apple): never sit black AND silent while the video format is unseeded
Both pumps (Stage2Pipeline + the stage-1 StreamPump) dropped every AU in a
silent guard while 'format' was nil — the opening IDR's parameter sets never
arrived or never parsed, and under the host's infinite GOP nothing re-delivers
them unless the client asks. Field 2026-08-12 (iOS, H265): sessions decoding
nothing, requesting nothing, host streaming perfectly — a black stream with
zero recovery requests, randomly self-healing only when some later event
produced a fresh IDR.

While format is nil after the first received AU, the pumps now set awaitingIDR
(routing through the existing 100 ms-throttled recovery.request()) and log the
state once, so a lost/unparsed opening IDR converts to a normal recovery
round-trip instead of a permanent silent black.
2026-08-12 13:16:58 +02:00
enricobuehler d6b9862f1e fix(win-display): prune unplugged targets from the CCD restore snapshot
A monitor unplugged mid-session left the teardown replaying a snapshot whose
paths reference an absent target — SetDisplayConfig rejects the WHOLE array
with 0x57 ERROR_INVALID_PARAMETER, nothing restores, the desk stays dark, and
the next session snapshots the wreckage (the poisoned-snapshot chain; field
2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping
between black and working at random, incl. one that genuinely encoded black).

The restore now prunes the snapshot against a live QDC_ALL_PATHS availability
sweep before replaying: stale paths drop, the mode table is rebuilt with only
the entries the survivors reference (an orphaned mode entry is itself an
0x57), and an empty survivor set skips the apply so the dark-desk backstop
decides. Verbatim behavior is unchanged when everything is still attached or
when the availability query itself fails.
2026-08-12 13:15:02 +02:00
enricobuehler f4e39a442b feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m45s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 34s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Failing after 7m55s
ci / docs-site (pull_request) Successful in 7m54s
ci / rust-arm64 (pull_request) Successful in 9m13s
nix / flake (pull_request) Successful in 14m26s
Productizes the adl-emul probe (the prior commit) as the display-policy axis its
PR promised: the ADL FFI moves to pf_win_display::adl_emul (one surface shared by
the probe tool and the host, so a reporter's probe and the console's toggle
exercise byte-identical driver calls), and an EXPERIMENTAL edid_lock axis joins
ddc_power_off/pnp_disable_monitors — orthogonal to presets, off by default.

At the first Exclusive isolate the host pins each occupied AMD connector's live
EDID + ADL_EMUL_MODE_ALWAYS (the software HPD dummy) BEFORE the physicals
deactivate; last-member teardown unlocks. Pinned emulation outlives the process,
so a crash journal (edid-lock-active.json) unlocks on the next host start,
mirroring the pnp_disable_monitors recovery. Inert without an AMD driver.

The console shows the toggle ONLY when the GPU inventory lists an AMD adapter —
the lever exists nowhere else, and a toggle that can never act is the 'saved and
then did nothing' trap the enforced-axes list exists to prevent.
2026-08-12 08:47:42 +02:00
enricobuehler a0577cb86e feat(tools/display-disturb): adl-emul — AMD connector-emulation probe (software HPD dummy)
The standby-sink stall program's §3 dead-end list marked ADL EmulationMode
'likely Pro-gated' on field hearsay, with 'probe once, log rc' as the owed
falsification — never run. Three RX 9070 XT field cases later (ASUS
VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI), this is that
probe, shippable to reporters: read-only caps/board-layout/connection-state
walk by default, --lock pins the live EDID + ADL_EMUL_MODE_ALWAYS on
occupied connectors (the software HPD-holding dummy), --unlock restores.
Every call prints the bench's epoch_ms correlation line with the decoded
ADL rc — ADL_ERR_NOT_SUPPORTED(-8) vs ADL_OK on consumer Adrenalin is the
Pro-gating answer, and a --lock run during a stream with the sink asleep
is the direct A/B for the metronomic stall class.

atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
2026-08-12 08:16:33 +02:00
enricobuehler 2a62fe7857 fix(client): stop the double-arm race re-freezing RFI-healed streams
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at
frame-index-gap detection (which fires the RFI), and ~120 ms later when the
reassembler ages the lost frame into frames_dropped and poll() re-armed
unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two
signals — so the stale climb re-froze a bit-exact-healed stream, the host
swallowed the re-ask as an RFI echo, and the picture stayed frozen until the
overdue backstop extracted a full IDR: the field 'H265 freezes on every loss,
AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower
IDR path usually lands after the climb and dodged the race).

The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
2026-08-12 08:11:35 +02:00
enricobuehler 66ba61b12c Merge pull request 'Safety round 2: ASAN+LSAN over the C ABI boundary, two soundness fixes, and WP4's AvFrame RAII' (#172) from worktree-safety-round-2 into main
audit / bun-audit (plugin-kit) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 24s
audit / bun-audit (web) (push) Successful in 27s
audit / cargo-audit (push) Successful in 34s
audit / docs-site-audit (push) Successful in 26s
apple / swift (push) Successful in 1m46s
audit / pnpm-audit (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m35s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m10s
audit / license-gate (push) Successful in 7m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
android / android (push) Successful in 8m2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
audit / c-abi-asan (push) Successful in 7m51s
arch / build-publish (push) Successful in 8m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
deb / build-publish (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m39s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Failing after 1m4s
deb / build-publish-host (push) Successful in 8m54s
release / apple (push) Successful in 12m10s
windows-host / package (push) Successful in 13m41s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m19s
apple / screenshots (push) Successful in 5m52s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
ci / rust (push) Successful in 13m52s
flatpak / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m41s
Reviewed-on: #172
2026-08-12 05:57:16 +00:00
enricobuehler 5002849737 feat(pf-encode): WP4 — AvFrame/AvSwsContext RAII across all three libav backends
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m45s
apple / swift (pull_request) Successful in 1m49s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m7s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m56s
android / android (pull_request) Successful in 6m21s
ci / rust (pull_request) Successful in 7m42s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 12m28s
Two newtypes beside AvBuffer/AvFilterGraph (same house shape: alloc/from_raw
rejects the allocator's null once, as_ptr lends, Drop frees, no Clone; NonNull
inside so the Options get a niche). 8 av_frame_alloc + 3 sws_getContext sites
converted; all 22 hand-placed av_frame_free and 5 sws_freeContext calls are
gone, and the three hand-written Drop impls (CpuInner, SystemInner,
NvencEncoder) with them.

The live defect this closes: ZeroCopyInner::submit (ffmpeg_win) leaked the
frame AND one pooled hwframe surface on each of three ? exits between the
pool pull and the send — under a SAFETY comment asserting no leak — and with
POOL=8, eight such failures starved the pool and wedged the encoder with no
error naming the cause. Every exit now returns the surface.

Drop-order care (the hidden cost the survey flagged): NvencEncoder's sws_csc
moved to field #1 (its hand-Drop freed it before all fields; this path runs
on every stall-watchdog recovery via *self = fresh); CpuInner's nv12/sws
declaration order flipped to match its hand-Drop; SystemInner's already
agreed. Pinned by FIELD ORDER comments, not offset_of asserts — the survey's
assert suggestion is the wrong tool: offset_of measures repr(Rust) memory
layout, which the compiler may reorder independently of the declaration
order that drop order actually follows.

The dmabuf path keeps its early descriptor release via an explicit drop()
at the exact point the hand-written free sat.

Gates: .25 clippy -D warnings + tests green (nvenc,vulkan-encode,pyrowave);
.133 check --all-targets + clippy --release -D warnings + 80 tests green
(nvenc,amf-qsv,qsv; test step needs ffmpeg\bin on Path — 0xC0000135
otherwise). Owed on hardware: the #[ignore]d alloc/drop cycles on
.136/.116/.173/.47 and the pool-exhaustion assertion (9th submit succeeds
after 8 forced failures).
2026-08-12 00:31:14 +02:00
enricobuehler 9a59504ba4 fix(punktfunk-core): validate InputKind before forming &InputEvent in the C ABI
abi.rs's two send-input entry points built &InputEvent straight out of
caller memory with ev.as_ref(); InputKind is repr(u8) with 16 valid
discriminants, so a C embedder writing ev->kind = 42 was immediate UB the
moment the reference formed — in a file whose stated principle is that
failures become status codes. New read_input_event() checks null, reads
the tag as a raw byte, validates through the same InputKind::from_u8 the
wire path uses, and only then forms the reference; bad tags return
InvalidArg. Every other field is a plain integer, valid for any pattern.

Test stages the event in MaybeUninit storage so the test itself never
holds a reference to the invalid value. 380 lib tests + the C harness
round-trip + clippy -D warnings green on .25; header regenerated.
2026-08-12 00:12:01 +02:00
enricobuehler e8c306b9c0 fix(punktfunk-host): WP3c/3d — align the TOKEN_USER buffer, make EqualSid fail closed
3c: forming &TOKEN_USER (align 8) out of a bare [u8; 256] (align 1) was UB
by the validity rule whenever the stack slot landed misaligned — shipped
codegen happened to 8-align it, which is luck, not a contract. Fixed with
a repr(align(8)) wrapper that keeps the buffer at 256 BYTES; the comment
records why [u64; 32] is the wrong shape (len() would silently become 32
and misclassify every hand-run host as SYSTEM via ERROR_INSUFFICIENT_BUFFER,
invisibly to a SYSTEM-side test). Length arg now size_of_val.

3d: EqualSid().is_ok() read BOTH 'SIDs differ' and 'EqualSid failed' as
Err, so a genuine failure yielded 'not SYSTEM' — the fail-OPEN direction,
contradicting the documented fail-closed contract. Now split three ways on
the last-error code, with SetLastError(0) cleared first so a stale value
cannot misclassify.

Gate: cargo check -p punktfunk-host + cargo clippy --release -D warnings
both green on .133 (real MSVC, fresh extraction, sentinel-verified).
2026-08-12 00:12:00 +02:00
enricobuehler c3b57438e1 chore(ci): c-abi-asan job in audit.yml — the harness under ASAN+LSAN, weekly + on demand
Same shape as the miri job (dated nightly, own san- cache prefixes,
non-blocking day one via a step-level ::warning::, a proved-it-ran grep).
run.sh gains PF_SAN_TOOLCHAIN so CI can pin its dated nightly — bare
+nightly would ask for the rolling channel the job never installs. Both
the pinned and vanilla paths re-verified green on .25.
2026-08-12 00:02:26 +02:00
enricobuehler e20b614059 chore(safety): PF_SAN sanitizer gate for the C ABI harness
PF_SAN=address builds the punktfunk-core staticlib on nightly with
-Zsanitizer/-Zbuild-std and the C harness with clang -fsanitize, so ASAN
instruments both sides of the boundary at once and LSAN (detect_leaks=1)
becomes the first automated check on abi.rs's Box::into_raw/from_raw leak
contract. Verified on the .25 box: green run passes byte-exact; deleting
one punktfunk_session_free() in the harness makes LSAN report the 308
Rust-side allocations behind the handle and the script exit 1.

The harness binary moves from mktemp to target/ — a debug+ASAN static
binary can exceed a tmpfs /tmp (it did, on .25's 3.6G tmpfs).
2026-08-11 23:58:06 +02:00
enricobuehler 6eb89b3f34 Merge pull request 'The lint ratchets (WP2b + WP2c): crate-level gaps closed, the three-workspace hoist, three blocking grep gates' (#171) from worktree-lint-ratchets into main
apple / swift (push) Successful in 1m39s
windows-drivers / probe-and-proto (push) Successful in 26s
ci / rust-arm64 (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m15s
ci / web (push) Successful in 1m27s
android / android (push) Successful in 6m53s
ci / bun-nix (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m52s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m14s
release / apple (push) Successful in 10m10s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m20s
deb / build-publish-client-arm64 (push) Successful in 6m37s
apple / screenshots (push) Successful in 6m16s
ci / rust (push) Successful in 14m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 51s
decky / build-publish (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish (push) Successful in 11m15s
deb / build-publish-host (push) Successful in 11m36s
arch / build-publish (push) Successful in 19m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m3s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 6m39s
flatpak / build-publish (push) Successful in 8m5s
windows-host / package (push) Successful in 15m24s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 14m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m5s
2026-08-11 21:57:26 +00:00
enricobuehler bbd26ea82c Merge pull request 'Miri interprets the FFI-free leaf crates, one of them at MSVC layout' (#169) from worktree-miri-ci into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
audit / cargo-audit (push) Successful in 42s
audit / bun-audit (sdk) (push) Successful in 31s
audit / bun-audit (plugin-kit) (push) Successful in 43s
audit / bun-audit (web) (push) Successful in 29s
audit / pnpm-audit (push) Successful in 10s
audit / docs-site-audit (push) Successful in 28s
audit / license-gate (push) Successful in 6m9s
audit / miri (push) Successful in 7m20s
2026-08-11 21:57:24 +00:00
enricobuehler 549fdf238b Merge branch 'main' into worktree-lint-ratchets
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 1m54s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m58s
ci / bun-nix (pull_request) Successful in 1m15s
ci / rust (pull_request) Successful in 6m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
windows-drivers / probe-and-proto (pull_request) Successful in 26s
android / android (pull_request) Successful in 7m1s
nix / flake (pull_request) Successful in 16m48s
2026-08-11 21:57:17 +00:00
enricobuehler 3ea411fa39 Merge branch 'main' into worktree-miri-ci
ci / docs-site (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 2m10s
ci / web (pull_request) Successful in 2m55s
ci / rust-arm64 (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 11m18s
2026-08-11 21:57:15 +00:00
enricobuehler 3b2fcd076d Merge pull request 'chore(api): regenerate openapi.json — #164's unpair change rewrote the unpairClient description without regenerating' (#170) from worktree-openapi-regen into main
ci / rust (push) Canceled after 31s
ci / rust-arm64 (push) Canceled after 29s
ci / web (push) Canceled after 26s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
2026-08-11 21:57:07 +00:00
enricobuehler d67ab9ede4 chore(safety): two .133 gate findings — cfg the abi lock helper, re-anchor a layer proof
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m30s
android / android (pull_request) Canceled after 1m45s
apple / swift (pull_request) Canceled after 1m41s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 1m43s
ci / rust-arm64 (pull_request) Canceled after 1m43s
nix / flake (pull_request) Canceled after 1m29s
windows-drivers / probe-and-proto (pull_request) Canceled after 0s
windows-drivers / driver-build (pull_request) Canceled after 1m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The tray leg builds punktfunk-core with default-features off, where
lock_recover's only callers (the quic-gated punktfunk_connection_* entry
points) do not exist — dead code under -D warnings. The helper takes the
same feature gate.

In pf-vkhdr-layer, rustfmt had reflowed destroy_surface's lookup into a
multiline closure, leaving the SAFETY comment outside the closure that
contains its unsafe block — the box's clippy rightly stopped accepting the
adjacency. The comment moves inside, directly above the block.
2026-08-11 23:51:01 +02:00
enricobuehler abec2a1457 chore(safety): nvenc_core — plain union-arm writes are safe by language rule
The .25 gate corrected the carve-out: rustc flags `unsafe { u.arm.field = x }`
as unused_unsafe — plain assignment through a union projection is safe
(writing an arm cannot itself be UB; the hazard is the mismatched READ).
The 11 plain writes go back to bare statements under their codec matches.
What stays in per-op unsafe blocks with arm-guard proofs is the real unsafe
surface: union reads, borrows, and the bindgen bitfield-setter calls — which
is exactly the surface the shipped 4:4:4 bug lived on (set_chromaFormatIDC
stamped under a wrong codec).
2026-08-11 23:45:36 +02:00
enricobuehler 5f097d530d chore(safety): exempt the two bindings-only sys crates from the hoisted deny
Linux fallout from the hoist the mac could not see: bindgen emits unsafe
blocks (layout tests/accessors) into OUT_DIR, where nobody hand-writes
SAFETY proofs — pyrowave-sys failed clippy on .25 with 17 of them, and
libvpl-sys would do the same on the Windows leg. Both crates are
bindings-only by charter (the safe wrapper lives with the consumer), so the
allow is crate-wide with the rationale at the crate root; the hand-written
link-sanity tests keep their proofs by convention.
2026-08-11 23:41:39 +02:00
enricobuehler 2bfd1cd2d5 chore(safety): three unsafe-hygiene grep gates, blocking in ci.yml (WP2c gates)
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint
covers:

A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real
   ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker
   with no contract (db659809 found two by hand). Contract-deferring fns
   (Vec::set_len shape) waive with `// unsafe-fn-no-op-ok: <reason>`; fenced
   files and `unsafe extern "ABI" fn` (signature-mandated markers) are
   skipped structurally.

B. unwrap/expect/panic! inside extern "C"/"system" bodies — an abort since
   Rust 1.81, not linted, not fuzzable (8b98d0b3). catch_unwind bodies are
   exempt; `// panic-in-extern-ok: <reason>` waives a deliberate abort.

C. Safe-but-process-global APIs (env::set_var/remove_var, sigaction,
   setlocale, set_current_dir) — the 972af299 environ race lived in a file
   with zero occurrences of the word `unsafe`. Per-file count ratchet with
   the baseline in the script; any increase or new file fails.

Making gate B clean on main surfaced 14 real instances of exactly its class —
`.lock().unwrap()` in unguarded extern fns, where a poisoned mutex aborts the
embedding process: six punktfunk-core abi.rs entry points (poll_frame,
next_au, next_audio, next_audio_pcm, next_cursor_shape, next_clipboard),
seven Android JNI entry points, and the Windows client's deeplink wnd_proc.
All fixed with poison-recovering locks (the slots are last-value caches,
valid whatever a poisoned writer left) and Option::insert for the
set-then-unwrap shape; punktfunk-core's 203 lib tests pass. Gate A's
findings were six genuine contract-deferring fns — waived with reasons, not
fixed, because the markers are correct.

Gate-of-the-gate: all three shown to FAIL on deliberately planted instances
(marker fn, panicking extern callback, env::set_var in an unlisted file) and
to run clean on the tree, before the ci.yml step made them blocking.
2026-08-11 23:36:17 +02:00
enricobuehler dfebb9dfbb chore(safety): hoist the unsafe lints into the workspace tables (WP2c hoist)
undocumented_unsafe_blocks joins unsafe_op_in_unsafe_fn in
[workspace.lints], and the ~100 scattered per-file #![deny(...)] attributes
(85 files) are deleted — a new crate, or a new module in an old one, is now
covered on creation rather than on remembering. The per-file form is how
pf-vkhdr-layer, wdk-probe and half of pf-clipboard stayed uncovered.

There are THREE workspaces, so the claim is made three times: the main
Cargo.toml, packaging/windows/drivers (workspace table + [lints]
workspace = true in all seven members), and packaging/windows/pf-vkhdr-layer
(its [lints] table, previous commit). pf-update now opts into workspace
lints; the two vendored member snapshots (cros-codecs, usbip-sim) stay out
deliberately and now both say so.

Newly-covered fallout was two link-sanity tests (pyrowave-sys, libvpl-sys)
— proofs written. Stale prose that claimed the workspace held
unsafe_op_in_unsafe_fn at "warn" (it has been deny) or pointed at the
deleted attributes is corrected.

nvenc_core.rs is carved OUT of the unsafe_op_in_unsafe_fn fence: its
exemption rationale ("raw entry-table calls almost line for line") was
false — the file makes zero FFI calls. Its unsafe surface is C-union writes
whose soundness hangs on which codec arm is active, and its own 4:4:4 note
records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that
per-operation blocks make visible. It now runs the strictest discipline in
the crate: clippy::multiple_unsafe_ops_per_block at deny, one union access
per block, each naming its codec guard.

Verified here: cargo fmt clean in all three workspaces; native clippy
-D warnings clean for everything that compiles on macOS (the three
pre-existing mac-native failures — pf-client-core wol.rs, pf-encode
dead-code/closure-call, probe mic_burst — reproduce on the clean tree).
Linux/Windows legs ride the .25/.133 gate.
2026-08-11 23:26:28 +02:00
enricobuehler f675b3710e chore(api): regenerate openapi.json — fcf4c9fd rewrote the unpairClient description without regenerating
ci / web (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 2m22s
ci / rust-arm64 (pull_request) Successful in 4m3s
ci / rust (pull_request) Successful in 6m58s
2026-08-11 23:23:08 +02:00
enricobuehler 23fa03b051 chore(safety): close the three crate-level lint gaps (WP2b)
pf-vkhdr-layer — the sharpest gap: an implicit layer injected into every
Vulkan game process, 32 unsafe usages, zero SAFETY comments, own workspace so
no lint table reached it, and an explicit missing_safety_doc allow. Now: a
[lints] table (unsafe_op_in_unsafe_fn + clippy::undocumented_unsafe_blocks,
both deny), the allow removed, every unsafe operation in an explicit block
with a real proof (loader layer protocol / Vulkan valid-usage), # Safety docs
on the contract-carrying fns, const layout asserts for the SurfaceFormat2Raw
mirror, the five helpers with no caller-facing contract demoted to safe fns,
and the two redundant `unsafe impl Send` deleted (fn pointers and vk handles
are Send intrinsically — the type-check proves it).

wdk-probe — 21 unsafe blocks, 12 proofs: the 9 missing SAFETY comments are
written (the iddcx_rt.rs DDI slot-dispatch ones are about table population
and PFN/index pairing, not pattern fill), the sibling denies added at the
crate root, missing_safety_doc allow dropped, # Safety on DriverEntry, and
the crate joins windows-drivers.yml's clippy list — it was the only driver
crate not in it.

pf-clipboard — the undocumented_unsafe_blocks deny moves from host/windows.rs
to the crate root so host/wayland.rs (4 blocks), host/mutter.rs (2) and any
future backend under host/ are covered on creation. All existing blocks
already carry proofs; free today, structural tomorrow.

Verified here: pf-vkhdr-layer cargo fmt --check + clippy --release
-D warnings at x86_64-pc-windows-msvc. wdk-probe and pf-clipboard compile
checks need the WDK/Linux boxes and ride the .133/.25 gate.
2026-08-11 23:10:00 +02:00
enricobuehler 6e4638dab5 ci(audit): interpret the FFI-free leaf crates under Miri, one at MSVC layout
ci / web (pull_request) Successful in 59s
ci / rust-arm64 (pull_request) Successful in 2m3s
ci / bun-nix (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 2m48s
ci / rust (pull_request) Failing after 8m8s
Adds a non-blocking `miri` job to audit.yml, per rust-safety-programme.md §7.

What it buys is one narrow, real thing: pf-driver-proto interpreted CROSS-COMPILED to
x86_64-pc-windows-msvc, on a Linux runner, with no Windows box in the loop. That crate is
`#![forbid(unsafe_code)]` and path-dep'd by BOTH the main workspace and the driver
workspace, so it is the layout oracle for every frame and IOCTL crossing that boundary,
and nothing else in CI checks it at MSVC layout. It is NOT unsafe coverage — Miri can
execute on the order of 2% of the host's unsafe and cannot run ash, windows-rs, ffmpeg,
CUDA or the WDK — so no "Miri coverage" number is reported anywhere.

Three steps, every one of them measured on 192.168.1.25 with a cold target dir and cold
sysroot cache, on the dated toolchain the job installs, BEFORE being committed:

  step A  pf-driver-proto + pf-host-config + pf-gpu   21 + 12 + 4 pass   43 s
  step B  pf-driver-proto @ x86_64-pc-windows-msvc           21 pass     26 s
  step C  punktfunk-core fec::gf8 with +avx2,+ssse3            2 pass     63 s

Four corrections to the §7.3 job spec, found while doing this and folded into comments:

* `-p punktfunk-core fec packet crypto` does not parse — cargo rejects the extra
  positionals. Corrected (filters after `--`) it selects 63 tests and was killed at a
  25-minute cap with not one test complete, so the bulk step is dropped entirely and only
  the narrow `fec::gf8` selection is kept, timed at 63 s.
* `nightly-2026-08-10` resolves to rustc 1.99.0-nightly (969b803cb 2026-08-09), NOT the
  12c36e253 2026-08-10 the doc cites: `nightly-<date>` names the day rustup PUBLISHED the
  build, which is compiled from the previous day's commit. The doc's hash came from the
  ROLLING `nightly` channel and was mislabelled. All three steps were re-run and are green
  on the dated pin actually installed here.
* fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`, so
  step C's RUSTFLAGS are load-bearing in both directions. Verified by probe: bare,
  avx2=false and the step would silently interpret the scalar fallback; with the flags,
  avx2=true and `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays
  false either way, so that branch is simply not covered.
* `RUSTC_WRAPPER: ""` is a guard, not a fix, and the comment says so — audit.yml sets no
  sccache today, and cargo-miri warns "Ignoring `RUSTC_WRAPPER`, Miri does not support
  wrapping" and carries on regardless.

Non-blocking via a step-level `||`, not job-level continue-on-error, following the
precedent audit.yml already documents for docs-site-audit. Each step additionally asserts
a non-zero pass count, so a crate rename or a filter that stops matching surfaces as a
warning rather than as a green zero-test run. Both paths were exercised directly: a
failing run emits the annotation and still exits 0, and a zero-selection run trips the
guard, while a green run with empty bin/doctest targets does not false-positive.

Leak checking stays ON (no -Zmiri-ignore-leaks); the two deliberate leaks in the tree are
named in a comment so whoever expands coverage annotates those sites instead of blanket-
disabling the check. pf-bitstream and the FFI crates are excluded with the reasons inline
so they are not helpfully re-added. `paths:` is deliberately not widened to
crates/pf-driver-proto/** — that filter is workflow-level and would fire all six audit
jobs on every driver-proto edit.
2026-08-11 22:52:28 +02:00
enricobuehler 0c2ac333ae Merge pull request 'Chore/rust safety programme' (#164) from chore/rust-safety-programme into main
apple / swift (push) Successful in 1m43s
ci / web (push) Successful in 1m33s
windows-drivers / probe-and-proto (push) Successful in 27s
ci / docs-site (push) Successful in 1m44s
ci / bun-nix (push) Successful in 21s
windows-drivers / driver-build (push) Successful in 1m45s
ci / rust-arm64 (push) Successful in 7m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
deb / build-publish-client-arm64 (push) Successful in 2m35s
arch / build-publish (push) Successful in 11m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
release / apple (push) Successful in 9m41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
ci / rust (push) Failing after 11m41s
android / android (push) Successful in 14m57s
deb / build-publish-host (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m13s
apple / screenshots (push) Successful in 6m20s
docker / builders-arm64cross (push) Successful in 18s
docker / deploy-docs (push) Successful in 44s
nix / flake (push) Successful in 14m52s
windows-host / package (push) Successful in 20m38s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 13m0s
windows-host / canary-manifest (push) Successful in 49s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m24s
flatpak / build-publish (push) Successful in 24m54s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 39m36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 38m33s
2026-08-11 20:47:15 +00:00
enricobuehler bc70a58fb1 Merge main into chore/rust-safety-programme
windows-drivers / probe-and-proto (pull_request) Successful in 22s
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / docs-site (pull_request) Successful in 3m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 4m46s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Failing after 10m11s
nix / flake (pull_request) Successful in 15m6s
Two conflicts: the test-module import list in gamescope.rs (union — the branch's takeover-state
tests and main's WSI opt-out tests both stay), and next_frame_timed_out in pf-capture, where the
branch still carried the pre-#168 else-if chain — resolved to main's match-based refactor, which
already embeds the same arm semantics plus the provisional-budget latch gate.
2026-08-11 22:34:38 +02:00
enricobuehler ce25aca7bd Merge pull request 'Two black screens from the .41 field session — a NO_FOCUS window stole the composite, and one truncated timeout downgraded the host forever' (#168) from worktree-blackscreen-fixes into main
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m46s
deb / build-publish (push) Successful in 5m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
deb / build-publish-host (push) Successful in 7m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
arch / build-publish (push) Successful in 11m28s
android / android (push) Canceled after 6m31s
ci / rust (push) Canceled after 2m21s
ci / rust-arm64 (push) Canceled after 1m13s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Canceled after 5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m2s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-11 20:32:16 +00:00
enricobuehler 5587699a85 Merge pull request 'Every pinned card gets a library, and it launches with that card's profile' (#167) from worktree-console-pinned-profile-library into main
apple / swift (push) Successful in 1m40s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 1m47s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 56s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 1m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 55s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m34s
release / apple (push) Successful in 9m29s
flatpak / build-publish (push) Canceled after 13m45s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-11 20:30:03 +00:00
enricobuehler c946fcdcb5 Merge pull request 'build(web): silence rollup's "use client" directive warnings in the nitro pass' (#166) from build/web-silence-rollup-directive-warnings into main
arch / build-publish (push) Canceled after 47s
ci / bun-nix (push) Successful in 23s
ci / rust (push) Canceled after 42s
ci / docs-site (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 59s
ci / web (push) Canceled after 58s
deb / build-publish (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 53s
deb / build-publish-client-arm64 (push) Canceled after 43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m28s
windows-host / package (push) Canceled after 3m49s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-11 20:29:36 +00:00
enricobuehler fcf4c9fd63 fix(mgmt): unpair now revokes a LIVE session on both planes
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / docs-site (pull_request) Successful in 2m32s
windows-drivers / driver-build (pull_request) Successful in 1m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m54s
ci / rust (pull_request) Failing after 10m42s
nix / flake (pull_request) Canceled after 8m26s
windows-drivers / probe-and-proto (pull_request) Canceled after 0s
An unpair removed the certificate but left the revoked client's running
session streaming until the client chose to leave. Now it is a complete
revocation:

- GameStream: when the removed certificate owns the active launch, the
  session is quit_session'd — the ENet control thread's ended-session arm
  gives the client the standard TERMINATION+disconnect. (An owner-less
  launch cannot be attributed and is left to the WP0 port teardown when the
  last pairing goes.) The endpoint docstring's long-standing caveat
  ('removes the client from the listing without severing its ability to
  reconnect') is retired: TLS handshakes complete by design, authorization
  is per-request, and a live session no longer survives its own revocation.
- Native: session_status::stop_by_fingerprint signals the unpaired
  client's live session(s) to tear down deliberately (quit+stop), matched
  by the registry's client label — the fingerprint's 12-hex-char prefix for
  every pairable client; anonymous/TOFU sessions carry IP labels and are
  never touched (they have no pairing to revoke).

(The unpair-didn't-PERSIST half of 'unpairing was broken' was already fixed
in 13d57210 — save_paired was never called; this closes the other half.)

Gates: Linux amd64 both flavors clippy --all-targets -D warnings clean;
session_status 2/2 (new revocation test), the extended paired-clients test
green in both flavors, native_pairing test green.
2026-08-11 22:17:41 +02:00
enricobuehler cc8eb7df08 feat(clients): every pinned card gets a library, and it launches with that card's profile
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.

Two shapes of bug, one per client:

**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.

**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.

So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:

  * a game that exits returns to the shelf it was launched from, pin and all,
    rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
    `ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
    drops a pin that was unpinned while the game ran, rather than reopening a card
    that no longer exists.
  * Android's stats overlay finally names the profile a library launch used
    (`profileName` was never set on that path).
  * Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
    ignored — and refuses an unknown or ambiguous reference exactly as the connect
    route does (§10.6) rather than degrading to the binding.
  * every shelf says which one it is, in the card's own `host · profile` shape:
    the console's title, GTK's page title, Android's ConsoleHeader, Apple's
    navigation title and its console heading.

Verified per platform, none of it on trust:
  * console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
    pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
  * Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
    pass alone caught `navigationDestination(item:)` needing Hashable, and an
    iOS-only screen was confirmed genuinely compiled by planting a type error.
  * Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
    expectation flipped to match.
  * Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
    cold (3m10s) — that client cannot compile on a Mac.
2026-08-11 22:13:54 +02:00
enricobuehler 9c6e06d3b9 feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally
identical, and GameStream stays runtime-opt-in via --gamestream /
PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control
(the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS
advert, the compat media path (stream/video/audio), pen/gamepad/input
decode, apps, crypto, cert (the RSA identity), and tls's
Moonlight-client-cert leniency. AppState keeps the shared vocabulary
unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN
endpoints (routes, handlers, OpenAPI entries, lane classifications, tests)
exist only under the feature.

Building --no-default-features --features pyrowave yields the hardened
NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158
unsafe sites) and no rsa (the identity split's legacy fallback became a
pem-only read — rustls/ring serves an existing RSA cert without the crate —
so the accepted Marvin advisory no longer applies to native-only builds).
Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only
flavor clippy-clean and fails if cargo tree finds either crate in its graph.
serve --gamestream (or the env knob) against such a binary refuses to start
with a clear error rather than serving less than the operator configured.

En route: the logs-paging test assumed a quiet process-global log ring
between its cursors and raced other tests' legitimate log lines (the
identity tests added new emitters) — it now asserts on its own markers
within the page.

Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean;
default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests
identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only,
present default. .133 Windows — both flavors clippy clean (clean-first,
sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional
gate PASSES on the default build.
2026-08-11 22:05:30 +02:00
enricobuehler f5fa9649b7 fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and
pressing A on that card has always connected with the pinned profile as the
one-off the resolver prefers over the host's binding. Y on the same card opens
a library — it is paired and saved, which is the only thing the hint bar asks —
and every title launched off that shelf went out with no profile at all, so the
host's default binding won. Connecting straight from the card honoured the
profile; going through its library did not, which is the shape a user reads as
"the pin works until I pick a game".

The screen was the leak: LibraryScreen copied the row's address, port,
fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to
send and hardcoded `profile: None`, under a comment asserting that game
launches follow the binding. They should follow the card: a launch off a pinned
card's shelf is that card's connect with a title attached.

The screen now carries the row's pin and sends its id as the one-off. It also
says so, in the card's own `host · profile` shape: the shelf's title names the
profile, and so does the connecting card, so which of a host's cards you came
in on is legible from the screen rather than inferred from the tile you pressed
two screens ago. Off the host's primary tile there is no pin and nothing
changes — `None`, and the binding decides.

Console-only. The Apple and Android consoles keep Y off pinned cards outright
(`hasLibrary: profile == nil`), the GTK client clones the card's whole
ConnectRequest — profile included — into its library, and the Windows client
offers "Browse library" from the primary tile only. The console's own
copy-link and wake-then-connect paths already carried the pin.

Both directions are tested, and the pinned one was checked against a reverted
fix: it fails with exactly the reported symptom (left: None, right: "hdr").
2026-08-11 21:42:14 +02:00
enricobuehler 1009e14a44 build(web): silence rollup's "use client" directive warnings in the nitro pass
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 2m52s
ci / rust (pull_request) Successful in 7m9s
The nitro server build re-bundles the whole dep tree (`noExternals: true`), so
every React package shipping a `"use client"` banner earns a MODULE_LEVEL_DIRECTIVE
warning — ~150 locally, ~800 in CI — which buries the warnings worth reading.

Ignoring the banner is correct rather than papered over: this bundle is the
Bun/Nitro server, not an RSC module graph, and TanStack Start splits client from
server with its own transform, so nothing downstream consults it.

Supplying `onwarn` replaces nitro's own handler, so its three filters
(CIRCULAR_DEPENDENCY, EVAL, "Unsupported source map comment") are restated.

Verified: `bun run build` drops from 148 such lines to 0 with no other log
delta; `tsc --noEmit` and `biome check` clean.
2026-08-11 21:00:19 +02:00
enricobuehler e658ad726b feat(host): the identity split — the native planes get their own P-256 identity
One RSA-2048 identity served every plane, because Moonlight mandates RSA and
the planes grew out of the GameStream host. The native punktfunk/1 QUIC plane
and the management API now share a separate ECDSA P-256 identity
(native-cert.pem/native-key.pem, src/identity.rs): ring-generated via rcgen
(no rsa crate on the native path — the accepted Marvin advisory stops
applying once WP19 gates the compat planes), real SANs (localhost, loopback,
machine hostname — the legacy cert had none), and browser-compatible on
purpose: Ed25519 was rejected because no mainstream browser accepts an
Ed25519 server cert and /api/docs is opened in one. GameStream keeps the RSA
identity untouched (Moonlight pins it; its pairing hashes bind its X.509
signature bytes).

Migration is pin-preserving by construction. Clients TOFU-pin ONE leaf-DER
SHA-256 for both QUIC and the mgmt/library API, so the identity is resolved
ONCE in serve (the planes cannot race the first-run mint) under the rule:
identity files exist → use them; else the native trust store is EMPTY →
mint P-256 (fresh installs); else keep presenting the legacy RSA cert the
paired clients pinned, and log the migration path (unpair all, restart,
re-pair). Fingerprint pinning is algorithm-agnostic — existing shipped
clients pair against P-256 hosts unchanged.

Followers updated: the tray's loopback pin and the plugin SDK's mgmt CA
prefer native-cert.pem → cert.pem; the Windows runner ACL grant lists both
(the grant loop tolerates absent files). The in-process native tests now run
on an EPHEMERAL identity — they previously read, and would newly have
MINTED, identity files in the real config dir, which on a dev box that is
also a live host would have switched its identity and stranded every pinned
client.

Gates: Linux amd64 clippy --all-targets -D warnings clean (host+tray);
identity 2/2, mgmt 37/37, control 6/6, native 68/68 (C-ABI roundtrips over
the ephemeral identity). .133 Windows clippy clean; the port-lifecycle gate
re-run PASSES with the split live — the fresh host minted P-256 and served
mgmt over it (curl 200/204), ports tracked the paired list as before.
2026-08-11 20:52:01 +02:00
enricobuehler 23d0452157 feat(host): GameStream opt-in on every route; the native plane is deny(unsafe_code)-enforced
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.

Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.

Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].

Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
2026-08-11 20:21:16 +02:00
enricobuehler 13d5721049 feat(gamestream): the ENet control port exists only while a pairing does (WP0)
rusty_enet — a c2rust-style transpile of C ENet, 158 unsafe sites — parsed
unauthenticated UDP on 47999 from GameStream startup, before any client had
ever paired: the host's entire pre-auth-reachable unsafe surface. Pairing
itself is HTTPS on nvhttp and never touches the port, so it now binds only
while the paired-client list is non-empty: a Gate in control.rs reconciles
the port to the list (armed only under --gamestream), pairing phase 4 brings
it up before the new client can /launch, and removing the last pairing tears
it down — a live client gets the same termination+disconnect farewell as a
host-side session end. A never-paired host on a hostile LAN exposes no ENet.

En route: the management API's unpair never called save_paired, so a restart
resurrected the client — and would now have silently re-opened the port; it
persists (the test now runs against a throwaway PUNKTFUNK_CONFIG_DIR so it
can't clobber a real paired.json). rusty_enet is pinned =0.4.0 per the WP,
left to the cargo-audit job to flag advisories against it.

Gate (amd64 container): clippy --all-targets -D warnings clean;
gamestream::control 6/6; mgmt::tests 37/37 incl. the regenerated
api/openapi.json. On-box .133 verification (ports/pair/stream) still owed.
2026-08-11 19:29:50 +02:00
enricobuehler d4366e7464 fix(pf-encode): the Vulkan extension probe walked a driver-filled array with no bound
ci / web (pull_request) Successful in 1m20s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m49s
windows-drivers / probe-and-proto (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 24s
android / android (pull_request) Successful in 4m37s
ci / rust (pull_request) Successful in 10m25s
`ext_advertised` did `CStr::from_ptr(e.extension_name.as_ptr())` over a
driver-filled `[c_char; VK_MAX_EXTENSION_NAME_SIZE]`, and `vk_build.rs` open-coded
the identical call a second time. Neither had an in-Rust bound: a driver that
fills all 256 bytes without a NUL runs the walk into the NEXT
`ExtensionProperties`, and on the LAST element past the allocation.

The SAFETY comment asserted the spec guarantee ("a spec-guaranteed NUL-terminated
byte array") instead of enforcing it. That is the defect class this programme
keeps finding: a proof that restates what the other side promised rather than
checking it. Vulkan drivers are exactly the other side.

The bounded answer already shipped in the same crate — `pyrowave.rs:210` uses
ash's `extension_name_as_c_str()` for the identical job. It stops at
VK_MAX_EXTENSION_NAME_SIZE and returns Err when there is no terminator, so a
malformed entry is a non-match instead of an overrun. Both sites now route
through the one helper, which is no longer unsafe at all.

Deletes 2 unsafe operations and one duplicated walk.

⚠ The pre-existing test could not have caught this: it only ever built
well-formed, NUL-terminated entries. Added a case whose LAST element is 256
non-NUL bytes — the exact shape that used to leave the array — and a
prefix-match case, so the bound is now asserted rather than assumed.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain):
  cargo check  -p pf-encode --features vulkan-encode,pyrowave --locked      ok
  cargo test   -p pf-encode --features vulkan-encode,pyrowave ext_advertised
                                                              2 passed / 0 failed
  cargo clippy -p pf-encode --all-targets --locked
        --features vulkan-encode,pyrowave -- -D warnings                    clean
Linux-only code (`enc/linux/`), so the Windows leg is unaffected.
2026-08-11 16:34:34 +02:00
enricobuehler cd72f77a3c fix(pf-encode): the AMF layout guards broke Windows clippy — 0*SLOT and 1*SLOT
windows-drivers / probe-and-proto (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 2m10s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / web (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 4m44s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Canceled after 5m25s
ci / docs-site (pull_request) Canceled after 1m7s
`27f08340` wrote every vtable offset assertion as `offset_of!(T, f) == N * SLOT`
so the slot INDEX stays visible in the assertion. For N=0 and N=1 that is
`0 * SLOT` and `1 * SLOT`, which clippy rejects as `erasing_op` and
`identity_op` — six errors, and windows-host.yml runs clippy with `-D warnings`,
so the branch as pushed would have turned the Windows leg red.

This is the blind spot the programme document names in §1.5, demonstrated on the
programme's own first code commit: 44% of the host's unsafe is `#[cfg(windows)]`,
no Linux or macOS check compiles it, and `cargo fmt`/`cargo check` on a Mac are
all clean. Only the .133 gate sees it.

Fixed with a `const fn slot(i: usize) -> usize` rather than by writing the two
offending cases as bare `0` and `SLOT`: that would have made those two the only
assertions where the slot index is invisible, and the index is the entire point.

Also records the cheap local gate that would have caught this without a Windows
round-trip: `amf_sys.rs` depends on nothing but `c_void`, so copying it into a
throwaway one-file crate and running `cargo clippy -- -D warnings` reproduces the
exact error on any host. Verified by reintroducing `0 * SLOT` and watching the
harness fail with the same message the runner gave.

Verified on 192.168.1.133 (Windows CI runner, the box with the WDK), after a
`cargo clean -p pf-encode` that reported `Removed 47 files, 135.5MiB` so the
recompile is real and not a cached green:
  cargo check -p pf-encode                                                 ok
  cargo check -p pf-encode --all-targets --features nvenc,amf-qsv,qsv      ok
  cargo clippy -p pf-encode --all-targets --features nvenc,amf-qsv,qsv
        -- -D warnings                                          exit 0 (was 101)
  cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings
                                                                exit 0 (was 101)
The gate also greps the extracted tree for the assertions before building, so a
stale upload cannot produce a passing run.
2026-08-11 16:28:59 +02:00
enricobuehler cd3f5474bf fix(pf-driver-proto): a layout test read an align-8 struct out of an align-1 buffer
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m48s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 20s
windows-drivers / driver-build (pull_request) Successful in 2m14s
windows-drivers / probe-and-proto (pull_request) Successful in 40s
android / android (pull_request) Successful in 4m11s
ci / rust-arm64 (pull_request) Successful in 3m7s
ci / rust (pull_request) Successful in 7m4s
`control_structs_roundtrip_through_bytes` built the legacy-size wire form in a
stack `let mut legacy = [0u8; 40]` (align 1) and then called
`bytemuck::from_bytes::<control::AddRequest>`. `AddRequest` opens with
`session_id: u64`, so it is align 8, and `from_bytes` hands back a REFERENCE
into the buffer — it panics unless the buffer happens to be 8-aligned.

A stack `[u8; 40]` usually is, which is why this passed on every machine and
every CI leg since it was written. Under Miri it fails outright: Miri does not
let an accidentally-favourable stack slot stand in for a guarantee.

Switched to `pod_read_unaligned`, which reads by value and has no alignment
precondition. That is not a new idea here — `ChannelProof::parse` at lib.rs:1013
already carries a comment saying "`pod_read_unaligned`, NOT `from_bytes`" for
exactly this reason. This site is the only other one in the crate that reads a
POD out of a stack byte array; every other `from_bytes` call in the tests reads
from `bytes_of(&x)`, which is aligned by construction.

Test-only, so no shipped defect — but the crate is `#![forbid(unsafe_code)]` and
is path-dep'd by BOTH the main workspace and the driver workspace, so it is the
layout oracle for every frame and IOCTL that crosses that boundary. A test that
cannot be trusted to fail is worth fixing there more than anywhere else.

Found by the first Miri run ever performed against this repo.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0):
  cargo +nightly miri test -p pf-driver-proto                              21/21
  cargo +nightly miri test -p pf-driver-proto --target x86_64-pc-windows-msvc
                                                                          21/21
  cargo test -p pf-driver-proto --locked                                     ok
  cargo clippy -p pf-driver-proto --all-targets --locked -- -D warnings    clean

The cross-target run is the interesting one: it interprets the crate at MSVC
layout on a Linux box with no Windows anywhere. Nothing else in CI does that.
2026-08-11 13:57:34 +02:00
enricobuehler 972af2992f fix(pf-capture): the gamescope cursor fallback rewrote environ under a live multithreaded host
`connect_via_env_swap` did set_var("XAUTHORITY", …) / connect / restore, guarded
by a mutex that serialised this source against itself and against nothing else.
`getenv` takes no lock. setenv/unsetenv rewrite the process-global `environ`, and
glibc REALLOCATES that array when a variable is added — while, at that exact
moment, the PipeWire thread is inside pw_init()'s dlopen making bare getenv()
calls and EGL/CUDA init is running alongside. The file's own doc already called
the pattern "unsound from a live multithreaded host"; it stayed as a fallback.

Three things made it worse than the comment suggested:

- The damaging branch is the one where XAUTHORITY is ABSENT and therefore gets
  ADDED (the realloc case). scripts/punktfunk-host.service deliberately does not
  import the login shell's environment, so absent is the DOCUMENTED NORMAL
  configuration for the shipped unit, not an edge case.
- `rediscover` re-runs this every 2 s for the whole session. A display whose
  connect fails is never pushed into `displays`, so the dead-display skip never
  covers it — the race is not once at startup, it repeats forever.
- It is unfixable in place. Sharing pf_vdisplay's ENV_LOCK is the wrong layer: it
  cannot make C `getenv` take a lock.

The fix is to stop writing `environ` at all. Connecting with an explicitly empty
auth token is what the swap actually achieved: we only reach the fallback when
our own lookup found no usable MIT-MAGIC-COOKIE-1 entry, and x11rb's internal
lookup reads the same file with a STRICTER matcher (it matches family/address
too, which we deliberately do not), so where we find nothing it finds nothing
either and connects unauthenticated. That is exactly why the swap "worked"
against a nested Xwayland started without -auth.

Gives up one case: an .Xauthority using an auth family we decline to guess at but
x11rb would have handled. A gamescope Xwayland writes a single-entry
MIT-MAGIC-COOKIE-1 file, so it is not reachable here, and declining to attach a
cursor overlay beats tearing `environ` out from under a live session.

Also removes XAUTH_LOCK, whose only user this was.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain, pipewire
dev headers present): `cargo check -p pf-capture --locked` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` both clean.
Not verified on glass: the fallback is only reached when the cookie parse fails,
so a normal gamescope session does not enter it. Forcing it needs a nested
Xwayland started without -auth, or a mangled cookie file, on .181/.136.
2026-08-11 13:54:56 +02:00
enricobuehler df6f270e7b chore(safety): forbid unsafe on the crates that are already at zero
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.

`forbid(unsafe_code)`:

  punktfunk-encode-worker  the binary that carries cap_sys_nice. Its header
                           claims "no Wayland, no D-Bus, no network, no
                           plugins"; this makes the memory-safety half of that
                           claim mechanical. `forbid`, not `deny`, so it cannot
                           be re-opened by an #[allow] further down.
  pf-update-check          parses a signed, network-fetched manifest and its own
                           header says it "owns the part where being wrong is a
                           security bug". Signature checking is worthless if the
                           parser around it can be walked out of bounds.
  pf-vaadec                its header states the design constraint outright — it
                           links no libva and compiles on macOS, "which is the
                           point". The crate is full of hand-declared libva
                           repr(C) mirrors; one raw deref and it stops being the
                           CPU-testable half.
  tools/cursor-probe       free, and a probe is where "just deref it to see" is
                           most tempting.

`deny(unsafe_code)` + one localized allow:

  pf-update                root runs this. Its single unsafe operation, a bare
                           geteuid, moves into a named `effective_uid()` helper
                           carrying the crate's one #[allow(unsafe_code)].

Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.

Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
2026-08-11 13:49:41 +02:00
enricobuehler 27f0834025 fix(pf-encode): const-assert the AMF vtable and POD layouts
amf_sys.rs mirrors five AMF COM vtables by hand and amf.rs dispatches through
them BY SLOT POSITION — 18 distinct slots across the five tables. The mirrors
carried 118 `Slot` placeholders whose only job is to hold the following slots at
their C offsets, and not one layout assertion of any kind. A slot inserted,
removed or reordered in an AMF header bump calls an arbitrary function pointer
through a mismatched signature: no compile error, no runtime signal.

`AMF_MIN_VERSION` does not defend against this. It checks a version NUMBER, not
a layout, and it is a floor with no ceiling.

The three POD checks that did exist (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`)
lived in amf.rs's `#[cfg(test)]` module, so they were verified only when someone
ran pf-encode's tests, on Windows, with AMF enabled — and NEVER in a release
build, which is exactly where a mis-mirrored `AMFVariantStruct` does its damage:
it crosses the FFI BY VALUE on every SetProperty. This is the same hole
`a8dd348b` closed for the cuda.h mirrors and missed here.

Adds ~40 `const _: () = assert!(...)` guards next to the mirrors: size of each
of the five vtables, the byte offset of every slot amf.rs actually calls, the
three POD layouts promoted out of the test module, and the AMFData/AMFBuffer
shared-prefix agreement that `create_surface_from_dx11_native`'s
AMFSurface-through-AMFData reinterpretation silently depends on.

Verified by compiling amf_sys.rs standalone (it needs only `c_void`, and a
repr(C) struct of code pointers has the same layout on any 64-bit target, so a
macOS const-eval proves the Windows arithmetic), and by deliberately breaking one
offset to confirm the guard actually fires rather than silently passing.

That check earned its keep immediately: `alloc_buffer` sits at slot 43, not 42.
Counting AMFInterface(3) + AMFPropertyStorage(10) + the AMFContext block by hand
is exactly the error these assertions exist to catch.

Zero runtime behaviour change. The `AMF_MIN_VERSION` ceiling is deliberately NOT
part of this commit: a ceiling would make the next AMF driver release refuse
encode on every AMD box, so it needs a warn-and-continue policy plus an env
override and a real AMF session to gate it.
2026-08-11 13:49:25 +02:00
enricobuehler db6683a585 chore(safety): commit the unsafe census, fix its two bugs, record the baseline
Founding commit for a host-focused Rust safety programme. Adds the census tool
that measures the programme, the 2026-08-11 baseline it produces, and the
programme document itself.

The metric is SHIPPED NON-FFI UNSAFE OPERATIONS: 713. Raw `unsafe {}` block
count is the wrong target and the workspace manifest already says why — 63.3%
of unsafe operations in host scope (1542 of 2435) are a single third-party FFI
call that ash/windows-rs/ffmpeg mark unsafe on our behalf. A block count also
rewards merging blocks, ignores SAFETY comments, and IMPROVES when code moves
from Linux to Windows, because no local check can see the Windows half.

The tool shipped here had two defects, both fixed:

- `in_test_mod` cached parsed `#[cfg(test)]` spans in a dict keyed on `id(src)`,
  the memory ADDRESS of the source string. CPython recycles addresses, so once
  one file's source was collected the next file's string could be allocated at
  the same address and silently inherit the previous file's test spans. Ten
  consecutive runs over an unchanged tree produced 694, 695, 696, 701, 703,
  709, 710, 713, 714 and 721. Fixed by holding a strong reference to the string
  beside its spans, which makes the address un-recyclable while the entry is
  live. Five consecutive runs now agree exactly.

- The layout-assertion regex matched `const _: () = assert!(...)` but not the
  `const _: () = { ... };` block form, which 18 files use — including abi.rs,
  pf-inject/linux/gamepad.rs and pf-capture/.../idd_push/probes.rs. It reported
  102 unguarded repr(C) declarations across 25 files where the true figure is
  60 across 22, defaming three well-guarded files.

A metric that is not reproducible is not a ratchet. The acceptance gate for
this commit is therefore five consecutive identical runs, not one.

Baseline: 713 shipped non-FFI unsafe operations; 60 unguarded repr(C)
declarations across 22 files; unsafe reachable pre-authentication by an
unpaired peer = 0 first-party.
2026-08-11 13:42:05 +02:00
enricobuehler 7ffafb5ef3 chore(api): regenerate openapi.json after merging main
`main` gained the launcher brand tokens (`f62a48d4`) while this branch was open, and both sides
touch the generated document — so it was regenerated from the MERGED source rather than
text-merged. Verified to carry both: the 18 launcher-token entries from main, and this branch's
corrected schema descriptions. No `required` array changed, so no client regeneration is needed.
2026-08-11 11:03:42 +02:00
enricobuehler 4b686f026a Merge branch 'main' into worktree-vd-sweep-2
# Conflicts:
#	api/openapi.json
2026-08-11 10:57:35 +02:00
enricobuehler d6132f7523 chore(api): regenerate openapi.json for the pf-vdisplay policy doc corrections
The sweep rewrote doc comments on `ToSchema` types (`KeepAlive`, `Topology`, `ModeConflict`,
`Identity`, `LayoutMode`, `Layout`, `DisplayPolicy`, `EffectivePolicy`), and utoipa emits those
verbatim as schema descriptions — so the checked-in snapshot went stale and
`mgmt::tests::openapi_document_is_complete_and_checked_in` would have failed.

Several of the corrected descriptions were shipping outright falsehoods to API consumers. The worst:
`KeepAlive::Forever` documented itself as "**Not honored until the display-lifecycle stage**" while
the mgmt handler honors it end-to-end and the `gaming-rig` preset selects it (sweep item 11.7).

Diff is descriptions only — the `required` arrays are unchanged, so no SDK or client regeneration is
needed. Generated with `cargo run -p punktfunk-host -- openapi` in `ci/rust-ci.Dockerfile` under
`--platform linux/amd64`, and confirmed by running the host's own drift test there (37 mgmt tests).

`docs-site/public/openapi.json` is deliberately untouched: it is already ~34 KB behind `api/` from
earlier work, and refreshing it here would sweep in unrelated changes.
2026-08-11 10:55:27 +02:00
enricobuehler dc4d8d6832 fix(pf-vdisplay): correct the regressions this sweep introduced
An adversarial review of the sweep's own diff raised 39 claims; 23 survived independent
verification. This commit fixes them. Several are cases where the sweep traded one bug for another.

**The display budget was enforced in the wrong place.** The new Linux `max_displays` ceiling sat in
`registry::acquire` — which runs again on every mid-stream rebuild. All three create-before-drop
paths hold the old lease while acquiring the new display, and only the mode-switch path passes
`supersedes`, so a session at the ceiling counted itself against the budget and could never recover
from capture loss or a Game↔Desktop switch. At `max_displays = 1` that is a single streaming client.
Moved to `admission::admit`, which is where Windows has always applied it and which is reached once
per connect — so a rebuild cannot hit it.

**"Cannot tell" was collapsed into "wrong mode".** `unanimous_output_size` returning `None` for two
disagreeing gamescopes was compared with `== Some(target)`, so ambiguity took the destructive branch:
a nested per-title gamescope — the normal Game Mode shape — made every connect restart the box's
session and kill the running game. Now a three-state `BoxOutputSize`, where `Ambiguous` mirrors the
live node instead of re-moding, and the post-restart wait asks "did what we asked for come up"
rather than demanding unanimity.

**Decide-then-act lost its mutual exclusion.** Re-scoping the `MANAGED_SESSION` guard fixed the
shutdown restore but let two concurrent creates at the same mode both relaunch, the second stopping
the unit the first was polling. A separate `MANAGED_LAUNCH` mutex restores the exclusion without
putting launch progress back into the lock the restore samples.

**Per-axis policy salvage was applied to a selector.** `preset` chooses the other axes, so salvaging
it to the default silently re-pointed the whole document; it now refuses the document instead. A file
whose every axis is unreadable also reported `configured() == Some(default)` — flipping Linux
identity from Shared to PerClient — and now correctly reports unconfigured.

**The six `#[serde(default)]` on `EffectivePolicy` are reverted**: they loosened `POST/PUT
/display/presets` (an omitted axis defaulted where it used to 400), which nobody asked for. The
catalog salvage they were added for now lives in a private Deserialize-only mirror type, so the read
path stays lenient and the wire contract stays strict.

Also: the Windows create path stored the OS-committed refresh in the field `acquire` uses as its
resize discriminator, so a same-mode re-acquire looked like a hotplug — the requested and committed
modes are now separate fields; `output_within`'s timeout arm detached both reader threads (now
bounded by a drain grace, capped at 16 MiB, and logged honestly — a `systemd-run --pipe` unit escapes
the process group and cannot be reached); `reenable_outputs_kscreen` abandoned the mode restore
whenever kscreen-doctor hit its budget even though the enable may have landed (now tri-state);
`write_atomic` replaced a symlinked portal config with a regular file, severing dotfiles management;
several new budgets were too short for the helper they bound (`steam -shutdown` was being killed
before it could deliver the request; `linger_enabled` read a 300 ms timeout as "not lingering" and
hard-failed a correctly configured box); and a restore logged an operator-facing error for a
`systemctl` call that had merely outlived its budget while systemd still owned the queued job.

Verified: 107 tests on macOS, 202 on Linux (executed in a container, not merely type-checked),
Linux and Windows clippy clean at `-D warnings`, fmt clean.
2026-08-11 10:06:16 +02:00
enricobuehler 8b98d0b3ec fix(pf-capture): a sweep found nine real defects behind comments that asserted the opposite
Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth.
Both compile gates are green: `scripts/xcheck.sh windows clippy` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI
image (the Linux half needs libpipewire, so it cannot ride xcheck).

Code defects, each one contradicted by a comment sitting next to it:

* `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING`
  state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so
  `PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert
  overwrote it in place — torn frames, silently. Now `OUT_RING - 1`.
* The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying
  `CURSOR_SHOWING` — how an app hides the pointer for its own window. The last
  rasterised arrow was then blended into a game that had hidden its cursor. Every
  rasterise gate already tested `handle != 0`; the published verdict now agrees.
* The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so
  a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic
  taking down capture. Poison-tolerant now, which also makes the poison unreachable.
* `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead
  panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle
  already planted in the driver's WUDFHost. Refuses before the first duplication.
* `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale
  stall clock into its next stream and reported capture loss microseconds in.
* `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers
  published into one slot, and a failed spawn destroyed a working reader. Idempotent now.
* `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`.
* `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the
  `bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words
  as cursor pixels and cached them.
* A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as
  the generic "no acceptable format" timeout. It is logged, and parsed once, not twice.

Comment corrections, all verified against the code they describe: four claims that a
failed open falls back to DDA (removed — the caller drops the keepalive under
"no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the
client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit
taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor
meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor
channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage"
(~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR
latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view
is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded
channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc
line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`,
which had none.
2026-08-11 10:04:58 +02:00
enricobuehler 6b33750edc fix(pf-vdisplay): one non-UTF-8 byte in a portal config destroyed the whole file — in the module written to prevent exactly that
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.

So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.

Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.

Also in the wlr/Mutter family:

* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
  disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
  wrong, while the code went to real trouble to preserve refresh. Each head now carries its
  pre-connect scale and transform, and x advances by the LOGICAL width.

* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
  `HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
  keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.

* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
  `wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
  before/after name diff also ran outside any lock, so two concurrent creates could adopt each
  other's output. Both now run under a create lock, with a stray sweep on the failure path.

* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
  Mutter was already fixed for. The guard is now built before the wait, in both copies.

* The xdpw chooser file was written per session and never removed, permanently shadowing the
  config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
  not the session — scoped deliberately, because tying removal to the keepalive would let one
  session delete another's selection hours later.

* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
  host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
  it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
  GBM/dmabuf diagnosis it never verified.

* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
  echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.

Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
2026-08-11 09:22:32 +02:00
enricobuehler ef72d102b6 fix(pf-vdisplay): KWin's re-enable reported success when it matched no outputs at all, leaving a physical monitor dark
* **`reenable_outputs` returned `true` when it matched NONE of the requested outputs.** Unresolvable
  outputs were `continue`d and the return was the apply verdict alone — but an empty
  `kde_output_configuration_v2` still gets an `applied` event. So a total no-op suppressed the
  `reenable_outputs_kscreen` backstop and the operator's physical monitor stayed dark. Now counts
  staged outputs and returns `ok && matched == outputs.len()`, and refuses to apply an empty
  configuration at all.

* **The kscreen restore logged "restored the physical/bootstrap outputs" unconditionally**, with
  both call results discarded — including when `kscreen_ok` returned false on its 5 s budget, which
  is exactly the wedged state that fallback exists for.

* **`Session::open` swallowed every failure reason** — connect error, barrier timeout, missing global
  — and three of four callers degraded to kscreen-doctor with zero log. This is the class that hid
  the KWin >= 6.7 registry regression: a shipped fallback firing silently on every machine. It now
  logs at warn with the reason and the caller's operation name.

* `last_name` was seeded with a name kscreen-doctor can never resolve (KWin's address is
  `Virtual-punktfunk…`), so the intended default was guarded by an `is_none()` that could never hold
  and `apply_position` ran against no output. `our_uuid` was never reset per `create` and only
  assigned under `outcome.handled`, so a supersede positioned the *previous* output and never fell
  back.

* `probe()`'s `roundtrip` was the only unbudgeted compositor wait in the crate — every sibling path
  is budgeted — and it is reached from an async mgmt handler. Now bounded at 3 s. The pre-`created`
  dispatch loops gained deadlines and now set `stop` on the timeout arm.

* Every `wl_output` global was bound for the session's life with no `GlobalRemove` arm and no
  `release()`, on the virtual-output path too, which never reads them: unbounded growth on a
  hotplugging session.

* `monitors::list` was the one KWin call site with no kscreen fallback at all, despite `list_monitors`
  failing on exactly the condition the other four fall back for. It has one now.

* `CVT_H_GRANULARITY` and `MANAGED_PREFIX` existed as two literals under prose asserting they match;
  the second copy now imports the first.

The wider facade extraction (item 9.1) is deliberately not in this commit, but its two prerequisites
are — a comment at the restore seam records why they had to come first: a fallback arm that returns a
value the helper never checked re-introduces the silent success, behind a seam whose selling point is
one honest log per decline.

Also corrects the `PhysicalMonitor` type doc, which claimed "logical geometry throughout" while
`width`/`height` are the mode's PIXELS and `x`/`y` are logical, and adds the `logical_size()` helper
that is the only correct way to compare an extent against a position.
2026-08-11 09:22:11 +02:00
enricobuehler b2c03f1904 fix(pf-vdisplay): a managed launch blocked the shutdown restore that was meant to rescue it, and re-moding could flip the operator's own screen
The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.

* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
  blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
  never ran and the box was left with no session at all. `create_managed_session` now decides under
  the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
  consumes the record in a short scope at the top. Same shape the SteamOS twin already used.

* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
  `if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
  to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
  momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
  operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
  now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
  so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.

* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
  `/proc` enumerated first — and four consumers treated it as this session's output size. It now
  answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
  `heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
  selected. Its test previously passed `None`, which is why the hazard was invisible.

Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.

Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
2026-08-11 09:22:09 +02:00
enricobuehler db65980979 fix(pf-vdisplay): the ghost-monitor reap fed live devices to pnputil, and two unsafe fns had no unsafe in them
Windows half of the sweep — the reap bug, a panic that poisons two locks, and a round of unsafe
reduction.

* **The ghost reap selected the wrong devices.** It filtered `Status -ne 'OK'`, a HEALTH field: that
  matches devices that are PRESENT but in Error/Degraded/Unknown, not the ABSENT ones the reap is
  for — and it handed them to `pnputil /remove-device`, contradicting its own documented contract.
  It runs from `add_monitor`'s mid-session slot-exhaustion recovery, so the blast radius is a live
  session. Now filters on `-not $_.Present`.

* **`ensure_pinger` still used the panicking `thread::spawn` while holding two locks**, poisoning
  both — the un-fixed twin of a fix that already landed for `ensure_exclusive_watch`. Same shape
  applied.

Unsafe reduction, continuing the program that made pf-win-display's CCD helpers safe fns:

* `resolve_target_gdi` and `reisolate_after_swap` were `unsafe fn`s containing zero unsafe
  operations, and the three call-site SAFETY proofs described FFI they no longer perform. Both are
  now safe fns and those blocks are gone.
* `VdisplayDriver::open`'s `# Safety` section named no caller obligation — the same empty shape an
  earlier phase already removed from `open_device`.
* `(*detail).DevicePath.as_ptr()` derived a pointer from a `[u16; 1]` field and handed it to
  `CreateFileW`, which reads the whole flexible-array path beyond it. Now taken with `&raw const`
  from the full struct, so the pointer carries the provenance of the bytes actually read — the same
  correction already made for `MONITORINFOEXW` in ddc.rs.

Comment fixes, all verified against the code: three intra-doc links to a type this crate does not
have; a doc-comment run merged so that `shrink_action` — the gate that keeps a `Primary` group's
physical panels lit — read as undocumented while its rationale sat on an unrelated polling helper;
and the backend module header, which documented itself against a `sudovda` module that does not
exist and a fallback the crate says was removed.

Adds the first tests for `knobs.rs`, `instance.rs` and `driver.rs` — including `is_privileged_sid`,
the security-relevant predicate that decides whether an existing single-instance name is another
host or a squat, which had no coverage on any platform.
2026-08-11 08:49:06 +02:00
enricobuehler a1ff0dde0c fix(pf-vdisplay): the host promised HDR and cursor forwarding for gamescope sessions it did not start
`gamescope_ours_and` answered "did WE spawn this gamescope?" by reading `PUNKTFUNK_GAMESCOPE_NODE`.
Phase 2.3 deleted the code that published that key — routing.rs's own doc says "Nothing is written
back to the two knobs" — but this consumer was never migrated, so the read now returns "not
attaching" for every attach.

Both consumers then answer for a session this host has no flags on. On a plain box with a foreign
gamescope already running, `pick_gamescope_mode` resolves Attach at its fifth rung while the env key
stays unset, and the probe half only inspects the resolved BINARY, which is our patched build:

* `gamescope_composites_cursor()` returns true, so the host attaches no XFixes reader and blends
  nothing — while the stock gamescope actually running was never given
  `--pipewire-composite-cursor`, so the stream carries no pointer at all.
* `gamescope_hdr_available()` returns true, so the Welcome fixes `bit_depth` at 10 and the session
  negotiates BT.2020/PQ over an 8-bit SDR composite. The Welcome cannot take that back.

The same two failures hit the `capture_monitor` mirror route on any Bazzite or SteamOS box, where
the running Game Mode gamescope is by definition not one this host spawned.

The question is now asked of the resolved route rather than the environment, via a pure
`session_is_a_foreign_gamescope` that runs — and is tested — on every platform. The residual gap is
named in the doc rather than papered over: `create_managed_session`'s create-time degrade to a
foreign attach is still invisible to a ladder re-run.

Also in this commit:

* Two unguarded session-env reads now take `ENV_LOCK` (`detect()`'s `XDG_CURRENT_DESKTOP` fallback
  and `effective_topology()`'s legacy pins). `apply_session_env` `set_var`s those same keys from
  another thread, which is the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `mirror.rs`'s `names_ours_conclusively` was a `matches!` whose omitted default was the UNSAFE
  direction — a new backend would silently get its own virtual displays mirrored. Now exhaustive, so
  adding a `Compositor` is a compile error at the one site where the answer is a safety decision.
* `MirrorDisplay` overrides `poolable_now() -> false`; its `create` always reports `External`, so
  the trait's `true` default was a pre-create claim contradicting the post-create fact. The trait
  doc now says plainly that the default is a default and not a fact.
* The crate front-door doc listed 3 of 7 backends and quoted line counts half the size of the
  current crate; `routing.rs`'s summary was attached to the wrong item and described a published env
  channel that no longer exists; `available()` is no longer documented as cheap when it forks
  `gamescope --version` and does an unbudgeted Wayland roundtrip per call.
2026-08-11 08:48:49 +02:00
enricobuehler 9d58f4c170 fix(pf-vdisplay): one unreadable byte reverted the host to built-in display defaults, and one bad preset dropped the whole catalog
The policy layer folded every failure into "unconfigured", then wrote that emptiness back.

* **Any parse error reverted the WHOLE policy.** An unknown enum variant, a mistyped scalar, an
  EACCES or EIO — all became `Err(_) => None`, i.e. the host silently ran on built-in defaults with
  the operator's `display-settings.json` still sitting on disk. Parsing is now layered: strict
  first, then per-axis salvage so one unreadable axis costs only that axis, and only `NotFound` is
  quiet — EACCES/EIO warn loudly that the host is on defaults. `version` is read instead of being
  blindly rewritten to 1.

* **One malformed entry dropped the entire custom-preset catalog**, and the next CRUD atomically
  renamed the empty vector over the file. Entries are parsed one at a time now; a lossy load is
  flagged and refuses to overwrite.

* `sanitized()` clamped `max_displays` but never `KeepAlive::Duration.seconds`, so a PUT could pin a
  display for ~136 years — a deadline the reaper never reaches and a nonsense `expires_in_ms` in
  `/display/state`. Clamped to a day, in both `sanitized()` and `sanitize_preset_fields`, and
  sanitization now runs on LOAD as well as on write.

* The two stores' temp files had fixed names and no write lock, so concurrent saves could interleave
  serialize -> rename -> in-memory update. Unique suffixes, a lock, and the in-memory update ordered
  after the rename.

* `new_preset_id` never consulted the loaded entries for collisions.

* **Manual layout could place an unpinned display exactly on top of a pinned one**: the fallback was
  the unconditional auto-row prefix sum, blind to where prior members were pinned. Unpinned members
  now pack clear of the pins. Layout keys are canonicalized and unusable ones dropped at write time
  rather than persisted-and-ignored.

Adds 20 tests, all running on macOS: a 20k-round randomized property test asserting no unpinned
member ever overlaps a sibling (verified to fail against the pre-fix `arrange_manual`), the salvage
and quarantine paths, the clamps, and a field-count guard that fails the moment a 13th policy axis
appears without being wired into the merge path.

Note: `partial_json_fills_defaults` was renamed to `serde_defaults_fill_a_partial_document` with no
assertion weakened — it pins the FILE contract (an old settings file must still load), which is not
the mgmt PUT contract that sweep item 11.1 is about.
2026-08-11 08:48:47 +02:00
enricobuehler 61ff543acc fix(pf-vdisplay): a new client could be handed a streaming client's display, and a blind /proc scan tore every backend down
Five defects in the registry/identity half, plus the restructure that finally makes them testable.

* **A new client could be assigned a LIVE client's identity slot.** `DisplayIdentityMap::resolve`
  LRU-evicted purely on its `seen` stamp, with no knowledge of which ids are streaming. On Windows
  that id keys the manager's slot map, so the newcomer took the plain-JOIN branch and inherited the
  other client's monitor, capture target and stop flag. `resolve` now takes the live set, never
  evicts a live id, and REFUSES rather than hand one over — degrading to the shared/auto identity.

* **A transient `ActiveKind::None` invalidated every backend entry, including live streaming ones.**
  A `read_dir("/proc")` that happened to fail satisfied the change test and bumped the session
  epoch. A `None` observation is no longer evidence a desktop went away, and no longer overwrites
  the baseline (which would have bumped the epoch on the next poll anyway).

* **The Linux pool had no display ceiling at all** — `max_displays` was enforced only on Windows,
  while the pool keys on the CLIENT-SUPPLIED mode, so each distinct requested resolution minted a
  new display. Now capped in `linux::acquire`, gated on `poolable_now` so a gamescope attach or
  managed session (which consumes no pool slot) is not refused.

* **Two different definitions of "display group"** — `group_key` and a bare backend-name compare —
  and only one separated gamescope spawns. Unified as `pool::in_group`. The `position_for_new`
  collection also lacked the supersede exclusion the topology check 70 lines earlier had, so a
  mid-stream resize auto-rowed the replacement past its own dying predecessor, walking the display
  one width to the right on every mode switch.

* **Lifecycle events were wrong in both directions**: `Created` fired on keep-alive reuse, and
  `Released` fired only from the mgmt endpoint — never from a lease drop, the linger reaper,
  `mark_failed`, `retire` or `invalidate_backend`. All six now emit.

Also: `Release::Noop` no longer runs a full teardown (the one outcome the state machine defines as
"do nothing"); a failed linger-reaper spawn logs and retries instead of consuming its `Once` and
never tearing a kept display down again; group ids are a monotonic per-key counter instead of an
index into the currently-live sorted set, so an unrelated group appearing no longer renumbers a
display; and a corrupt `display-identity.json` is renamed to `.bad` with a warning rather than
silently overwritten, which used to reset every client's stable id and its saved DPI.

The pure half of the pool (`Entry`, `group_key`, `epoch_matches`, `take_expired`, `at_display_budget`,
`position_for_new`, `assign_group_ids`, `assemble_displays`) is now a non-cfg'd `mod pool`, so the
registry's decisions are exercised on every platform's CI instead of only on a Linux box. Crate test
count 53 -> 94.
2026-08-11 08:48:15 +02:00
enricobuehler dd9bbaf1c5 fix(pf-vdisplay): a helper that outran the pipe buffer had its output thrown away as a timeout
`output_within` read stdout/stderr only after the child exited, and its doc justified that with
"these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds
64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it,
and a successful query is reported to the caller as `TimedOut` with its answer discarded.

The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB
routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing
1 MiB into an undrained pipe never exits.

Both pipes are now drained on their own threads, concurrently with the wait.

That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an
empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did
for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c`
wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait
for an EOF that never arrives. The child is now the leader of its own process group and the guard
`killpg`s it, which is the Unix shape of the Job object the Windows half already used.

Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is
`cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for
nothing (sweep item 13.19).
2026-08-11 08:22:16 +02:00
275 changed files with 13623 additions and 3436 deletions
+269
View File
@@ -15,6 +15,17 @@
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# * miri → NON-BLOCKING interpretation of the few FFI-free leaf crates, one of them
# cross-compiled to MSVC layout. Not a supply-chain scan; it lives here because
# audit.yml already has exactly the shape it needs (weekly cron,
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
# the only automated check on its Box::into_raw/from_raw leak contract. Same
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
# must not ride the per-push leg.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
@@ -44,6 +55,13 @@ on:
- 'about.toml'
- '.gitea/workflows/audit.yml'
workflow_dispatch:
# NOTE on the `paths:` list above and the `miri:` job: `crates/pf-driver-proto/**` is deliberately
# NOT listed, even though that crate is what the Miri job exists to watch. `paths:` is a
# WORKFLOW-level filter — adding it would fire all six jobs (three bun trees, pnpm, cargo-audit,
# the license gate) on every driver-proto edit, onto a fleet where 37 of 46 jobs contend for
# ubuntu-24.04, to run one 2-minute job. Weekly cron + workflow_dispatch is the day-one cadence;
# revisit once the job has a green history, and if you do, prefer moving miri to its own workflow
# file over widening this filter.
jobs:
cargo-audit:
@@ -177,3 +195,254 @@ jobs:
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
# ── Miri ─────────────────────────────────────────────────────────────────────────────────────
# WHAT THIS BUYS, precisely — one thing, and it is worth having:
# It interprets `pf-driver-proto` CROSS-COMPILED TO `x86_64-pc-windows-msvc`, on a Linux
# runner, with no Windows box anywhere in the loop. That crate is `#![forbid(unsafe_code)]`
# and is path-dep'd by BOTH the main workspace and the driver workspace, so it is the layout
# oracle for every frame and IOCTL crossing that boundary — and drift there is silent
# corruption, not a compile error. Nothing else in CI checks it at MSVC layout.
# On the first run ever performed against this repo it found a real defect: a layout test
# reading an align-8 struct out of an align-1 stack buffer, which had passed on every machine
# and every CI leg since it was written because a stack `[u8; 40]` usually lands 8-aligned.
#
# WHAT IT DOES NOT BUY — do not let anyone report this as unsafe coverage, and do not publish a
# "Miri coverage" percentage; it would be noise. Miri can execute on the order of 2% of the
# host's unsafe. It cannot run ash, windows-rs, ffmpeg, CUDA or the WDK, and in those crates
# the unsafe *is* the foreign call, so there is nothing for an interpreter to execute. This
# job is a targeted instrument for three leaf surfaces, not a safety net.
#
# NON-BLOCKING, deliberately, and via a step-level `||` — NOT job-level `continue-on-error`,
# which act_runner does not reliably honor (same reasoning as docs-site-audit above; a red job
# here would take the whole run red). Flip to blocking only after several weeks of green
# establish the nightly-drift rate.
#
# Do NOT add crates here because they merely compile under Miri. Add them because they contain
# pure-Rust unsafe or a layout contract worth interpreting. Explicitly excluded:
# * pf-bitstream — its compile did not finish in 27 min at 2.1 GB RSS, and it is
# `forbid(unsafe_code)`, so there is nothing to find. Do not re-add it.
# * pf-update-check — ring; every FFI crate — dies on the first foreign call. Structural.
# * punktfunk-core in bulk — `-- fec packet crypto` selects 63 tests and was killed at a
# 25-minute cap with not one test reported complete. Only the narrow
# `fec::gf8` selection below is affordable, and it was timed before it
# was committed. Do not widen this filter without timing the result.
#
# MEASURED, not estimated — 192.168.1.25 (Ubuntu, 8 cores), on the DATED toolchain this job
# actually installs, with a COLD target dir and a COLD sysroot cache (so each step's figure
# includes building the Miri sysroot it needs) and a warm cargo registry. Every step below has
# been run start to finish; nothing here is extrapolated:
# step A 21 + 12 + 4 pass 43 s
# step B 21 pass 26 s
# step C 2 pass 63 s
# TOTAL 132 s cold. Interpretation itself is ~10 s of that; the rest is compiling, plus ~38 s
# of one-time sysroot builds (21 s host + 17 s MSVC) that the cache below then carries.
# Warm, the three steps are ~6 s / ~3 s / ~10 s. `timeout-minutes: 30` is therefore vast
# headroom, kept deliberately so a first fully-uncached run — which additionally downloads a
# ~400 MB toolchain and the registry — cannot trip it.
# If you add a step, MEASURE IT FIRST. The estimate this job replaced said "under 15 s across
# all four steps" and was extrapolated from a partial run; the real punktfunk-core figure was
# >25 min. Extrapolation is exactly how that happened.
miri:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# A DATED nightly, bumped deliberately — exactly like rust-toolchain.toml, and for the same
# reason. The cache keys below carry this value, so bumping it self-invalidates them.
# ⚠ `nightly-<date>` names the day rustup PUBLISHED the build, and that build is compiled
# from the PREVIOUS day's commit. This pin therefore resolves to
# `rustc 1.99.0-nightly (969b803cb 2026-08-09)` [verified by installing it], NOT the
# `12c36e253 2026-08-10` that the rust-safety programme doc's §7 table cites — that figure
# came from the ROLLING `nightly` channel and was mislabelled as the dated one. Harmless,
# but do not "fix" the date to chase that hash: all three steps below were re-run and are
# green on the dated toolchain this job actually installs.
MIRI_TOOLCHAIN: nightly-2026-08-10
# A GUARD, not a fix for a present problem: audit.yml sets no sccache — only ci.yml does, at
# workflow level (ci.yml:27). `cargo-miri` REPLACES rustc and cannot be wrapped; it prints
# "Ignoring `RUSTC_WRAPPER` environment variable, Miri does not support wrapping" and
# carries on [verified]. This keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
# -Zmiri-disable-isolation: pf-gpu's tests mkdir, and Miri aborts them without it [verified].
# -Zmiri-symbolic-alignment-check: the whole point — it refuses to let an accidentally
# favourable stack slot stand in for an alignment guarantee. This is the flag that caught
# the pf-driver-proto defect.
# NOTE the absence of -Zmiri-ignore-leaks. Miri leak-checks by DEFAULT, and that is the one
# leak-detection capability it offers here. None of the crates below leaks, so the job is
# green. The tree does contain DELIBERATE leaks (pf-umdf-util/src/section.rs `ViewCell`,
# gamepad_raii.rs leak-on-timeout) — when coverage ever reaches them, annotate those two
# sites; do not blanket-disable the check.
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
steps:
- uses: actions/checkout@v4
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
# belongs with the toolchain, not with the lockfile.
- name: cache the nightly toolchain + Miri sysroots
uses: actions/cache@v4
with:
path: |
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
~/.cache/miri
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: miri-registry-v1-
# The image needs no change for this: ci/rust-ci.Dockerfile:51-54 installs via rustup and
# `chmod -R a+w`s both RUSTUP_HOME and CARGO_HOME, so a job can add a toolchain at runtime.
# `rust-src` is required — cargo-miri builds its sysroot from source, per target.
#
# This does NOT disturb the 1.96.0 pin: `cargo +<toolchain>` overrides rust-toolchain.toml
# for that single invocation only, so `cargo fmt` / `clippy` keep resolving 1.96.0 and the
# fmt-parity contract in CLAUDE.md is untouched. The two echo lines below keep that claim
# honest in the log. They are deliberately NOT `rustup show active-toolchain`: that command
# RESOLVES the toolchain file and would install the whole 1.96.0 toolchain just to print a
# line, in a job where every cargo call is `+$MIRI_TOOLCHAIN` and 1.96.0 is never needed.
# Deliberately NOT `rustup override set` — that writes persistent per-directory state into
# the runner's rustup config, which leaks into unrelated later jobs on a self-hosted fleet.
# Deliberately NOT a second rust-toolchain.toml in a subdirectory — that would apply to
# every cargo invocation under that subtree including fmt, which is the drift the root pin
# exists to prevent.
- name: install the pinned nightly + miri
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$MIRI_TOOLCHAIN" \
--profile minimal \
--component miri,rust-src \
--target x86_64-pc-windows-msvc
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$MIRI_TOOLCHAIN" --version
# A run that reports `0 passed` is a selection that matched nothing, not a success — that
# exact mistake has already cost one round-trip here. So each step below checks a zero exit
# AND that at least one target reported a non-zero pass count, which is what catches a
# crate rename or a `--` filter that stops matching. (Each step legitimately prints several
# `0 passed` lines too — the empty bin/doctest targets — so the check is "at least one
# non-zero", not "no zeroes".) Expected counts at the time of writing: 21 + 12 + 4.
- name: miri — FFI-free leaf crates (native)
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto -p pf-host-config -p pf-gpu 2>&1 | tee /tmp/miri-native.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-native.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (FFI-free leaf crates, native) did not pass — non-blocking; see punktfunk-planning design/rust-safety-programme.md §7"
# THE step that justifies the job: pf-driver-proto at MSVC layout, on Linux, no Windows box.
# Expected: 21 passed. If this one ever goes red, treat it as a layout-contract break
# between the host and driver workspaces until proven otherwise.
- name: miri — pf-driver-proto at x86_64-pc-windows-msvc layout
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto --target x86_64-pc-windows-msvc 2>&1 | tee /tmp/miri-msvc.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-msvc.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
env:
RUSTFLAGS: -C target-feature=+avx2,+ssse3
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
# Box::into_raw/from_raw leak contract.
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
# default-feature (quic-less, opus-less) core only.
c-abi-asan:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
SAN_TOOLCHAIN: nightly-2026-08-10
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: san-registry-v1-
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
- name: install the pinned nightly + rust-src
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$SAN_TOOLCHAIN" --version
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
# bun-audit job's apt-get above relies on the same fact).
- name: ensure clang's ASAN runtime
run: |
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
fi
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
# nightly installed above — without it the script would ask for the ROLLING `nightly`
# channel, which this job deliberately does not install.
- name: C ABI harness under ASAN+LSAN
run: |
set -o pipefail
ok=1
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
+35 -3
View File
@@ -111,9 +111,31 @@ jobs:
- name: Format
run: cargo fmt --all --check
# rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers
# carrying no contract, panic across an extern boundary (an abort since 1.81), and
# process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo.
# Both failure modes were demonstrated before this became blocking (planted instances).
- name: Unsafe-hygiene grep gates
run: sh scripts/ci/check-unsafe-hygiene.sh
- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# WP19 (rust-safety): the hardened NATIVE-ONLY host — no Moonlight-compat planes, no
# `rusty_enet` (transpiled C ENet), no `rsa`. Kept compiling here so the cfg boundary can't
# rot, and the dependency claim is ASSERTED, not assumed: `cargo tree -i` must find neither
# crate in the native-only graph (it exits non-zero with "nothing depends on" — inverted).
- name: Clippy + tree (native-only host, no gamestream feature)
run: |
cargo clippy -p punktfunk-host --no-default-features --features pyrowave \
--all-targets --locked -- -D warnings
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rusty_enet 2>/dev/null | grep -q rusty_enet; then
echo "native-only build still depends on rusty_enet"; exit 1; fi
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rsa 2>/dev/null | grep -q "^rsa"; then
echo "native-only build still depends on rsa"; exit 1; fi
- name: Build
run: cargo build --workspace --locked
@@ -124,8 +146,8 @@ jobs:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
# `cargo build`, where warnings are not errors, so pf-encode's own
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
# `cargo build`, where warnings are not errors, so the `undocumented_unsafe_blocks` deny
# (now hoisted into [workspace.lints]) — pf-encode's stated unsafe-proof gate —
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.)
#
@@ -165,7 +187,17 @@ jobs:
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|| { cat /tmp/core-build.log; exit 1; }
cat /tmp/core-build.log
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
# checked-in header stays untouched and the drift check below stays green while the
# header is silently stale. So first assert the regeneration actually happened.
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
grep -q "punktfunk-core: wrote" /tmp/core-build.log
if grep -q "cbindgen failed" /tmp/core-build.log; then
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
fi
git config --global --add safe.directory "$PWD"
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
+4 -3
View File
@@ -159,9 +159,10 @@ jobs:
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
# `undocumented_unsafe_blocks`); this step keeps them from regressing. wdk-probe is a
# toolchain-only probe crate, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
# runs the same gates.
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay -p wdk-probe --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
+106
View File
@@ -14,6 +14,98 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.27.1 — in development
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
The secure native-only host is the default everywhere; the Moonlight-compat planes (plain-HTTP
pairing + the legacy GCM path, security-review #5/#9) are enabled only by an explicit choice:
- **The shipped systemd user unit** (`scripts/punktfunk-host.service`, installed by deb/RPM/Arch/
sysext) runs bare `serve``--gamestream` is no longer baked into `ExecStart`. Opt in via the
new **`PUNKTFUNK_GAMESTREAM=1`** knob in `host.env` (pf-host-config; equivalent to the flag —
either source enables), so no unit editing survives-upgrades dance is needed.
**Upgrade note:** a packaged host that served Moonlight by default becomes native-only until
the operator sets the knob (a hand-made `ExecStart` drop-in keeps winning as before).
- **NixOS module**: `services.punktfunk.host.gamestream` default flipped `true``false`
(module-check gained a "default is native-only" assertion); enabling it still opens the
GameStream firewall ports.
- **Steam Deck installer**: `--gamestream` opts in (was on-by-default with `--no-gamestream`;
the old flag is still accepted as explicit-off).
- Windows was already opt-in (unchecked installer task) and is unchanged.
### The ENet control port now exists only while a pairing does (rust-safety WP0)
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
surface — no longer listens unconditionally: UDP 47999 binds when the paired-client list becomes
non-empty and is torn down when the last pairing is removed (a live client gets the same
TERMINATION+disconnect farewell as a host-side session end). Pairing itself is HTTPS on nvhttp and
never touches the port, so a never-paired `--gamestream` host exposes no ENet at all. En route:
the management API's unpair endpoint never persisted (`save_paired` was missing), so an unpair
lasted only until the next restart — fixed. `rusty_enet` is now pinned `=0.4.0`.
**Unpair is now a complete revocation, on both planes.** Beyond the persistence fix above, an
unpair used to leave the revoked client's LIVE session streaming until the client chose to
leave. Now: unpairing a GameStream client whose certificate owns the active launch ends that
session (the client gets the standard TERMINATION+disconnect, and unpair-all still closes the
ENet port); unpairing a native client deliberately stops its live punktfunk/1 session(s)
(matched by certificate fingerprint — anonymous/TOFU sessions are unaffected, they have no
pairing to revoke). The unpair endpoint's long-standing docstring caveat ("removes the client
from the listing without severing its ability to reconnect") is retired: TLS-level handshakes
still complete by design, but authorization is per-request and a live session no longer
survives its own revocation.
### GameStream is now a cargo feature (compile-time isolation — packager-visible)
The Moonlight-compat planes (nvhttp pairing, RTSP, the ENet control stream, `_nvstream` mDNS,
the compat media path) are gated behind a new **`gamestream` cargo feature — default ON**, so
every stock package is behaviorally identical (GameStream stays runtime-opt-in via
`--gamestream` / `PUNKTFUNK_GAMESTREAM`). Building with
`--no-default-features --features pyrowave` produces the **hardened native-only host**:
- **no `rusty_enet`** — the c2rust-transpiled C ENet stack (158 unsafe sites) is absent from
the binary, provably (`cargo tree -i rusty_enet` finds nothing; CI asserts it);
- **no `rsa`** — the native planes run on the P-256 identity (above), and the legacy-identity
fallback is a pem-only read (rustls/ring serves an existing RSA cert without the crate), so
the accepted Marvin advisory (RUSTSEC-2023-0071) no longer applies to native-only builds;
- ~6,700 lines of Moonlight protocol code gone; `serve --gamestream` (or the env knob) against
such a binary **refuses to start** with a clear error rather than serving less than asked;
- the native-only management API (and its OpenAPI document) has no GameStream PIN endpoints
(`/api/v1/pair`, `/api/v1/pair/pin`); everything else — including the paired-client list and
unpair — is identical, so consoles work unchanged.
The checked-in `api/openapi.json` remains the default-features document.
### The identity split — the native planes get their own (P-256) host identity
One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the
planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API
now share a separate **ECDSA P-256** identity (`native-cert.pem`/`native-key.pem`): generated by
ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs
(localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted
`rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched.
**Migration is pin-preserving by construction**: clients TOFU-pin the leaf-cert SHA-256 at
pairing and use that one pin for both QUIC and the mgmt/library API, so the new identity is
adopted **only when the native trust store is empty** (fresh installs, or after an explicit
unpair-all + restart). An upgraded host with live native pairings keeps presenting the legacy
RSA cert those clients pinned, and logs the migration path. Fingerprint pinning is
algorithm-agnostic, so existing shipped clients pair against P-256 hosts unchanged.
Follow-the-identity consumers updated in-tree: the tray's loopback pin and the plugin SDK's
mgmt CA now prefer `native-cert.pem` (falling back to `cert.pem`), and the Windows runner ACL
grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a **fresh**
(P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=<config>/native-cert.pem` in its
environment or rebuild against the current SDK.
### Memory-safety, compiler-enforced (embedder-visible lint tightening)
`punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network
bytes is safe Rust by compiler-enforced invariant. The documented `#![allow]` carve-outs are the
client surface (`abi`, `client`) and the platform syscall-batching shims under `transport`
(`udp/{apple,linux,windows}`, `qos_windows`) — none of which interpret attacker bytes. In
`punktfunk-host`, the modules a secure-default host exposes (`native`, `native_pairing`, `mgmt`,
`mgmt_token`, `discovery`, `wol`) are `#[forbid(unsafe_code)]`. If you embed `punktfunk-core` and
patch it, new unsafe outside the carve-outs is now a compile error.
### NixOS + KDE — session detection, the other half
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
@@ -92,6 +184,20 @@ but latches nothing; only the full-length attempts that follow hand down negotia
classification is a pure function with tests
(`pf_capture::linux::first_frame_timeout_tests`).
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
within one device period — below the jitter buffer's prime depth, so nothing is audible.
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
third-party virtual audio driver misbehaves while its render side is paused.
## v0.27.0
87 commits since v0.26.0.
Generated
+1
View File
@@ -1116,6 +1116,7 @@ dependencies = [
name = "display-disturb"
version = "0.27.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
+13 -2
View File
@@ -66,8 +66,8 @@ ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.27.0"
edition = "2021"
rust-version = "1.82"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
@@ -101,6 +101,17 @@ repository = "https://git.unom.io/unom/punktfunk"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
# The companion lint: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof. Hoisted here
# from ~85 per-file `#![deny(...)]` attributes so a NEW crate (or a new module in an old one) is
# covered on creation rather than on remembering — the per-file form left pf-vkhdr-layer,
# wdk-probe, and half of pf-clipboard uncovered for months. NOTE: this table reaches only crates
# with `[lints] workspace = true`; `packaging/windows/drivers` and `packaging/windows/pf-vkhdr-layer`
# are SEPARATE workspaces and restate it (any "workspace-wide" claim must be made three times or it
# is false). Of the members, only the two vendored snapshots (pf-bitstream/vendor/cros-codecs,
# punktfunk-host/vendor/usbip-sim) stay out, deliberately — upstream code stays pristine.
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+14 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.26.0"
"version": "0.27.0"
},
"paths": {
"/api/v1/clients": {
@@ -53,7 +53,7 @@
"clients"
],
"summary": "Unpair a client",
"description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.",
"operationId": "unpairClient",
"parameters": [
{
@@ -4753,6 +4753,10 @@
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
},
"edid_lock": {
"type": "boolean",
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
},
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -4788,7 +4792,7 @@
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.",
"minimum": 0
}
}
@@ -4857,7 +4861,7 @@
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].",
"required": [
"keep_alive",
"topology",
@@ -5915,7 +5919,7 @@
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.",
"enum": [
"shared",
"per-client",
@@ -6132,14 +6136,14 @@
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.",
"required": [
"mode"
],
@@ -6183,6 +6187,7 @@
},
"positions": {
"type": "object",
"description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
@@ -6194,7 +6199,7 @@
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.",
"enum": [
"auto-row",
"manual"
@@ -6354,7 +6359,7 @@
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.",
"enum": [
"separate",
"steal",
@@ -56,6 +56,7 @@ import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@@ -74,7 +75,7 @@ fun App(forceGamepadUi: Boolean = false) {
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
@@ -139,9 +140,9 @@ fun App(forceGamepadUi: Boolean = false) {
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
reopenLibrary =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
} else {
null
}
@@ -154,8 +155,8 @@ fun App(forceGamepadUi: Boolean = false) {
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -282,15 +283,19 @@ fun GamepadShell(
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
* Open this library shelf instead of Home on the way in set when a game launched from it has
* just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
var libraryPinId by remember { mutableStateOf<String?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
@@ -301,15 +306,21 @@ fun GamepadShell(
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
?.let { kh ->
libraryHost = kh
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
screen = GamepadScreen.Library
}
onReopenLibraryHandled()
}
@@ -377,7 +388,11 @@ fun GamepadShell(
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
onOpenLibrary = { host, pinId ->
libraryHost = host
libraryPinId = pinId
screen = GamepadScreen.Library
},
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
@@ -407,8 +422,9 @@ fun GamepadShell(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
navActive = s == screen,
pinnedProfileId = libraryPinId,
)
} ?: run { screen = GamepadScreen.Home }
}
@@ -42,7 +42,7 @@ internal fun ConnectPrompts(
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost) -> Unit,
onOpenLibrary: (KnownHost, String?) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
@@ -119,9 +119,11 @@ internal fun ConnectPrompts(
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (libraryEnabled && pin == null) {
{ onDismissOptions(); onOpenLibrary(kh) }
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
// start the card, not a property of the host.
onLibrary = if (libraryEnabled) {
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
} else {
null
},
@@ -100,7 +100,9 @@ fun ConnectScreen(
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
@@ -772,7 +774,7 @@ fun ConnectScreen(
awaiting == null && editTarget == null && optionsTarget == null &&
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
@@ -86,8 +86,10 @@ class HomeTile(
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
* its own shelf and every launch off it carries this profile.
*/
val pinnedProfileId: String? = null,
/**
@@ -101,9 +103,10 @@ class HomeTile(
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
// returns a clear "pair first" message if the host hasn't authorized this device for its
// management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
@@ -94,6 +94,13 @@ fun LibraryScreen(
onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
/**
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
* connect. Null = the host's tile, and the host's binding decides the same rule
* [ProfileStore.resolveFor] applies to every other connect.
*/
pinnedProfileId: String? = null,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
@@ -104,6 +111,14 @@ fun LibraryScreen(
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
// titles on one shelf stream differently.
val profile = remember(host.id, pinnedProfileId) {
ProfileStore(context).resolveFor(host, pinnedProfileId)
}
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
@@ -133,7 +148,16 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("${host.name} — Library")
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
// launch here will use is a property of the shelf, not something to remember from
// the tile two screens back.
ConsoleHeader(
if (pinnedProfileId != null && profile != null) {
"${host.name} · ${profile.name} — Library"
} else {
"${host.name} — Library"
},
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
is LibState.Loading -> LoadingState()
@@ -145,7 +169,7 @@ fun LibraryScreen(
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
context, streamSettings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
@@ -153,11 +177,14 @@ fun LibraryScreen(
onLaunched(
ActiveSession(
handle,
settings,
streamSettings,
host.clipboardSync,
profileName = profile?.name,
hostId = host.id,
// Where to come back to when this game exits.
// Where to come back to when this game exits
// this shelf, pin and all, not the host's default one.
launchedFromLibrary = true,
libraryProfileId = pinnedProfileId,
),
)
}
@@ -71,8 +71,24 @@ data class ActiveSession(
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
/**
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
* trip above lands back on the SAME shelf a player who launched from a pinned card is still
* on that card when the game exits, and coming back to the host's default shelf would silently
* change what the next title streams with.
*/
val libraryProfileId: String? = null,
)
/**
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
* ones, because a hostId that arrives without its profile is not "the same shelf" it is the
* default one wearing the same name.
*/
data class LibraryReturn(val hostId: String, val profileId: String? = null)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -4,7 +4,6 @@ import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -177,9 +176,10 @@ class HomeTilesTest {
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Host tile library (Y); pin tile → none, because a pin is a shortcut, not a second host.
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
// the pinned profile — the library is a way to start a card, not a host-level action.
assertTrue(result[0].hasLibrary)
assertFalse(result[1].hasLibrary)
assertTrue(result[1].hasLibrary)
}
/**
@@ -43,10 +43,12 @@ struct OutputReady {
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
Au(Frame, bool),
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
/// request).
Au(Frame, u32),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
@@ -603,7 +605,11 @@ fn feeder_loop(
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
let gap = if au_first {
client.note_frame_index(frame.frame_index)
} else {
0
};
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
@@ -691,9 +697,12 @@ fn dispatch_event(
match ev {
DecodeEvent::Au(f, gap) => {
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
// fold `on_decoded` (keyed by the pts the codec will echo).
if gap {
gate.arm(Instant::now());
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
// race — see `ReanchorGate::arm_expecting_drops`).
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
// arms the freeze gate so the decoder's concealment is held off the screen until the
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
if client.note_frame_index(frame.frame_index) {
gate.arm(Instant::now());
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
let gap = client.note_frame_index(frame.frame_index);
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
+3 -3
View File
@@ -200,7 +200,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
///
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_this: JObject,
@@ -214,7 +214,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -245,7 +245,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
///
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_this: JObject,
+2 -2
View File
@@ -60,7 +60,7 @@ const TAG_HID_RAW: u8 = 0x05;
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
/// poll thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_this: JObject,
@@ -99,7 +99,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
_this: JObject,
+3 -3
View File
@@ -54,7 +54,7 @@ mod probe;
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
@@ -74,7 +74,7 @@ pub extern "system" fn JNI_OnLoad(
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
/// linked `punktfunk-core` is the one we expect.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
_env: JNIEnv,
_this: JObject,
@@ -83,7 +83,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
}
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
+1 -1
View File
@@ -14,7 +14,7 @@ use std::time::Duration;
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -40,7 +40,7 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
}
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
_env: JNIEnv,
_this: JObject,
@@ -53,7 +53,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
_env: JNIEnv,
_this: JObject,
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
/// counter, newest wins.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
_env: JNIEnv,
_this: JObject,
@@ -88,7 +88,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or 1.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
_env: JNIEnv,
_this: JObject,
@@ -106,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
/// clipboard's current text (the host is pasting our offer).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
mut env: JNIEnv,
_this: JObject,
@@ -125,7 +125,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeTe
}
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
_env: JNIEnv,
_this: JObject,
@@ -144,7 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
/// can never split a UTF-8 sequence.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
env: JNIEnv,
_this: JObject,
+12 -12
View File
@@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle};
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
@@ -36,12 +36,12 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
/// handle / `""` fingerprint.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
match env.new_string(token) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -74,7 +74,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
_env: JNIEnv,
_this: JObject,
@@ -120,7 +120,7 @@ fn force_parts_sysprop() -> bool {
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
/// host's approval-park window) so a slow operator approval lands on this same parked connection
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
@@ -322,7 +322,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_this: JObject,
@@ -344,7 +344,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
_env: JNIEnv,
_this: JObject,
@@ -363,7 +363,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -388,7 +388,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
/// handle. Cheap (one atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
_env: JNIEnv,
_this: JObject,
@@ -413,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
/// atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
_env: JNIEnv,
_this: JObject,
@@ -433,7 +433,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
+18 -18
View File
@@ -35,7 +35,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
}
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
_env: JNIEnv,
_this: JObject,
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_this: JObject,
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
_env: JNIEnv,
_this: JObject,
@@ -86,7 +86,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
_env: JNIEnv,
_this: JObject,
@@ -103,7 +103,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_this: JObject,
@@ -128,7 +128,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
_env: JNIEnv,
_this: JObject,
@@ -151,7 +151,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
_env: JNIEnv,
_this: JObject,
@@ -168,7 +168,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
/// (design/pen-tablet-input.md §7). `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
_env: JNIEnv,
_this: JObject,
@@ -197,7 +197,7 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
/// while in range (Kotlin side — see `StylusStream`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
env: JNIEnv,
_this: JObject,
@@ -264,7 +264,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
mut env: JNIEnv,
_this: JObject,
@@ -296,7 +296,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
_this: JObject,
@@ -318,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
/// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
_this: JObject,
@@ -343,7 +343,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_this: JObject,
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
///
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
/// the `Auto` rule inside the predicate itself.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
_env: JNIEnv,
_this: JObject,
@@ -399,7 +399,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_this: JObject,
@@ -415,7 +415,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
/// own report rate (~250500 Hz) — the direct-buffer read avoids a JNI array copy per report.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
env: JNIEnv,
_this: JObject,
@@ -455,7 +455,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
@@ -485,7 +485,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
@@ -45,6 +45,15 @@ pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
})
}
/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a
/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"`
/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class).
/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a
/// poisoned writer left is still valid to inspect or replace.
pub(crate) fn lock_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
+24 -24
View File
@@ -8,7 +8,7 @@ use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
use super::{jni_guard, lock_recover, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
@@ -19,7 +19,7 @@ use super::{jni_guard, SessionHandle};
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
mut env: JNIEnv,
_this: JObject,
@@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.video.lock().unwrap();
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return; // already streaming
}
@@ -91,7 +91,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -116,7 +116,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -140,7 +140,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
/// device).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -161,7 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecode
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_env: JNIEnv,
_this: JObject,
@@ -210,7 +210,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv,
_this: JObject,
@@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
if h.video.lock().unwrap().is_none() {
if lock_recover(&h.video).is_none() {
return std::ptr::null_mut(); // not streaming → no stats
}
let snap = h
@@ -312,7 +312,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
_this: JObject,
@@ -346,7 +346,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
/// pure `jni` + an atomic store, so it links on the host build too.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
_env: JNIEnv,
_this: JObject,
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_this: JObject,
@@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.audio.lock().unwrap();
let mut guard = lock_recover(&h.audio);
if guard.is_some() {
return; // already playing
}
@@ -398,7 +398,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
/// closing the session). No-op on `0`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_env: JNIEnv,
_this: JObject,
@@ -422,7 +422,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
/// the rest of the session streaming.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_this: JObject,
@@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.mic.lock().unwrap();
let mut guard = lock_recover(&h.mic);
if let Some(m) = guard.as_ref() {
return m.session_id(); // already capturing — same stream, same session
}
@@ -457,7 +457,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_env: JNIEnv,
_this: JObject,
@@ -484,7 +484,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
@@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
*lock_recover(&h.pad_audio) = Some(p);
1
}
None => 0,
@@ -530,7 +530,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
@@ -553,7 +553,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
@@ -594,7 +594,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
/// no captured audio leaves the process.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_this: JObject,
@@ -617,7 +617,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_this: JObject,
@@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.mic.lock().unwrap().is_some())
jboolean::from(lock_recover(&h.mic).is_some())
})
}
+2 -2
View File
@@ -23,7 +23,7 @@ const PROBE_RESULT_LEN: usize = 6;
/// **briefly pausing video**. Non-blocking: poll
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_this: JObject,
@@ -54,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
///
/// Layout (doubles so one array carries both the counts and the percentages):
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
+1 -1
View File
@@ -10,7 +10,7 @@ use jni::JNIEnv;
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
/// Returns true if at least one datagram went out.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -71,7 +71,7 @@ struct ContentView: View {
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
@State private var awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
@State private var libraryTarget: LibraryTarget?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" phase of the connect overlay). Available on every platform now that the iOS/tvOS
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
@@ -412,10 +412,10 @@ struct ContentView: View {
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
.onChange(of: model.returnToLibrary) { _, shelf in
guard let shelf else { return }
model.returnToLibrary = nil
libraryTarget = host
libraryTarget = shelf
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -448,9 +448,9 @@ struct ContentView: View {
// (the coverflow is a GeometryReader, ideal zero), so without a frame it collapses to a
// tiny panel.
#if os(macOS)
.sheet(item: $libraryTarget) { host in
.sheet(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
}
@@ -461,9 +461,9 @@ struct ContentView: View {
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
.fullScreenCover(item: touchLibraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
}
#endif
@@ -573,7 +573,7 @@ struct ContentView: View {
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
private var touchLibraryTarget: Binding<LibraryTarget?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
@@ -743,6 +743,25 @@ struct ContentView: View {
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
// connect link and refuses the same way (§10.6): an unknown or ambiguous reference must
// never quietly degrade to the host's binding, which is a different shelf wearing the same
// host's name.
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
@@ -755,7 +774,7 @@ struct ContentView: View {
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
@@ -833,9 +852,9 @@ struct ContentView: View {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
}
@@ -1234,6 +1253,10 @@ struct ContentView: View {
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
launchID: launchID,
// Where a game exit returns to, when this connect launched a title: the shelf that
// title was picked on the host's own, or the pinned card whose profile this connect
// is using. Ignored by the model unless there is a launchID.
shelf: LibraryTarget(host: host, profile: profile),
allowTofu: allowTofu,
requestAccess: requestAccess,
onUnreachable: onUnreachable)
@@ -1289,9 +1312,13 @@ struct ContentView: View {
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
/// asks the host to launch it.
private func launchTitle(_ host: StoredHost, _ id: String) {
/// A title picked on a library shelf: dial its host, booting straight into that title with
/// the shelf's profile. A pinned card's shelf carries its card's profile as the one-off, so a
/// launch made there streams with the profile the card promises; the host's own shelf carries
/// `.inherit` and the binding decides, exactly as a plain card tap does.
private func launchTitle(_ shelf: LibraryTarget, _ id: String) {
libraryTarget = nil
connect(host, launchID: id)
connect(shelf.host, launchID: id, profile: shelf.profile)
}
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
@@ -75,7 +75,7 @@ struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
@@ -90,7 +90,7 @@ struct GamepadHomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
let launchTitle: (LibraryTarget, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -263,7 +263,7 @@ struct GamepadHomeView: View {
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
if let shelf = libraryTarget { return .library(shelf) }
return nil
}
@@ -289,10 +289,10 @@ struct GamepadHomeView: View {
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
case .library(let shelf):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
store: store, target: shelf,
onLaunch: { launchTitle(shelf, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
@@ -501,9 +501,10 @@ struct GamepadHomeView: View {
isPaired: host.pinnedSHA256 != nil,
isConnecting: connecting,
filled: true,
// A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -539,12 +540,14 @@ struct GamepadHomeView: View {
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
}
}
@@ -12,24 +12,26 @@ import SwiftUI
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
let host: StoredHost
let target: LibraryTarget
let onLaunch: (String) -> Void
let close: () -> Void
var controllerActive = true
/// `.compact` in a landscape phone window tighter chrome, like every gamepad screen.
@Environment(\.verticalSizeClass) private var vSizeClass
/// Resolves a pinned shelf's profile name for the title.
@ObservedObject private var profiles = ProfileStore.shared
private var compact: Bool { vSizeClass == .compact }
var body: some View {
LibraryView(
store: store, host: host, onLaunch: onLaunch,
store: store, target: target, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(host.displayName) — Library")
Text("\(target.title(in: profiles)) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
@@ -22,14 +22,16 @@ enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
case library(LibraryTarget)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
case .library(let shelf): return "library-\(shelf.id)"
}
}
@@ -24,7 +24,7 @@ struct HomeView: View {
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
@@ -34,8 +34,9 @@ struct HomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Picked a title in the (experimental) library start a session that launches it, with the
/// shelf's profile (a pinned card's own; the host's binding on its primary card).
let onLaunchTitle: (LibraryTarget, String) -> Void
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
@@ -154,8 +155,8 @@ struct HomeView: View {
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
.navigationDestination(item: $libraryTarget) { shelf in
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
}
#endif
#if !os(tvOS)
@@ -263,9 +264,13 @@ struct HomeView: View {
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
host: host,
isOnline: isOnline(host),
@@ -219,6 +219,13 @@ struct HostCardView: View {
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action it is this card's connect with a title picked
// first so a pinned card offers it and opens its own shelf, whose launches carry the
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
// are about the machine, and a shortcut has no business claiming them.)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
@@ -6,11 +6,53 @@
import PunktfunkKit
import SwiftUI
/// Which library shelf is open: a host, and when it was opened from a PINNED host+profile card
/// (design/client-settings-profiles.md §5.2a) that card's profile, which every title launched off
/// the shelf then runs with, exactly as the card's own tap would.
///
/// One value rather than a host plus a profile carried beside it: a host and its pinned cards are
/// different cards on the grid, so "which library" is not answered by the host alone. That is also
/// why `id` folds the profile in a presentation keyed on the host would not re-present when you
/// move between a host's own shelf and one of its pins.
struct LibraryTarget: Identifiable, Hashable {
let host: StoredHost
/// `.inherit` from the host's own card (its binding decides, as it always has); `.profile` from
/// a pinned card. `.defaults` never reaches here nothing opens a library "with the globals".
var profile: ProfileSelection = .inherit
var id: String {
switch profile {
case .inherit: host.id.uuidString
case .defaults: "\(host.id.uuidString)#defaults"
case .profile(let id): "\(host.id.uuidString)#\(id)"
}
}
/// The pinned profile's id, if this shelf belongs to a pinned card.
var pinnedProfileID: String? {
if case .profile(let id) = profile { return id }
return nil
}
/// What the screen calls itself: the host, and the profile when a pinned card opened it the
/// same `host · profile` shape that card wears, so which shelf you are on is on screen rather
/// than remembered from the card you pressed. A pin whose profile has since been deleted
/// resolves as no profile everywhere else, and reads as the plain host here.
@MainActor func title(in catalog: ProfileStore) -> String {
guard let id = pinnedProfileID, let profile = catalog.profile(id: id) else {
return host.displayName
}
return "\(host.displayName) \u{b7} \(profile.name)"
}
}
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
/// The shelf being browsed the host, plus the pinned profile when a pinned card opened it.
let target: LibraryTarget
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
/// through). `nil` browse-only (cards aren't tappable). The PROFILE a launch runs with is the
/// caller's to apply: it holds `target` and connects with `target.profile`.
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil every sheet/cover
/// presentation falls back to the environment dismiss.
@@ -20,6 +62,12 @@ struct LibraryView: View {
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
@ObservedObject private var profiles = ProfileStore.shared
/// The host this shelf belongs to every fetch, every poster URL and the launch itself address
/// it, and a pinned shelf is the same host seen through one of its cards.
private var host: StoredHost { target.host }
@State private var games: [GameEntry] = []
@State private var loading = false
@@ -50,7 +98,7 @@ struct LibraryView: View {
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
.navigationTitle("\(target.title(in: profiles)) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -70,9 +70,14 @@ final class SessionModel: ObservableObject {
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// WHICH library shelf that title was launched from a host's own, or one of its pinned
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
/// launches with that card's profile, so returning to the host's default shelf would quietly
/// change what the next title streams with.
private var launchedShelf: LibraryTarget?
/// Set when a session ended because its game exited and it began as a library launch: the
/// shelf to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: LibraryTarget?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -275,6 +280,9 @@ final class SessionModel: ObservableObject {
func connect(to host: StoredHost, effective: EffectiveSettings,
gamepad: PunktfunkConnection.GamepadType = .auto,
launchID: String? = nil,
/// The library shelf `launchID` was picked on, so a game exit can return to it.
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
shelf: LibraryTarget? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false,
@@ -283,6 +291,7 @@ final class SessionModel: ObservableObject {
phase = .connecting
activeHost = host
launchedTitleID = launchID
launchedShelf = shelf
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -663,6 +672,7 @@ final class SessionModel: ObservableObject {
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
launchedShelf = nil
phase = .idle
fps = 0
mbps = 0
@@ -692,13 +702,16 @@ final class SessionModel: ObservableObject {
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title so no banner, and back to the library it came from.
if cameFromLibrary, let host {
returnToLibrary = host
if cameFromLibrary, host != nil, let shelf {
returnToLibrary = shelf
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
/// after close.
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
noteFrameIndexGapWidth(frameIndex) > 0
}
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH how many frames this arrival revealed
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
/// received AU. Returns 0 after close.
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return false }
var gap = false
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
return gap
guard let h = handle, !closeRequested else { return 0 }
var width: UInt32 = 0
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
return width
}
/// Cumulative access units the hostclient reassembler dropped as unrecoverable (FEC couldn't
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
lock.unlock()
}
/// `arm()` for a loss detected as a frame-index gap of a known width
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
/// RFI anchor already healed (the double-arm race the Rust gate's docs tell the story).
func arm(expectingDrops: UInt64) {
lock.lock()
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
lock.unlock()
}
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
/// `decoderKeyframe: false` VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Pump-side events (loss recovery, format seeding) the stage-2 sibling of StreamPump's log.
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
@@ -921,7 +923,11 @@ public final class Stage2Pipeline {
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze
// the following concealed frames are withheld until a clean re-anchor.
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included)
@@ -932,6 +938,21 @@ public final class Stage2Pipeline {
}
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
if format == nil {
// No decodable format yet: the opening IDR's parameter sets never
// arrived (or never parsed), and under the host's infinite GOP nothing
// re-delivers them unless we ASK. Without this the guard below drops
// every AU silently, forever the field "black stream, zero recovery
// requests" state (2026-08-12): the host streams perfectly, the client
// shows nothing and says nothing. awaitingIDR routes through the same
// 100 ms-throttled recovery.request() at the top of the loop.
if !awaitingIDR {
pumpLog.warning(
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
)
}
awaitingIDR = true
}
guard let f = format, !token.isStopped else { return true }
if decoder.decode(au: au, format: f) {
decodeFailRun = 0
@@ -100,7 +100,11 @@ final class StreamPump {
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze.
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat {
@@ -116,6 +120,21 @@ final class StreamPump {
}
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
if format == nil {
// No decodable format yet: the opening IDR's parameter sets never
// arrived (or never parsed), and under the host's infinite GOP nothing
// re-delivers them unless we ASK. Without this the format guard below
// drops every AU silently, forever the field "black stream, zero
// recovery requests" state (2026-08-12). awaitingIDR routes through the
// same 100 ms-throttled recovery.request() at the top of the loop.
if !awaitingIDR {
awaitingSince = Date()
pumpLog.warning(
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
)
}
awaitingIDR = true
}
let failed = layer.status == .failed
if failed {
// Decode wedged hard (the cold-first-connect case a lost/corrupt opening
@@ -229,7 +229,7 @@ public struct EffectiveSettings: Equatable, Sendable {
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
public enum ProfileSelection: Hashable, Sendable {
/// No pick the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
+18 -9
View File
@@ -870,6 +870,23 @@ fn deliver_deep_link(url: String) {
}
}
/// The crate's one runtime env mutation, isolated so `main.rs`'s `deny(unsafe_code)` covers
/// everything else and the exemption is a named function rather than a whole call site.
#[allow(unsafe_code)]
fn clear_steam_sdl_device_filter() {
for var in [
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
// SAFETY: called at the top of `run()`, before GTK init or any other thread
// exists in this process — nothing reads the environment concurrently.
unsafe { std::env::remove_var(var) };
}
}
}
pub fn run() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
@@ -879,15 +896,7 @@ pub fn run() -> glib::ExitCode {
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
// physical pad Steam Input has virtualized; the Settings controller list needs the
// real devices (same rationale as the session binary).
for var in [
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
std::env::remove_var(var);
}
}
clear_steam_sdl_device_filter();
// Headless paths (no GTK window).
if let Some(pin) = crate::cli::arg_value("--pair") {
return crate::cli::headless_pair(&pin);
+4 -1
View File
@@ -3,7 +3,10 @@
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
#![forbid(unsafe_code)]
// `deny`, not `forbid`, since edition 2024: clearing Steam's SDL device filter and the spawn
// test's `HOME` scoping mutate the process env, which is now an unsafe call. Both carry a named
// `#[allow(unsafe_code)]` with the proof at the site; everything else stays compiler-refused.
#![deny(unsafe_code)]
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
+6 -1
View File
@@ -128,6 +128,8 @@ mod tests {
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
/// deliberately does not split into several that would race over the same env var.
#[test]
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
#[allow(unsafe_code)]
fn the_plan_carries_resolved_settings_not_defaults() {
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
@@ -135,7 +137,10 @@ mod tests {
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
let cfg = home.join(".config/punktfunk");
std::fs::create_dir_all(&cfg).unwrap();
std::env::set_var("HOME", &home);
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
// replaced environ storage alive, and every reader tolerates either value.
unsafe { std::env::set_var("HOME", &home) };
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
// further — the two layers the spec has to carry.
+7
View File
@@ -466,6 +466,13 @@ impl relm4::factory::FactoryComponent for HostCard {
// offering them here would blur what the card is.
let launch = gio::Menu::new();
launch.append(Some("Connect"), Some("card.connect"));
// …and the same stream with a title picked first. The library is a way to
// START this card, not a property of the host, so it belongs to a shortcut
// as much as Connect does — and the card's request carries its profile, so
// what launches from that grid is this card's profile, not the binding.
if *library_enabled {
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append_section(None, &launch);
let links = gio::Menu::new();
+21 -1
View File
@@ -44,6 +44,26 @@ struct State {
mock: Cell<bool>,
}
/// What the page calls the host it is browsing. A request that carries a one-off profile
/// came from a PINNED card (design §5.2a), and every title launched off this grid inherits
/// it — so the page names it, the same `host · profile` shape the card wears. A plain card
/// says nothing extra: its binding is the host's own default, not a second thing to read.
/// A one-off whose profile has since been deleted resolves as no profile everywhere else,
/// and reads as a plain host here.
fn page_host_label(req: &ConnectRequest) -> String {
let Some(id) = req.profile.as_deref().filter(|id| !id.is_empty()) else {
return req.name.clone();
};
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.find(|p| p.id == id)
.map_or_else(
|| req.name.clone(),
|p| format!("{} \u{b7} {}", req.name, p.name),
)
}
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
pub fn open(
@@ -194,7 +214,7 @@ fn build(
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title(format!("{} — Library", req.name))
.title(format!("{} — Library", page_host_label(&req)))
.child(&toolbar)
.build();
+15 -11
View File
@@ -13,7 +13,7 @@
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
use crate::session_main::{
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos,
};
use pf_client_core::gamepad::is_steam_deck;
use pf_client_core::{discovery, library, trust, wol};
@@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 {
let json_status = arg_flag("--json-status");
let settings_at_start = trust::Settings::load();
// The console's window and its input models are built ONCE, from the global defaults, and
// live across every launch — so the presentation-tier fields below (stats tier, touch and
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
// live across every launch — so the presentation-tier fields below (touch and mouse model,
// shortcut inhibit, match-window, render scale) are latched here and a per-host profile
// cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio,
// pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap
// means rebuilding the presenter's models per launch — profiles P4 territory, not P0.
//
// ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's
// own settings screen writes the tier to the file and redraws its row, so the choice looked
// taken while every stream kept the tier the process started on — "no matter what I select
// the overlay is stuck on Detailed", cured only by restarting the app. It now rides
// `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop
// until the first stream. Anything else moved off this snapshot has to travel the same way.
let latched_mouse = settings_at_start.mouse_mode();
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
@@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 {
),
fullscreen: fullscreen_mode(),
window_pos: window_pos(),
// `--stats` forces the overlay visible without demoting a richer chosen tier.
stats_verbosity: match settings_at_start.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
// Seeds the loop only — every launch carries its own freshly resolved tier.
stats_verbosity: stats_tier(&settings_at_start),
touch_mode: settings_at_start.touch_mode(),
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll,
+74 -10
View File
@@ -116,6 +116,28 @@ mod session_main {
std::env::args().any(|a| a == flag)
}
/// The stats-overlay tier a session starts on: the resolved setting, except that
/// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an
/// explicitly chosen richer tier.
///
/// One helper because three callers need the identical rule — both run modes' presenter
/// options and the per-launch [`session_params`] — and a fourth reading of it would be
/// the bug this is here to prevent.
pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity {
stats_tier_with(settings.stats_verbosity(), arg_flag("--stats"))
}
/// [`stats_tier`]'s rule, with argv lifted out so it is testable.
pub(crate) fn stats_tier_with(
chosen: trust::StatsVerbosity,
stats_flag: bool,
) -> trust::StatsVerbosity {
match chosen {
trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal,
v => v,
}
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
@@ -420,6 +442,12 @@ mod session_main {
connect_timeout: connect_timeout(),
force_software,
profile,
// Presentation-tier, carried per launch rather than read once by the run loop:
// the console streams many sessions through ONE loop, so this is the only way a
// tier the user picked between streams (or one a host's profile carries) reaches
// the overlay before the app is restarted. Single mode passes the same value its
// presenter options already hold, so it changes nothing there.
stats_verbosity: stats_tier(settings),
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
// advertised only when the presenter has real on-glass latch stamps
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
@@ -509,8 +537,13 @@ mod session_main {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
// SAFETY: called at the very top of `run()`, before this process creates any
// thread — the Vulkan loader, SDL, and the session runtime all start later.
Ok(v) if !v.is_empty() => unsafe {
std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}"))
},
// SAFETY: as above — single-threaded startup.
_ => unsafe { std::env::set_var("RADV_PERFTEST", TOKEN) },
}
tracing::info!(
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
@@ -804,7 +837,10 @@ mod session_main {
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
] {
if std::env::var_os(var).is_none() && !value.is_empty() {
std::env::set_var(var, value);
// SAFETY: still the single-threaded startup stretch of `run()` — the
// early-exit probes above return out of the process, and everything that
// spawns threads (the session, the console, SDL) only starts below.
unsafe { std::env::set_var(var, value) };
}
}
}
@@ -818,7 +854,9 @@ mod session_main {
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
std::env::remove_var(var);
// SAFETY: as the settings block above — single-threaded startup, before SDL
// (the reader of these variables) or any other thread exists.
unsafe { std::env::remove_var(var) };
}
}
@@ -926,12 +964,7 @@ mod session_main {
window_title: format!("Punktfunk · {title}"),
fullscreen,
window_pos: window_pos(),
// `--stats` forces the overlay visible (tooling/debug runs) without
// demoting an explicitly chosen richer tier.
stats_verbosity: match settings.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
stats_verbosity: stats_tier(&settings),
touch_mode: settings.touch_mode(),
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
@@ -1000,6 +1033,37 @@ mod session_main {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trust::StatsVerbosity as V;
/// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every
/// richer chosen tier alone. Both run modes' presenter options AND the per-launch
/// params read this one rule, which is the point of having it.
#[test]
fn the_stats_flag_lifts_off_and_demotes_nothing() {
assert_eq!(stats_tier_with(V::Off, true), V::Normal);
assert_eq!(stats_tier_with(V::Off, false), V::Off);
for chosen in [V::Compact, V::Normal, V::Detailed] {
assert_eq!(stats_tier_with(chosen, true), chosen);
assert_eq!(stats_tier_with(chosen, false), chosen);
}
}
/// The console reads the file ONCE for its window, so a tier changed between streams
/// can only reach the overlay by riding the launch. Guards the wiring the field exists
/// for: whatever settings a launch resolved is what the params carry.
#[test]
fn a_launch_carries_the_tier_its_settings_resolved() {
let mut s = trust::Settings::default();
for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] {
s.set_stats_verbosity(chosen);
assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen);
}
}
}
}
#[cfg(any(target_os = "linux", windows))]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "punktfunk-client-windows"
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
version.workspace = true
edition.workspace = true
# Not workspace-inherited (1.82): windows-reactor at the pinned rev declares rust-version 1.95+
# Not workspace-inherited (1.85): windows-reactor at the pinned rev declares rust-version 1.95+
# and edition 2024. rust-toolchain.toml pins 1.96, so this records reality rather than raising it.
rust-version = "1.96"
license.workspace = true
+79 -14
View File
@@ -318,10 +318,10 @@ fn edit_editor(
if !addr.is_empty() {
h.addr = addr;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
if p != 0 {
h.port = p;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>()
&& p != 0
{
h.port = p;
}
let mac = mac_draft.borrow().trim().to_string();
h.mac = if mac.is_empty() {
@@ -913,9 +913,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
// record, and a pin whose profile is gone simply doesn't render. Their menu is
// deliberately short: a pinned tile is a shortcut, not a second host, so it carries
// only what STARTS it (the library — this tile's connect with a title picked first,
// which is why the grid it opens launches with the tile's profile), the link that
// reproduces it, and the way to remove it. Everything that configures the machine —
// pair, speed test, wake, edit, forget, and pinning itself — stays on the primary
// tile's menu, the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
continue;
@@ -923,6 +927,67 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
let pinned_menu = {
let (svc, target) = (props.svc.clone(), pinned_target.clone());
let (fp, pin_id) = (k.fp_hex.clone(), id.clone());
let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone());
let link_host = k.clone();
let link_profile = id.clone();
let unpin_label = format!("{MENU_UNPIN}{name}");
let unpin_item = unpin_label.clone();
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
let mut items = Vec::new();
// Same gate as the primary tile's: the mgmt API needs the paired
// identity, and the page is behind the experimental toggle.
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_separator());
items.push(menu_item(unpin_label));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
MENU_LIBRARY => {
// The shared target IS what the library page launches through, so
// parking THIS tile's target here is what makes its grid launch
// with the pinned profile.
*svc.ctx.shared.target.lock().unwrap() = target.clone();
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
Some(link_profile.as_str()),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
other if other == unpin_item => {
tracing::info!(pin = %pin_id, host = %fp, on = false, "pin toggle");
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|x| x != &pin_id);
if let Err(e) = known.save() {
tracing::warn!(
error = %format!("{e:#}"), "saving a pin"
);
}
}
// Same reason as the primary tile's toggle: nothing the page reads
// as state changed, so the bump is what makes this tile vanish NOW.
set_hosts_rev.call(hosts_rev + 1);
}
_ => {}
})
};
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
@@ -935,7 +1000,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
(!k.paired).then_some(("Trusted", Pill::Info)),
Some((name.as_str(), accent.clone())),
),
None,
Some(pinned_menu),
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
@@ -1029,12 +1094,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.close_button_text("Cancel")
.is_open(pending.is_some())
.on_closed(move |r: ContentDialogResult| {
if r == ContentDialogResult::Primary {
if let Some((fp, _)) = &pending {
let mut known = KnownHosts::load();
known.remove_by_fp(fp);
let _ = known.save();
}
if r == ContentDialogResult::Primary
&& let Some((fp, _)) = &pending
{
let mut known = KnownHosts::load();
known.remove_by_fp(fp);
let _ = known.save();
}
sf.call(None); // re-renders the page; the row is gone on the next load
})
+40 -37
View File
@@ -515,35 +515,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
move || {
std::thread::Builder::new()
.name("pf-probe".into())
.spawn(move || loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
.spawn(move || {
loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
})
})
})
.collect();
let map: HashMap<String, bool> =
handles.into_iter().filter_map(|h| h.join().ok()).collect();
set_probed.call(map);
std::thread::sleep(Duration::from_secs(12));
.collect();
let map: HashMap<String, bool> =
handles.into_iter().filter_map(|h| h.join().ok()).collect();
set_probed.call(map);
std::thread::sleep(Duration::from_secs(12));
}
})
.ok();
}
@@ -560,14 +562,15 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
cx.use_effect(screen.clone(), {
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
let (s, set_anim, generation) =
(screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // a newer navigation superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
@@ -593,18 +596,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
cx.use_effect(settings_nav.clone(), {
let (s, set_nav_anim, gen) = (
let (s, set_nav_anim, generation) = (
settings_nav.clone(),
set_nav_anim.clone(),
nav_gen.borrow().clone(),
);
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // a newer section switch superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
@@ -628,10 +631,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
cx.use_effect(show_add, {
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
let (set_add_anim, generation) = (set_add_anim.clone(), add_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
if !show_add {
set_add_anim.call(0.0);
return;
@@ -639,7 +642,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
std::thread::spawn(move || {
const STEPS: u32 = 12;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // reopened/closed mid-tween — a newer run owns the value
}
let p = f64::from(i) / f64::from(STEPS);
+7 -1
View File
@@ -176,7 +176,13 @@ unsafe extern "system" fn wnd_proc(
let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
let url = String::from_utf16_lossy(slice);
tracing::debug!(%url, "link from another instance");
INBOX.lock().unwrap().push(url);
// Poison-recover, never unwrap: a panic out of a window procedure is an abort since
// Rust 1.81, and the inbox is a plain Vec that stays valid whatever a poisoned
// writer left behind.
INBOX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(url);
return LRESULT(1);
}
}
+5 -5
View File
@@ -15,7 +15,6 @@
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
// terminal's console at startup (see main), so their output is still visible when run from a shell.
@@ -83,10 +82,11 @@ fn main() {
// where the user's hosts already are. A hand-off that finds nobody falls through and this
// process becomes the shell that opens it, so the link is never simply lost.
let link = deeplink::positional_url(&args);
if let Some(url) = &link {
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
return;
}
if let Some(url) = &link
&& !deeplink::claim_primary()
&& deeplink::forward_to_primary(url)
{
return;
}
if flag("--discover") {
+7
View File
@@ -10,6 +10,11 @@
#![allow(non_snake_case)]
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
#![allow(improper_ctypes)]
// The workspace-wide undocumented_unsafe_blocks deny cannot apply to GENERATED code: bindgen
// emits `unsafe {}` in layout tests/accessors and nobody hand-writes proofs into OUT_DIR. This
// crate is bindings-only by charter (the safe wrapper lives with the consumer), so the allow is
// crate-wide; the hand-written link-sanity test below still carries its proof by convention.
#![allow(clippy::undocumented_unsafe_blocks)]
// Generated code — clippy findings in it (missing safety docs on generated unsafe fns, style
// nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
// linted surface.
@@ -27,6 +32,8 @@ mod tests {
/// implementations — that's fine, MFXLoad itself must still succeed).
#[test]
fn dispatcher_links_and_loads() {
// SAFETY: MFXLoad allocates the dispatcher's loader context (documented to work with no
// driver present) and MFXUnload frees that same non-null handle; nothing else is touched.
unsafe {
let loader = MFXLoad();
assert!(!loader.is_null(), "MFXLoad returned NULL");
+1 -1
View File
@@ -7,7 +7,7 @@
[package]
name = "pf-capture"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
-7
View File
@@ -7,13 +7,6 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator).
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
+31 -9
View File
@@ -1,4 +1,4 @@
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path.
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`).
//!
//! Two dedicated threads, because both stacks are tied to their thread:
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
@@ -7,9 +7,13 @@
//! drops; ashpd's `Session` has no `Drop`);
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
//!
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
//! comes from the negotiated PipeWire format, not the portal's size hint.
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; frames leave that
//! thread through a ONE-DEEP OVERWRITING slot (`FrameSlot`) plus a wakeup edge — not the bounded
//! `sync_channel(8)` this once used, which was drop-NEWEST and so handed a stalled consumer stale
//! frames (see `FrameSlot`'s own note). The payload is not necessarily BGRx either: the negotiation
//! can settle on packed RGB, NV12, YUV444 or 10-bit PQ, and on a dmabuf passthrough it never touches
//! the CPU. The authoritative frame size comes from the negotiated PipeWire format, not the portal's
//! size hint.
//!
//! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a
//! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context
@@ -18,8 +22,9 @@
//! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end,
//! or a retried/failed pipeline build) therefore leaves nothing behind on either side.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{anyhow, Context, Result};
@@ -173,8 +178,9 @@ pub struct PortalCapturer {
/// capture, not per frame.
negotiation_confirmed: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the SDR
/// downgrade for THIS [`Self::hdr_source`] only, not process-wide) and by
/// [`hdr_meta`](Capturer::hdr_meta).
hdr_offer: bool,
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
@@ -463,7 +469,10 @@ fn spawn_pipewire(
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
// HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR
// offer is dropped — SDR capture, loudly.
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// The shared parser, not a bare `== "1"` compare — matching `PUNKTFUNK_PIPEWIRE_NV12` below.
// A bare compare silently ignored `PUNKTFUNK_FORCE_SHM=true`/`=on`/`=yes`, so the knob looked
// set and did nothing.
let force_shm = pf_host_config::env_on("PUNKTFUNK_FORCE_SHM").unwrap_or(false);
let want_hdr = if want_hdr && force_shm {
tracing::warn!(
"HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \
@@ -555,6 +564,14 @@ impl Capturer for PortalCapturer {
// every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland
// that appears later is adopted, and follows whichever one gamescope draws the pointer on.
// `frame_size` lets it map root-space coordinates into frame space.
//
// Idempotent by construction. The contract says "called once", but nothing enforced it, and a
// second call evaluated `spawn` BEFORE dropping the old source: two readers then published
// into the same slot for the construction window, and a `spawn` that returned `None` destroyed
// a perfectly good reader outright.
if self._gs_cursor.is_some() {
return;
}
self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn(
targets,
Arc::clone(&self.signals.cursor_live),
@@ -661,6 +678,11 @@ impl Capturer for PortalCapturer {
if let Ok(mut slot) = self.slot.lock() {
*slot = None;
}
// Clear the stall clock for the same reason the mailbox is flushed: a pooled capturer
// whose previous stream ended mid-stall carried that `Instant` into the next one, so the
// first `try_latest` that saw `!streaming` found the 1500 ms grace already expired and
// reported capture loss on a stream that had been running for microseconds.
self.stall_since = None;
}
}
+14 -1
View File
@@ -1506,7 +1506,20 @@ pub fn pipewire_thread(
{
return;
}
if ud.info.parse(param).is_ok() {
// Parse ONCE — `parse` takes `&mut self` — and report a failure instead of swallowing it.
// On `Err`, `negotiated` stays false and `format`/`modifier`/`frame_size` keep their
// previous values, so the capture dies on the generic "the compositor offered no format
// this consumer accepts" timeout — sending the operator hunting a format mismatch when
// the real fault was a malformed Format pod we DID accept.
let parsed = ud.info.parse(param);
if let Err(e) = &parsed {
tracing::error!(
error = %e,
"pipewire: failed to parse the negotiated Format pod — capture will time out \
with no usable format"
);
}
if parsed.is_ok() {
ud.signals.negotiated.store(true, Ordering::Relaxed);
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
+11 -1
View File
@@ -197,6 +197,15 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
return;
}
// SPA's second "no image data" signal, distinct from the `bitmap_offset == 0` position-only
// case above: `spa_meta_bitmap.offset` is the offset of the PIXELS within the bitmap struct,
// and 0 means there are none. Without this, `pix_off == 0` made the pixel extent start at the
// `spa_meta_bitmap` header itself, so a producer signalling an invisible pointer got its own
// header words (format/size/stride/offset) decoded and cached as the cursor bitmap. In bounds,
// so not unsound — just garbage pixels blitted into every later frame.
if pix_off == 0 {
return;
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
@@ -327,7 +336,8 @@ pub(super) fn composite_cursor_rgb10(
}
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
/// position. Cheap: a straight-alpha blit over at most 1024×1024 pixels (the accepted cap; real
/// cursors are ≤96 px), clipped to the frame —
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
pub(super) fn composite_cursor(
tight: &mut [u8],
+2 -1
View File
@@ -377,7 +377,8 @@ pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
/// attach it (harmless). Size is a range up to a 1024×1024 bitmap — see the note on `max` below for
/// why this is not the "bigger than any real cursor" 256² it used to be.
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
fn meta_size(w: u32, h: u32) -> i32 {
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
+38 -35
View File
@@ -55,16 +55,6 @@ use x11rb::rust_connection::{DefaultStream, RustConnection};
use crate::GamescopeCursorTargets;
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
///
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
/// environ. That is why the primary path parses the cookie itself and never touches the
/// environment.
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
@@ -267,17 +257,18 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
/// environment.
///
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
/// sharing it would be the wrong layer AND would still not fix `getenv`.
/// `set_var`'d it around each connect under a mutex. That is unsound from a live multithreaded
/// host: the lock serialised this source against itself, but `getenv` takes no lock, so any
/// concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment, since
/// the PipeWire thread is starting up) could read the swapped value or race the environ rewrite
/// outright. The project already has a process-wide env-lock discipline elsewhere, but sharing it
/// would be the wrong layer AND would still not fix `getenv`.
///
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
/// unexpected layout, or an auth family whose entry we decline to guess at).
/// the cookie IT found. Where that finds nothing usable we connect with an explicitly empty token
/// ([`connect_unauthenticated`]) rather than swapping the environment — this process no longer
/// writes `environ` at all.
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
let Some(path) = xauthority else {
// No per-display cookie file to inject: the ambient environment is already what this
@@ -289,16 +280,16 @@ fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection,
Ok(v) => return Ok(v),
Err(e) => tracing::debug!(
dpy = %dpy, xauthority = %path, error = %e,
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
"gamescope cursor: cookie connect failed — retrying unauthenticated"
),
},
None => tracing::debug!(
dpy = %dpy, xauthority = %path,
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
XAUTHORITY env swap"
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — connecting \
unauthenticated"
),
}
connect_via_env_swap(dpy, path)
connect_unauthenticated(dpy)
}
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
@@ -331,19 +322,31 @@ fn connect_with_cookie(
.map_err(|e| format!("setup: {e}"))
}
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
/// is a fallback and not the path taken.
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
std::env::set_var("XAUTHORITY", xauthority);
let out = RustConnection::connect(Some(dpy));
match prev {
Some(p) => std::env::set_var("XAUTHORITY", p),
None => std::env::remove_var("XAUTHORITY"),
}
out.map_err(|e| format!("connect: {e}"))
/// Last-resort fallback (see [`connect_conn`]): connect with an EXPLICITLY EMPTY auth token.
///
/// This replaces a `set_var("XAUTHORITY", …)` / connect / restore dance, which was unsound and is
/// not fixable in place. `setenv`/`unsetenv` rewrite the process-global `environ`; glibc
/// *reallocates* that array when a variable is added, and the host is emphatically multithreaded
/// at this moment — `attach_gamescope_cursor` runs while the PipeWire thread is inside `pw_init`'s
/// `dlopen` and a dozen bare `getenv()` calls, with EGL/CUDA init alongside. A mutex here
/// serialised this source against itself and against nothing else, because `getenv` takes no lock.
/// The damaging branch is the one where `XAUTHORITY` is ABSENT and therefore gets *added* — which
/// `scripts/punktfunk-host.service` makes the normal configuration, since the unit deliberately
/// does not import the login shell's environment. And `rediscover` re-runs this every 2 s for the
/// whole session, because a display whose connect fails is never recorded and so is never skipped.
///
/// Connecting with an empty token is what the swap actually achieved. We only reach here when our
/// own lookup found no usable `MIT-MAGIC-COOKIE-1` entry, and x11rb's internal lookup reads the
/// same file with a STRICTER matcher (it also matches family/address, which we deliberately do
/// not) — so where we find nothing, it finds nothing too, and connects unauthenticated. That is
/// precisely why the swap "worked" against a nested Xwayland started without `-auth`.
///
/// The one case this gives up is an `.Xauthority` whose entry uses an auth family we decline to
/// guess at but x11rb would have handled. A gamescope Xwayland writes a single-entry
/// MIT-MAGIC-COOKIE-1 file, so that case is not reachable here — and a cursor overlay that
/// declines to attach is the correct outcome anyway, against a torn `environ` in a live session.
fn connect_unauthenticated(dpy: &str) -> Result<(RustConnection, usize), String> {
connect_with_cookie(dpy, Vec::new(), Vec::new())
}
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
+6 -7
View File
@@ -9,9 +9,6 @@
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
//! module contains no capturer.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
@@ -67,7 +64,7 @@ pub(crate) fn hybrid_hook_hits() -> u64 {
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
// different core), so the "same-thread, no flush needed" assumption was wrong.
#[link(name = "kernel32")]
extern "system" {
unsafe extern "system" {
fn FlushInstructionCache(h: *mut c_void, base: *const c_void, size: usize) -> i32;
fn GetCurrentProcess() -> *mut c_void;
}
@@ -554,9 +551,11 @@ impl HdrP010Converter {
let mut ps_uv = None;
device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?;
let sd = D3D11_SAMPLER_DESC {
// POINT: the Y pass samples a single texel centre exactly, and the UV pass does its OWN
// 2x2 box average via 4 explicit taps at texel centres (offset half a texel). Point
// sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// POINT: the Y pass samples a single texel centre exactly, and the UV pass takes its OWN
// two explicit taps on the 2x2 block's LEFT column (left-cositing) and averages them.
// Point sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// (It was a 4-tap CENTER-sited 2x2 box until that was found to shift chroma by half a
// luma pixel — see `HDR_P010_UV_PS`.)
Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
+17 -12
View File
@@ -16,9 +16,6 @@
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
//! drift is a compile error rather than a "must match" comment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
VideoConverter, WinCaptureTarget,
@@ -337,6 +334,7 @@ use channel::ChannelBroker;
use descriptor::{DescriptorPoller, DisplayDescriptor};
use stall::{StallEvidence, StallWatch};
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
pub struct IddPushCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
@@ -652,14 +650,18 @@ impl IddPushCapturer {
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
/// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// state plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that
/// SM-side CSC, because the video processor can only produce subsampled output). The
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
/// survives only in the CODEC choice: an HDR-negotiated H.26x session still follows a host
/// "Use HDR" flip in either direction.) HDR and 4:4:4 now COMPOSE: an HDR display that
/// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to
/// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to
/// 4:2:0 *after* the Welcome had already promised 4:4:4.)
@@ -969,7 +971,7 @@ impl IddPushCapturer {
},
Usage: D3D11_USAGE_DEFAULT,
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
// NVENC registers it as encode input. (PyroWave uses its own
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
@@ -1970,9 +1972,12 @@ impl Capturer for IddPushCapturer {
fn pipeline_depth(&self) -> usize {
// 2 = one frame deferred: submit N+1 (capture + convert/copy into a fresh out-ring texture) while
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
// always has its own texture).
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining). The ceiling is `OUT_RING - 1`, NOT
// `OUT_RING`: `d` frames in flight need `d + 1` textures, because the rotation has to hand out a
// slot that is not one of the `d` still being encoded. Clamping to `OUT_RING` admitted depth 3 on
// a 3-slot ring, where `repeat_last`'s rotation lands back on the slot NVENC is reading and the
// convert overwrites it in place — torn frames, silently, with no error anywhere.
pf_host_config::config().idd_depth.clamp(1, OUT_RING - 1)
}
fn capture_target_id(&self) -> Option<u32> {
@@ -2,9 +2,6 @@
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
//! and delivers them as bare handle values over the SYSTEM-only control device.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
@@ -160,7 +157,18 @@ impl ChannelBroker {
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
// An ERROR, not a `debug_assert`: in a release build the assert is compiled out and the
// over-long slice instead panics on `req.texture_handles[k]` in the middle of
// `duplicate_and_deliver` — after handles have already been planted in WUDFHost. That panic
// unwinds straight past the reap below, leaking every duplicate made so far into the driver
// process. Refuse before the first duplication, while there is nothing to reap.
if slots.len() > control::RING_LEN_USIZE {
anyhow::bail!(
"frame channel: {} ring slots exceeds the wire limit of {}",
slots.len(),
control::RING_LEN_USIZE
);
}
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
@@ -5,9 +5,6 @@
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
//! cursor forwarder, the wire, the client renderer) is shared.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use pf_driver_proto::cursor::{
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
@@ -42,7 +39,9 @@ impl CursorShared {
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
// mapped once and unmapped never (the capturer's life = the session's life).
// mapped once here and unmapped exactly once by `MappedSection::drop` (which unmaps before
// closing the mapping handle). No borrow into the view outlives the `MappedSection`: every
// access goes through `&self` accessors on the owner.
let section = unsafe {
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
@@ -10,9 +10,6 @@
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::core::s;
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
@@ -20,9 +20,6 @@
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::Win32::Graphics::Gdi::{
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
@@ -55,8 +52,10 @@ struct Shape {
serial: u64,
}
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` every [`Self::INTERVAL`] (4 ms, ~250 Hz —
/// see that constant for why 16 ms was the bug), rasterises the `HCURSOR` when its handle value
/// changes and when [`Self::EXTENT_PROBE`] catches a resize under a STABLE handle, and publishes a
/// ready [`pf_frame::CursorOverlay`] snapshot; the
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
/// on the capture/encode thread).
@@ -186,7 +185,6 @@ fn run(
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
if let Some(fresh) = fresh {
if fresh != rect {
@@ -302,7 +300,14 @@ fn run(
serial: s.serial,
hot_x: s.hot_x,
hot_y: s.hot_y,
visible: showing && in_rect,
// `handle != 0` is part of "visible", not just of "worth rasterising": `SetCursor(NULL)`
// — how a game or a video player hides the pointer for its own window — leaves
// `CURSOR_SHOWING` set with a NULL `hCursor`. Judging on the flags alone published
// `visible: true` carrying the last shape we rasterised, so the composite path blended a
// ghost arrow into a game that had hidden its cursor, and the forward path told the
// client to draw one too. Every rasterise gate below already tests this; the published
// verdict has to agree with them.
visible: showing && in_rect && handle != 0,
}
});
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
@@ -1,12 +1,8 @@
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
/// virtual target.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -33,9 +33,6 @@
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
@@ -142,7 +139,12 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
(*record).EventHeader.ProcessId,
)
};
let mut ring = RING.lock().unwrap();
// Poison-tolerant, and that is load-bearing rather than tidy: this is an `extern "system"`
// callback invoked from an OS thread, so a panic here unwinds across an FFI boundary and
// ABORTS the host process. `unwrap()` made a single poisoned lock turn every subsequent event
// delivery into a hard abort — a diagnostic taking down capture. Nothing else under this lock
// can panic, so recovering the guard also makes the poison unreachable in the first place.
let mut ring = RING.lock().unwrap_or_else(|e| e.into_inner());
if ring.len() == RING_CAP {
ring.pop_front();
}
@@ -152,8 +152,11 @@ impl IddPushCapturer {
}
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller decides the display's fate
/// itself — retire it, or reuse the monitor for a retry — instead of this function tearing it down
/// (audit §5.1 — no more 20 s black bail). There is no second capture path to fall back TO: DDA was
/// removed (see `lib.rs`), and `punktfunk-host`'s caller drops the returned keepalive under
/// `.context("IDD-push capture open (no fallback)")`. "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
#[allow(clippy::too_many_arguments)]
pub fn open(
@@ -666,7 +669,7 @@ impl IddPushCapturer {
// wait for the first compose) until the capturer drops with the session.
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
// it back to the caller for the DDA fallback (audit §5.1).
// it back to the caller to retire or reuse the display (audit §5.1).
_keepalive: Box::new(()),
};
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
@@ -675,15 +678,15 @@ impl IddPushCapturer {
me.refresh_sdr_white_scale();
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
// format/size the ring can't match) becomes an open failure the caller handles by retiring the
// display, instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// a first frame**, else fail so the caller can retire the display and rebuild (audit §5.1 +
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
@@ -25,9 +25,6 @@
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
//! microseconds each, so the engine is invisible next to a streaming session.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
@@ -53,7 +50,9 @@ use super::stall::ProbeWindow;
/// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call
/// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at
/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage.
/// `completed_at`. Capped at 512 samples: at the fastest producer's ~20 Hz that is ~26 s of
/// coverage, ~51 s for the 100 ms loops — comfortably longer than the seconds-old windows a stall
/// report asks for, but NOT the "several minutes" this used to claim.
struct Ring {
samples: Mutex<VecDeque<(Instant, Duration, u64)>>,
}
@@ -1,9 +1,6 @@
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
//! holes in DWM frame delivery that open while the desktop was actively composing.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
@@ -317,7 +314,8 @@ impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// The RECENT pre-gap frames must all fit in this span (8 frames spanning 400 ms is 7 intervals,
/// so the real bar is ≈ ≥ 17.5 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
@@ -535,14 +533,47 @@ impl StallWatch {
suspects)"
);
} else {
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
// the same way, so this read mirrors what the driver decided — modulo a machine
// env edited after either process started, which a restart heals). The RX 9070
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
// say whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
};
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
};
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
rt_gpu_driver,
rt_gpu_host,
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) a punktfunk process holding \
REALTIME GPU priority is the field-proven amplifier of exactly this \
signature on AMD. Otherwise: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
-1
View File
@@ -18,7 +18,6 @@
// proof of why it is sound. This crate held ~91 unsafe items with NO enforcement while every
// other subsystem crate denied it — the decoders' `unsafe impl Send`s had a one-line aside
// instead of an argument precisely because nothing required one.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", windows))]
mod au_dump;
+4 -4
View File
@@ -164,10 +164,10 @@ fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
.ok()?
.reply()
.ok()?;
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
// expression its temporary would outlive it.
let id = reply.value32()?.next();
id
// Inline is sound since edition 2024: tail-expression temporaries now drop BEFORE the
// block's locals, so the iterator borrowing `reply` no longer outlives it (the 2021 rule
// forced a `let` binding here).
reply.value32()?.next()
}
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
+19 -1
View File
@@ -104,6 +104,19 @@ pub struct SessionParams {
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
/// re-reading any store (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
/// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to
/// this host. Presentation-tier, like [`profile`](Self::profile): the session controller
/// never reads it, it rides along so the presenter can adopt it when a browse-mode launch
/// starts.
///
/// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window
/// and its run loop ONCE and streams many sessions through them, so a tier taken only from
/// the loop's start-of-process options could never change again — a user picking a tier in
/// the console's settings screen saw the row move, the file updated, and every stream keep
/// the old overlay until the app was restarted. Carrying it per launch is what lets the
/// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the
/// console too. The in-stream cycle chord still wins for the rest of the stream it moved.
pub stats_verbosity: crate::trust::StatsVerbosity,
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
@@ -885,7 +898,12 @@ fn pump(
Some(exp) => {
if let Some(gap) = index_gap(exp, frame.frame_index) {
let now = Instant::now();
gate.arm(now);
// Credited arm: the reassembler books these same lost frames into
// `frames_dropped` up to ~120 ms from now; the credit keeps that
// delayed climb from re-freezing a stream the RFI anchor healed in
// between (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
gate.arm_expecting_drops(now, u64::from(gap));
next_expected_index = Some(frame.frame_index.wrapping_add(1));
// The gap carries the PRECISE lost range — [first missing, newest
// received - 1] — so this is the one recovery signal that can drive true
@@ -2241,7 +2241,7 @@ mod parity {
// `desc.Height` rows at `RowPitch` and the chroma plane follows at byte
// offset `RowPitch * desc.Height`, so `total` below is exactly the mapped
// extent and every sub-slice read is inside it. `Unmap` pairs the `Map`.
let out = unsafe {
unsafe {
let src: ID3D11Resource = pool.cast().expect("pool -> resource");
let dst: ID3D11Resource = staging.cast().expect("staging -> resource");
self.ctx
@@ -2268,8 +2268,7 @@ mod parity {
}
self.ctx.Unmap(&staging, 0);
out
};
out
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
[package]
name = "pf-clipboard"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
+2 -2
View File
@@ -17,8 +17,8 @@
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; the deny enforcing it sits at
// the crate root (lib.rs), covering every backend.
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
+4
View File
@@ -10,6 +10,10 @@
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
//! platform split lives entirely behind [`start`].
// Unsafe-proof program: every `unsafe` block in any backend carries a `// SAFETY:` proof,
// enforced workspace-wide by `[workspace.lints]` — a new backend under `host/` is covered on
// creation.
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
-1
View File
@@ -11,7 +11,6 @@
//! capture hint, start banner.
// Unsafe-proof program: every `unsafe {}` in the Skia/Vulkan overlay carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", windows))]
mod anim;
+1 -1
View File
@@ -209,7 +209,7 @@ impl Screen {
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Library(s) => s.title(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
+25 -6
View File
@@ -10,7 +10,7 @@ use crate::library::{
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use crate::model::{ConsoleCmd, HostRow};
use crate::model::{ConsoleCmd, HostRow, ProfileChip};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{accent, fg, Fonts, W};
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
port: u16,
fp_hex: String,
mgmt: u16,
/// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather
/// than the host's primary tile: every launch off this shelf is that card's connect
/// with a title attached, so it carries the same one-off profile the card's plain
/// A-press would. `None` = the primary tile, where the host's binding decides.
pin: Option<ProfileChip>,
shared: Option<LibraryShared>,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
@@ -48,6 +53,7 @@ impl LibraryScreen {
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
pin: host.pin.clone(),
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
@@ -60,8 +66,13 @@ impl LibraryScreen {
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
/// profile every launch off it will use, in the card's own `host · profile` shape.
pub(crate) fn title(&self) -> String {
match &self.pin {
Some(p) => format!("{} \u{b7} {}", self.host_name, p.name),
None => self.host_name.clone(),
}
}
fn fetch_cmd(&self) -> ConsoleCmd {
@@ -123,10 +134,18 @@ impl LibraryScreen {
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
// A pinned card's shelf says which profile it is launching with,
// the same way its tile and this screen's title do.
title: match &self.pin {
Some(p) => format!("{} \u{b7} {}", g.title, p.name),
None => g.title.clone(),
},
request_access: false,
// Game launches follow the host's default binding.
profile: None,
// A game launch off a PINNED card's shelf is that card's connect
// with a title attached — it carries the card's profile as the
// one-off. Off the primary tile there is none, and the host's
// default binding decides.
profile: self.pin.as_ref().map(|p| p.id.clone()),
});
Some(MenuPulse::Confirm)
}
+8 -4
View File
@@ -382,13 +382,13 @@ impl SettingsScreen {
}
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
};
}
RowId::NoProfiles => {
return match msg {
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
};
}
_ => {}
}
@@ -1054,12 +1054,16 @@ mod tests {
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
// set after the closure ran on EVERY call, so two parallel tests could race the
// write; setting once under the OnceLock is what makes this sound.)
unsafe { std::env::set_var("HOME", &dir) };
dir
});
std::env::set_var("HOME", dir);
}
/// Render the screen once so its strip and list carry real geometry, then hand back a
+88 -4
View File
@@ -49,13 +49,16 @@ fn motion_matches_the_shared_vectors() {
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("HOME", &dir);
dir.clone()
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
// re-set after the closure ran on EVERY call, so two parallel tests could race the
// write; setting once under the OnceLock is what makes this sound.)
unsafe { std::env::set_var("HOME", &dir) };
dir
});
std::env::set_var("HOME", dir);
}
fn hosts() -> Vec<HostRow> {
@@ -180,6 +183,87 @@ fn finish_motion(s: &mut Shell) {
s.motion = Motion::None;
}
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
///
/// The card's plain A-press always carried its profile; Y — which the card offers, being
/// paired and saved — opened a library screen that knew only the host, so every title
/// launched off it silently fell back to the host's default binding. The profile a user
/// pinned is the whole reason they pressed that card.
#[test]
fn a_pinned_cards_library_launches_with_its_profile() {
let mut rows = hosts();
let card = HostRow {
key: "aa11\u{0}hdr".into(),
pin: Some(crate::model::ProfileChip {
id: "hdr".into(),
name: "HDR".into(),
accent: None,
}),
..rows[0].clone()
};
rows.insert(1, card);
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
console.set_hosts(rows);
s.sync();
// Focus the pinned card (it sits right after its host's primary tile), then Y.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Secondary);
finish_motion(&mut s);
match s.stack.last() {
Some(Screen::Library(l)) => assert_eq!(
l.title(),
"Living Room PC \u{b7} HDR",
"the shelf names the profile it will launch with"
),
_ => panic!("Y on a pinned card opens its library"),
}
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
match s.take_action() {
Some(OverlayAction::Launch {
launch, profile, ..
}) => {
assert_eq!(launch.as_deref(), Some("steam:570"));
assert_eq!(
profile.as_deref(),
Some("hdr"),
"the launch carries the pinned card's profile"
);
}
_ => panic!("A on a title raises a launch"),
}
}
/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides,
/// which is what the resolver sees as `None`.
#[test]
fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Secondary); // paired+online host focused first
finish_motion(&mut s);
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { profile: None, .. })
));
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
+2 -2
View File
@@ -9,8 +9,8 @@
[package]
name = "pf-driver-proto"
version = "0.0.1"
edition = "2021"
rust-version = "1.82"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
publish = false
+7 -1
View File
@@ -1712,7 +1712,13 @@ mod tests {
let mut legacy = [0u8; 40];
legacy[..control::ADD_REQUEST_LEGACY_SIZE]
.copy_from_slice(&bytes[..control::ADD_REQUEST_LEGACY_SIZE]);
let old = *bytemuck::from_bytes::<control::AddRequest>(&legacy);
// `pod_read_unaligned`, NOT `from_bytes` — same rule as `ChannelProof::parse` above, and
// for the same reason. `legacy` is a `[u8; 40]` (align 1) but `AddRequest` opens with a
// `u64`, so it is align 8; `from_bytes` takes a REFERENCE into the buffer and panics
// unless the buffer happens to be 8-aligned. A stack `[u8; 40]` usually is, which is why
// this passed everywhere for so long — Miri caught it because Miri does not let an
// accidentally-favourable stack slot stand in for a guarantee.
let old = bytemuck::pod_read_unaligned::<control::AddRequest>(&legacy);
assert_eq!(old.preferred_monitor_id, 7);
assert_eq!(
(
-1
View File
@@ -52,7 +52,6 @@
//! ([`dxva::as_bytes`] / [`dxva::slice_bytes`]), fenced behind a sealed trait
//! that only this crate's `#[repr(C)]` PODs implement, and carrying a written
//! proof — enforced:
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod config;
pub mod descriptors;
+1 -1
View File
@@ -6,7 +6,7 @@
[package]
name = "pf-encode"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
+84
View File
@@ -48,6 +48,8 @@ impl AvBuffer {
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
/// once, here).
///
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
/// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it.
@@ -117,6 +119,88 @@ impl Drop for AvFilterGraph {
}
}
/// An owned `AVFrame`, freed exactly once when it drops.
///
/// The house pattern (`AvBuffer` above): `alloc` rejects the allocator's null once, `as_ptr`
/// lends, `Drop` frees, no `Clone`. Before this type existed the crate held 8 `av_frame_alloc`
/// sites matched by 22 hand-placed `av_frame_free`s — an ownership contract upheld by nobody,
/// and broken in practice: the Windows zero-copy submit path leaked the frame AND a pooled
/// hwframe surface on three `?` exits, under a comment asserting the opposite (fixed in the
/// same change that introduced this type).
///
/// Why not ffmpeg-next's own RAII frame (`frame::Video::empty()`, already used as `VideoFrame`
/// in the Linux NVENC path): `Frame::empty()` does not null-check — on allocator failure it
/// wraps null and the next field write through it is UB — whereas every open-coded site here
/// null-checked. This type keeps that: `alloc` returns `Option`, mirroring
/// `AvFilterGraph::alloc`.
pub(crate) struct AvFrame(std::ptr::NonNull<ffi::AVFrame>);
impl AvFrame {
/// Allocate a frame, rejecting the null `av_frame_alloc` returns on OOM.
///
/// Safe: the call takes no arguments and has no precondition a caller could violate — the
/// only contract is what happens to the result, and that is exactly what this type owns.
pub(crate) fn alloc() -> Option<Self> {
// SAFETY: parameterless allocator; it returns either a fresh, uniquely-owned frame whose
// ownership passes to the value returned here, or null (rejected by NonNull::new).
std::ptr::NonNull::new(unsafe { ffi::av_frame_alloc() }).map(AvFrame)
}
/// The borrowed pointer, for the ffmpeg calls that fill or read the frame without taking
/// ownership of it. Borrowed only — the `AvFrame` stays the owner, so callers must not free
/// or move-from what this returns.
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFrame {
self.0.as_ptr()
}
}
impl Drop for AvFrame {
fn drop(&mut self) {
let mut p = self.0.as_ptr();
// SAFETY: `p` is the non-null frame `alloc` took ownership of, and this type is its
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly
// once. `av_frame_free` unrefs any buffers the frame holds (returning pooled hwframe
// surfaces to their pool) and frees the frame; it nulls only the local copy.
unsafe { ffi::av_frame_free(&mut p) };
}
}
/// An owned swscale context, freed exactly once when it drops.
///
/// Same ownership question as the frame above — `sws_getContext` at 3 sites was matched by 5
/// hand-placed `sws_freeContext`s, two of them inside hand-written `Drop` impls whose real job
/// this type absorbs.
pub(crate) struct AvSwsContext(std::ptr::NonNull<ffi::SwsContext>);
impl AvSwsContext {
/// Take ownership of a freshly-created `SwsContext`, rejecting the null `sws_getContext`
/// returns on failure (unsupported conversion or OOM).
///
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
/// # Safety
/// `p` must be null, or a live `SwsContext` whose ownership passes to the returned value —
/// nothing else may free it.
pub(crate) unsafe fn from_raw(p: *mut ffi::SwsContext) -> Option<Self> {
std::ptr::NonNull::new(p).map(AvSwsContext)
}
/// The borrowed pointer, for `sws_scale` calls. Borrowed only — the `AvSwsContext` stays
/// the owner.
pub(crate) fn as_ptr(&self) -> *mut ffi::SwsContext {
self.0.as_ptr()
}
}
impl Drop for AvSwsContext {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null context `from_raw` took ownership of, and this type
// is its sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs
// exactly once.
unsafe { ffi::sws_freeContext(self.0.as_ptr()) };
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
+66 -71
View File
@@ -12,8 +12,6 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -26,8 +24,8 @@ use std::os::raw::c_int;
use std::ptr;
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -193,6 +191,17 @@ struct OpenArgs {
}
pub struct NvencEncoder {
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced ran before any field
// drop, freeing `sws_csc` ahead of `enc`/`frame`/`cuda` — and this path runs on every
// stall-watchdog recovery via `*self = fresh` in `reset`. Declaration order is what
// preserves that sequence now (drop order follows declaration; an offset_of assert cannot
// pin it — repr(Rust) may lay memory out in any order).
/// CPU CSC paths only: swscale context converting the captured packed source into
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
/// worker's GPU convert delivers ready CUDA frames).
sws_csc: Option<AvSwsContext>,
enc: encoder::video::Encoder,
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
/// Mutating it in place across frames is sound only because the encoder is opened with
@@ -201,12 +210,6 @@ pub struct NvencEncoder {
frame: Option<VideoFrame>,
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
cuda: Option<CudaHw>,
/// CPU CSC paths only: swscale context converting the captured packed source into
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
sws_csc: Option<*mut ffi::SwsContext>,
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
want_444: bool,
src_format: PixelFormat,
@@ -228,7 +231,7 @@ pub struct NvencEncoder {
args: OpenArgs,
}
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` a raw `SwsContext`; the encoder lives on a single
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` an owned `SwsContext`; the encoder lives on a single
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
@@ -610,14 +613,13 @@ impl NvencEncoder {
);
}
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
// retry, which recurses into `Self::open`, and the plain error return). Creating the
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
// so this placement makes the leak unrepresentable rather than merely unlikely.
// Built HERE, below the fallible encoder open, NOT above it — historically because the
// context's only free was `Drop for NvencEncoder`, which needs a CONSTRUCTED `Self` that
// does not exist on `open`'s early returns; creating it above them leaked one per failed
// attempt, and `open_nvenc_probed`'s EINVAL bitrate ladder calls `open` up to ~10 times.
// The owned `AvSwsContext` now frees itself on any exit, but the placement stays: it
// documents the dependency on the post-open `nvenc_pixel`, and there is no reason to
// build a context an early return would just throw away.
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
@@ -642,10 +644,10 @@ impl NvencEncoder {
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
// null-checked below.
// defaults" (documented as accepted). No Rust memory is borrowed; ownership of the
// returned context passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe {
ffi::sws_getContext(
AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
@@ -656,11 +658,11 @@ impl NvencEncoder {
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
))
};
if sws.is_null() {
let Some(sws) = sws else {
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
}
};
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
@@ -680,7 +682,16 @@ impl NvencEncoder {
SWS_CS_ITU709
});
let dst_range = i32::from(full_range_444);
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
ffi::sws_setColorspaceDetails(
sws.as_ptr(),
cs,
1,
cs,
dst_range,
0,
1 << 16,
1 << 16,
);
}
}
Some(sws)
@@ -694,10 +705,10 @@ impl NvencEncoder {
Some(VideoFrame::new(nvenc_pixel, width, height))
};
Ok(NvencEncoder {
sws_csc,
enc,
frame,
cuda: cuda_hw,
sws_csc,
want_444,
src_format: format,
width,
@@ -840,7 +851,7 @@ impl NvencEncoder {
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
// conversion at all — just a row copy honouring the destination stride.
if let Some(sws) = self.sws_csc {
if let Some(sws) = self.sws_csc.as_ref().map(AvSwsContext::as_ptr) {
let frame = self
.frame
.as_mut()
@@ -929,27 +940,23 @@ impl NvencEncoder {
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
// (`make_current()?`), the precondition for the device-pointer copies below.
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
// failure we free `f` and bail.
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
// * `f` is an owned `AvFrame` — every exit below (bail, copy error, success) drops it
// exactly once, releasing its ref on the pooled surface. `av_hwframe_get_buffer` fills
// it with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`).
// * For NV12 we read `data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the live frame, valid for the surface
// dims ffmpeg allocated — and pass them to the cuda copy helpers, which device→device
// copy `buf` (the imported `DeviceBuffer`, owned by the caller and live for this call)
// into the surface.
// * `avcodec_send_frame` takes its own ref of the pooled surface, so the drop afterwards
// is the sole owning free. Single-threaded encoder → no race.
unsafe {
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
let f = AvFrame::alloc().context("av_frame_alloc failed")?;
// Pooled CUDA surface: sets format, width/height, data[0]/linesize[0], buf[0] and
// hw_frames_ctx. Reused across frames (the pool recycles), keeping NVENC's
// registration cache warm.
let r = ffi::av_hwframe_get_buffer(frames_ref, f, 0);
let r = ffi::av_hwframe_get_buffer(frames_ref, f.as_ptr(), 0);
if r < 0 {
ffi::av_frame_free(&mut f);
bail!("av_hwframe_get_buffer(CUDA) failed ({r})");
}
// NV12 surfaces are two-plane (Y in data[0], interleaved UV in data[1]); YUV444
@@ -960,41 +967,36 @@ impl NvencEncoder {
let copy_res = if buf.yuv444 {
let dsts = core::array::from_fn(|i| {
(
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr,
(*f).linesize[i] as usize,
(*f.as_ptr()).data[i] as pf_zerocopy::cuda::CUdeviceptr,
(*f.as_ptr()).linesize[i] as usize,
)
});
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
} else if self.want_444 {
ffi::av_frame_free(&mut f);
bail!(
"4:4:4 session but the zero-copy frame is not YUV444 (LINEAR/gamescope \
capture has no GPU 4:4:4 convert) unset PUNKTFUNK_ZEROCOPY to use the \
CPU 4:4:4 path on this compositor"
);
} else if buf.is_nv12() {
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let y_pitch = (*f).linesize[0] as usize;
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f).linesize[1] as usize;
let y_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let y_pitch = (*f.as_ptr()).linesize[0] as usize;
let uv_ptr = (*f.as_ptr()).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f.as_ptr()).linesize[1] as usize;
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
} else {
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f).linesize[0] as usize;
let dst_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f.as_ptr()).linesize[0] as usize;
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
};
if let Err(e) = copy_res {
ffi::av_frame_free(&mut f);
return Err(e).context("copy imported buffer into NVENC surface");
}
(*f).pts = pts;
(*f).pict_type = if idr {
copy_res.context("copy imported buffer into NVENC surface")?;
(*f.as_ptr()).pts = pts;
(*f.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f);
ffi::av_frame_free(&mut f);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(CUDA) failed ({r})");
}
@@ -1003,16 +1005,9 @@ impl NvencEncoder {
}
}
impl Drop for NvencEncoder {
fn drop(&mut self) {
if let Some(sws) = self.sws_csc.take() {
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
unsafe { ffi::sws_freeContext(sws) };
}
}
}
// No `Drop` for `NvencEncoder`: `sws_csc` (`Option<AvSwsContext>`) frees itself, and as field #1
// it does so ahead of `enc`/`frame`/`cuda` — the same sequence the hand-written `Drop` performed
// (see the field-order note on the struct).
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
/// an encoder open it *expects* to fail.
+74 -60
View File
@@ -63,8 +63,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
@@ -1654,7 +1652,7 @@ impl NvencCudaEncoder {
return Err(nvenc_status::call_err(
"register_resource (CUDADEVICEPTR)",
e,
))
));
}
}
self.ring.push(RingSlot {
@@ -2781,6 +2779,20 @@ mod tests {
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
use pf_zerocopy::cuda::DeviceBuffer;
/// Env knob for the `#[ignore]`d hardware spikes, which every caller's doc says to run ALONE
/// with `--test-threads=1` (they mutate process env and own the GPU).
fn set_env(key: &str, val: impl AsRef<std::ffi::OsStr>) {
// SAFETY: only reached from the manually-run `--test-threads=1` hardware tests, so no
// other thread exists in this process to read or write the environment concurrently.
unsafe { std::env::set_var(key, val) };
}
/// [`set_env`]'s companion; the same single-threaded-run contract.
fn remove_env(key: &str) {
// SAFETY: as `set_env` — single-threaded manual test run, no concurrent env access.
unsafe { std::env::remove_var(key) };
}
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
@@ -3317,8 +3329,8 @@ mod tests {
// Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the
// switch below is a real change rather than a no-op.
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -3430,8 +3442,8 @@ mod tests {
}
enc.flush().ok();
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that
@@ -3470,7 +3482,7 @@ mod tests {
const SETTLE: u32 = 16;
let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
// Separate buffers rotated per frame, so identical content can't let the encoder
// skip-code everything and erase the difference we are trying to measure.
@@ -3484,7 +3496,7 @@ mod tests {
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (u128, u128, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split);
set_env("PUNKTFUNK_SPLIT_ENCODE", open_split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -3556,9 +3568,15 @@ mod tests {
println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000);
println!(" (early = first half of the measured window, late = second half)");
println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU");
println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU");
println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU");
println!(
" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"
);
println!(
" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"
);
println!(
" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"
);
if c_early > c_late + c_late / 8 {
println!(
" ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \
@@ -3585,8 +3603,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
let _ = (a_bytes, b_bytes, c_bytes);
}
@@ -3620,8 +3638,8 @@ mod tests {
// Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU
// advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from.
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -3666,7 +3684,7 @@ mod tests {
"S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \
flip the arbitration reduces to S1a's plain split switch here."
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
return;
}
@@ -3717,7 +3735,7 @@ mod tests {
}
enc.flush().ok();
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
}
/// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was
@@ -3757,12 +3775,12 @@ mod tests {
// produced a spurious "D5 REFUTED" on the first run of this test.
let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) {
match split {
Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v),
None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"),
Some(v) => set_env("PUNKTFUNK_SPLIT_ENCODE", v),
None => remove_env("PUNKTFUNK_SPLIT_ENCODE"),
}
match subframe {
Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v),
None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"),
Some(v) => set_env("PUNKTFUNK_NVENC_SUBFRAME", v),
None => remove_env("PUNKTFUNK_NVENC_SUBFRAME"),
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
@@ -3843,8 +3861,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use
@@ -3872,13 +3890,13 @@ mod tests {
const WARMUP: u32 = 8;
const MEASURED: u32 = 24;
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
// → (requested mode, mode actually opened, p50 µs, engines the driver reports)
let run = |split: &str| -> (u32, u128, i32) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -3942,11 +3960,7 @@ mod tests {
};
println!(
" req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]",
if honoured {
"HONOURED"
} else {
"FELL BACK"
}
if honoured { "HONOURED" } else { "FELL BACK" }
);
}
println!(
@@ -3954,8 +3968,8 @@ mod tests {
HONOURED but no faster than DISABLE was accepted and did nothing."
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
@@ -3977,9 +3991,9 @@ mod tests {
const H: u32 = 2160;
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
set_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
@@ -4047,8 +4061,8 @@ mod tests {
max_forced_split_mode(enc_engines)
);
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
// The verdict cache is process-global: leaving this session's result in it would steer
// every later test that opens the same config with the split env unset (the D5 legs do
// exactly that).
@@ -4088,14 +4102,14 @@ mod tests {
})
.unwrap_or((3840, 2160, 60));
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here
// (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args).
let frames: Vec<CapturedFrame> = (0..4).map(|i| rgb10_frame(w, h, i)).collect();
let run = |split: &str| -> (u128, u8, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::X2Rgb10,
@@ -4159,8 +4173,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind
@@ -4192,7 +4206,7 @@ mod tests {
})
.unwrap_or((3840, 2160, 60));
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low
// bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every
@@ -4209,7 +4223,7 @@ mod tests {
let frames: Vec<CapturedFrame> =
(0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect();
let run = |split: &str| -> (u128, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -4252,8 +4266,8 @@ mod tests {
);
}
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
@@ -4660,12 +4674,12 @@ mod tests {
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
}
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
set_env("PUNKTFUNK_NVENC_SLICES", "4");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "1");
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
@@ -4772,8 +4786,8 @@ mod tests {
const W: u32 = 1920;
const H: u32 = 1080;
// Defaults under test — make sure another test's knobs aren't leaking in.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -4868,8 +4882,8 @@ mod tests {
const W: u32 = 1920;
const H: u32 = 1080;
// The ceiling under test is the negotiated one, not the operator override.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
@@ -4913,16 +4927,16 @@ mod tests {
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
}
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
set_env("PUNKTFUNK_NVENC_SLICES", "1");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
@@ -4945,8 +4959,8 @@ mod tests {
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
// disarms and the plain poll path carries the session.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
remove_env("PUNKTFUNK_NVENC_SLICES");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
+86 -118
View File
@@ -19,8 +19,6 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -36,8 +34,8 @@ use std::ptr;
use std::sync::{Mutex, OnceLock};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, AvFrame,
AvSwsContext, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -546,8 +544,13 @@ impl VaapiHw {
struct CpuInner {
enc: encoder::video::Encoder,
hw: VaapiHw,
sws: *mut ffi::SwsContext,
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src)
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `nv12` BEFORE
// `sws` — the reverse of the old declaration order — and field-DECLARATION order is what
// preserves that now (drop order follows declaration; an offset_of assert cannot pin it,
// repr(Rust) may lay memory out in any order).
/// Reusable software NV12/P010 staging frame (swscale dst → upload src).
nv12: AvFrame,
sws: AvSwsContext,
src_format: PixelFormat,
width: u32,
height: u32,
@@ -602,10 +605,10 @@ impl CpuInner {
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
// just below.
// memory is borrowed — only by-value ints/enums — and ownership of the returned context
// passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe {
ffi::sws_getContext(
AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
@@ -616,16 +619,15 @@ impl CpuInner {
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
))
};
if sws.is_null() {
let Some(sws) = sws else {
bail!(
"sws_getContext(RGB→{})",
if ten_bit { "P010" } else { "NV12" }
);
}
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
// check immediately preceding returned false). The coefficient table from
};
// SAFETY: `sws` is the live owned context from above. The coefficient table from
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
// libswscale static const valid for the whole process, reused here for both the inverse
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
@@ -637,32 +639,22 @@ impl CpuInner {
} else {
SWS_CS_ITU709
});
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
ffi::sws_setColorspaceDetails(sws.as_ptr(), cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
}
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
// unique fresh pointer, so none of these writes alias anything.
let nv12 = unsafe {
let f = ffi::av_frame_alloc();
if f.is_null() {
ffi::sws_freeContext(sws);
bail!("av_frame_alloc(staging) failed");
}
(*f).format = staging_av as c_int;
(*f).width = width as c_int;
(*f).height = height as c_int;
if ffi::av_frame_get_buffer(f, 0) < 0 {
let mut f = f;
ffi::av_frame_free(&mut f);
ffi::sws_freeContext(sws);
let nv12 = AvFrame::alloc().context("av_frame_alloc(staging) failed")?;
// SAFETY: writing the plain `format`/`width`/`height` fields through the owned frame's
// pointer stays inside its allocation (sole owner, not yet shared).
// `av_frame_get_buffer` allocates backing storage for those dims/format; on failure the
// owned `nv12` (and the `sws` above it) simply drop — the hand-written unwind this
// replaced had to free both by hand on every branch.
unsafe {
(*nv12.as_ptr()).format = staging_av as c_int;
(*nv12.as_ptr()).width = width as c_int;
(*nv12.as_ptr()).height = height as c_int;
if ffi::av_frame_get_buffer(nv12.as_ptr(), 0) < 0 {
bail!("av_frame_get_buffer(staging) failed");
}
f
};
}
tracing::info!(
encoder = codec.vaapi_name(),
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
@@ -671,8 +663,8 @@ impl CpuInner {
Ok(CpuInner {
enc,
hw,
sws,
nv12,
sws,
src_format: format,
width,
height,
@@ -693,49 +685,43 @@ impl CpuInner {
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
// `self.sws` is the owned context built in `open`; it writes into `self.nv12` (an owned
// frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `hwf` is an owned `AvFrame` — every exit below drops it exactly once, releasing its ref
// on the pooled VAAPI surface. `av_hwframe_get_buffer` pulls that surface from the live
// non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads the staged NV12 into
// it. `avcodec_send_frame` takes its own ref, so the drop afterwards is the sole owning
// free. The encoder runs only on this thread (see `unsafe impl Send`), so no
// aliasing/data race.
unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale(
self.sws,
self.sws.as_ptr(),
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.nv12).data.as_ptr(),
(*self.nv12).linesize.as_ptr(),
(*self.nv12.as_ptr()).data.as_ptr(),
(*self.nv12.as_ptr()).linesize.as_ptr(),
) < 0
{
bail!("sws_scale RGB→NV12 failed");
}
let mut hwf = ffi::av_frame_alloc();
if hwf.is_null() {
bail!("av_frame_alloc(hw) failed");
}
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
ffi::av_frame_free(&mut hwf);
let hwf = AvFrame::alloc().context("av_frame_alloc(hw) failed")?;
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf.as_ptr(), 0) < 0 {
bail!("av_hwframe_get_buffer(VAAPI) failed");
}
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 {
ffi::av_frame_free(&mut hwf);
if ffi::av_hwframe_transfer_data(hwf.as_ptr(), self.nv12.as_ptr(), 0) < 0 {
bail!("av_hwframe_transfer_data(→VAAPI) failed");
}
(*hwf).pts = pts;
(*hwf).pict_type = if idr {
(*hwf.as_ptr()).pts = pts;
(*hwf.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf);
ffi::av_frame_free(&mut hwf);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
@@ -744,24 +730,10 @@ impl CpuInner {
}
}
impl Drop for CpuInner {
fn drop(&mut self) {
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
// irrelevant to soundness.
unsafe {
if !self.nv12.is_null() {
ffi::av_frame_free(&mut self.nv12);
}
if !self.sws.is_null() {
ffi::sws_freeContext(self.sws);
}
}
}
}
// No `Drop` for `CpuInner`: `nv12` (`AvFrame`) and `sws` (`AvSwsContext`) free themselves, in
// field-declaration order — the same nv12-then-sws sequence the hand-written `Drop` performed
// (see the field-order note on the struct). The encoder holds its own `av_buffer_ref`'d
// device/frames copies, so their order against `enc`/`hw` is irrelevant to soundness.
// ---------------------------------------------------------------------------------------------
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode.
@@ -1043,16 +1015,20 @@ impl DmabufInner {
// whole synchronous `submit`; we describe one object/layer/plane from its
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
// * `drm`/`nv12` are owned `AvFrame`s — every exit drops each exactly once (the
// hand-placed frees this replaced were branch-clean, but only by inspection). We set
// `drm`'s scalar fields and `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref
// of the live owned ctx).
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
// buffersrc; KEEP_REF keeps our own `drm` ref, dropped explicitly right after the push
// (the same point the hand-written free sat, kept so the descriptor's release timing
// across the pull does not change). We pull the converted surface with
// `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the dmabuf (owned by
// the caller) is read while still valid. `nv12` is sent into the live owned `self.enc`
// (takes its own ref) and dropped. Single-threaded encoder → no race.
unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
@@ -1077,21 +1053,18 @@ impl DmabufInner {
desc.layers[0].planes[0].offset = dmabuf.offset as isize;
desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
let mut drm = ffi::av_frame_alloc();
if drm.is_null() {
bail!("av_frame_alloc(drm) failed");
}
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm).width = self.width as c_int;
(*drm).height = self.height as c_int;
let drm = AvFrame::alloc().context("av_frame_alloc(drm) failed")?;
(*drm.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm.as_ptr()).width = self.width as c_int;
(*drm.as_ptr()).height = self.height as c_int;
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
(*drm.as_ptr()).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm.as_ptr()).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
(*drm.as_ptr()).data[0] = Box::into_raw(desc) as *mut u8;
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns).
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
@@ -1102,8 +1075,8 @@ impl DmabufInner {
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
}
(*drm).buf[0] = ffi::av_buffer_create(
(*drm).data[0],
(*drm.as_ptr()).buf[0] = ffi::av_buffer_create(
(*drm.as_ptr()).data[0],
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
Some(free_desc),
ptr::null_mut(),
@@ -1113,45 +1086,40 @@ impl DmabufInner {
// Push through hwmap → scale_vaapi; pull the NV12 surface back out.
let r = ffi::av_buffersrc_add_frame_flags(
self.src,
drm,
drm.as_ptr(),
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
);
ffi::av_frame_free(&mut drm);
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
// deliberately NOT counted: that one is the encoder stalling, which the in-place
// rebuild above us exists to recover, and disabling zero-copy over it would be a
// permanent penalty for a transient fault.
drop(drm); // release our ref where the hand-written free sat (see the SAFETY note)
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
// deliberately NOT counted: that one is the encoder stalling, which the in-place
// rebuild above us exists to recover, and disabling zero-copy over it would be a
// permanent penalty for a transient fault.
if r < 0 {
let e = format!("av_buffersrc_add_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}");
}
t_push = t0.elapsed();
let mut nv12 = ffi::av_frame_alloc();
if nv12.is_null() {
bail!("av_frame_alloc(nv12) failed");
}
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
let nv12 = AvFrame::alloc().context("av_frame_alloc(nv12) failed")?;
let r = ffi::av_buffersink_get_frame(self.sink, nv12.as_ptr());
if r < 0 {
ffi::av_frame_free(&mut nv12);
let e = format!("av_buffersink_get_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}");
}
pf_zerocopy::note_raw_dmabuf_import_ok();
t_pull = t0.elapsed() - t_push;
(*nv12).pts = pts;
(*nv12).pict_type = if idr {
(*nv12.as_ptr()).pts = pts;
(*nv12.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12);
ffi::av_frame_free(&mut nv12);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
+5 -5
View File
@@ -17,7 +17,7 @@
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
use super::*;
use crate::vk_util::{find_mem, make_plain_image, make_view};
use crate::vk_util::{ext_advertised, find_mem, make_plain_image, make_view};
use anyhow::{bail, Result};
use ash::vk;
use std::ffi::c_void;
@@ -53,10 +53,10 @@ pub(super) unsafe fn probe_rgb_direct(
let Ok(exts) = instance.enumerate_device_extension_properties(pd) else {
return Err("probe-failed(ext-enum)");
};
if !exts
.iter()
.any(|e| std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == vrgb::EXTENSION_NAME)
{
// Route through `vk_util::ext_advertised` rather than open-coding the walk a second time:
// this copy used the same unbounded `CStr::from_ptr` and had the same read-past-the-array
// hazard on a driver that fills all VK_MAX_EXTENSION_NAME_SIZE bytes without a NUL.
if !ext_advertised(&exts, vrgb::EXTENSION_NAME) {
return Err("no-ext(mesa<26.0-or-no-efc)");
}
// 2. Feature bit.
+32 -5
View File
@@ -19,11 +19,15 @@ use pf_frame::PixelFormat;
/// barriers were used without the extension ever being enabled; `pf-presenter/dmabuf.rs` is the
/// in-repo precedent that enables it).
pub(super) fn ext_advertised(exts: &[vk::ExtensionProperties], name: &std::ffi::CStr) -> bool {
exts.iter().any(|e| {
// SAFETY: `extension_name` is a spec-guaranteed NUL-terminated UTF-8 byte array inside
// the driver-filled `VkExtensionProperties` (VK_MAX_EXTENSION_NAME_SIZE bound).
unsafe { std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == name }
})
// `extension_name_as_c_str()` is ash's BOUNDED accessor: it stops at
// `VK_MAX_EXTENSION_NAME_SIZE` and returns `Err` when the array holds no NUL, so a
// malformed driver entry is a non-match rather than a read past the array. The previous
// `CStr::from_ptr(e.extension_name.as_ptr())` had no in-Rust bound at all — its SAFETY
// comment asserted the spec guarantee instead of enforcing it, so a driver that filled all
// 256 bytes without a terminator ran the walk into the NEXT `ExtensionProperties` and, on
// the last element, past the allocation. Same accessor `pyrowave.rs` already uses for the
// identical job. No unsafe, no unchecked read, same answer on every well-formed driver.
exts.iter().any(|e| e.extension_name_as_c_str() == Ok(name))
}
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
@@ -453,6 +457,29 @@ mod tests {
));
}
/// A driver entry with NO terminator anywhere in `extension_name` must be a non-match, not a
/// read past the array.
///
/// This is the case the old `CStr::from_ptr(e.extension_name.as_ptr())` could not survive:
/// with every one of VK_MAX_EXTENSION_NAME_SIZE bytes non-NUL it walked into the NEXT
/// `ExtensionProperties`, and on the LAST element past the allocation entirely. The old test
/// only ever built well-formed, NUL-terminated entries, so it proved nothing about the bound
/// — which is why the hazard survived a SAFETY comment that asserted the spec guarantee
/// rather than enforcing it.
#[test]
fn ext_advertised_rejects_unterminated_name_without_overrunning() {
let mut bad = ash::vk::ExtensionProperties::default();
bad.extension_name.fill(b'A' as std::ffi::c_char);
// Deliberately LAST, so an unbounded walk would leave the whole array.
let exts = [ash::vk::ExtensionProperties::default(), bad];
assert!(!super::ext_advertised(
&exts,
ash::ext::queue_family_foreign::NAME
));
// And a name that is a prefix of the garbage still must not match.
assert!(!super::ext_advertised(&exts, c"AAAA"));
}
use super::*;
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
+4 -7
View File
@@ -41,9 +41,6 @@
//! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a
//! small buffer set).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{Context, Result};
use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat};
use pf_zerocopy::ipc;
@@ -428,11 +425,11 @@ pub fn run_from_args(args: &[String]) -> Result<()> {
/// the priority intent (it arrives explicitly in `Hello`) and the worker path itself (nothing here
/// spawns a worker, and a stale value in a core dump is just noise).
fn sanitize_env() {
// Single-threaded — this runs before anything in this process creates a thread, which is the
// one situation where mutating the environment is sound (the `getenv` race the house rule
// about `set_var` is about needs a second thread).
for k in ["PYROWAVE_QUEUE_PRIORITY", "PUNKTFUNK_ENCODE_WORKER"] {
std::env::remove_var(k);
// SAFETY: single-threaded — this runs before anything in this process creates a thread,
// which is the one situation where mutating the environment is sound (the `getenv` race
// `remove_var`'s contract is about needs a second thread).
unsafe { std::env::remove_var(k) };
}
}
+39 -20
View File
@@ -5,12 +5,16 @@
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
// calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// UNSAFE-LINT EXEMPTION REMOVED — the old fence rationale ("raw nvEncodeAPI entry-table calls
// almost line for line") was false for this file: it makes ZERO FFI calls. Its unsafe surface is
// C-union access whose soundness hangs entirely on which codec arm is active, and the 4:4:4 note
// below records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that per-operation
// visibility makes findable. So this file runs the strictest discipline in the crate: every
// union READ, borrow, or bitfield-setter call sits in its own `unsafe {}` block naming the codec
// guard it relies on. (Plain union-arm field WRITES are safe by language rule — writing an arm
// cannot itself be UB; the hazard is the mismatched read — so those stay bare, guarded by the
// same codec matches.)
#![deny(clippy::multiple_unsafe_ops_per_block)]
use super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
@@ -694,10 +698,9 @@ mod tests {
};
assert_eq!(cfg.profileGUID, nv::NV_ENC_HEVC_PROFILE_FREXT_GUID);
// SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
unsafe {
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3);
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2);
}
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3) };
// SAFETY: same HEVC arm as above.
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2) };
}
#[test]
@@ -1210,6 +1213,8 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
// are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
match c.codec {
Codec::H265 => {
// Plain union-arm writes are safe by language rule (the hazard is a mismatched
// READ later); the match on `c.codec` keeps the arm honest.
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
@@ -1264,21 +1269,29 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
}
if want_444 && c.codec == Codec::H265 {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
// SAFETY: HEVC session (guarded by `c.codec == Codec::H265` on this branch), so
// `hevcConfig` is the active arm.
unsafe { cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3) };
if c.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
// SAFETY: same HEVC arm, same branch guard. (Main 4:4:4 10)
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
}
} else if c.bit_depth == 10 {
match c.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active arm.
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8);
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm.
unsafe { cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2) };
// SAFETY: same AV1 arm, same match guard.
unsafe {
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8)
};
}
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
@@ -1306,7 +1319,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
};
match c.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active
// arm; the borrow is dropped before any other union access.
let vui = unsafe { &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters };
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
@@ -1315,7 +1330,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
// SAFETY: H.264 session (matched on `c.codec`), so `h264Config` is the active
// arm; the borrow is dropped before any other union access.
let vui = unsafe { &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters };
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
@@ -1324,7 +1341,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm;
// the borrow is dropped before any other union access.
let av1 = unsafe { &mut cfg.encodeCodecConfig.av1Config };
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
-2
View File
@@ -12,8 +12,6 @@
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{EncodedFrame, Encoder};
use anyhow::{bail, ensure, Context, Result};
+10 -28
View File
@@ -49,8 +49,6 @@
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
@@ -2239,38 +2237,22 @@ impl Encoder for AmfEncoder {
mod tests {
use super::*;
/// The mirrored `AMFVariantStruct` must match the C layout: 4-byte tag + 4 padding + 16-byte
/// union = 24 bytes, align 8, payload at offset 8 (it is passed BY VALUE across the FFI).
// The LAYOUT of `AmfVariant`, `AmfGuid` and `AmfHdrMetadata` is no longer asserted here.
// Those checks moved to `const _: ()` assertions beside the mirrors themselves in
// `amf_sys.rs`, together with per-slot offset guards for the five vtables. As `#[test]`s
// they only ran when someone ran pf-encode's tests, on Windows, with AMF enabled — never in
// a release build, which is precisely where a mis-mirrored `AMFVariantStruct` would do its
// damage. As const assertions they hold on EVERY build that compiles the module.
//
// What stays here is the part a layout check cannot express: that the little-endian packing
// of the union payload matches what the C side will read out of those bytes.
#[test]
fn variant_layout_matches_c() {
assert_eq!(std::mem::size_of::<AmfVariant>(), 24);
assert_eq!(std::mem::align_of::<AmfVariant>(), 8);
assert_eq!(std::mem::offset_of!(AmfVariant, payload), 8);
fn variant_payload_packing_matches_c() {
let v = AmfVariant::from_rate(60, 1);
assert_eq!(v.payload[0], 60u64 | (1u64 << 32));
assert_eq!(AmfVariant::from_i64(-1).payload[0], u64::MAX);
}
/// `AMFGuid` is the flattened Win32-GUID layout (16 bytes).
#[test]
fn guid_layout_matches_c() {
assert_eq!(std::mem::size_of::<sys::AmfGuid>(), 16);
}
/// `AMFHDRMetadata` (components/ColorSpace.h): 8×u16 + 2×u32 + 2×u16 = 28 bytes, no padding.
#[test]
fn hdr_metadata_layout_matches_c() {
assert_eq!(std::mem::size_of::<sys::AmfHdrMetadata>(), 28);
assert_eq!(
std::mem::offset_of!(sys::AmfHdrMetadata, max_mastering_luminance),
16
);
assert_eq!(
std::mem::offset_of!(sys::AmfHdrMetadata, max_content_light_level),
24
);
}
/// A representative HDR10 grade for the live tests (BT.2020 primaries, 1000-nit mastering)
/// in [`HdrMeta`]'s ST.2086 wire units/order (primaries G, B, R).
fn sample_hdr_meta() -> punktfunk_core::quic::HdrMeta {
+120
View File
@@ -409,6 +409,126 @@ pub struct AmfBufferVtbl {
pub remove_observer_buffer: Slot,
}
// -- Layout guards ---------------------------------------------------------------------------
//
// THE CONTRACT, STATED ONCE. Everything above is a hand-written mirror of a C type this crate
// does not own and cannot include. Two classes of drift are possible and NEITHER fails to
// compile on its own:
//
// 1. A POD passed by value (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`) whose field offsets
// disagree with the C struct. The runtime then reads a tag or a payload out of the wrong
// bytes — `AmfVariant` crosses the FFI by value on EVERY `SetProperty`.
// 2. A vtable slot inserted, removed or reordered. `amf.rs` dispatches BY POSITION through
// these mirrors, so a shifted slot calls an arbitrary function pointer through a
// mismatched signature. There is no compile error, no runtime signal, and the failure is
// whatever the neighbouring AMF entry point happens to do with our arguments.
//
// `AMF_MIN_VERSION` does not defend against either: it checks a version NUMBER, not a layout,
// and it is a floor with no ceiling. The assertions below are the actual defence. They are
// `const _: ()` rather than `#[cfg(test)]` deliberately — the three POD checks below used to
// live only in `amf.rs`'s test module, which means they were verified only when someone ran
// pf-encode's tests, on Windows, with AMF enabled, and NEVER in a release build. This is the
// same hole `a8dd348b` closed for the cuda.h mirrors; it was missed here.
//
// Every slot index below was counted against the vtable declarations above. A slot is asserted
// when `amf.rs` calls it — those are the ones whose displacement is directly exploitable — plus
// the total size of each table, which catches an insertion PAST the last called slot (invisible
// to a per-slot check, but still a sign the mirror has drifted from the header).
/// One vtable slot. Every mirrored table is a flat array of these, so an offset in bytes is
/// always `index * SLOT`.
const SLOT: usize = core::mem::size_of::<Slot>();
/// Byte offset of vtable slot `i`. A `const fn` rather than a bare `i * SLOT` expression because
/// clippy's `erasing_op`/`identity_op` reject `0 * SLOT` and `1 * SLOT` under the `-D warnings`
/// the Windows CI leg runs with — and writing those two as bare `0` and `SLOT` would be the one
/// place the slot INDEX stops being visible, which is the entire readability of these assertions.
const fn slot(i: usize) -> usize {
i * SLOT
}
// Every slot is a plain code pointer, so all five tables are pointer-sized-array-shaped. If this
// ever fails, the tables are not flat arrays any more and every offset below is meaningless.
const _: () = assert!(SLOT == core::mem::size_of::<usize>());
const _: () = assert!(core::mem::align_of::<Slot>() == core::mem::align_of::<usize>());
// -- PODs crossing the FFI by value --
// `AMFVariantStruct`: 4-byte tag + 4 padding + 16-byte union = 24 bytes, payload at 8.
const _: () = assert!(core::mem::size_of::<AmfVariant>() == 24);
const _: () = assert!(core::mem::align_of::<AmfVariant>() == 8);
const _: () = assert!(core::mem::offset_of!(AmfVariant, payload) == 8);
// `AMFGuid`: the flattened Win32 GUID.
const _: () = assert!(core::mem::size_of::<AmfGuid>() == 16);
const _: () = assert!(core::mem::align_of::<AmfGuid>() == 4);
// `AMFHDRMetadata` (components/ColorSpace.h): 8×u16 + 2×u32 + 2×u16 = 28 bytes, no padding.
const _: () = assert!(core::mem::size_of::<AmfHdrMetadata>() == 28);
const _: () = assert!(core::mem::offset_of!(AmfHdrMetadata, max_mastering_luminance) == 16);
const _: () = assert!(core::mem::offset_of!(AmfHdrMetadata, max_content_light_level) == 24);
// -- AMFFactory (7 slots) — `create_context` 0, `create_component` 1 --
const _: () = assert!(core::mem::size_of::<AmfFactoryVtbl>() == slot(7));
const _: () = assert!(core::mem::offset_of!(AmfFactoryVtbl, create_context) == slot(0));
const _: () = assert!(core::mem::offset_of!(AmfFactoryVtbl, create_component) == slot(1));
// -- AMFContext (55 slots) = AMFInterface(3) + AMFPropertyStorage(10) + AMFContext(42) --
const _: () = assert!(core::mem::size_of::<AmfContextVtbl>() == slot(55));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, terminate) == slot(13));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, init_dx11) == slot(18));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, alloc_buffer) == slot(43));
const _: () =
assert!(core::mem::offset_of!(AmfContextVtbl, create_surface_from_dx11_native) == slot(49));
// -- AMFComponent (28 slots) = AMFInterface(3) + PropertyStorage(10) + StorageEx(4) + Component(11) --
const _: () = assert!(core::mem::size_of::<AmfComponentVtbl>() == slot(28));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, set_property) == slot(3));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, init) == slot(17));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, terminate) == slot(19));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, drain) == slot(20));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, flush) == slot(21));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, submit_input) == slot(22));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, query_output) == slot(23));
// -- AMFData (23 slots) = AMFInterface(3) + AMFPropertyStorage(10) + AMFData(10) --
const _: () = assert!(core::mem::size_of::<AmfDataVtbl>() == slot(23));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, query_interface) == slot(2));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, set_property) == slot(3));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, get_property) == slot(4));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, set_pts) == slot(19));
// -- AMFBuffer (28 slots) = the AMFData prefix (23) + AMFBuffer(5) --
const _: () = assert!(core::mem::size_of::<AmfBufferVtbl>() == slot(28));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, get_size) == slot(24));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, get_native) == slot(25));
// -- The shared-prefix agreement --
// `AMFBuffer` derives from `AMFData`, and `create_surface_from_dx11_native` hands back an
// `AMFSurface*` that this module drives through the `AmfData` mirror on the strength of that
// single-inheritance prefix (see the comment on that slot). If the two mirrors ever disagree
// about where a shared slot lives, that reinterpretation is silently wrong — so assert the
// agreement rather than restating it in prose.
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, release) == core::mem::offset_of!(AmfBufferVtbl, release)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, set_property)
== core::mem::offset_of!(AmfBufferVtbl, set_property)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, get_property)
== core::mem::offset_of!(AmfBufferVtbl, get_property)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, set_pts) == core::mem::offset_of!(AmfBufferVtbl, set_pts)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, get_duration)
== core::mem::offset_of!(AmfBufferVtbl, get_duration)
);
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
+89 -119
View File
@@ -37,8 +37,6 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -61,8 +59,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -499,10 +497,14 @@ fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
struct SystemInner {
enc: encoder::video::Encoder,
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `sw_frame`
// before `sws`, and field-DECLARATION order is what preserves that now (an offset_of assert
// cannot pin this — repr(Rust) may reorder memory independently of declaration order, and
// drop order follows declaration).
/// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
sw_frame: *mut ffi::AVFrame,
/// swscale ctx for the BGRA→NV12 fallback (built lazily; null for the YUV-readback path).
sws: *mut ffi::SwsContext,
sw_frame: AvFrame,
/// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
sws: Option<AvSwsContext>,
/// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
staging: Option<ID3D11Texture2D>,
ctx: Option<ID3D11DeviceContext>,
@@ -549,26 +551,18 @@ impl SystemInner {
ptr::null_mut(),
)?
};
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
// `self.sw_frame` and freed exactly once in `Drop`.
let sw_frame = unsafe {
let f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc(sw) failed");
}
(*f).format = sw_av as c_int;
(*f).width = width as c_int;
(*f).height = height as c_int;
if ffi::av_frame_get_buffer(f, 0) < 0 {
let mut f = f;
ffi::av_frame_free(&mut f);
let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
// SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
// its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
// owned `sw_frame` simply drops (freed once, by the wrapper).
unsafe {
(*sw_frame.as_ptr()).format = sw_av as c_int;
(*sw_frame.as_ptr()).width = width as c_int;
(*sw_frame.as_ptr()).height = height as c_int;
if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
bail!("av_frame_get_buffer(sw) failed");
}
f
};
}
tracing::info!(
encoder = vendor.encoder_name(codec),
"{} encode active ({width}x{height}@{fps}, system-memory {} path)",
@@ -578,7 +572,7 @@ impl SystemInner {
Ok(SystemInner {
enc,
sw_frame,
sws: ptr::null_mut(),
sws: None,
staging: None,
ctx: None,
format,
@@ -634,13 +628,13 @@ impl SystemInner {
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
// (it references the frame's buffers itself).
unsafe {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
(*self.sw_frame.as_ptr()).pts = pts;
(*self.sw_frame.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame.as_ptr());
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
@@ -705,10 +699,10 @@ impl SystemInner {
let total = pitch.saturating_mul(h + h.div_ceil(2));
let mapped = std::slice::from_raw_parts(base, total);
let chroma_off = pitch * h;
let y_dst = (*self.sw_frame).data[0];
let y_stride = (*self.sw_frame).linesize[0] as usize;
let uv_dst = (*self.sw_frame).data[1];
let uv_stride = (*self.sw_frame).linesize[1] as usize;
let y_dst = (*self.sw_frame.as_ptr()).data[0];
let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
let uv_dst = (*self.sw_frame.as_ptr()).data[1];
let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
for y in 0..h {
let s = &mapped[y * pitch..y * pitch + row_bytes];
ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), row_bytes);
@@ -748,7 +742,7 @@ impl SystemInner {
let pitch = map.RowPitch as usize;
let h = self.height as usize;
let base = map.pData as *const u8;
self.ensure_sws(
let sws = self.ensure_sws(
pixel_to_av(Pixel::BGRA),
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709,
@@ -756,13 +750,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
);
ctx.Unmap(&staging, 0);
if r < 0 {
@@ -798,7 +792,7 @@ impl SystemInner {
let h = self.height as usize;
let base = map.pData as *const u8;
// RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited.
self.ensure_sws(
let sws = self.ensure_sws(
ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
SWS_CS_BT2020,
@@ -806,13 +800,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
);
ctx.Unmap(&staging, 0);
if r < 0 {
@@ -844,7 +838,7 @@ impl SystemInner {
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
unsafe {
self.ensure_sws(
let sws = self.ensure_sws(
pixel_to_av(sws_src(format)?),
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709,
@@ -852,13 +846,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
) < 0
{
bail!("sws_scale RGB→NV12 failed");
@@ -872,23 +866,24 @@ impl SystemInner {
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
///
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
/// (freed once in `Drop`).
/// (an owned `AvSwsContext`, freed by its own drop). Returns the borrowed pointer for the
/// caller's `sws_scale` — borrowed only, `self.sws` stays the owner.
fn ensure_sws(
&mut self,
src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat,
cs: c_int,
) -> Result<()> {
if !self.sws.is_null() {
return Ok(());
) -> Result<*mut ffi::SwsContext> {
if let Some(sws) = &self.sws {
return Ok(sws.as_ptr());
}
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
// null trio, and returns an owned context or null — which is checked before use, so
// `sws_setColorspaceDetails` and the store below only ever see a live one.
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
// process, and the call only reads it.
// null trio, and returns an owned context or null — `from_raw` rejects the null, so
// `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
// `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
// valid for the process, and the call only reads it.
let sws = unsafe {
let sws = ffi::sws_getContext(
let raw = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
@@ -900,36 +895,22 @@ impl SystemInner {
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
let Some(owned) = AvSwsContext::from_raw(raw) else {
bail!("sws_getContext(RGB→YUV) failed");
}
};
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws
ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
owned
};
self.sws = sws;
Ok(())
Ok(self.sws.insert(sws).as_ptr())
}
}
impl Drop for SystemInner {
fn drop(&mut self) {
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
// exclusively, so there is no double-free or use-after-free.
unsafe {
if !self.sw_frame.is_null() {
ffi::av_frame_free(&mut self.sw_frame);
}
if !self.sws.is_null() {
ffi::sws_freeContext(self.sws);
}
}
}
}
// No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
// themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
// `Drop` performed, pinned by the offset_of assert at the struct.
// ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
@@ -1214,32 +1195,29 @@ impl ZeroCopyInner {
}
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
// no double-free, no use-after-free.
// SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
// between the pool pull and the send, which as hand-placed frees previously leaked the
// frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
// the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
// is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
// that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
// null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
// SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
// pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
// on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
// For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
// `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
// the hand-written free did. `avcodec_send_frame` only internally refs the input frame,
// so the drops are the sole owning frees — no leak, no double-free, no use-after-free.
unsafe {
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
let mut d3d = ffi::av_frame_alloc();
if d3d.is_null() {
bail!("av_frame_alloc(d3d11) failed");
}
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
if r < 0 {
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
}
let dst_ptr = (*d3d).data[0] as *mut c_void;
let dst_index = (*d3d).data[1] as usize as u32;
let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
.ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?;
// GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
@@ -1249,58 +1227,50 @@ impl ZeroCopyInner {
self.ctx
.CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);
(*d3d).pts = pts;
(*d3d).pict_type = if idr {
(*d3d.as_ptr()).pts = pts;
(*d3d.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let send = match self.vendor {
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d),
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d.as_ptr()),
WinVendor::Qsv => {
// Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
let mut qsv = ffi::av_frame_alloc();
if qsv.is_null() {
ffi::av_frame_free(&mut d3d);
bail!("av_frame_alloc(qsv) failed");
}
let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
// matching the null check above it. The `Option` is what the raw pointer's
// "null means AMF" convention was already encoding.
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("QSV send path without a derived QSV frames context");
};
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
(*qsv.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
(*qsv.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
let r = ffi::av_hwframe_map(
qsv,
d3d,
qsv.as_ptr(),
d3d.as_ptr(),
ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
);
if r < 0 {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
}
(*qsv).pts = pts;
(*qsv).pict_type = (*d3d).pict_type;
let s = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv);
ffi::av_frame_free(&mut qsv);
s
(*qsv.as_ptr()).pts = pts;
(*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
// `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
// same point the hand-written `av_frame_free(&mut qsv)` did.
}
};
ffi::av_frame_free(&mut d3d);
if send < 0 {
bail!(
"avcodec_send_frame({}) failed ({send})",
self.vendor.label()
);
}
// `d3d` drops here (and on every early exit above), returning the pooled surface.
}
Ok(())
}
+14 -7
View File
@@ -39,8 +39,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
@@ -1618,7 +1616,9 @@ impl Encoder for NvencD3d11Encoder {
let frame = match &captured.payload {
FramePayload::D3d11(f) => f,
FramePayload::Cpu(_) => {
bail!("NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)")
bail!(
"NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)"
)
}
};
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
@@ -2884,8 +2884,12 @@ mod tests {
let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
// Isolate the split variable exactly as the Linux spike does.
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
// SAFETY: this `#[ignore]`d hardware spike is run alone (manual RTX-box run, one test),
// so no other thread exists to read or write the environment concurrently.
unsafe {
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
}
// SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`.
unsafe {
@@ -3009,8 +3013,11 @@ mod tests {
enc.flush().ok();
}
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
// SAFETY: as the set above — single-threaded manual test run, no concurrent env access.
unsafe {
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
}
}
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe

Some files were not shown because too many files have changed in this diff Show More