Compare commits

...
Author SHA1 Message Date
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 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
120 changed files with 1734 additions and 838 deletions
+269
View File
@@ -15,6 +15,17 @@
# fails if any crate carries a license outside the allowlist — the regression # 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 # guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.) # 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 # Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand. # change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]). # To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
@@ -44,6 +55,13 @@ on:
- 'about.toml' - 'about.toml'
- '.gitea/workflows/audit.yml' - '.gitea/workflows/audit.yml'
workflow_dispatch: 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: jobs:
cargo-audit: 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 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 about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml 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."
+9 -2
View File
@@ -111,6 +111,13 @@ jobs:
- name: Format - name: Format
run: cargo fmt --all --check 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) - name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings run: cargo clippy --workspace --all-targets --locked -- -D warnings
@@ -139,8 +146,8 @@ jobs:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates # `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 # 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 # 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 # `cargo build`, where warnings are not errors, so the `undocumented_unsafe_blocks` deny
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate — # (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 # was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.) # `default = ["pyrowave"]`, so the steps above already cover it.)
# #
+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 # 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 # 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` + # `// 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 # `undocumented_unsafe_blocks`); this step keeps them from regressing. wdk-probe is a
# toolchain-only probe crate and is excluded.) # toolchain-only probe crate, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
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 # 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 - 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 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 - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
+11
View File
@@ -101,6 +101,17 @@ repository = "https://git.unom.io/unom/punktfunk"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny" 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] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
+1 -1
View File
@@ -53,7 +53,7 @@
"clients" "clients"
], ],
"summary": "Unpair a client", "summary": "Unpair a client",
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Removing the last pairing also closes the GameStream ENet control port\n(UDP 47999), which is only bound while at least one pairing exists. Caveat: the nvhttp TLS\nlayer does 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", "operationId": "unpairClient",
"parameters": [ "parameters": [
{ {
@@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; 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) /// 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 /// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
@@ -41,7 +41,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
env: JNIEnv<'local>, env: JNIEnv<'local>,
_this: JObject<'local>, _this: JObject<'local>,
) -> jni::sys::jstring { ) -> 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) { match env.new_string(token) {
Ok(s) => s.into_raw(), Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(), Err(_) => JObject::null().into_raw(),
@@ -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. /// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle { pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
+7 -7
View File
@@ -8,7 +8,7 @@ use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring}; use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv; use jni::JNIEnv;
use super::{jni_guard, SessionHandle}; use super::{jni_guard, lock_recover, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature, /// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow` /// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
@@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; 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() { if guard.is_some() {
return; // already streaming return; // already streaming
} }
@@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; 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 return std::ptr::null_mut(); // not streaming → no stats
} }
let snap = h let snap = h
@@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; 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() { if guard.is_some() {
return; // already playing return; // already playing
} }
@@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; 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() { if let Some(m) = guard.as_ref() {
return m.session_id(); // already capturing — same stream, same session return m.session_id(); // already capturing — same stream, same session
} }
@@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
speaker != 0, speaker != 0,
) { ) {
Some(p) => { Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p); *lock_recover(&h.pad_audio) = Some(p);
1 1
} }
None => 0, None => 0,
@@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.mic.lock().unwrap().is_some()) jboolean::from(lock_recover(&h.mic).is_some())
}) })
} }
+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 slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
let url = String::from_utf16_lossy(slice); let url = String::from_utf16_lossy(slice);
tracing::debug!(%url, "link from another instance"); 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); return LRESULT(1);
} }
} }
-1
View File
@@ -15,7 +15,6 @@
//! (measure the path: probe burst → goodput / loss / recommended bitrate) //! (measure the path: probe burst → goodput / loss / recommended bitrate)
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof. // 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) // 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 // 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. // terminal's console at startup (see main), so their output is still visible when run from a shell.
+7
View File
@@ -10,6 +10,11 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern. // Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
#![allow(improper_ctypes)] #![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 // 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 // nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
// linted surface. // linted surface.
@@ -27,6 +32,8 @@ mod tests {
/// implementations — that's fine, MFXLoad itself must still succeed). /// implementations — that's fine, MFXLoad itself must still succeed).
#[test] #[test]
fn dispatcher_links_and_loads() { 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 { unsafe {
let loader = MFXLoad(); let loader = MFXLoad();
assert!(!loader.is_null(), "MFXLoad returned NULL"); assert!(!loader.is_null(), "MFXLoad returned NULL");
-7
View File
@@ -7,13 +7,6 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host //! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator). //! 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 anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as // The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
-1
View File
@@ -25,7 +25,6 @@
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof // 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 // 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. // (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy}; use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
-3
View File
@@ -9,9 +9,6 @@
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this //! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
//! module contains no capturer. //! 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}; 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 // The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
@@ -16,9 +16,6 @@
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so //! [`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. //! 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::{ use super::dxgi::{
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare, make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
VideoConverter, WinCaptureTarget, VideoConverter, WinCaptureTarget,
@@ -2,9 +2,6 @@
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost //! 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. //! 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::*; use super::*;
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects /// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
@@ -5,9 +5,6 @@
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the //! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
//! cursor forwarder, the wire, the client renderer) is shared. //! 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 super::*;
use pf_driver_proto::cursor::{ use pf_driver_proto::cursor::{
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET, CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
@@ -10,9 +10,6 @@
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely //! 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. //! 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 super::*;
use windows::core::s; use windows::core::s;
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST; use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
@@ -20,9 +20,6 @@
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs` //! `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. //! `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 super::*;
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER, DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
@@ -1,9 +1,6 @@
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the //! 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. //! 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::*; use super::*;
/// The display descriptor the capture loop follows: live HDR state + active resolution of the /// The display descriptor the capture loop follows: live HDR state + active resolution of the
@@ -33,9 +33,6 @@
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land //! 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. //! 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::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -25,9 +25,6 @@
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost //! ([`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. //! 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::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak}; use std::sync::{Arc, Mutex, Weak};
@@ -1,9 +1,6 @@
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms //! 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. //! 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::*; use super::*;
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the /// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
@@ -536,14 +533,47 @@ impl StallWatch {
suspects)" suspects)"
); );
} else { } 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!( tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()), period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated, os_correlated = correlated,
connected_inactive = %suspects, connected_inactive = %suspects,
rt_gpu_driver,
rt_gpu_host,
verdicts = %verdict_tally, verdicts = %verdict_tally,
classes = %class_tally, classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \ "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), \ connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \ display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \ 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 // 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 // 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. // instead of an argument precisely because nothing required one.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
mod au_dump; mod au_dump;
+2 -2
View File
@@ -17,8 +17,8 @@
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs //! (`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. //! 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). // Every `unsafe` block in this file carries a `// SAFETY:` proof; the deny enforcing it sits at
#![deny(clippy::undocumented_unsafe_blocks)] // the crate root (lib.rs), covering every backend.
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::{Arc, Mutex}; 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 //! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
//! platform split lives entirely behind [`start`]. //! 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::atomic::AtomicBool;
use std::sync::Arc; use std::sync::Arc;
-1
View File
@@ -11,7 +11,6 @@
//! capture hint, start banner. //! capture hint, start banner.
// Unsafe-proof program: every `unsafe {}` in the Skia/Vulkan overlay carries a `// SAFETY:` proof. // 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))] #[cfg(any(target_os = "linux", windows))]
mod anim; mod anim;
-1
View File
@@ -52,7 +52,6 @@
//! ([`dxva::as_bytes`] / [`dxva::slice_bytes`]), fenced behind a sealed trait //! ([`dxva::as_bytes`] / [`dxva::slice_bytes`]), fenced behind a sealed trait
//! that only this crate's `#[repr(C)]` PODs implement, and carrying a written //! that only this crate's `#[repr(C)]` PODs implement, and carrying a written
//! proof — enforced: //! proof — enforced:
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod config; pub mod config;
pub mod descriptors; pub mod descriptors;
+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 /// allocator returns on failure (so the `is_null` check every caller used to open-code happens
/// once, here). /// 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 /// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value — /// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it. /// 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 /// 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 /// 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. /// `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). //! 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 //! 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 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 super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -26,8 +24,8 @@ use std::os::raw::c_int;
use std::ptr; use std::ptr;
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709, apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_POINT, SWS_CS_ITU709, SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -193,6 +191,17 @@ struct OpenArgs {
} }
pub struct NvencEncoder { 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, enc: encoder::video::Encoder,
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path). /// 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 /// 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>, frame: Option<VideoFrame>,
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`). /// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
cuda: Option<CudaHw>, 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. /// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
want_444: bool, want_444: bool,
src_format: PixelFormat, src_format: PixelFormat,
@@ -228,7 +231,7 @@ pub struct NvencEncoder {
args: OpenArgs, 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. // 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` // 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. // 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 // Built HERE, below the fallible encoder open, NOT above it — historically because the
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED // context's only free was `Drop for NvencEncoder`, which needs a CONSTRUCTED `Self` that
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported // does not exist on `open`'s early returns; creating it above them leaked one per failed
// retry, which recurses into `Self::open`, and the plain error return). Creating the // attempt, and `open_nvenc_probed`'s EINVAL bitrate ladder calls `open` up to ~10 times.
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL // The owned `AvSwsContext` now frees itself on any exit, but the placement stays: it
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a // documents the dependency on the post-open `nvenc_pixel`, and there is no reason to
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return, // build a context an early return would just throw away.
// so this placement makes the leak unrepresentable rather than merely unlikely.
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's // 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 // 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 // (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 // 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 // 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 // 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 // defaults" (documented as accepted). No Rust memory is borrowed; ownership of the
// null-checked below. // returned context passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe { let sws = unsafe {
ffi::sws_getContext( AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int, width as c_int,
height as c_int, height as c_int,
src_av, src_av,
@@ -656,11 +658,11 @@ impl NvencEncoder {
ptr::null_mut(), ptr::null_mut(),
ptr::null_mut(), ptr::null_mut(),
ptr::null(), ptr::null(),
) ))
}; };
if sws.is_null() { let Some(sws) = sws else {
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed"); bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
} };
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle — // 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`/ // 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 // `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
@@ -680,7 +682,16 @@ impl NvencEncoder {
SWS_CS_ITU709 SWS_CS_ITU709
}); });
let dst_range = i32::from(full_range_444); 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) Some(sws)
@@ -694,10 +705,10 @@ impl NvencEncoder {
Some(VideoFrame::new(nvenc_pixel, width, height)) Some(VideoFrame::new(nvenc_pixel, width, height))
}; };
Ok(NvencEncoder { Ok(NvencEncoder {
sws_csc,
enc, enc,
frame, frame,
cuda: cuda_hw, cuda: cuda_hw,
sws_csc,
want_444, want_444,
src_format: format, src_format: format,
width, width,
@@ -840,7 +851,7 @@ impl NvencEncoder {
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp // 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 // 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. // 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 let frame = self
.frame .frame
.as_mut() .as_mut()
@@ -929,27 +940,23 @@ impl NvencEncoder {
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via // 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 // `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
// (`make_current()?`), the precondition for the device-pointer copies below. // (`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` // * `f` is an owned `AvFrame` — every exit below (bail, copy error, success) drops it
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on // exactly once, releasing its ref on the pooled surface. `av_hwframe_get_buffer` fills
// failure we free `f` and bail. // it with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`).
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else // * For NV12 we read `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 // `data[0]`/`linesize[0]` — in-struct fields of the live frame, valid for the surface
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf` // dims ffmpeg allocated — and pass them to the cuda copy helpers, which device→device
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface. // copy `buf` (the imported `DeviceBuffer`, owned by the caller and live for this call)
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and // into the surface.
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of // * `avcodec_send_frame` takes its own ref of the pooled surface, so the drop afterwards
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race. // is the sole owning free. Single-threaded encoder → no race.
unsafe { unsafe {
let mut f = ffi::av_frame_alloc(); let f = AvFrame::alloc().context("av_frame_alloc failed")?;
if f.is_null() {
bail!("av_frame_alloc failed");
}
// Pooled CUDA surface: sets format, width/height, data[0]/linesize[0], buf[0] and // 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 // hw_frames_ctx. Reused across frames (the pool recycles), keeping NVENC's
// registration cache warm. // 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 { if r < 0 {
ffi::av_frame_free(&mut f);
bail!("av_hwframe_get_buffer(CUDA) failed ({r})"); bail!("av_hwframe_get_buffer(CUDA) failed ({r})");
} }
// NV12 surfaces are two-plane (Y in data[0], interleaved UV in data[1]); YUV444 // 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 copy_res = if buf.yuv444 {
let dsts = core::array::from_fn(|i| { let dsts = core::array::from_fn(|i| {
( (
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr, (*f.as_ptr()).data[i] as pf_zerocopy::cuda::CUdeviceptr,
(*f).linesize[i] as usize, (*f.as_ptr()).linesize[i] as usize,
) )
}); });
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true) pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
} else if self.want_444 { } else if self.want_444 {
ffi::av_frame_free(&mut f);
bail!( bail!(
"4:4:4 session but the zero-copy frame is not YUV444 (LINEAR/gamescope \ "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 \ capture has no GPU 4:4:4 convert) unset PUNKTFUNK_ZEROCOPY to use the \
CPU 4:4:4 path on this compositor" CPU 4:4:4 path on this compositor"
); );
} else if buf.is_nv12() { } else if buf.is_nv12() {
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let y_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let y_pitch = (*f).linesize[0] as usize; let y_pitch = (*f.as_ptr()).linesize[0] as usize;
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr; let uv_ptr = (*f.as_ptr()).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f).linesize[1] as usize; 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) pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
} else { } else {
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let dst_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f).linesize[0] as usize; let dst_pitch = (*f.as_ptr()).linesize[0] as usize;
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true) pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
}; };
if let Err(e) = copy_res { copy_res.context("copy imported buffer into NVENC surface")?;
ffi::av_frame_free(&mut f); (*f.as_ptr()).pts = pts;
return Err(e).context("copy imported buffer into NVENC surface"); (*f.as_ptr()).pict_type = if idr {
}
(*f).pts = pts;
(*f).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I ffi::AVPictureType::AV_PICTURE_TYPE_I
} else { } else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE ffi::AVPictureType::AV_PICTURE_TYPE_NONE
}; };
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f); let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f.as_ptr());
ffi::av_frame_free(&mut f);
if r < 0 { if r < 0 {
bail!("avcodec_send_frame(CUDA) failed ({r})"); bail!("avcodec_send_frame(CUDA) failed ({r})");
} }
@@ -1003,16 +1005,9 @@ impl NvencEncoder {
} }
} }
impl Drop for NvencEncoder { // No `Drop` for `NvencEncoder`: `sws_csc` (`Option<AvSwsContext>`) frees itself, and as field #1
fn drop(&mut self) { // it does so ahead of `enc`/`frame`/`cuda` — the same sequence the hand-written `Drop` performed
if let Some(sws) = self.sws_csc.take() { // (see the field-order note on the struct).
// 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) };
}
}
}
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around /// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
/// an encoder open it *expects* to fail. /// an encoder open it *expects* to fail.
@@ -63,8 +63,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not // 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. // wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)] #![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::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid, apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
+86 -118
View File
@@ -19,8 +19,6 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`), //! 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 //! 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. //! *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 super::{Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -36,8 +34,8 @@ use std::ptr;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome, apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, AvFrame,
SWS_CS_ITU709, SWS_POINT, AvSwsContext, PollOutcome, SWS_CS_ITU709, SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -546,8 +544,13 @@ impl VaapiHw {
struct CpuInner { struct CpuInner {
enc: encoder::video::Encoder, enc: encoder::video::Encoder,
hw: VaapiHw, hw: VaapiHw,
sws: *mut ffi::SwsContext, // FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `nv12` BEFORE
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src) // `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, src_format: PixelFormat,
width: u32, width: u32,
height: 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_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, // `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 // 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 // memory is borrowed — only by-value ints/enums — and ownership of the returned context
// just below. // passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe { let sws = unsafe {
ffi::sws_getContext( AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int, width as c_int,
height as c_int, height as c_int,
src_av, src_av,
@@ -616,16 +619,15 @@ impl CpuInner {
ptr::null_mut(), ptr::null_mut(),
ptr::null_mut(), ptr::null_mut(),
ptr::null(), ptr::null(),
) ))
}; };
if sws.is_null() { let Some(sws) = sws else {
bail!( bail!(
"sws_getContext(RGB→{})", "sws_getContext(RGB→{})",
if ten_bit { "P010" } else { "NV12" } if ten_bit { "P010" } else { "NV12" }
); );
} };
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()` // SAFETY: `sws` is the live owned context from above. The coefficient table from
// check immediately preceding returned false). The coefficient table from
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a // `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 // 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 // (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
@@ -637,32 +639,22 @@ impl CpuInner {
} else { } else {
SWS_CS_ITU709 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 let nv12 = AvFrame::alloc().context("av_frame_alloc(staging) failed")?;
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/ // SAFETY: writing the plain `format`/`width`/`height` fields through the owned frame's
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared). // pointer stays inside its allocation (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we // `av_frame_get_buffer` allocates backing storage for those dims/format; on failure the
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned // owned `nv12` (and the `sws` above it) simply drop — the hand-written unwind this
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a // replaced had to free both by hand on every branch.
// unique fresh pointer, so none of these writes alias anything. unsafe {
let nv12 = unsafe { (*nv12.as_ptr()).format = staging_av as c_int;
let f = ffi::av_frame_alloc(); (*nv12.as_ptr()).width = width as c_int;
if f.is_null() { (*nv12.as_ptr()).height = height as c_int;
ffi::sws_freeContext(sws); if ffi::av_frame_get_buffer(nv12.as_ptr(), 0) < 0 {
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);
bail!("av_frame_get_buffer(staging) failed"); bail!("av_frame_get_buffer(staging) failed");
} }
f }
};
tracing::info!( tracing::info!(
encoder = codec.vaapi_name(), encoder = codec.vaapi_name(),
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)", "VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
@@ -671,8 +663,8 @@ impl CpuInner {
Ok(CpuInner { Ok(CpuInner {
enc, enc,
hw, hw,
sws,
nv12, nv12,
sws,
src_format: format, src_format: format,
width, width,
height, height,
@@ -693,49 +685,43 @@ impl CpuInner {
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from // `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 // `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. // 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 // `self.sws` is the owned context built in `open`; it writes into `self.nv12` (an owned
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`). // 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 // `hwf` is an owned `AvFrame` — every exit below drops it exactly once, releasing its ref
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads // on the pooled VAAPI surface. `av_hwframe_get_buffer` pulls that surface from the live
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write // non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads the staged NV12 into
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live // it. `avcodec_send_frame` takes its own ref, so the drop afterwards is the sole owning
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once. // free. The encoder runs only on this thread (see `unsafe impl Send`), so no
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race. // aliasing/data race.
unsafe { unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()]; 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]; let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale( if ffi::sws_scale(
self.sws, self.sws.as_ptr(),
src_data.as_ptr(), src_data.as_ptr(),
src_stride.as_ptr(), src_stride.as_ptr(),
0, 0,
h as c_int, h as c_int,
(*self.nv12).data.as_ptr(), (*self.nv12.as_ptr()).data.as_ptr(),
(*self.nv12).linesize.as_ptr(), (*self.nv12.as_ptr()).linesize.as_ptr(),
) < 0 ) < 0
{ {
bail!("sws_scale RGB→NV12 failed"); bail!("sws_scale RGB→NV12 failed");
} }
let mut hwf = ffi::av_frame_alloc(); let hwf = AvFrame::alloc().context("av_frame_alloc(hw) failed")?;
if hwf.is_null() { if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf.as_ptr(), 0) < 0 {
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);
bail!("av_hwframe_get_buffer(VAAPI) failed"); bail!("av_hwframe_get_buffer(VAAPI) failed");
} }
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 { if ffi::av_hwframe_transfer_data(hwf.as_ptr(), self.nv12.as_ptr(), 0) < 0 {
ffi::av_frame_free(&mut hwf);
bail!("av_hwframe_transfer_data(→VAAPI) failed"); bail!("av_hwframe_transfer_data(→VAAPI) failed");
} }
(*hwf).pts = pts; (*hwf.as_ptr()).pts = pts;
(*hwf).pict_type = if idr { (*hwf.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I ffi::AVPictureType::AV_PICTURE_TYPE_I
} else { } else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE ffi::AVPictureType::AV_PICTURE_TYPE_NONE
}; };
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf); let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf.as_ptr());
ffi::av_frame_free(&mut hwf);
if r < 0 { if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})"); bail!("avcodec_send_frame(VAAPI) failed ({r})");
} }
@@ -744,24 +730,10 @@ impl CpuInner {
} }
} }
impl Drop for CpuInner { // No `Drop` for `CpuInner`: `nv12` (`AvFrame`) and `sws` (`AvSwsContext`) free themselves, in
fn drop(&mut self) { // field-declaration order — the same nv12-then-sws sequence the hand-written `Drop` performed
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each // (see the field-order note on the struct). The encoder holds its own `av_buffer_ref`'d
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double // device/frames copies, so their order against `enc`/`hw` is irrelevant to soundness.
// 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);
}
}
}
}
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode. // 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 // 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 // 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. // 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 // * `drm`/`nv12` are owned `AvFrame`s — every exit drops each exactly once (the
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx). // 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] = // * `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 // 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. // 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 // * `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 // buffersrc; KEEP_REF keeps our own `drm` ref, dropped explicitly right after the push
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the // (the same point the hand-written free sat, kept so the descriptor's release timing
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned // across the pull does not change). We pull the converted surface with
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race. // `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 { unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane). // 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()); 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].offset = dmabuf.offset as isize;
desc.layers[0].planes[0].pitch = dmabuf.stride as isize; desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
let mut drm = ffi::av_frame_alloc(); let drm = AvFrame::alloc().context("av_frame_alloc(drm) failed")?;
if drm.is_null() { (*drm.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
bail!("av_frame_alloc(drm) failed"); (*drm.as_ptr()).width = self.width as c_int;
} (*drm.as_ptr()).height = self.height as c_int;
(*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;
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so // 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 // 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 — // 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). // Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG; (*drm.as_ptr()).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB; (*drm.as_ptr()).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr()); (*drm.as_ptr()).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()).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, // 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). // 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) { 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). // reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) }; unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
} }
(*drm).buf[0] = ffi::av_buffer_create( (*drm.as_ptr()).buf[0] = ffi::av_buffer_create(
(*drm).data[0], (*drm.as_ptr()).data[0],
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(), std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
Some(free_desc), Some(free_desc),
ptr::null_mut(), ptr::null_mut(),
@@ -1113,45 +1086,40 @@ impl DmabufInner {
// Push through hwmap → scale_vaapi; pull the NV12 surface back out. // Push through hwmap → scale_vaapi; pull the NV12 surface back out.
let r = ffi::av_buffersrc_add_frame_flags( let r = ffi::av_buffersrc_add_frame_flags(
self.src, self.src,
drm, drm.as_ptr(),
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int, ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
); );
ffi::av_frame_free(&mut drm); 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 // 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 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 — // 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 // 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 // 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 // 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 // rebuild above us exists to recover, and disabling zero-copy over it would be a
// permanent penalty for a transient fault. // permanent penalty for a transient fault.
if r < 0 { if r < 0 {
let e = format!("av_buffersrc_add_frame failed ({r})"); let e = format!("av_buffersrc_add_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e); pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}"); bail!("{e}");
} }
t_push = t0.elapsed(); t_push = t0.elapsed();
let mut nv12 = ffi::av_frame_alloc(); let nv12 = AvFrame::alloc().context("av_frame_alloc(nv12) failed")?;
if nv12.is_null() { let r = ffi::av_buffersink_get_frame(self.sink, nv12.as_ptr());
bail!("av_frame_alloc(nv12) failed");
}
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
if r < 0 { if r < 0 {
ffi::av_frame_free(&mut nv12);
let e = format!("av_buffersink_get_frame failed ({r})"); let e = format!("av_buffersink_get_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e); pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}"); bail!("{e}");
} }
pf_zerocopy::note_raw_dmabuf_import_ok(); pf_zerocopy::note_raw_dmabuf_import_ok();
t_pull = t0.elapsed() - t_push; t_pull = t0.elapsed() - t_push;
(*nv12).pts = pts; (*nv12.as_ptr()).pts = pts;
(*nv12).pict_type = if idr { (*nv12.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I ffi::AVPictureType::AV_PICTURE_TYPE_I
} else { } else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE ffi::AVPictureType::AV_PICTURE_TYPE_NONE
}; };
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12); let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12.as_ptr());
ffi::av_frame_free(&mut nv12);
if r < 0 { if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})"); bail!("avcodec_send_frame(VAAPI) failed ({r})");
} }
-3
View File
@@ -41,9 +41,6 @@
//! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a //! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a
//! small buffer set). //! 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 anyhow::{Context, Result};
use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat}; use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat};
use pf_zerocopy::ipc; use pf_zerocopy::ipc;
+39 -20
View File
@@ -5,12 +5,16 @@
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the //! `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`]. //! 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 // UNSAFE-LINT EXEMPTION REMOVED — the old fence rationale ("raw nvEncodeAPI entry-table calls
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it // almost line for line") was false for this file: it makes ZERO FFI calls. Its unsafe surface is
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature. // C-union access whose soundness hangs entirely on which codec arm is active, and the 4:4:4 note
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the // below records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that per-operation
// calls — until then the lint is off HERE and enforced everywhere else. // visibility makes findable. So this file runs the strictest discipline in the crate: every
#![allow(unsafe_op_in_unsafe_fn)] // 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 super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv; 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); 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. // SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
unsafe { unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3) };
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3); // SAFETY: same HEVC arm as above.
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2); unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2) };
}
} }
#[test] #[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. // are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
match c.codec { match c.codec {
Codec::H265 => { 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.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0; 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 { if want_444 && c.codec == Codec::H265 {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID; 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 { 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 { } else if c.bit_depth == 10 {
match c.codec { match c.codec {
Codec::H265 => { Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID; 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 => { Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2); // SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm.
cfg.encodeCodecConfig unsafe { cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2) };
.av1Config // SAFETY: same AV1 arm, same match guard.
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8); 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::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"), 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 { match c.codec {
Codec::H265 => { 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.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0; vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1; 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; vui.colourMatrix = mat;
} }
Codec::H264 => { 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.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0; vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1; 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; vui.colourMatrix = mat;
} }
Codec::Av1 => { 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.colorPrimaries = prim;
av1.transferCharacteristics = trc; av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat; 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 //! 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 //! "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. //! 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 super::{EncodedFrame, Encoder};
use anyhow::{bail, ensure, Context, Result}; use anyhow::{bail, ensure, Context, Result};
-2
View File
@@ -49,8 +49,6 @@
// restate the signature. Clearing this file means DELETING the markers that carry no caller // 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. // contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)] #![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 super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
+89 -119
View File
@@ -37,8 +37,6 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The //! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't //! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`. //! 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 super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -61,8 +59,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
}; };
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020, apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_CS_ITU709, SWS_POINT, SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -499,10 +497,14 @@ fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
struct SystemInner { struct SystemInner {
enc: encoder::video::Encoder, 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. /// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
sw_frame: *mut ffi::AVFrame, sw_frame: AvFrame,
/// swscale ctx for the BGRA→NV12 fallback (built lazily; null for the YUV-readback path). /// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
sws: *mut ffi::SwsContext, sws: Option<AvSwsContext>,
/// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device). /// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
staging: Option<ID3D11Texture2D>, staging: Option<ID3D11Texture2D>,
ctx: Option<ID3D11DeviceContext>, ctx: Option<ID3D11DeviceContext>,
@@ -549,26 +551,18 @@ impl SystemInner {
ptr::null_mut(), ptr::null_mut(),
)? )?
}; };
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that // SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we // its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into // owned `sw_frame` simply drops (freed once, by the wrapper).
// `self.sw_frame` and freed exactly once in `Drop`. unsafe {
let sw_frame = unsafe { (*sw_frame.as_ptr()).format = sw_av as c_int;
let f = ffi::av_frame_alloc(); (*sw_frame.as_ptr()).width = width as c_int;
if f.is_null() { (*sw_frame.as_ptr()).height = height as c_int;
bail!("av_frame_alloc(sw) failed"); if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
}
(*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);
bail!("av_frame_get_buffer(sw) failed"); bail!("av_frame_get_buffer(sw) failed");
} }
f }
};
tracing::info!( tracing::info!(
encoder = vendor.encoder_name(codec), encoder = vendor.encoder_name(codec),
"{} encode active ({width}x{height}@{fps}, system-memory {} path)", "{} encode active ({width}x{height}@{fps}, system-memory {} path)",
@@ -578,7 +572,7 @@ impl SystemInner {
Ok(SystemInner { Ok(SystemInner {
enc, enc,
sw_frame, sw_frame,
sws: ptr::null_mut(), sws: None,
staging: None, staging: None,
ctx: None, ctx: None,
format, format,
@@ -634,13 +628,13 @@ impl SystemInner {
// frame and `self.enc`'s own context, both live for the call and neither retained by libav // frame and `self.enc`'s own context, both live for the call and neither retained by libav
// (it references the frame's buffers itself). // (it references the frame's buffers itself).
unsafe { unsafe {
(*self.sw_frame).pts = pts; (*self.sw_frame.as_ptr()).pts = pts;
(*self.sw_frame).pict_type = if idr { (*self.sw_frame.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I ffi::AVPictureType::AV_PICTURE_TYPE_I
} else { } else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE 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 { if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win"); 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 total = pitch.saturating_mul(h + h.div_ceil(2));
let mapped = std::slice::from_raw_parts(base, total); let mapped = std::slice::from_raw_parts(base, total);
let chroma_off = pitch * h; let chroma_off = pitch * h;
let y_dst = (*self.sw_frame).data[0]; let y_dst = (*self.sw_frame.as_ptr()).data[0];
let y_stride = (*self.sw_frame).linesize[0] as usize; let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
let uv_dst = (*self.sw_frame).data[1]; let uv_dst = (*self.sw_frame.as_ptr()).data[1];
let uv_stride = (*self.sw_frame).linesize[1] as usize; let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
for y in 0..h { for y in 0..h {
let s = &mapped[y * pitch..y * pitch + row_bytes]; let s = &mapped[y * pitch..y * pitch + row_bytes];
ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), 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 pitch = map.RowPitch as usize;
let h = self.height as usize; let h = self.height as usize;
let base = map.pData as *const u8; let base = map.pData as *const u8;
self.ensure_sws( let sws = self.ensure_sws(
pixel_to_av(Pixel::BGRA), pixel_to_av(Pixel::BGRA),
ffi::AVPixelFormat::AV_PIX_FMT_NV12, ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709, SWS_CS_ITU709,
@@ -756,13 +750,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()]; 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 src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale( let r = ffi::sws_scale(
self.sws, sws,
src_data.as_ptr(), src_data.as_ptr(),
src_stride.as_ptr(), src_stride.as_ptr(),
0, 0,
h as c_int, h as c_int,
(*self.sw_frame).data.as_ptr(), (*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(), (*self.sw_frame.as_ptr()).linesize.as_ptr(),
); );
ctx.Unmap(&staging, 0); ctx.Unmap(&staging, 0);
if r < 0 { if r < 0 {
@@ -798,7 +792,7 @@ impl SystemInner {
let h = self.height as usize; let h = self.height as usize;
let base = map.pData as *const u8; let base = map.pData as *const u8;
// RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited. // 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_X2BGR10LE,
ffi::AVPixelFormat::AV_PIX_FMT_P010LE, ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
SWS_CS_BT2020, SWS_CS_BT2020,
@@ -806,13 +800,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()]; 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 src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale( let r = ffi::sws_scale(
self.sws, sws,
src_data.as_ptr(), src_data.as_ptr(),
src_stride.as_ptr(), src_stride.as_ptr(),
0, 0,
h as c_int, h as c_int,
(*self.sw_frame).data.as_ptr(), (*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(), (*self.sw_frame.as_ptr()).linesize.as_ptr(),
); );
ctx.Unmap(&staging, 0); ctx.Unmap(&staging, 0);
if r < 0 { if r < 0 {
@@ -844,7 +838,7 @@ impl SystemInner {
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned // `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
// `sw_frame`. `send` then hands `sw_frame` to the encoder. // `sw_frame`. `send` then hands `sw_frame` to the encoder.
unsafe { unsafe {
self.ensure_sws( let sws = self.ensure_sws(
pixel_to_av(sws_src(format)?), pixel_to_av(sws_src(format)?),
ffi::AVPixelFormat::AV_PIX_FMT_NV12, ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709, 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_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]; let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale( if ffi::sws_scale(
self.sws, sws,
src_data.as_ptr(), src_data.as_ptr(),
src_stride.as_ptr(), src_stride.as_ptr(),
0, 0,
h as c_int, h as c_int,
(*self.sw_frame).data.as_ptr(), (*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(), (*self.sw_frame.as_ptr()).linesize.as_ptr(),
) < 0 ) < 0
{ {
bail!("sws_scale RGB→NV12 failed"); 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. /// 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` /// 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( fn ensure_sws(
&mut self, &mut self,
src_av: ffi::AVPixelFormat, src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat, dst_av: ffi::AVPixelFormat,
cs: c_int, cs: c_int,
) -> Result<()> { ) -> Result<*mut ffi::SwsContext> {
if !self.sws.is_null() { if let Some(sws) = &self.sws {
return Ok(()); return Ok(sws.as_ptr());
} }
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params" // 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 // null trio, and returns an owned context or null — `from_raw` rejects the null, so
// `sws_setColorspaceDetails` and the store below only ever see a live one. // `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the // `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
// process, and the call only reads it. // valid for the process, and the call only reads it.
let sws = unsafe { let sws = unsafe {
let sws = ffi::sws_getContext( let raw = ffi::sws_getContext(
self.width as c_int, self.width as c_int,
self.height as c_int, self.height as c_int,
src_av, src_av,
@@ -900,36 +895,22 @@ impl SystemInner {
ptr::null_mut(), ptr::null_mut(),
ptr::null(), ptr::null(),
); );
if sws.is_null() { let Some(owned) = AvSwsContext::from_raw(raw) else {
bail!("sws_getContext(RGB→YUV) failed"); bail!("sws_getContext(RGB→YUV) failed");
} };
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI // 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. // we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs); let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16); ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws owned
}; };
self.sws = sws; Ok(self.sws.insert(sws).as_ptr())
Ok(())
} }
} }
impl Drop for SystemInner { // No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
fn drop(&mut self) { // themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it // `Drop` performed, pinned by the offset_of assert at the struct.
// 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);
}
}
}
}
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture // 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<()> { 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 // SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure // between the pool pull and the send, which as hand-placed frees previously leaked the
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array // frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release // the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled // is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees // that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., // null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU // SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an // pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the // on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so // For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak, // `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
// no double-free, no use-after-free. // 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 { unsafe {
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice. // 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(); let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
if d3d.is_null() { let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
bail!("av_frame_alloc(d3d11) failed");
}
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
if r < 0 { if r < 0 {
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_get_buffer(D3D11) failed ({r})"); bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
} }
let dst_ptr = (*d3d).data[0] as *mut c_void; let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
let dst_index = (*d3d).data[1] as usize as u32; let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr) let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
.ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?; .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 // GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
@@ -1249,58 +1227,50 @@ impl ZeroCopyInner {
self.ctx self.ctx
.CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None); .CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);
(*d3d).pts = pts; (*d3d.as_ptr()).pts = pts;
(*d3d).pict_type = if idr { (*d3d.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I ffi::AVPictureType::AV_PICTURE_TYPE_I
} else { } else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE ffi::AVPictureType::AV_PICTURE_TYPE_NONE
}; };
let send = match self.vendor { 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 => { WinVendor::Qsv => {
// Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame. // Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
let mut qsv = ffi::av_frame_alloc(); let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
if qsv.is_null() {
ffi::av_frame_free(&mut d3d);
bail!("av_frame_alloc(qsv) failed");
}
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and // 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, // 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 // matching the null check above it. The `Option` is what the raw pointer's
// "null means AMF" convention was already encoding. // "null means AMF" convention was already encoding.
let Some(qsv_frames) = self.qsv_frames.as_ref() else { 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"); bail!("QSV send path without a derived QSV frames context");
}; };
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int; (*qsv.as_ptr()).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()).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. // The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
let r = ffi::av_hwframe_map( let r = ffi::av_hwframe_map(
qsv, qsv.as_ptr(),
d3d, d3d.as_ptr(),
ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int, ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
); );
if r < 0 { if r < 0 {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_map(D3D11→QSV) failed ({r})"); bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
} }
(*qsv).pts = pts; (*qsv.as_ptr()).pts = pts;
(*qsv).pict_type = (*d3d).pict_type; (*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
let s = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv); ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
ffi::av_frame_free(&mut qsv); // `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
s // same point the hand-written `av_frame_free(&mut qsv)` did.
} }
}; };
ffi::av_frame_free(&mut d3d);
if send < 0 { if send < 0 {
bail!( bail!(
"avcodec_send_frame({}) failed ({send})", "avcodec_send_frame({}) failed ({send})",
self.vendor.label() self.vendor.label()
); );
} }
// `d3d` drops here (and on every early exit above), returning the pooled surface.
} }
Ok(()) Ok(())
} }
@@ -39,8 +39,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not // 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. // wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)] #![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::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
-3
View File
@@ -37,9 +37,6 @@
//! it stays behind the same gate and falls back to IDR wherever the driver declines. 4:4:4 stays //! it stays behind the same gate and falls back to IDR wherever the driver declines. 4:4:4 stays
//! `false` until probed on real hardware (design §8.6). //! `false` until probed on real hardware (design §8.6).
// 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 super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use libvpl_sys as vpl; use libvpl_sys as vpl;
-1
View File
@@ -12,7 +12,6 @@
// `#[cfg(test)]` instead. // `#[cfg(test)]` instead.
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof // Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). As a parent module this also covers the child modules (windows/linux backends). // program). As a parent module this also covers the child modules (windows/linux backends).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
use pf_frame::{CapturedFrame, PixelFormat}; use pf_frame::{CapturedFrame, PixelFormat};
+42 -27
View File
@@ -7,9 +7,6 @@
//! The win32u GPU-preference hook, the HDR/video-engine converters, and the self-tests stay in the //! The win32u GPU-preference hook, the HDR/video-engine converters, and the self-tests stay in the
//! capture crate — they are capture mechanics, not shared identity. //! capture crate — they are capture mechanics, not shared identity.
// 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 anyhow::{Context, Result};
use windows::core::Interface; use windows::core::Interface;
use windows::Win32::Foundation::{HMODULE, LUID}; use windows::Win32::Foundation::{HMODULE, LUID};
@@ -158,18 +155,26 @@ enum PrioMode {
Off, Off,
/// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5). /// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5).
Static(i32), Static(i32),
/// The default: HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or /// Opt-in (`auto`): HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
/// HAGS on with comfortable VRAM headroom (with a monitor that downgrades the moment VRAM /// HAGS on with comfortable VRAM headroom (with a monitor that downgrades the moment VRAM
/// tightens). REALTIME is the proven ceiling-raiser (it is how our brief encode preempts a /// tightens). REALTIME is the T2.3 ceiling-raiser (a higher-priority context preempts at
/// saturating game), but REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC /// pixel granularity), but it carries TWO field-proven hazards: REALTIME + NVIDIA + HAGS +
/// hang the gate takes the win everywhere it cannot hit the hazard. /// near-full VRAM is a documented NVENC hang (the VRAM gate covers that one), and on AMD the
/// upgrade itself produced a metronomic content-starving stall class (~3.6 s period, RX 9070
/// XT, 2026-08-12 A/B: pinning `high` removed it) that no VRAM gate can see — which is why
/// `auto` is no longer the default.
Auto, Auto,
} }
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **auto**). /// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **high**).
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, /// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
/// REALTIME 5. `realtime` pins REALTIME statically (no gate — the operator owns the hazard); /// REALTIME 5. `realtime` pins REALTIME statically (no gate — the operator owns the hazard);
/// `high` restores the pre-T2.3 static default. /// `auto` is the T2.3 gated-REALTIME mode, opt-in since the 2026-08-12 field A/B convicted the
/// REALTIME upgrade of its own metronomic stall class on AMD (see [`PrioMode::Auto`]) — HIGH is
/// the Sunshine/Apollo-parity lever that delivered the original decisive win, and the default
/// must not hold REALTIME anywhere (the same inversion as the vdisplay driver's `PFVD_RT_GPU`
/// ladder, which fixed the faster ~1.8 s metronome the same day). Unrecognized values read as
/// the default, not as `auto` — a typo must not opt a box into the hazard.
fn configured_gpu_priority_mode() -> PrioMode { fn configured_gpu_priority_mode() -> PrioMode {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS") match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok() .ok()
@@ -177,9 +182,10 @@ fn configured_gpu_priority_mode() -> PrioMode {
{ {
Some("off") => PrioMode::Off, Some("off") => PrioMode::Off,
Some("normal") => PrioMode::Static(2), Some("normal") => PrioMode::Static(2),
Some("high") => PrioMode::Static(4),
Some("realtime") => PrioMode::Static(5), Some("realtime") => PrioMode::Static(5),
_ => PrioMode::Auto, Some("auto") => PrioMode::Auto,
// `high`, unset, and anything unrecognized all land on the HIGH default.
_ => PrioMode::Static(4),
} }
} }
@@ -278,14 +284,17 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but /// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling /// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority` /// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is the /// alone, which we measured as no help) lets our brief encode preempt the game. Default is a
/// T2.3 `auto` mode: HIGH immediately here, then [`auto_priority_gate`] upgrades to REALTIME /// static HIGH — the class that delivered that win. The T2.3 `auto` mode (HIGH here, then
/// where the NVIDIA+HAGS+full-VRAM NVENC-hang hazard cannot bite (and a monitor downgrades when /// [`auto_priority_gate`] upgrades to REALTIME behind the NVENC-hang VRAM gate) is opt-in since
/// it could). Runs once per process; best-effort. /// the 2026-08-12 field A/B: on AMD the REALTIME upgrade generated its own metronomic
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default auto; `high` = the /// content-starving stall class (~3.6 s period) that the VRAM gate cannot see, and pinning HIGH
/// pre-gate static behavior; `realtime` = pinned, operator owns the hazard). Best-effort: /// removed it. Runs once per process; best-effort.
/// silently no-ops under a UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY, /// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default high; `auto` = the
/// so the D3DKMT call is a no-op). /// gated-REALTIME upgrade, operator opts into the AMD stall hazard for the extra ceiling;
/// `realtime` = pinned, operator owns every hazard). Best-effort: silently no-ops under a
/// UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a
/// no-op).
fn elevate_process_gpu_priority() { fn elevate_process_gpu_priority() {
use std::sync::Once; use std::sync::Once;
static ONCE: Once = Once::new(); static ONCE: Once = Once::new();
@@ -319,17 +328,23 @@ fn elevate_process_gpu_priority() {
}); });
} }
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) -------------------------------- // --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — OPT-IN since 2026-08-12 ------
// //
// REALTIME GPU scheduling priority is the genuine cross-process ceiling-raiser under a saturating // REALTIME GPU scheduling priority is the genuine cross-process ceiling-raiser under a saturating
// game (a higher-priority context preempts at pixel granularity — the Async-TimeWarp mechanism), // game (a higher-priority context preempts at pixel granularity — the Async-TimeWarp mechanism),
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. The one documented // and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. Two field-proven
// hazard: REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC. So: probe HAGS once via // hazards bound it. (1) REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC — the VRAM
// D3DKMT; HAGS off ⇒ REALTIME unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM // gate below exists for that one: probe HAGS once via D3DKMT; HAGS off ⇒ REALTIME
// headroom, with a monitor thread that downgrades to HIGH the moment usage crosses // unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM headroom, with a monitor
// [`VRAM_DOWNGRADE_PCT`] of the OS budget and restores REALTIME after it has stayed under // thread that downgrades to HIGH the moment usage crosses [`VRAM_DOWNGRADE_PCT`] of the OS
// [`VRAM_RESTORE_PCT`] for [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping // budget and restores REALTIME after it has stayed under [`VRAM_RESTORE_PCT`] for
// on the boundary of the hazard window). // [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping on the boundary of the
// hazard window). (2) On AMD (RX 9070 XT A/B), a punktfunk process holding REALTIME generated a
// metronomic content-starving stall class — every ~3.6 s ALL processes' presents paused
// 150800 ms with the GPU responsive — that no VRAM gate can see, and the vdisplay driver's
// REALTIME swap-chain raise produced the same pathology on its own ~1.8 s beat. That second
// hazard is why the whole gate now runs only under an explicit `auto`, and the default stays a
// static HIGH.
/// Downgrade REALTIME→HIGH when local VRAM usage exceeds this share of the OS budget. /// Downgrade REALTIME→HIGH when local VRAM usage exceeds this share of the OS budget.
const VRAM_DOWNGRADE_PCT: u64 = 92; const VRAM_DOWNGRADE_PCT: u64 = 92;
-1
View File
@@ -10,7 +10,6 @@
//! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation). //! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation).
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. // Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod hdr; pub mod hdr;
pub mod metronome; pub mod metronome;
-3
View File
@@ -11,9 +11,6 @@
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit. //! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
//! See `design/host-latency-plan.md` Tier 3A. //! See `design/host-latency-plan.md` Tier 3A.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod imp { mod imp {
#![allow(non_snake_case)] #![allow(non_snake_case)]
-3
View File
@@ -3,9 +3,6 @@
//! can't deschedule them; the native, GameStream, and direct-NVENC send threads all reach this the //! can't deschedule them; the native, GameStream, and direct-NVENC send threads all reach this the
//! same way (`pf_frame::thread_qos::boost_thread_priority`). //! same way (`pf_frame::thread_qos::boost_thread_priority`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
/// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our /// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our
/// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the /// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the
/// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is /// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is
-1
View File
@@ -23,7 +23,6 @@
//! live session actually encodes on, for the console's "in use" display. //! live session actually encodes on, for the console's "in use" display.
// Unsafe-proof program: every `unsafe {}` in this leaf carries a `// SAFETY:` proof. // Unsafe-proof program: every `unsafe {}` in this leaf carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -15,9 +15,6 @@
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership //! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise. //! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::pad_slots::PadSlots; use crate::pad_slots::PadSlots;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS}; use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
@@ -17,8 +17,6 @@
//! output's logical rectangle — the same shape the libei backend uses with its EI region. //! output's logical rectangle — the same shape the libei backend uses with its EI region.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] #![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
-3
View File
@@ -6,9 +6,6 @@
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier //! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
//! state so the compositor resolves shifted keysyms correctly. //! state so the compositor resolves shifted keysyms correctly.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use punktfunk_core::input::InputKind; use punktfunk_core::input::InputKind;
@@ -15,9 +15,6 @@
//! with its position (never at a stale point), tip edges get their own DOWN/UP frames, and a //! with its position (never at a stale point), tip edges get their own DOWN/UP frames, and a
//! range-leave is a final frame without `INRANGE`. //! range-leave is a final frame without `INRANGE`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{ use punktfunk_core::quic::{
@@ -14,9 +14,6 @@
//! user's, and any layout re-reads a *position* as a *character* — on a German host that is //! user's, and any layout re-reads a *position* as a *character* — on a German host that is
//! exactly the y↔z swap / ü-on-ö scramble. //! exactly the y↔z swap / ü-on-ö scramble.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::mem::size_of; use std::mem::size_of;
-7
View File
@@ -14,13 +14,6 @@
// Scaffold: trait methods + per-OS backends are defined ahead of the target that uses them. // Scaffold: trait methods + per-OS backends are defined ahead of the target that uses them.
#![allow(dead_code)] #![allow(dead_code)]
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and its companion: without this, an `unsafe fn` body needs no blocks, so an unproven FFI call
// could hide inside one and still satisfy the deny above. The workspace keeps
// `unsafe_op_in_unsafe_fn` at `warn` while the encoder backends are cleared; this crate is at zero.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
-1
View File
@@ -17,7 +17,6 @@
//! the decode chain there is Vulkan → D3D11VA → software. //! the decode chain there is Vulkan → D3D11VA → software.
// Unsafe-proof program: every `unsafe {}` in this crate carries a `// SAFETY:` proof. // Unsafe-proof program: every `unsafe {}` in this crate carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
// THE VULKAN CONTRACT, stated once - most `// SAFETY:` proofs in this crate are an instance of it. // THE VULKAN CONTRACT, stated once - most `// SAFETY:` proofs in this crate are an instance of it.
// //
+3
View File
@@ -18,3 +18,6 @@ path = "src/main.rs"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
[lints]
workspace = true
-7
View File
@@ -39,13 +39,6 @@
// honest. (Was a bare crate-wide allow whose "scaffold, defined ahead of the target that uses them" // honest. (Was a bare crate-wide allow whose "scaffold, defined ahead of the target that uses them"
// rationale had stopped being true.) // rationale had stopped being true.)
#![cfg_attr(not(target_os = "linux"), allow(dead_code))] #![cfg_attr(not(target_os = "linux"), allow(dead_code))]
// Every `unsafe` block in this file 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 this crate's hardest
// FFI from the deny above — every IOCTL wrapper, and `restore_displays_ccd`, the call the whole
// Windows teardown path depends on to give the operator their physical panels back.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result; use anyhow::Result;
pub use punktfunk_core::Mode; pub use punktfunk_core::Mode;
@@ -23,9 +23,6 @@
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside //! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
//! the KWin session's environment. //! the KWin session's environment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Mode, VirtualDisplay, VirtualOutput}; use super::{Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::os::fd::{AsFd, AsRawFd}; use std::os::fd::{AsFd, AsRawFd};
@@ -20,8 +20,6 @@
//! each output's name / enabled / priority / current-mode size, then build a //! each output's name / enabled / priority / current-mode size, then build a
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`. //! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd}; use std::os::fd::{AsFd, AsRawFd};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -14,9 +14,6 @@
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it — //! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
//! is a no-op, so it can never tear down the live monitor). //! is a no-op, so it can never tear down the live monitor).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
@@ -81,6 +81,8 @@ pub(crate) trait VdisplayDriver: Send + Sync {
/// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards. /// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards.
/// The default errs so a backend without support routes to the re-arrival fallback. /// The default errs so a backend without support routes to the re-arrival fallback.
/// ///
// unsafe-fn-no-op-ok: trait method — the "dev is live" contract binds every impl; this
// default body is a stub that bails.
/// # Safety /// # Safety
/// `dev` must be the live control handle. /// `dev` must be the live control handle.
unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> { unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> {
@@ -114,6 +116,7 @@ mod tests {
fn open(&self, _reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> { fn open(&self, _reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
anyhow::bail!("fake driver has no control device") anyhow::bail!("fake driver has no control device")
} }
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
unsafe fn add_monitor( unsafe fn add_monitor(
&self, &self,
_dev: HANDLE, _dev: HANDLE,
@@ -125,9 +128,11 @@ mod tests {
) -> Result<AddedMonitor> { ) -> Result<AddedMonitor> {
anyhow::bail!("fake driver adds no monitors") anyhow::bail!("fake driver adds no monitors")
} }
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
unsafe fn remove_monitor(&self, _dev: HANDLE, _key: &MonitorKey) -> Result<()> { unsafe fn remove_monitor(&self, _dev: HANDLE, _key: &MonitorKey) -> Result<()> {
Ok(()) Ok(())
} }
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
unsafe fn ping(&self, _dev: HANDLE) -> Result<()> { unsafe fn ping(&self, _dev: HANDLE) -> Result<()> {
Ok(()) Ok(())
} }
@@ -16,9 +16,6 @@
//! Only the driver-specific bits (GUID, IOCTL codes, request/reply structs, the version handshake) are //! Only the driver-specific bits (GUID, IOCTL codes, request/reply structs, the version handshake) are
//! here, per `pf_driver_proto`. //! here, per `pf_driver_proto`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::ffi::c_void; use std::ffi::c_void;
use std::mem::size_of; use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
+3 -4
View File
@@ -112,10 +112,9 @@
//! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate //! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate
//! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the //! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the
//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is //! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof, //! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof — enforced by
//! enforced (and unlike the encoder there is NO file-level //! the workspace `[workspace.lints]` tables, and (unlike the encoder) with NO file-level
//! `unsafe_op_in_unsafe_fn` exemption every operation is individually fenced): //! `unsafe_op_in_unsafe_fn` exemption: every operation is individually fenced.
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod caps; pub mod caps;
pub mod caps_av1; pub mod caps_av1;
-2
View File
@@ -88,8 +88,6 @@
//! the readback geometry (row pitch / crop) or intra decode; mismatches that //! the readback geometry (row pitch / crop) or intra decode; mismatches that
//! only appear on later frames point at inter prediction / DPB management. //! only appear on later frames point at inter prediction / DPB management.
#![deny(clippy::undocumented_unsafe_blocks)]
mod common; mod common;
use ash::vk; use ash::vk;
-2
View File
@@ -39,8 +39,6 @@
//! so releases pass `false`), soak, and both vendors' DPB arrangements at once //! so releases pass `false`), soak, and both vendors' DPB arrangements at once
//! (each box exercises only its own). //! (each box exercises only its own).
#![deny(clippy::undocumented_unsafe_blocks)]
mod common; mod common;
use ash::vk; use ash::vk;
@@ -28,9 +28,6 @@
//! suspects — without ever touching the CCD lock itself (the display-config lock is exactly what //! suspects — without ever touching the CCD lock itself (the display-config lock is exactly what
//! stalls during churn; the capture thread must never block on it). //! stalls during churn; the capture thread must never block on 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::collections::VecDeque;
use std::sync::{Mutex, Once, OnceLock}; use std::sync::{Mutex, Once, OnceLock};
use std::time::Instant; use std::time::Instant;
-2
View File
@@ -12,8 +12,6 @@
// `win_display` has denied both unsafe-proof lints since its CCD helpers stopped being `unsafe fn`; // `win_display` has denied both unsafe-proof lints since its CCD helpers stopped being `unsafe fn`;
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`, // hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
// `display_events`) and any future one are covered by default rather than by remembering to opt in. // `display_events`) and any future one are covered by default rather than by remembering to opt in.
#![deny(clippy::undocumented_unsafe_blocks)]
#![deny(unsafe_op_in_unsafe_fn)]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub mod display_events; pub mod display_events;
-7
View File
@@ -8,13 +8,6 @@
//! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The //! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The
//! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal. //! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal.
// Every `unsafe` block in this file 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 every CCD/GDI helper
// below — including `restore_displays_ccd`, the call pf-vdisplay's teardown path depends on to give
// the operator their physical panels back.
#![deny(unsafe_op_in_unsafe_fn)]
// The CCD/GDI helpers below are SAFE fns. They were `unsafe fn` for a decade of habit rather than a // The CCD/GDI helpers below are SAFE fns. They were `unsafe fn` for a decade of habit rather than a
// memory-safety obligation: every one takes `Copy` scalars or borrowed Rust data, returns owned // memory-safety obligation: every one takes `Copy` scalars or borrowed Rust data, returns owned
// values, and discharges its own FFI preconditions internally (`retry_set_display_config` even binds // values, and discharges its own FFI preconditions internally (`retry_set_display_config` even binds
-3
View File
@@ -14,9 +14,6 @@
//! wait, no harm, and `WaitOutcome::NoFence` tells us the driver doesn't fence (so zero-copy //! wait, no harm, and `WaitOutcome::NoFence` tells us the driver doesn't fence (so zero-copy
//! would still race). //! would still race).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
-3
View File
@@ -6,9 +6,6 @@
//! A worker death — the whole point of the isolation — surfaces as an `Err` with //! A worker death — the whole point of the isolation — surfaces as an `Err` with
//! [`RemoteImporter::dead`] set, never as a host fault. //! [`RemoteImporter::dead`] set, never as a host fault.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE}; use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
use super::egl::DmabufPlane; use super::egl::DmabufPlane;
use super::ipc; use super::ipc;
-2
View File
@@ -18,8 +18,6 @@
//! driver — see [`super::egl`].) //! driver — see [`super::egl`].)
#![allow(non_camel_case_types, non_snake_case)] #![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::os::raw::{c_uint, c_void}; use std::os::raw::{c_uint, c_void};
-2
View File
@@ -5,8 +5,6 @@
//! and drive this layer. //! and drive this layer.
#![allow(non_camel_case_types, non_snake_case)] #![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::os::raw::{c_int, c_uint, c_void}; use std::os::raw::{c_int, c_uint, c_void};
-2
View File
@@ -12,8 +12,6 @@
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately. //! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
#![allow(non_upper_case_globals)] #![allow(non_upper_case_globals)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{ensure, Context as _, Result}; use anyhow::{ensure, Context as _, Result};
-2
View File
@@ -5,8 +5,6 @@
//! [`super`]. //! [`super`].
#![allow(non_upper_case_globals)] #![allow(non_upper_case_globals)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, ensure, Result}; use anyhow::{bail, ensure, Result};
use std::os::raw::{c_int, c_void}; use std::os::raw::{c_int, c_void};
-3
View File
@@ -18,9 +18,6 @@
//! inode with `punktfunk-host`, because a shared inode shares the file capability — so it passes //! inode with `punktfunk-host`, because a shared inode shares the file capability — so it passes
//! its own resolved path to [`spawn_worker`] instead. //! its own resolved path to [`spawn_worker`] instead.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize; use serde::Serialize;
use std::fs::File; use std::fs::File;
-3
View File
@@ -34,9 +34,6 @@
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite //! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
//! mode degrades to no cursor (warned once) — never a failed session. //! mode degrades to no cursor (warned once) — never a failed session.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr}; use super::cuda::{self, CUdeviceptr};
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use ash::vk; use ash::vk;
-3
View File
@@ -16,9 +16,6 @@
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the //! a stream's life). Falls back cleanly: any init/import error disables the importer and the
//! CPU mmap path takes over. //! CPU mmap path takes over.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{anyhow, bail, Context as _, Result}; use anyhow::{anyhow, bail, Context as _, Result};
use ash::vk; use ash::vk;
-3
View File
@@ -9,9 +9,6 @@
//! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled //! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled
//! device memory is never freed under a frame the host still reads. //! device memory is never freed under a frame the host still reads.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer}; use super::cuda::{self, CUdeviceptr, DeviceBuffer};
use super::egl::{DmabufPlane, EglImporter}; use super::egl::{DmabufPlane, EglImporter};
use super::ipc; use super::ipc;
+3 -7
View File
@@ -8,13 +8,9 @@
//! consumes the shared frame vocabulary, which sits ABOVE this crate (this crate provides the //! consumes the shared frame vocabulary, which sits ABOVE this crate (this crate provides the
//! `DeviceBuffer` that vocabulary's `FramePayload::Cuda` owns). //! `DeviceBuffer` that vocabulary's `FramePayload::Cuda` owns).
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. Each // Unsafe-proof program: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof, and
// file keeps its own `#![deny(...)]` too; this crate-root deny is the catch-all gate. // `unsafe fn` bodies need explicit blocks (~45 functions' worth of raw driver calls used to sit
// `unsafe_op_in_unsafe_fn` closes the gap the clippy lint leaves: operations inside an // outside that invariant). Both lints are enforced by the workspace `[workspace.lints]` tables.
// `unsafe fn` body are not "unsafe blocks", so without it ~45 functions' worth of raw driver
// calls sat OUTSIDE the invariant this crate advertises.
#![deny(clippy::undocumented_unsafe_blocks)]
#![deny(unsafe_op_in_unsafe_fn)]
/// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll). /// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+88 -28
View File
@@ -53,6 +53,19 @@ use std::os::raw::c_char;
use std::panic::AssertUnwindSafe; use std::panic::AssertUnwindSafe;
use std::ptr; use std::ptr;
/// Poison-recovering lock for the C ABI surface. `.lock().unwrap()` inside an `extern "C"` fn
/// turns a poisoned mutex (some other thread panicked mid-write) into a panic across the C
/// boundary — an abort since Rust 1.81, exactly the class the panic-in-extern grep gate exists
/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so
/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand
/// out; recovering the guard is strictly better than aborting the embedding application.
/// (`quic`-gated with its only callers, the `punktfunk_connection_*` entry points — a
/// `default-features = false` consumer like the tray would otherwise see dead code.)
#[cfg(feature = "quic")]
fn lock_recover<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Opaque session handle. Pointer-only from C. /// Opaque session handle. Pointer-only from C.
pub struct PunktfunkSession { pub struct PunktfunkSession {
inner: Session, inner: Session,
@@ -471,8 +484,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
} }
match s.inner.poll_frame() { match s.inner.poll_frame() {
Ok(frame) => { Ok(frame) => {
s.last_frame = Some(frame); let f = s.last_frame.insert(frame);
let f = s.last_frame.as_ref().unwrap();
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value. // matching `#[repr(C)]` type, written once by value.
unsafe { unsafe {
@@ -494,8 +506,10 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
/// Client: serialize and send one input event to the host. /// Client: serialize and send one input event to the host.
/// ///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety /// # Safety
/// `s` is a valid client handle; `ev` points to a valid [`InputEvent`]. /// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn punktfunk_send_input( pub unsafe extern "C" fn punktfunk_send_input(
s: *mut PunktfunkSession, s: *mut PunktfunkSession,
@@ -509,12 +523,11 @@ pub unsafe extern "C" fn punktfunk_send_input(
Some(s) => s, Some(s) => s,
None => return PunktfunkStatus::NullPointer, None => return PunktfunkStatus::NullPointer,
}; };
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller // SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` // for the one field where a reference formed too early would be UB instead.
// here handles. let ev = match unsafe { read_input_event(ev) } {
let ev = match unsafe { ev.as_ref() } { Ok(e) => e,
Some(e) => e, Err(status) => return status,
None => return PunktfunkStatus::NullPointer,
}; };
match s.inner.send_input(ev) { match s.inner.send_input(ev) {
Ok(()) => PunktfunkStatus::Ok, Ok(()) => PunktfunkStatus::Ok,
@@ -523,6 +536,31 @@ pub unsafe extern "C" fn punktfunk_send_input(
}) })
} }
/// Validate caller memory as an [`InputEvent`] WITHOUT forming the reference first.
///
/// `InputEvent.kind` is a `#[repr(u8)]` enum with 16 valid discriminants, and a C embedder
/// writing `ev->kind = 42` is not a decodable error once `&InputEvent` exists — forming the
/// reference IS the UB, by the language's validity rule. So the tag is read as a raw byte and
/// validated through the same `InputKind::from_u8` the wire path uses (`input.rs::decode`),
/// and the typed reference comes into existence only afterwards. Every other field is a plain
/// integer (or the `[u8; 3]` pad), valid for any bit pattern.
///
/// # Safety
/// `ev` is null (reported as a status) or readable for `size_of::<InputEvent>()` bytes.
unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent, PunktfunkStatus> {
if ev.is_null() {
return Err(PunktfunkStatus::NullPointer);
}
// SAFETY: non-null per the check above, readable per this fn's contract; a one-byte read
// at offset 0 (the `kind` tag — repr(C) puts it first) cannot itself be UB for any value.
if crate::input::InputKind::from_u8(unsafe { ev.cast::<u8>().read() }).is_none() {
return Err(PunktfunkStatus::InvalidArg);
}
// SAFETY: non-null, readable, and the discriminant byte was just validated — every field
// of the repr(C) struct now holds a valid bit pattern for its type.
Ok(unsafe { &*ev })
}
/// Register the host-side input callback (pass a NULL fn pointer to clear). The callback /// Register the host-side input callback (pass a NULL fn pointer to clear). The callback
/// fires from within [`punktfunk_host_poll_input`], on the calling thread. /// fires from within [`punktfunk_host_poll_input`], on the calling thread.
/// ///
@@ -2249,9 +2287,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
.next_frame(std::time::Duration::from_millis(timeout_ms as u64)) .next_frame(std::time::Duration::from_millis(timeout_ms as u64))
{ {
Ok(frame) => { Ok(frame) => {
let mut slot = c.last.lock().unwrap(); let mut slot = lock_recover(&c.last);
*slot = Some(frame); let f = slot.insert(frame);
let f = slot.as_ref().unwrap();
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value. // matching `#[repr(C)]` type, written once by value.
unsafe { unsafe {
@@ -2314,9 +2351,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
.next_audio(std::time::Duration::from_millis(timeout_ms as u64)) .next_audio(std::time::Duration::from_millis(timeout_ms as u64))
{ {
Ok(pkt) => { Ok(pkt) => {
let mut slot = c.last_audio.lock().unwrap(); let mut slot = lock_recover(&c.last_audio);
*slot = Some(pkt); let p = slot.insert(pkt);
let p = slot.as_ref().unwrap();
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value. // matching `#[repr(C)]` type, written once by value.
unsafe { unsafe {
@@ -2467,7 +2503,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
Ok(pkt) => pkt, Ok(pkt) => pkt,
Err(e) => return e.status(), Err(e) => return e.status(),
}; };
let mut state = c.audio_pcm.lock().unwrap(); let mut state = lock_recover(&c.audio_pcm);
match state.decode_packet(&pkt.data, pkt.seq, channels) { match state.decode_packet(&pkt.data, pkt.seq, channels) {
// Nothing to hand out this call: a DTX silence marker with no loss owed before it. // Nothing to hand out this call: a DTX silence marker with no loss owed before it.
Ok(0) => PunktfunkStatus::NoFrame, Ok(0) => PunktfunkStatus::NoFrame,
@@ -3072,9 +3108,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
.next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64)) .next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64))
{ {
Ok(shape) => { Ok(shape) => {
let mut slot = c.last_cursor_shape.lock().unwrap(); let mut slot = lock_recover(&c.last_cursor_shape);
*slot = Some(shape); let sh = slot.insert(shape);
let sh = slot.as_ref().unwrap();
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value. // matching `#[repr(C)]` type, written once by value.
unsafe { unsafe {
@@ -3363,8 +3398,10 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue). /// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
/// ///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety /// # Safety
/// `c` is a valid connection handle; `ev` points to a valid [`InputEvent`]. /// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_send_input( pub unsafe extern "C" fn punktfunk_connection_send_input(
@@ -3379,12 +3416,11 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
Some(c) => c, Some(c) => c,
None => return PunktfunkStatus::NullPointer, None => return PunktfunkStatus::NullPointer,
}; };
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller // SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` // for the one field where a reference formed too early would be UB instead.
// here handles. let ev = match unsafe { read_input_event(ev) } {
let ev = match unsafe { ev.as_ref() } { Ok(e) => e,
Some(e) => e, Err(status) => return status,
None => return PunktfunkStatus::NullPointer,
}; };
match c.inner.send_input(ev) { match c.inner.send_input(ev) {
Ok(()) => PunktfunkStatus::Ok, Ok(()) => PunktfunkStatus::Ok,
@@ -4053,7 +4089,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
.next_clip(std::time::Duration::from_millis(timeout_ms as u64)) .next_clip(std::time::Duration::from_millis(timeout_ms as u64))
{ {
Ok(ev) => { Ok(ev) => {
let mut slot = c.last_clip.lock().unwrap(); let mut slot = lock_recover(&c.last_clip);
let out_ev = build_clip_event(ev, &mut slot); let out_ev = build_clip_event(ev, &mut slot);
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path, // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value. // written once by value.
@@ -4065,7 +4101,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
// traffic is sporadic, so without this a one-off 50 MiB paste stays resident // traffic is sporadic, so without this a one-off 50 MiB paste stays resident
// for the rest of the session (there is no other release entry point). The // for the rest of the session (there is no other release entry point). The
// borrow contract already says `out` data is valid only until the next call. // borrow contract already says `out` data is valid only until the next call.
*c.last_clip.lock().unwrap() = None; *lock_recover(&c.last_clip) = None;
e.status() e.status()
} }
} }
@@ -4809,6 +4845,30 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
mod tests { mod tests {
use super::*; use super::*;
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
/// exists on the test's own side either.
#[test]
fn read_input_event_rejects_null_and_bad_discriminant() {
// SAFETY: null is the documented reported-not-UB case.
let null_result = unsafe { read_input_event(std::ptr::null()) };
assert_eq!(null_result.unwrap_err(), PunktfunkStatus::NullPointer);
let mut slot = core::mem::MaybeUninit::<InputEvent>::zeroed();
let p = slot.as_mut_ptr();
// SAFETY: writing one byte at offset 0 of aligned, sized storage.
unsafe { p.cast::<u8>().write(42) };
// SAFETY: `p` is aligned and readable for the full struct.
let bad_tag = unsafe { read_input_event(p) };
assert_eq!(bad_tag.unwrap_err(), PunktfunkStatus::InvalidArg);
// SAFETY: as above; tag 0 (KeyDown) + zeroed fields is a fully valid event.
unsafe { p.cast::<u8>().write(0) };
// SAFETY: as above.
let ev = unsafe { read_input_event(p) }.expect("valid tag must pass");
assert_eq!(ev.kind, crate::input::InputKind::KeyDown);
}
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the /// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic /// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19). /// packing idiom — no struct growth, so the size guard above stays at 19).
-1
View File
@@ -46,7 +46,6 @@
// `qos_windows`) — sendmmsg/recvmsg_x/USO/qWAVE move caller-owned buffers, nothing more. // `qos_windows`) — sendmmsg/recvmsg_x/USO/qWAVE move caller-owned buffers, nothing more.
// A new module parsing wire data may NOT add a carve-out. // A new module parsing wire data may NOT add a carve-out.
#![deny(unsafe_code)] #![deny(unsafe_code)]
#![deny(clippy::undocumented_unsafe_blocks)]
#![forbid(unsafe_op_in_unsafe_fn)] #![forbid(unsafe_op_in_unsafe_fn)]
pub mod abi; pub mod abi;
+35 -7
View File
@@ -11,24 +11,52 @@ profile="${1:-debug}"
build_flag="" build_flag=""
[ "$profile" = "release" ] && build_flag="--release" [ "$profile" = "release" ] && build_flag="--release"
echo ">> building punktfunk-core staticlib ($profile)" # PF_SAN=address instruments BOTH sides of the C boundary at once: the staticlib via
cargo build -p punktfunk-core $build_flag >/dev/null # -Zsanitizer (nightly + -Zbuild-std, so std itself is instrumented) and the harness via
# clang -fsanitize. LSAN rides along (detect_leaks=1) and is the only automated check on
# the Box::into_raw/from_raw leak contract in abi.rs. Linux x86_64 only; -Zbuild-std
# defeats sccache, so this belongs on a cron/dispatch job, not the per-push leg.
san="${PF_SAN:-}"
toolchain=""
target_args=""
target_sub=""
if [ -n "$san" ]; then
san_target="x86_64-unknown-linux-gnu"
# -Zsanitizer/-Zbuild-std need a nightly; PF_SAN_TOOLCHAIN pins a dated one (CI does).
toolchain="+${PF_SAN_TOOLCHAIN:-nightly}"
target_args="-Z build-std --target $san_target"
target_sub="$san_target/"
export RUSTFLAGS="-Zsanitizer=$san${RUSTFLAGS:+ $RUSTFLAGS}"
fi
staticlib="$ws/target/$profile/libpunktfunk_core.a" echo ">> building punktfunk-core staticlib ($profile${san:+, sanitizer=$san})"
cargo $toolchain build $target_args -p punktfunk-core $build_flag >/dev/null
staticlib="$ws/target/${target_sub}$profile/libpunktfunk_core.a"
header_dir="$ws/include" header_dir="$ws/include"
[ -f "$staticlib" ] || { echo "missing $staticlib"; exit 1; } [ -f "$staticlib" ] || { echo "missing $staticlib"; exit 1; }
[ -f "$header_dir/punktfunk_core.h" ] || { echo "missing generated header"; exit 1; } [ -f "$header_dir/punktfunk_core.h" ] || { echo "missing generated header"; exit 1; }
# Ask rustc what native libs the staticlib needs to link into a C program. # Ask rustc what native libs the staticlib needs to link into a C program.
native_libs="$(cargo rustc -p punktfunk-core --lib --crate-type staticlib $build_flag -- \ native_libs="$(cargo $toolchain rustc $target_args -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
--print native-static-libs 2>&1 | sed -n 's/.*native-static-libs: //p' | tail -1)" --print native-static-libs 2>&1 | sed -n 's/.*native-static-libs: //p' | tail -1)"
echo ">> native libs: ${native_libs:-<none>}" echo ">> native libs: ${native_libs:-<none>}"
out="$(mktemp -d)/punktfunk_harness" # Not mktemp: a debug+ASAN static binary can exceed a tmpfs /tmp; target/ is real disk.
out="$ws/target/${target_sub}$profile/punktfunk_harness"
cc="${CC:-cc}" cc="${CC:-cc}"
cflags=""
if [ -n "$san" ]; then
cc="${CC:-clang}"
cflags="-fsanitize=$san -fno-omit-frame-pointer"
fi
echo ">> compiling + linking harness" echo ">> compiling + linking harness"
$cc -std=c11 -Wall -Wextra -O2 -I "$header_dir" \ $cc -std=c11 -Wall -Wextra -O2 $cflags ${CFLAGS:-} -I "$header_dir" \
"$here/harness.c" "$staticlib" $native_libs -o "$out" "$here/harness.c" "$staticlib" $native_libs -o "$out"
echo ">> running" echo ">> running"
"$out" if [ -n "$san" ]; then
ASAN_OPTIONS="detect_leaks=1${ASAN_OPTIONS:+:$ASAN_OPTIONS}" "$out"
else
"$out"
fi
@@ -46,9 +46,6 @@
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still //! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still
//! computed, since the mic must still pick a target. //! computed, since the mic must still pick a target.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring}; use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use std::ffi::c_void; use std::ffi::c_void;
@@ -21,9 +21,6 @@
//! endpoint of that name, and the probe restores the default playback/recording devices it //! endpoint of that name, and the probe restores the default playback/recording devices it
//! disturbed before exiting. //! disturbed before exiting.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::pad_endpoint as pe; use super::pad_endpoint as pe;
use super::{audio_control, SAMPLE_RATE}; use super::{audio_control, SAMPLE_RATE};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -24,9 +24,6 @@
//! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk //! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk
//! removes what punktfunk created, and nothing else. //! removes what punktfunk created, and nothing else.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{audio_control, audio_probe, minted, pad_endpoint as pe}; use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result; use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo; use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
@@ -35,9 +35,6 @@
//! COM discipline matches the sibling modules: WASAPI/COM objects live on the thread that made //! COM discipline matches the sibling modules: WASAPI/COM objects live on the thread that made
//! them (the provisioning worker, the capture thread); only channels and plain data cross. //! them (the provisioning worker, the capture thread); only channels and plain data cross.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{audio_control, AudioCapturer, SAMPLE_RATE}; use super::{audio_control, AudioCapturer, SAMPLE_RATE};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::collections::{HashSet, VecDeque}; use std::collections::{HashSet, VecDeque};
@@ -28,9 +28,6 @@
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI //! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`). //! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE}; use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -17,9 +17,6 @@
//! data packets are consumed immediately and missing parity only costs loss recovery — so //! data packets are consumed immediately and missing parity only costs loss recovery — so
//! the validated stereo path stays byte-identical (data packets only, exactly as before). //! the validated stereo path stays byte-identical (data packets only, exactly as before).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", target_os = "windows", test))] #[cfg(any(target_os = "linux", target_os = "windows", test))]
use crate::audio::SAMPLE_RATE; use crate::audio::SAMPLE_RATE;
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -3,9 +3,6 @@
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or //! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
//! a synthetic test pattern (default). Runs on its own native thread. //! a synthetic test pattern (default). Runs on its own native thread.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::video::{FrameType, VideoPacketizer}; use super::video::{FrameType, VideoPacketizer};
use super::VIDEO_PORT; use super::VIDEO_PORT;
use crate::capture::{self, Capturer, FastSyntheticCapturer}; use crate::capture::{self, Capturer, FastSyntheticCapturer};
+28 -7
View File
@@ -508,15 +508,25 @@ fn running_as_system() -> bool {
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() { if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed return true; // fail closed
} }
let mut buf = [0u8; 256]; // TOKEN_USER is align-8; a bare `[u8; 256]` is align-1, and forming `&TOKEN_USER` out of it
// below would be UB by the language rule whenever the stack slot happens to land misaligned.
// (Shipped codegen happens to 8-align it today — that is luck, not a guarantee.) The wrapper
// keeps the buffer at 256 BYTES: redeclaring as `[u64; 32]` would silently turn the length
// argument below into 32 — `len()` counts elements — and a console operator's 44-byte
// TOKEN_USER+SID would then fail with ERROR_INSUFFICIENT_BUFFER, misclassifying every
// hand-run host as SYSTEM (it fits exactly for SYSTEM's own 16-byte S-1-5-18, so a
// SYSTEM-side test would not catch it).
#[repr(align(8))]
struct TokenUserBuf([u8; 256]);
let mut buf = TokenUserBuf([0u8; 256]);
let mut len = 0u32; let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param. // SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe { let got = unsafe {
GetTokenInformation( GetTokenInformation(
token, token,
TokenUser, TokenUser,
Some(buf.as_mut_ptr().cast()), Some(buf.0.as_mut_ptr().cast()),
buf.len() as u32, std::mem::size_of_val(&buf) as u32,
&mut len, &mut len,
) )
}; };
@@ -542,11 +552,22 @@ fn running_as_system() -> bool {
{ {
return true; // fail closed return true; // fail closed
} }
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into // SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation (align guaranteed by
// the same buffer, and both SIDs are valid for this comparison. // TokenUserBuf); its `User.Sid` points into the same buffer, and both SIDs are valid for
// this comparison.
unsafe { unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER); let tu = &*(buf.0.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok() // windows-rs maps EqualSid's BOOL(0) to Err BOTH for "SIDs differ" and for a genuine
// failure, telling them apart only via GetLastError — so clear it first (a stale value
// from an earlier call would otherwise read as failure) and split three ways. `.is_ok()`
// here previously meant an EqualSid ERROR yielded "not SYSTEM" — the fail-OPEN
// direction, contradicting the contract in the doc comment above.
windows::Win32::Foundation::SetLastError(windows::Win32::Foundation::WIN32_ERROR(0));
match EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())) {
Ok(()) => true, // equal: we are SYSTEM
Err(e) if e.code().is_ok() => false, // BOOL(0), last-error 0: genuinely not equal
Err(_) => true, // EqualSid itself failed: fail closed
}
} }
} }
@@ -8,8 +8,6 @@
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer //! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
//! gains working `SPA_META_SyncTimeline`. //! gains working `SPA_META_SyncTimeline`.
#![allow(dead_code)] #![allow(dead_code)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
//! //!
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual //! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf //! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
@@ -37,8 +37,6 @@
//! self-heals. Deliberately //! self-heals. Deliberately
//! NOT default-on: it defeats idle downclocking for the whole box and is wrong on //! NOT default-on: it defeats idle downclocking for the whole box and is wrong on
//! battery-powered hosts. //! battery-powered hosts.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::os::raw::{c_char, c_int, c_uint, c_void}; use std::os::raw::{c_char, c_int, c_uint, c_void};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
+3 -9
View File
@@ -13,16 +13,10 @@
// Scaffold: trait methods and config paths are defined ahead of their backends. // Scaffold: trait methods and config paths are defined ahead of their backends.
#![allow(dead_code)] #![allow(dead_code)]
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in the crate must carry a `// SAFETY:` // Unsafe-proof program (both lints now enforced by the workspace `[workspace.lints]` tables):
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers // every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof, and `unsafe fn` bodies need
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders. // explicit blocks. Keep the `unsafe fn` marker only where a caller can actually violate
#![deny(clippy::undocumented_unsafe_blocks)]
// The companion gate: a proof only covers what it is attached to, and an `unsafe fn` body without
// this lint needs no blocks at all — so an unproven FFI call could hide inside one and satisfy the
// deny above. The workspace sets `unsafe_op_in_unsafe_fn` to `warn` (a ratchet across ~590 sites);
// this crate is at zero, so it denies. Keep the marker only where a caller can actually violate
// something — a raw pointer or a borrowed `HANDLE` parameter, as in `service::spawn_host`. // something — a raw pointer or a borrowed `HANDLE` parameter, as in `service::spawn_host`.
#![deny(unsafe_op_in_unsafe_fn)]
mod audio; mod audio;
mod bringup; mod bringup;
-3
View File
@@ -22,9 +22,6 @@
//! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared //! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared
//! with GameStream pairing) and logs the SHA-256 fingerprint clients pin. //! with GameStream pairing) and logs the SHA-256 fingerprint clients pin.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role}; use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
@@ -9,7 +9,6 @@
//! diagnosis. The Rust-panic analogue (a panic hook that tees into `tracing`) lives in `main()`. //! diagnosis. The Rust-panic analogue (a panic hook that tees into `tracing`) lives in `main()`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use windows::Win32::Foundation::HMODULE; use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::Diagnostics::Debug::{ use windows::Win32::System::Diagnostics::Debug::{
@@ -14,13 +14,8 @@
//! that is correct for launching *our own* streamer, but a store launcher needs the real user's token //! that is correct for launching *our own* streamer, but a store launcher needs the real user's token
//! for activation + auth). The host process itself stays SYSTEM. //! for activation + auth). The host process itself stays SYSTEM.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // This module is at zero `unsafe fn` markers; hold it there — `merged_env_block`'s pointer walk
#![deny(clippy::undocumented_unsafe_blocks)] // is the one real contract here, and it must not silently re-absorb the FFI calls around it.
// …and the proofs only cover the whole file once an `unsafe fn` body needs its own blocks: the
// workspace sets `unsafe_op_in_unsafe_fn` to `warn`, which is a ratchet, not a floor. This module is
// at zero, so hold it there — `merged_env_block`'s pointer walk is the one real contract here, and
// it must not silently re-absorb the FFI calls around it.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::path::Path; use std::path::Path;
@@ -25,9 +25,6 @@
//! loaded into the service's environment and carried to the host child. Logs land in //! loaded into the service's environment and carried to the host child. Logs land in
//! `%ProgramData%\punktfunk\logs\`. //! `%ProgramData%\punktfunk\logs\`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::ffi::{c_void, OsString}; use std::ffi::{c_void, OsString};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
+4
View File
@@ -1,3 +1,7 @@
# Vendored snapshot — like pf-bitstream/vendor/cros-codecs, deliberately NOT opted into the
# workspace [lints] tables: upstream code stays as close to pristine as the trim allows, so a
# re-sync against upstream stays a diff, not an archaeology dig. (Zero `unsafe` today anyway.)
#
# Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the # Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the
# USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local # USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local
# `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB # `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB
-1
View File
@@ -11,7 +11,6 @@
//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal //! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal
//! window at every sign-in. //! window at every sign-in.
// Unsafe-proof program: every `unsafe {}` in the tray carries a `// SAFETY:` proof. // Unsafe-proof program: every `unsafe {}` in the tray carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
#![cfg_attr(windows, windows_subsystem = "windows")] #![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+7
View File
@@ -9,6 +9,11 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern. // Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
#![allow(improper_ctypes)] #![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)]
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
include!(concat!(env!("OUT_DIR"), "/bindings.rs")); include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
@@ -23,6 +28,8 @@ mod tests {
#[test] #[test]
fn api_version_matches_vendored_pin() { fn api_version_matches_vendored_pin() {
let (mut major, mut minor, mut patch) = (0u32, 0u32, 0u32); let (mut major, mut minor, mut patch) = (0u32, 0u32, 0u32);
// SAFETY: the version query writes three u32s through live local out-pointers and
// touches no device or global state.
unsafe { pyrowave_get_api_version(&mut major, &mut minor, &mut patch) }; unsafe { pyrowave_get_api_version(&mut major, &mut minor, &mut patch) };
assert_eq!((major, minor, patch), (0, 4, 0), "vendored pyrowave API version moved — re-check the §4.2 protocol coupling before bumping"); assert_eq!((major, minor, patch), (0, 4, 0), "vendored pyrowave API version moved — re-check the §4.2 protocol coupling before bumping");
} }
+6 -2
View File
@@ -2259,8 +2259,10 @@ PunktfunkStatus punktfunk_client_poll_frame(PunktfunkSession *s, PunktfunkFrame
// Client: serialize and send one input event to the host. // Client: serialize and send one input event to the host.
// //
// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
//
// # Safety // # Safety
// `s` is a valid client handle; `ev` points to a valid [`InputEvent`]. // `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
PunktfunkStatus punktfunk_send_input(PunktfunkSession *s, const PunktfunkInputEvent *ev); PunktfunkStatus punktfunk_send_input(PunktfunkSession *s, const PunktfunkInputEvent *ev);
// Register the host-side input callback (pass a NULL fn pointer to clear). The callback // Register the host-side input callback (pass a NULL fn pointer to clear). The callback
@@ -3024,8 +3026,10 @@ PunktfunkStatus punktfunk_connection_shard_payload(PunktfunkConnection *c, uint3
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Send one input event to the host as a QUIC datagram (non-blocking enqueue). // Send one input event to the host as a QUIC datagram (non-blocking enqueue).
// //
// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
//
// # Safety // # Safety
// `c` is a valid connection handle; `ev` points to a valid [`InputEvent`]. // `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
PunktfunkStatus punktfunk_connection_send_input(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_send_input(PunktfunkConnection *c,
const PunktfunkInputEvent *ev); const PunktfunkInputEvent *ev);
#endif #endif
+11
View File
@@ -15,6 +15,17 @@ version = "0.0.1"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
publish = false publish = false
# The same unsafe discipline as the main workspace (see its Cargo.toml for the full rationale).
# Restated here because THIS IS A SEPARATE WORKSPACE: the main tree's [workspace.lints] does not
# reach these crates, so any "workspace-wide" lint claim has to be made here too or it is false
# for the driver surface. Every member opts in with `[lints] workspace = true`.
# (`unsafe_op_in_unsafe_fn` is already the edition-2024 default; deny pins it explicitly.)
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
[workspace.dependencies] [workspace.dependencies]
wdk = "0.4.1" wdk = "0.4.1"
wdk-sys = "0.5.1" wdk-sys = "0.5.1"
@@ -32,3 +32,6 @@ pf-umdf-util.workspace = true
default = ["hid"] default = ["hid"]
hid = ["wdk-sys/hid"] hid = ["wdk-sys/hid"]
nightly = ["wdk-sys/nightly", "wdk/nightly"] nightly = ["wdk-sys/nightly", "wdk/nightly"]
[lints]
workspace = true

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