Compare commits

...
Author SHA1 Message Date
enricobuehler ef0af3b558 docs(changelog): the Windows host no longer blocks system sleep while idle 2026-08-12 17:41:08 +02:00
enricobuehler 535e95c4c0 fix(punktfunk-host): idle-stop the virtual-mic stream so a Windows host can sleep
The mic pump's WASAPI backend rendered silence into the virtual mic's
render endpoint (typically the Steam Streaming Microphone) for the whole
host lifetime. A RUNNING stream makes the Windows audio stack hold a
kernel power request ("An audio stream is currently in use", visible in
powercfg /requests), so every idle Punktfunk host blocked system sleep
forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since
installing punktfunk; powercfg shows the Steam Streaming Microphone").

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

During a session the box stays awake through the session's own
DisplayWakeRequest (pf-frame), never through this silence.
PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case
a virtual audio driver misbehaves while its render side is paused.
2026-08-12 17:39:22 +02:00
enricobuehler 28b6633058 Merge pull request 'feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming' (#176) from worktree-edid-lock-toggle into main 2026-08-12 13:12:11 +00:00
enricobuehler c58217e403 Merge pull request 'fix(client): stop the double-arm race re-freezing RFI-healed streams' (#174) from worktree-gate-double-arm-fix into main 2026-08-12 13:11:51 +00:00
enricobuehler 23f9b1130e Merge pull request 'Black-stream fixes: CCD restore snapshot pruning, iOS silent-black recovery, devnode journal retention' (#175) from worktree-black-stream-fixes into main 2026-08-12 13:11:29 +00:00
enricobuehler c2c71f0ac5 Merge pull request 'fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default' (#173) from worktree-rt-gpu-priority-defaults into main 2026-08-12 13:11:02 +00:00
enricobuehler 6a506a8fa9 fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:

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

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

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

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

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

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

The restore now prunes the snapshot against a live QDC_ALL_PATHS availability
sweep before replaying: stale paths drop, the mode table is rebuilt with only
the entries the survivors reference (an orphaned mode entry is itself an
0x57), and an empty survivor set skips the apply so the dark-desk backstop
decides. Verbatim behavior is unchanged when everything is still attached or
when the availability query itself fails.
2026-08-12 13:15:02 +02:00
enricobuehler f4e39a442b feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming
Productizes the adl-emul probe (the prior commit) as the display-policy axis its
PR promised: the ADL FFI moves to pf_win_display::adl_emul (one surface shared by
the probe tool and the host, so a reporter's probe and the console's toggle
exercise byte-identical driver calls), and an EXPERIMENTAL edid_lock axis joins
ddc_power_off/pnp_disable_monitors — orthogonal to presets, off by default.

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

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

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

The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
2026-08-12 08:11:35 +02:00
enricobuehler 66ba61b12c Merge pull request 'Safety round 2: ASAN+LSAN over the C ABI boundary, two soundness fixes, and WP4's AvFrame RAII' (#172) from worktree-safety-round-2 into main
Reviewed-on: unom/punktfunk#172
2026-08-12 05:57:16 +00:00
enricobuehler 5002849737 feat(pf-encode): WP4 — AvFrame/AvSwsContext RAII across all three libav backends
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 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 2026-08-11 21:57:24 +00:00
enricobuehler 549fdf238b Merge branch 'main' into worktree-lint-ratchets 2026-08-11 21:57:17 +00:00
enricobuehler 3ea411fa39 Merge branch 'main' into worktree-miri-ci 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 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
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 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
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 2026-08-11 20:47:15 +00:00
142 changed files with 3412 additions and 933 deletions
+269
View File
@@ -15,6 +15,17 @@
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# * miri → NON-BLOCKING interpretation of the few FFI-free leaf crates, one of them
# cross-compiled to MSVC layout. Not a supply-chain scan; it lives here because
# audit.yml already has exactly the shape it needs (weekly cron,
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
# the only automated check on its Box::into_raw/from_raw leak contract. Same
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
# must not ride the per-push leg.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
@@ -44,6 +55,13 @@ on:
- 'about.toml'
- '.gitea/workflows/audit.yml'
workflow_dispatch:
# NOTE on the `paths:` list above and the `miri:` job: `crates/pf-driver-proto/**` is deliberately
# NOT listed, even though that crate is what the Miri job exists to watch. `paths:` is a
# WORKFLOW-level filter — adding it would fire all six jobs (three bun trees, pnpm, cargo-audit,
# the license gate) on every driver-proto edit, onto a fleet where 37 of 46 jobs contend for
# ubuntu-24.04, to run one 2-minute job. Weekly cron + workflow_dispatch is the day-one cadence;
# revisit once the job has a green history, and if you do, prefer moving miri to its own workflow
# file over widening this filter.
jobs:
cargo-audit:
@@ -177,3 +195,254 @@ jobs:
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
# ── Miri ─────────────────────────────────────────────────────────────────────────────────────
# WHAT THIS BUYS, precisely — one thing, and it is worth having:
# It interprets `pf-driver-proto` CROSS-COMPILED TO `x86_64-pc-windows-msvc`, on a Linux
# runner, with no Windows box anywhere in the loop. That crate is `#![forbid(unsafe_code)]`
# and is path-dep'd by BOTH the main workspace and the driver workspace, so it is the layout
# oracle for every frame and IOCTL crossing that boundary — and drift there is silent
# corruption, not a compile error. Nothing else in CI checks it at MSVC layout.
# On the first run ever performed against this repo it found a real defect: a layout test
# reading an align-8 struct out of an align-1 stack buffer, which had passed on every machine
# and every CI leg since it was written because a stack `[u8; 40]` usually lands 8-aligned.
#
# WHAT IT DOES NOT BUY — do not let anyone report this as unsafe coverage, and do not publish a
# "Miri coverage" percentage; it would be noise. Miri can execute on the order of 2% of the
# host's unsafe. It cannot run ash, windows-rs, ffmpeg, CUDA or the WDK, and in those crates
# the unsafe *is* the foreign call, so there is nothing for an interpreter to execute. This
# job is a targeted instrument for three leaf surfaces, not a safety net.
#
# NON-BLOCKING, deliberately, and via a step-level `||` — NOT job-level `continue-on-error`,
# which act_runner does not reliably honor (same reasoning as docs-site-audit above; a red job
# here would take the whole run red). Flip to blocking only after several weeks of green
# establish the nightly-drift rate.
#
# Do NOT add crates here because they merely compile under Miri. Add them because they contain
# pure-Rust unsafe or a layout contract worth interpreting. Explicitly excluded:
# * pf-bitstream — its compile did not finish in 27 min at 2.1 GB RSS, and it is
# `forbid(unsafe_code)`, so there is nothing to find. Do not re-add it.
# * pf-update-check — ring; every FFI crate — dies on the first foreign call. Structural.
# * punktfunk-core in bulk — `-- fec packet crypto` selects 63 tests and was killed at a
# 25-minute cap with not one test reported complete. Only the narrow
# `fec::gf8` selection below is affordable, and it was timed before it
# was committed. Do not widen this filter without timing the result.
#
# MEASURED, not estimated — 192.168.1.25 (Ubuntu, 8 cores), on the DATED toolchain this job
# actually installs, with a COLD target dir and a COLD sysroot cache (so each step's figure
# includes building the Miri sysroot it needs) and a warm cargo registry. Every step below has
# been run start to finish; nothing here is extrapolated:
# step A 21 + 12 + 4 pass 43 s
# step B 21 pass 26 s
# step C 2 pass 63 s
# TOTAL 132 s cold. Interpretation itself is ~10 s of that; the rest is compiling, plus ~38 s
# of one-time sysroot builds (21 s host + 17 s MSVC) that the cache below then carries.
# Warm, the three steps are ~6 s / ~3 s / ~10 s. `timeout-minutes: 30` is therefore vast
# headroom, kept deliberately so a first fully-uncached run — which additionally downloads a
# ~400 MB toolchain and the registry — cannot trip it.
# If you add a step, MEASURE IT FIRST. The estimate this job replaced said "under 15 s across
# all four steps" and was extrapolated from a partial run; the real punktfunk-core figure was
# >25 min. Extrapolation is exactly how that happened.
miri:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# A DATED nightly, bumped deliberately — exactly like rust-toolchain.toml, and for the same
# reason. The cache keys below carry this value, so bumping it self-invalidates them.
# ⚠ `nightly-<date>` names the day rustup PUBLISHED the build, and that build is compiled
# from the PREVIOUS day's commit. This pin therefore resolves to
# `rustc 1.99.0-nightly (969b803cb 2026-08-09)` [verified by installing it], NOT the
# `12c36e253 2026-08-10` that the rust-safety programme doc's §7 table cites — that figure
# came from the ROLLING `nightly` channel and was mislabelled as the dated one. Harmless,
# but do not "fix" the date to chase that hash: all three steps below were re-run and are
# green on the dated toolchain this job actually installs.
MIRI_TOOLCHAIN: nightly-2026-08-10
# A GUARD, not a fix for a present problem: audit.yml sets no sccache — only ci.yml does, at
# workflow level (ci.yml:27). `cargo-miri` REPLACES rustc and cannot be wrapped; it prints
# "Ignoring `RUSTC_WRAPPER` environment variable, Miri does not support wrapping" and
# carries on [verified]. This keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
# -Zmiri-disable-isolation: pf-gpu's tests mkdir, and Miri aborts them without it [verified].
# -Zmiri-symbolic-alignment-check: the whole point — it refuses to let an accidentally
# favourable stack slot stand in for an alignment guarantee. This is the flag that caught
# the pf-driver-proto defect.
# NOTE the absence of -Zmiri-ignore-leaks. Miri leak-checks by DEFAULT, and that is the one
# leak-detection capability it offers here. None of the crates below leaks, so the job is
# green. The tree does contain DELIBERATE leaks (pf-umdf-util/src/section.rs `ViewCell`,
# gamepad_raii.rs leak-on-timeout) — when coverage ever reaches them, annotate those two
# sites; do not blanket-disable the check.
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
steps:
- uses: actions/checkout@v4
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
# belongs with the toolchain, not with the lockfile.
- name: cache the nightly toolchain + Miri sysroots
uses: actions/cache@v4
with:
path: |
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
~/.cache/miri
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: miri-registry-v1-
# The image needs no change for this: ci/rust-ci.Dockerfile:51-54 installs via rustup and
# `chmod -R a+w`s both RUSTUP_HOME and CARGO_HOME, so a job can add a toolchain at runtime.
# `rust-src` is required — cargo-miri builds its sysroot from source, per target.
#
# This does NOT disturb the 1.96.0 pin: `cargo +<toolchain>` overrides rust-toolchain.toml
# for that single invocation only, so `cargo fmt` / `clippy` keep resolving 1.96.0 and the
# fmt-parity contract in CLAUDE.md is untouched. The two echo lines below keep that claim
# honest in the log. They are deliberately NOT `rustup show active-toolchain`: that command
# RESOLVES the toolchain file and would install the whole 1.96.0 toolchain just to print a
# line, in a job where every cargo call is `+$MIRI_TOOLCHAIN` and 1.96.0 is never needed.
# Deliberately NOT `rustup override set` — that writes persistent per-directory state into
# the runner's rustup config, which leaks into unrelated later jobs on a self-hosted fleet.
# Deliberately NOT a second rust-toolchain.toml in a subdirectory — that would apply to
# every cargo invocation under that subtree including fmt, which is the drift the root pin
# exists to prevent.
- name: install the pinned nightly + miri
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$MIRI_TOOLCHAIN" \
--profile minimal \
--component miri,rust-src \
--target x86_64-pc-windows-msvc
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$MIRI_TOOLCHAIN" --version
# A run that reports `0 passed` is a selection that matched nothing, not a success — that
# exact mistake has already cost one round-trip here. So each step below checks a zero exit
# AND that at least one target reported a non-zero pass count, which is what catches a
# crate rename or a `--` filter that stops matching. (Each step legitimately prints several
# `0 passed` lines too — the empty bin/doctest targets — so the check is "at least one
# non-zero", not "no zeroes".) Expected counts at the time of writing: 21 + 12 + 4.
- name: miri — FFI-free leaf crates (native)
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto -p pf-host-config -p pf-gpu 2>&1 | tee /tmp/miri-native.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-native.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (FFI-free leaf crates, native) did not pass — non-blocking; see punktfunk-planning design/rust-safety-programme.md §7"
# THE step that justifies the job: pf-driver-proto at MSVC layout, on Linux, no Windows box.
# Expected: 21 passed. If this one ever goes red, treat it as a layout-contract break
# between the host and driver workspaces until proven otherwise.
- name: miri — pf-driver-proto at x86_64-pc-windows-msvc layout
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto --target x86_64-pc-windows-msvc 2>&1 | tee /tmp/miri-msvc.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-msvc.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
env:
RUSTFLAGS: -C target-feature=+avx2,+ssse3
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
# Box::into_raw/from_raw leak contract.
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
# default-feature (quic-less, opus-less) core only.
c-abi-asan:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
SAN_TOOLCHAIN: nightly-2026-08-10
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: san-registry-v1-
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
- name: install the pinned nightly + rust-src
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$SAN_TOOLCHAIN" --version
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
# bun-audit job's apt-get above relies on the same fact).
- name: ensure clang's ASAN runtime
run: |
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
fi
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
# nightly installed above — without it the script would ask for the ROLLING `nightly`
# channel, which this job deliberately does not install.
- name: C ABI harness under ASAN+LSAN
run: |
set -o pipefail
ok=1
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
+9 -2
View File
@@ -111,6 +111,13 @@ jobs:
- name: Format
run: cargo fmt --all --check
# rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers
# carrying no contract, panic across an extern boundary (an abort since 1.81), and
# process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo.
# Both failure modes were demonstrated before this became blocking (planted instances).
- name: Unsafe-hygiene grep gates
run: sh scripts/ci/check-unsafe-hygiene.sh
- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings
@@ -139,8 +146,8 @@ jobs:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
# `cargo build`, where warnings are not errors, so pf-encode's own
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
# `cargo build`, where warnings are not errors, so the `undocumented_unsafe_blocks` deny
# (now hoisted into [workspace.lints]) — pf-encode's stated unsafe-proof gate —
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.)
#
+4 -3
View File
@@ -159,9 +159,10 @@ jobs:
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
# `undocumented_unsafe_blocks`); this step keeps them from regressing. wdk-probe is a
# toolchain-only probe crate, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
# runs the same gates.
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay -p wdk-probe --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
+14
View File
@@ -184,6 +184,20 @@ but latches nothing; only the full-length attempts that follow hand down negotia
classification is a pure function with tests
(`pf_capture::linux::first_frame_timeout_tests`).
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
within one device period — below the jitter buffer's prime depth, so nothing is audible.
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
third-party virtual audio driver misbehaves while its render side is paused.
## v0.27.0
87 commits since v0.26.0.
Generated
+1
View File
@@ -1116,6 +1116,7 @@ dependencies = [
name = "display-disturb"
version = "0.27.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
+11
View File
@@ -101,6 +101,17 @@ repository = "https://git.unom.io/unom/punktfunk"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
# The companion lint: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof. Hoisted here
# from ~85 per-file `#![deny(...)]` attributes so a NEW crate (or a new module in an old one) is
# covered on creation rather than on remembering — the per-file form left pf-vkhdr-layer,
# wdk-probe, and half of pf-clipboard uncovered for months. NOTE: this table reaches only crates
# with `[lints] workspace = true`; `packaging/windows/drivers` and `packaging/windows/pf-vkhdr-layer`
# are SEPARATE workspaces and restate it (any "workspace-wide" claim must be made three times or it
# is false). Of the members, only the two vendored snapshots (pf-bitstream/vendor/cros-codecs,
# punktfunk-host/vendor/usbip-sim) stay out, deliberately — upstream code stays pristine.
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+5 -1
View File
@@ -53,7 +53,7 @@
"clients"
],
"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",
"parameters": [
{
@@ -4753,6 +4753,10 @@
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
},
"edid_lock": {
"type": "boolean",
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
},
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -43,10 +43,12 @@ struct OutputReady {
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
Au(Frame, bool),
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
/// request).
Au(Frame, u32),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
@@ -603,7 +605,11 @@ fn feeder_loop(
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
let gap = if au_first {
client.note_frame_index(frame.frame_index)
} else {
0
};
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
@@ -691,9 +697,12 @@ fn dispatch_event(
match ev {
DecodeEvent::Au(f, gap) => {
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
// fold `on_decoded` (keyed by the pts the codec will echo).
if gap {
gate.arm(Instant::now());
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
// race — see `ReanchorGate::arm_expecting_drops`).
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
// arms the freeze gate so the decoder's concealment is held off the screen until the
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
if client.note_frame_index(frame.frame_index) {
gate.arm(Instant::now());
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
let gap = client.note_frame_index(frame.frame_index);
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
@@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle};
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
@@ -41,7 +41,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
env: JNIEnv<'local>,
_this: JObject<'local>,
) -> jni::sys::jstring {
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
match env.new_string(token) {
Ok(s) => s.into_raw(),
Err(_) => JObject::null().into_raw(),
@@ -45,6 +45,15 @@ pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
})
}
/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a
/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"`
/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class).
/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a
/// poisoned writer left is still valid to inspect or replace.
pub(crate) fn lock_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
pub(crate) struct SessionHandle {
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
+7 -7
View File
@@ -8,7 +8,7 @@ use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
use super::{jni_guard, lock_recover, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
@@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.video.lock().unwrap();
let mut guard = lock_recover(&h.video);
if guard.is_some() {
return; // already streaming
}
@@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
if h.video.lock().unwrap().is_none() {
if lock_recover(&h.video).is_none() {
return std::ptr::null_mut(); // not streaming → no stats
}
let snap = h
@@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.audio.lock().unwrap();
let mut guard = lock_recover(&h.audio);
if guard.is_some() {
return; // already playing
}
@@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.mic.lock().unwrap();
let mut guard = lock_recover(&h.mic);
if let Some(m) = guard.as_ref() {
return m.session_id(); // already capturing — same stream, same session
}
@@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
*lock_recover(&h.pad_audio) = Some(p);
1
}
None => 0,
@@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.mic.lock().unwrap().is_some())
jboolean::from(lock_recover(&h.mic).is_some())
})
}
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
/// after close.
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
noteFrameIndexGapWidth(frameIndex) > 0
}
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH how many frames this arrival revealed
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
/// received AU. Returns 0 after close.
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return false }
var gap = false
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
return gap
guard let h = handle, !closeRequested else { return 0 }
var width: UInt32 = 0
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
return width
}
/// Cumulative access units the hostclient reassembler dropped as unrecoverable (FEC couldn't
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
lock.unlock()
}
/// `arm()` for a loss detected as a frame-index gap of a known width
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
/// RFI anchor already healed (the double-arm race the Rust gate's docs tell the story).
func arm(expectingDrops: UInt64) {
lock.lock()
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
lock.unlock()
}
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
/// `decoderKeyframe: false` VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Pump-side events (loss recovery, format seeding) the stage-2 sibling of StreamPump's log.
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
@@ -921,7 +923,11 @@ public final class Stage2Pipeline {
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze
// the following concealed frames are withheld until a clean re-anchor.
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included)
@@ -932,6 +938,21 @@ public final class Stage2Pipeline {
}
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
if format == nil {
// No decodable format yet: the opening IDR's parameter sets never
// arrived (or never parsed), and under the host's infinite GOP nothing
// re-delivers them unless we ASK. Without this the guard below drops
// every AU silently, forever the field "black stream, zero recovery
// requests" state (2026-08-12): the host streams perfectly, the client
// shows nothing and says nothing. awaitingIDR routes through the same
// 100 ms-throttled recovery.request() at the top of the loop.
if !awaitingIDR {
pumpLog.warning(
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
)
}
awaitingIDR = true
}
guard let f = format, !token.isStopped else { return true }
if decoder.decode(au: au, format: f) {
decodeFailRun = 0
@@ -100,7 +100,11 @@ final class StreamPump {
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze.
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat {
@@ -116,6 +120,21 @@ final class StreamPump {
}
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
if format == nil {
// No decodable format yet: the opening IDR's parameter sets never
// arrived (or never parsed), and under the host's infinite GOP nothing
// re-delivers them unless we ASK. Without this the format guard below
// drops every AU silently, forever the field "black stream, zero
// recovery requests" state (2026-08-12). awaitingIDR routes through the
// same 100 ms-throttled recovery.request() at the top of the loop.
if !awaitingIDR {
awaitingSince = Date()
pumpLog.warning(
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
)
}
awaitingIDR = true
}
let failed = layer.status == .failed
if failed {
// Decode wedged hard (the cold-first-connect case a lost/corrupt opening
+7 -1
View File
@@ -176,7 +176,13 @@ unsafe extern "system" fn wnd_proc(
let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
let url = String::from_utf16_lossy(slice);
tracing::debug!(%url, "link from another instance");
INBOX.lock().unwrap().push(url);
// Poison-recover, never unwrap: a panic out of a window procedure is an abort since
// Rust 1.81, and the inbox is a plain Vec that stays valid whatever a poisoned
// writer left behind.
INBOX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(url);
return LRESULT(1);
}
}
-1
View File
@@ -15,7 +15,6 @@
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
// terminal's console at startup (see main), so their output is still visible when run from a shell.
+7
View File
@@ -10,6 +10,11 @@
#![allow(non_snake_case)]
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
#![allow(improper_ctypes)]
// The workspace-wide undocumented_unsafe_blocks deny cannot apply to GENERATED code: bindgen
// emits `unsafe {}` in layout tests/accessors and nobody hand-writes proofs into OUT_DIR. This
// crate is bindings-only by charter (the safe wrapper lives with the consumer), so the allow is
// crate-wide; the hand-written link-sanity test below still carries its proof by convention.
#![allow(clippy::undocumented_unsafe_blocks)]
// Generated code — clippy findings in it (missing safety docs on generated unsafe fns, style
// nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
// linted surface.
@@ -27,6 +32,8 @@ mod tests {
/// implementations — that's fine, MFXLoad itself must still succeed).
#[test]
fn dispatcher_links_and_loads() {
// SAFETY: MFXLoad allocates the dispatcher's loader context (documented to work with no
// driver present) and MFXUnload frees that same non-null handle; nothing else is touched.
unsafe {
let loader = MFXLoad();
assert!(!loader.is_null(), "MFXLoad returned NULL");
-7
View File
@@ -7,13 +7,6 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator).
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
-1
View File
@@ -25,7 +25,6 @@
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{anyhow, Context, Result};
-3
View File
@@ -9,9 +9,6 @@
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
//! module contains no capturer.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
@@ -16,9 +16,6 @@
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
//! drift is a compile error rather than a "must match" comment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
VideoConverter, WinCaptureTarget,
@@ -2,9 +2,6 @@
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
//! and delivers them as bare handle values over the SYSTEM-only control device.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
@@ -5,9 +5,6 @@
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
//! cursor forwarder, the wire, the client renderer) is shared.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use pf_driver_proto::cursor::{
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
@@ -10,9 +10,6 @@
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::core::s;
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
@@ -20,9 +20,6 @@
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
use windows::Win32::Graphics::Gdi::{
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
@@ -1,9 +1,6 @@
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// 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
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
@@ -25,9 +25,6 @@
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
//! microseconds each, so the engine is invisible next to a streaming session.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
@@ -1,9 +1,6 @@
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
//! holes in DWM frame delivery that open while the desktop was actively composing.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
@@ -536,14 +533,47 @@ impl StallWatch {
suspects)"
);
} else {
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
// the same way, so this read mirrors what the driver decided — modulo a machine
// env edited after either process started, which a restart heals). The RX 9070
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
// say whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
};
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
};
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
rt_gpu_driver,
rt_gpu_host,
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) a punktfunk process holding \
REALTIME GPU priority is the field-proven amplifier of exactly this \
signature on AMD. Otherwise: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
-1
View File
@@ -18,7 +18,6 @@
// proof of why it is sound. This crate held ~91 unsafe items with NO enforcement while every
// other subsystem crate denied it — the decoders' `unsafe impl Send`s had a one-line aside
// instead of an argument precisely because nothing required one.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", windows))]
mod au_dump;
+6 -1
View File
@@ -885,7 +885,12 @@ fn pump(
Some(exp) => {
if let Some(gap) = index_gap(exp, frame.frame_index) {
let now = Instant::now();
gate.arm(now);
// Credited arm: the reassembler books these same lost frames into
// `frames_dropped` up to ~120 ms from now; the credit keeps that
// delayed climb from re-freezing a stream the RFI anchor healed in
// between (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
gate.arm_expecting_drops(now, u64::from(gap));
next_expected_index = Some(frame.frame_index.wrapping_add(1));
// The gap carries the PRECISE lost range — [first missing, newest
// received - 1] — so this is the one recovery signal that can drive true
+2 -2
View File
@@ -17,8 +17,8 @@
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; the deny enforcing it sits at
// the crate root (lib.rs), covering every backend.
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
+4
View File
@@ -10,6 +10,10 @@
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
//! platform split lives entirely behind [`start`].
// Unsafe-proof program: every `unsafe` block in any backend carries a `// SAFETY:` proof,
// enforced workspace-wide by `[workspace.lints]` — a new backend under `host/` is covered on
// creation.
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
-1
View File
@@ -11,7 +11,6 @@
//! capture hint, start banner.
// Unsafe-proof program: every `unsafe {}` in the Skia/Vulkan overlay carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
#[cfg(any(target_os = "linux", windows))]
mod anim;
-1
View File
@@ -52,7 +52,6 @@
//! ([`dxva::as_bytes`] / [`dxva::slice_bytes`]), fenced behind a sealed trait
//! that only this crate's `#[repr(C)]` PODs implement, and carrying a written
//! proof — enforced:
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod config;
pub mod descriptors;
+84
View File
@@ -48,6 +48,8 @@ impl AvBuffer {
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
/// once, here).
///
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
/// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it.
@@ -117,6 +119,88 @@ impl Drop for AvFilterGraph {
}
}
/// An owned `AVFrame`, freed exactly once when it drops.
///
/// The house pattern (`AvBuffer` above): `alloc` rejects the allocator's null once, `as_ptr`
/// lends, `Drop` frees, no `Clone`. Before this type existed the crate held 8 `av_frame_alloc`
/// sites matched by 22 hand-placed `av_frame_free`s — an ownership contract upheld by nobody,
/// and broken in practice: the Windows zero-copy submit path leaked the frame AND a pooled
/// hwframe surface on three `?` exits, under a comment asserting the opposite (fixed in the
/// same change that introduced this type).
///
/// Why not ffmpeg-next's own RAII frame (`frame::Video::empty()`, already used as `VideoFrame`
/// in the Linux NVENC path): `Frame::empty()` does not null-check — on allocator failure it
/// wraps null and the next field write through it is UB — whereas every open-coded site here
/// null-checked. This type keeps that: `alloc` returns `Option`, mirroring
/// `AvFilterGraph::alloc`.
pub(crate) struct AvFrame(std::ptr::NonNull<ffi::AVFrame>);
impl AvFrame {
/// Allocate a frame, rejecting the null `av_frame_alloc` returns on OOM.
///
/// Safe: the call takes no arguments and has no precondition a caller could violate — the
/// only contract is what happens to the result, and that is exactly what this type owns.
pub(crate) fn alloc() -> Option<Self> {
// SAFETY: parameterless allocator; it returns either a fresh, uniquely-owned frame whose
// ownership passes to the value returned here, or null (rejected by NonNull::new).
std::ptr::NonNull::new(unsafe { ffi::av_frame_alloc() }).map(AvFrame)
}
/// The borrowed pointer, for the ffmpeg calls that fill or read the frame without taking
/// ownership of it. Borrowed only — the `AvFrame` stays the owner, so callers must not free
/// or move-from what this returns.
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFrame {
self.0.as_ptr()
}
}
impl Drop for AvFrame {
fn drop(&mut self) {
let mut p = self.0.as_ptr();
// SAFETY: `p` is the non-null frame `alloc` took ownership of, and this type is its
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly
// once. `av_frame_free` unrefs any buffers the frame holds (returning pooled hwframe
// surfaces to their pool) and frees the frame; it nulls only the local copy.
unsafe { ffi::av_frame_free(&mut p) };
}
}
/// An owned swscale context, freed exactly once when it drops.
///
/// Same ownership question as the frame above — `sws_getContext` at 3 sites was matched by 5
/// hand-placed `sws_freeContext`s, two of them inside hand-written `Drop` impls whose real job
/// this type absorbs.
pub(crate) struct AvSwsContext(std::ptr::NonNull<ffi::SwsContext>);
impl AvSwsContext {
/// Take ownership of a freshly-created `SwsContext`, rejecting the null `sws_getContext`
/// returns on failure (unsupported conversion or OOM).
///
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
/// # Safety
/// `p` must be null, or a live `SwsContext` whose ownership passes to the returned value —
/// nothing else may free it.
pub(crate) unsafe fn from_raw(p: *mut ffi::SwsContext) -> Option<Self> {
std::ptr::NonNull::new(p).map(AvSwsContext)
}
/// The borrowed pointer, for `sws_scale` calls. Borrowed only — the `AvSwsContext` stays
/// the owner.
pub(crate) fn as_ptr(&self) -> *mut ffi::SwsContext {
self.0.as_ptr()
}
}
impl Drop for AvSwsContext {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null context `from_raw` took ownership of, and this type
// is its sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs
// exactly once.
unsafe { ffi::sws_freeContext(self.0.as_ptr()) };
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
+66 -71
View File
@@ -12,8 +12,6 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -26,8 +24,8 @@ use std::os::raw::c_int;
use std::ptr;
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -193,6 +191,17 @@ struct OpenArgs {
}
pub struct NvencEncoder {
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced ran before any field
// drop, freeing `sws_csc` ahead of `enc`/`frame`/`cuda` — and this path runs on every
// stall-watchdog recovery via `*self = fresh` in `reset`. Declaration order is what
// preserves that sequence now (drop order follows declaration; an offset_of assert cannot
// pin it — repr(Rust) may lay memory out in any order).
/// CPU CSC paths only: swscale context converting the captured packed source into
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
/// worker's GPU convert delivers ready CUDA frames).
sws_csc: Option<AvSwsContext>,
enc: encoder::video::Encoder,
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
/// Mutating it in place across frames is sound only because the encoder is opened with
@@ -201,12 +210,6 @@ pub struct NvencEncoder {
frame: Option<VideoFrame>,
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
cuda: Option<CudaHw>,
/// CPU CSC paths only: swscale context converting the captured packed source into
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
sws_csc: Option<*mut ffi::SwsContext>,
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
want_444: bool,
src_format: PixelFormat,
@@ -228,7 +231,7 @@ pub struct NvencEncoder {
args: OpenArgs,
}
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` a raw `SwsContext`; the encoder lives on a single
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` an owned `SwsContext`; the encoder lives on a single
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
@@ -610,14 +613,13 @@ impl NvencEncoder {
);
}
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
// retry, which recurses into `Self::open`, and the plain error return). Creating the
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
// so this placement makes the leak unrepresentable rather than merely unlikely.
// Built HERE, below the fallible encoder open, NOT above it — historically because the
// context's only free was `Drop for NvencEncoder`, which needs a CONSTRUCTED `Self` that
// does not exist on `open`'s early returns; creating it above them leaked one per failed
// attempt, and `open_nvenc_probed`'s EINVAL bitrate ladder calls `open` up to ~10 times.
// The owned `AvSwsContext` now frees itself on any exit, but the placement stays: it
// documents the dependency on the post-open `nvenc_pixel`, and there is no reason to
// build a context an early return would just throw away.
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
@@ -642,10 +644,10 @@ impl NvencEncoder {
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
// null-checked below.
// defaults" (documented as accepted). No Rust memory is borrowed; ownership of the
// returned context passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe {
ffi::sws_getContext(
AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
@@ -656,11 +658,11 @@ impl NvencEncoder {
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
))
};
if sws.is_null() {
let Some(sws) = sws else {
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
}
};
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
@@ -680,7 +682,16 @@ impl NvencEncoder {
SWS_CS_ITU709
});
let dst_range = i32::from(full_range_444);
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
ffi::sws_setColorspaceDetails(
sws.as_ptr(),
cs,
1,
cs,
dst_range,
0,
1 << 16,
1 << 16,
);
}
}
Some(sws)
@@ -694,10 +705,10 @@ impl NvencEncoder {
Some(VideoFrame::new(nvenc_pixel, width, height))
};
Ok(NvencEncoder {
sws_csc,
enc,
frame,
cuda: cuda_hw,
sws_csc,
want_444,
src_format: format,
width,
@@ -840,7 +851,7 @@ impl NvencEncoder {
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
// conversion at all — just a row copy honouring the destination stride.
if let Some(sws) = self.sws_csc {
if let Some(sws) = self.sws_csc.as_ref().map(AvSwsContext::as_ptr) {
let frame = self
.frame
.as_mut()
@@ -929,27 +940,23 @@ impl NvencEncoder {
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
// (`make_current()?`), the precondition for the device-pointer copies below.
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
// failure we free `f` and bail.
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
// * `f` is an owned `AvFrame` — every exit below (bail, copy error, success) drops it
// exactly once, releasing its ref on the pooled surface. `av_hwframe_get_buffer` fills
// it with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`).
// * For NV12 we read `data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
// `data[0]`/`linesize[0]` — in-struct fields of the live frame, valid for the surface
// dims ffmpeg allocated — and pass them to the cuda copy helpers, which device→device
// copy `buf` (the imported `DeviceBuffer`, owned by the caller and live for this call)
// into the surface.
// * `avcodec_send_frame` takes its own ref of the pooled surface, so the drop afterwards
// is the sole owning free. Single-threaded encoder → no race.
unsafe {
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
let f = AvFrame::alloc().context("av_frame_alloc failed")?;
// Pooled CUDA surface: sets format, width/height, data[0]/linesize[0], buf[0] and
// hw_frames_ctx. Reused across frames (the pool recycles), keeping NVENC's
// registration cache warm.
let r = ffi::av_hwframe_get_buffer(frames_ref, f, 0);
let r = ffi::av_hwframe_get_buffer(frames_ref, f.as_ptr(), 0);
if r < 0 {
ffi::av_frame_free(&mut f);
bail!("av_hwframe_get_buffer(CUDA) failed ({r})");
}
// NV12 surfaces are two-plane (Y in data[0], interleaved UV in data[1]); YUV444
@@ -960,41 +967,36 @@ impl NvencEncoder {
let copy_res = if buf.yuv444 {
let dsts = core::array::from_fn(|i| {
(
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr,
(*f).linesize[i] as usize,
(*f.as_ptr()).data[i] as pf_zerocopy::cuda::CUdeviceptr,
(*f.as_ptr()).linesize[i] as usize,
)
});
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
} else if self.want_444 {
ffi::av_frame_free(&mut f);
bail!(
"4:4:4 session but the zero-copy frame is not YUV444 (LINEAR/gamescope \
capture has no GPU 4:4:4 convert) unset PUNKTFUNK_ZEROCOPY to use the \
CPU 4:4:4 path on this compositor"
);
} else if buf.is_nv12() {
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let y_pitch = (*f).linesize[0] as usize;
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f).linesize[1] as usize;
let y_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let y_pitch = (*f.as_ptr()).linesize[0] as usize;
let uv_ptr = (*f.as_ptr()).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f.as_ptr()).linesize[1] as usize;
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
} else {
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f).linesize[0] as usize;
let dst_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f.as_ptr()).linesize[0] as usize;
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
};
if let Err(e) = copy_res {
ffi::av_frame_free(&mut f);
return Err(e).context("copy imported buffer into NVENC surface");
}
(*f).pts = pts;
(*f).pict_type = if idr {
copy_res.context("copy imported buffer into NVENC surface")?;
(*f.as_ptr()).pts = pts;
(*f.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f);
ffi::av_frame_free(&mut f);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(CUDA) failed ({r})");
}
@@ -1003,16 +1005,9 @@ impl NvencEncoder {
}
}
impl Drop for NvencEncoder {
fn drop(&mut self) {
if let Some(sws) = self.sws_csc.take() {
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
unsafe { ffi::sws_freeContext(sws) };
}
}
}
// No `Drop` for `NvencEncoder`: `sws_csc` (`Option<AvSwsContext>`) frees itself, and as field #1
// it does so ahead of `enc`/`frame`/`cuda` — the same sequence the hand-written `Drop` performed
// (see the field-order note on the struct).
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
/// an encoder open it *expects* to fail.
@@ -63,8 +63,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
+86 -118
View File
@@ -19,8 +19,6 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -36,8 +34,8 @@ use std::ptr;
use std::sync::{Mutex, OnceLock};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, AvFrame,
AvSwsContext, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -546,8 +544,13 @@ impl VaapiHw {
struct CpuInner {
enc: encoder::video::Encoder,
hw: VaapiHw,
sws: *mut ffi::SwsContext,
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src)
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `nv12` BEFORE
// `sws` — the reverse of the old declaration order — and field-DECLARATION order is what
// preserves that now (drop order follows declaration; an offset_of assert cannot pin it,
// repr(Rust) may lay memory out in any order).
/// Reusable software NV12/P010 staging frame (swscale dst → upload src).
nv12: AvFrame,
sws: AvSwsContext,
src_format: PixelFormat,
width: u32,
height: u32,
@@ -602,10 +605,10 @@ impl CpuInner {
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
// just below.
// memory is borrowed — only by-value ints/enums — and ownership of the returned context
// passes to the `AvSwsContext` (null rejected by `from_raw`).
let sws = unsafe {
ffi::sws_getContext(
AvSwsContext::from_raw(ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
@@ -616,16 +619,15 @@ impl CpuInner {
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
))
};
if sws.is_null() {
let Some(sws) = sws else {
bail!(
"sws_getContext(RGB→{})",
if ten_bit { "P010" } else { "NV12" }
);
}
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
// check immediately preceding returned false). The coefficient table from
};
// SAFETY: `sws` is the live owned context from above. The coefficient table from
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
// libswscale static const valid for the whole process, reused here for both the inverse
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
@@ -637,32 +639,22 @@ impl CpuInner {
} else {
SWS_CS_ITU709
});
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
ffi::sws_setColorspaceDetails(sws.as_ptr(), cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
}
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
// unique fresh pointer, so none of these writes alias anything.
let nv12 = unsafe {
let f = ffi::av_frame_alloc();
if f.is_null() {
ffi::sws_freeContext(sws);
bail!("av_frame_alloc(staging) failed");
}
(*f).format = staging_av as c_int;
(*f).width = width as c_int;
(*f).height = height as c_int;
if ffi::av_frame_get_buffer(f, 0) < 0 {
let mut f = f;
ffi::av_frame_free(&mut f);
ffi::sws_freeContext(sws);
let nv12 = AvFrame::alloc().context("av_frame_alloc(staging) failed")?;
// SAFETY: writing the plain `format`/`width`/`height` fields through the owned frame's
// pointer stays inside its allocation (sole owner, not yet shared).
// `av_frame_get_buffer` allocates backing storage for those dims/format; on failure the
// owned `nv12` (and the `sws` above it) simply drop — the hand-written unwind this
// replaced had to free both by hand on every branch.
unsafe {
(*nv12.as_ptr()).format = staging_av as c_int;
(*nv12.as_ptr()).width = width as c_int;
(*nv12.as_ptr()).height = height as c_int;
if ffi::av_frame_get_buffer(nv12.as_ptr(), 0) < 0 {
bail!("av_frame_get_buffer(staging) failed");
}
f
};
}
tracing::info!(
encoder = codec.vaapi_name(),
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
@@ -671,8 +663,8 @@ impl CpuInner {
Ok(CpuInner {
enc,
hw,
sws,
nv12,
sws,
src_format: format,
width,
height,
@@ -693,49 +685,43 @@ impl CpuInner {
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
// `self.sws` is the owned context built in `open`; it writes into `self.nv12` (an owned
// frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `hwf` is an owned `AvFrame` — every exit below drops it exactly once, releasing its ref
// on the pooled VAAPI surface. `av_hwframe_get_buffer` pulls that surface from the live
// non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads the staged NV12 into
// it. `avcodec_send_frame` takes its own ref, so the drop afterwards is the sole owning
// free. The encoder runs only on this thread (see `unsafe impl Send`), so no
// aliasing/data race.
unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale(
self.sws,
self.sws.as_ptr(),
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.nv12).data.as_ptr(),
(*self.nv12).linesize.as_ptr(),
(*self.nv12.as_ptr()).data.as_ptr(),
(*self.nv12.as_ptr()).linesize.as_ptr(),
) < 0
{
bail!("sws_scale RGB→NV12 failed");
}
let mut hwf = ffi::av_frame_alloc();
if hwf.is_null() {
bail!("av_frame_alloc(hw) failed");
}
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
ffi::av_frame_free(&mut hwf);
let hwf = AvFrame::alloc().context("av_frame_alloc(hw) failed")?;
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf.as_ptr(), 0) < 0 {
bail!("av_hwframe_get_buffer(VAAPI) failed");
}
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 {
ffi::av_frame_free(&mut hwf);
if ffi::av_hwframe_transfer_data(hwf.as_ptr(), self.nv12.as_ptr(), 0) < 0 {
bail!("av_hwframe_transfer_data(→VAAPI) failed");
}
(*hwf).pts = pts;
(*hwf).pict_type = if idr {
(*hwf.as_ptr()).pts = pts;
(*hwf.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf);
ffi::av_frame_free(&mut hwf);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
@@ -744,24 +730,10 @@ impl CpuInner {
}
}
impl Drop for CpuInner {
fn drop(&mut self) {
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
// irrelevant to soundness.
unsafe {
if !self.nv12.is_null() {
ffi::av_frame_free(&mut self.nv12);
}
if !self.sws.is_null() {
ffi::sws_freeContext(self.sws);
}
}
}
}
// No `Drop` for `CpuInner`: `nv12` (`AvFrame`) and `sws` (`AvSwsContext`) free themselves, in
// field-declaration order — the same nv12-then-sws sequence the hand-written `Drop` performed
// (see the field-order note on the struct). The encoder holds its own `av_buffer_ref`'d
// device/frames copies, so their order against `enc`/`hw` is irrelevant to soundness.
// ---------------------------------------------------------------------------------------------
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode.
@@ -1043,16 +1015,20 @@ impl DmabufInner {
// whole synchronous `submit`; we describe one object/layer/plane from its
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
// * `drm`/`nv12` are owned `AvFrame`s — every exit drops each exactly once (the
// hand-placed frees this replaced were branch-clean, but only by inspection). We set
// `drm`'s scalar fields and `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref
// of the live owned ctx).
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
// buffersrc; KEEP_REF keeps our own `drm` ref, dropped explicitly right after the push
// (the same point the hand-written free sat, kept so the descriptor's release timing
// across the pull does not change). We pull the converted surface with
// `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the dmabuf (owned by
// the caller) is read while still valid. `nv12` is sent into the live owned `self.enc`
// (takes its own ref) and dropped. Single-threaded encoder → no race.
unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
@@ -1077,21 +1053,18 @@ impl DmabufInner {
desc.layers[0].planes[0].offset = dmabuf.offset as isize;
desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
let mut drm = ffi::av_frame_alloc();
if drm.is_null() {
bail!("av_frame_alloc(drm) failed");
}
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm).width = self.width as c_int;
(*drm).height = self.height as c_int;
let drm = AvFrame::alloc().context("av_frame_alloc(drm) failed")?;
(*drm.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm.as_ptr()).width = self.width as c_int;
(*drm.as_ptr()).height = self.height as c_int;
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
(*drm.as_ptr()).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm.as_ptr()).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
(*drm.as_ptr()).data[0] = Box::into_raw(desc) as *mut u8;
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns).
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
@@ -1102,8 +1075,8 @@ impl DmabufInner {
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
}
(*drm).buf[0] = ffi::av_buffer_create(
(*drm).data[0],
(*drm.as_ptr()).buf[0] = ffi::av_buffer_create(
(*drm.as_ptr()).data[0],
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
Some(free_desc),
ptr::null_mut(),
@@ -1113,45 +1086,40 @@ impl DmabufInner {
// Push through hwmap → scale_vaapi; pull the NV12 surface back out.
let r = ffi::av_buffersrc_add_frame_flags(
self.src,
drm,
drm.as_ptr(),
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
);
ffi::av_frame_free(&mut drm);
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
// deliberately NOT counted: that one is the encoder stalling, which the in-place
// rebuild above us exists to recover, and disabling zero-copy over it would be a
// permanent penalty for a transient fault.
drop(drm); // release our ref where the hand-written free sat (see the SAFETY note)
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
// deliberately NOT counted: that one is the encoder stalling, which the in-place
// rebuild above us exists to recover, and disabling zero-copy over it would be a
// permanent penalty for a transient fault.
if r < 0 {
let e = format!("av_buffersrc_add_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}");
}
t_push = t0.elapsed();
let mut nv12 = ffi::av_frame_alloc();
if nv12.is_null() {
bail!("av_frame_alloc(nv12) failed");
}
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
let nv12 = AvFrame::alloc().context("av_frame_alloc(nv12) failed")?;
let r = ffi::av_buffersink_get_frame(self.sink, nv12.as_ptr());
if r < 0 {
ffi::av_frame_free(&mut nv12);
let e = format!("av_buffersink_get_frame failed ({r})");
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
bail!("{e}");
}
pf_zerocopy::note_raw_dmabuf_import_ok();
t_pull = t0.elapsed() - t_push;
(*nv12).pts = pts;
(*nv12).pict_type = if idr {
(*nv12.as_ptr()).pts = pts;
(*nv12.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12);
ffi::av_frame_free(&mut nv12);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12.as_ptr());
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
-3
View File
@@ -41,9 +41,6 @@
//! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a
//! small buffer set).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{Context, Result};
use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat};
use pf_zerocopy::ipc;
+39 -20
View File
@@ -5,12 +5,16 @@
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
// calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// UNSAFE-LINT EXEMPTION REMOVED — the old fence rationale ("raw nvEncodeAPI entry-table calls
// almost line for line") was false for this file: it makes ZERO FFI calls. Its unsafe surface is
// C-union access whose soundness hangs entirely on which codec arm is active, and the 4:4:4 note
// below records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that per-operation
// visibility makes findable. So this file runs the strictest discipline in the crate: every
// union READ, borrow, or bitfield-setter call sits in its own `unsafe {}` block naming the codec
// guard it relies on. (Plain union-arm field WRITES are safe by language rule — writing an arm
// cannot itself be UB; the hazard is the mismatched read — so those stay bare, guarded by the
// same codec matches.)
#![deny(clippy::multiple_unsafe_ops_per_block)]
use super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
@@ -694,10 +698,9 @@ mod tests {
};
assert_eq!(cfg.profileGUID, nv::NV_ENC_HEVC_PROFILE_FREXT_GUID);
// SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
unsafe {
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3);
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2);
}
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3) };
// SAFETY: same HEVC arm as above.
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2) };
}
#[test]
@@ -1210,6 +1213,8 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
// are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
match c.codec {
Codec::H265 => {
// Plain union-arm writes are safe by language rule (the hazard is a mismatched
// READ later); the match on `c.codec` keeps the arm honest.
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
@@ -1264,21 +1269,29 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
}
if want_444 && c.codec == Codec::H265 {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
// SAFETY: HEVC session (guarded by `c.codec == Codec::H265` on this branch), so
// `hevcConfig` is the active arm.
unsafe { cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3) };
if c.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
// SAFETY: same HEVC arm, same branch guard. (Main 4:4:4 10)
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
}
} else if c.bit_depth == 10 {
match c.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active arm.
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8);
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm.
unsafe { cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2) };
// SAFETY: same AV1 arm, same match guard.
unsafe {
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8)
};
}
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
@@ -1306,7 +1319,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
};
match c.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active
// arm; the borrow is dropped before any other union access.
let vui = unsafe { &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters };
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
@@ -1315,7 +1330,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
// SAFETY: H.264 session (matched on `c.codec`), so `h264Config` is the active
// arm; the borrow is dropped before any other union access.
let vui = unsafe { &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters };
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
@@ -1324,7 +1341,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm;
// the borrow is dropped before any other union access.
let av1 = unsafe { &mut cfg.encodeCodecConfig.av1Config };
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
-2
View File
@@ -12,8 +12,6 @@
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{EncodedFrame, Encoder};
use anyhow::{bail, ensure, Context, Result};
-2
View File
@@ -49,8 +49,6 @@
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
+89 -119
View File
@@ -37,8 +37,6 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
@@ -61,8 +59,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -499,10 +497,14 @@ fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
struct SystemInner {
enc: encoder::video::Encoder,
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `sw_frame`
// before `sws`, and field-DECLARATION order is what preserves that now (an offset_of assert
// cannot pin this — repr(Rust) may reorder memory independently of declaration order, and
// drop order follows declaration).
/// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
sw_frame: *mut ffi::AVFrame,
/// swscale ctx for the BGRA→NV12 fallback (built lazily; null for the YUV-readback path).
sws: *mut ffi::SwsContext,
sw_frame: AvFrame,
/// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
sws: Option<AvSwsContext>,
/// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
staging: Option<ID3D11Texture2D>,
ctx: Option<ID3D11DeviceContext>,
@@ -549,26 +551,18 @@ impl SystemInner {
ptr::null_mut(),
)?
};
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
// `self.sw_frame` and freed exactly once in `Drop`.
let sw_frame = unsafe {
let f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc(sw) failed");
}
(*f).format = sw_av as c_int;
(*f).width = width as c_int;
(*f).height = height as c_int;
if ffi::av_frame_get_buffer(f, 0) < 0 {
let mut f = f;
ffi::av_frame_free(&mut f);
let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
// SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
// its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
// owned `sw_frame` simply drops (freed once, by the wrapper).
unsafe {
(*sw_frame.as_ptr()).format = sw_av as c_int;
(*sw_frame.as_ptr()).width = width as c_int;
(*sw_frame.as_ptr()).height = height as c_int;
if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
bail!("av_frame_get_buffer(sw) failed");
}
f
};
}
tracing::info!(
encoder = vendor.encoder_name(codec),
"{} encode active ({width}x{height}@{fps}, system-memory {} path)",
@@ -578,7 +572,7 @@ impl SystemInner {
Ok(SystemInner {
enc,
sw_frame,
sws: ptr::null_mut(),
sws: None,
staging: None,
ctx: None,
format,
@@ -634,13 +628,13 @@ impl SystemInner {
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
// (it references the frame's buffers itself).
unsafe {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
(*self.sw_frame.as_ptr()).pts = pts;
(*self.sw_frame.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame.as_ptr());
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
@@ -705,10 +699,10 @@ impl SystemInner {
let total = pitch.saturating_mul(h + h.div_ceil(2));
let mapped = std::slice::from_raw_parts(base, total);
let chroma_off = pitch * h;
let y_dst = (*self.sw_frame).data[0];
let y_stride = (*self.sw_frame).linesize[0] as usize;
let uv_dst = (*self.sw_frame).data[1];
let uv_stride = (*self.sw_frame).linesize[1] as usize;
let y_dst = (*self.sw_frame.as_ptr()).data[0];
let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
let uv_dst = (*self.sw_frame.as_ptr()).data[1];
let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
for y in 0..h {
let s = &mapped[y * pitch..y * pitch + row_bytes];
ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), row_bytes);
@@ -748,7 +742,7 @@ impl SystemInner {
let pitch = map.RowPitch as usize;
let h = self.height as usize;
let base = map.pData as *const u8;
self.ensure_sws(
let sws = self.ensure_sws(
pixel_to_av(Pixel::BGRA),
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709,
@@ -756,13 +750,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
);
ctx.Unmap(&staging, 0);
if r < 0 {
@@ -798,7 +792,7 @@ impl SystemInner {
let h = self.height as usize;
let base = map.pData as *const u8;
// RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited.
self.ensure_sws(
let sws = self.ensure_sws(
ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
SWS_CS_BT2020,
@@ -806,13 +800,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
let r = ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
);
ctx.Unmap(&staging, 0);
if r < 0 {
@@ -844,7 +838,7 @@ impl SystemInner {
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
unsafe {
self.ensure_sws(
let sws = self.ensure_sws(
pixel_to_av(sws_src(format)?),
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
SWS_CS_ITU709,
@@ -852,13 +846,13 @@ impl SystemInner {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale(
self.sws,
sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.sw_frame).data.as_ptr(),
(*self.sw_frame).linesize.as_ptr(),
(*self.sw_frame.as_ptr()).data.as_ptr(),
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
) < 0
{
bail!("sws_scale RGB→NV12 failed");
@@ -872,23 +866,24 @@ impl SystemInner {
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
///
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
/// (freed once in `Drop`).
/// (an owned `AvSwsContext`, freed by its own drop). Returns the borrowed pointer for the
/// caller's `sws_scale` — borrowed only, `self.sws` stays the owner.
fn ensure_sws(
&mut self,
src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat,
cs: c_int,
) -> Result<()> {
if !self.sws.is_null() {
return Ok(());
) -> Result<*mut ffi::SwsContext> {
if let Some(sws) = &self.sws {
return Ok(sws.as_ptr());
}
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
// null trio, and returns an owned context or null — which is checked before use, so
// `sws_setColorspaceDetails` and the store below only ever see a live one.
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
// process, and the call only reads it.
// null trio, and returns an owned context or null — `from_raw` rejects the null, so
// `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
// `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
// valid for the process, and the call only reads it.
let sws = unsafe {
let sws = ffi::sws_getContext(
let raw = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
@@ -900,36 +895,22 @@ impl SystemInner {
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
let Some(owned) = AvSwsContext::from_raw(raw) else {
bail!("sws_getContext(RGB→YUV) failed");
}
};
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws
ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
owned
};
self.sws = sws;
Ok(())
Ok(self.sws.insert(sws).as_ptr())
}
}
impl Drop for SystemInner {
fn drop(&mut self) {
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
// exclusively, so there is no double-free or use-after-free.
unsafe {
if !self.sw_frame.is_null() {
ffi::av_frame_free(&mut self.sw_frame);
}
if !self.sws.is_null() {
ffi::sws_freeContext(self.sws);
}
}
}
}
// No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
// themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
// `Drop` performed, pinned by the offset_of assert at the struct.
// ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
@@ -1214,32 +1195,29 @@ impl ZeroCopyInner {
}
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
// no double-free, no use-after-free.
// SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
// between the pool pull and the send, which as hand-placed frees previously leaked the
// frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
// the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
// is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
// that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
// null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
// SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
// pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
// on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
// For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
// `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
// the hand-written free did. `avcodec_send_frame` only internally refs the input frame,
// so the drops are the sole owning frees — no leak, no double-free, no use-after-free.
unsafe {
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
let mut d3d = ffi::av_frame_alloc();
if d3d.is_null() {
bail!("av_frame_alloc(d3d11) failed");
}
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
if r < 0 {
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
}
let dst_ptr = (*d3d).data[0] as *mut c_void;
let dst_index = (*d3d).data[1] as usize as u32;
let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
.ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?;
// GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
@@ -1249,58 +1227,50 @@ impl ZeroCopyInner {
self.ctx
.CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);
(*d3d).pts = pts;
(*d3d).pict_type = if idr {
(*d3d.as_ptr()).pts = pts;
(*d3d.as_ptr()).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let send = match self.vendor {
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d),
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d.as_ptr()),
WinVendor::Qsv => {
// Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
let mut qsv = ffi::av_frame_alloc();
if qsv.is_null() {
ffi::av_frame_free(&mut d3d);
bail!("av_frame_alloc(qsv) failed");
}
let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
// matching the null check above it. The `Option` is what the raw pointer's
// "null means AMF" convention was already encoding.
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("QSV send path without a derived QSV frames context");
};
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
(*qsv.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
(*qsv.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
let r = ffi::av_hwframe_map(
qsv,
d3d,
qsv.as_ptr(),
d3d.as_ptr(),
ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
);
if r < 0 {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
}
(*qsv).pts = pts;
(*qsv).pict_type = (*d3d).pict_type;
let s = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv);
ffi::av_frame_free(&mut qsv);
s
(*qsv.as_ptr()).pts = pts;
(*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
// `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
// same point the hand-written `av_frame_free(&mut qsv)` did.
}
};
ffi::av_frame_free(&mut d3d);
if send < 0 {
bail!(
"avcodec_send_frame({}) failed ({send})",
self.vendor.label()
);
}
// `d3d` drops here (and on every early exit above), returning the pooled surface.
}
Ok(())
}
@@ -39,8 +39,6 @@
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
-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
//! `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 anyhow::{anyhow, bail, Context, Result};
use libvpl_sys as vpl;
-1
View File
@@ -12,7 +12,6 @@
// `#[cfg(test)]` instead.
// 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).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result;
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
//! 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 windows::core::Interface;
use windows::Win32::Foundation::{HMODULE, LUID};
@@ -158,18 +155,26 @@ enum PrioMode {
Off,
/// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5).
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
/// tightens). REALTIME is the proven ceiling-raiser (it is how our brief encode preempts a
/// saturating game), but REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC
/// hang the gate takes the win everywhere it cannot hit the hazard.
/// tightens). REALTIME is the T2.3 ceiling-raiser (a higher-priority context preempts at
/// pixel granularity), but it carries TWO field-proven hazards: REALTIME + NVIDIA + HAGS +
/// 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,
}
/// 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,
/// 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 {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
@@ -177,9 +182,10 @@ fn configured_gpu_priority_mode() -> PrioMode {
{
Some("off") => PrioMode::Off,
Some("normal") => PrioMode::Static(2),
Some("high") => PrioMode::Static(4),
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
/// `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`
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is the
/// T2.3 `auto` mode: HIGH immediately here, then [`auto_priority_gate`] upgrades to REALTIME
/// where the NVIDIA+HAGS+full-VRAM NVENC-hang hazard cannot bite (and a monitor downgrades when
/// it could). Runs once per process; best-effort.
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default auto; `high` = the
/// pre-gate static behavior; `realtime` = pinned, operator owns the 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).
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is a
/// static HIGH — the class that delivered that win. The T2.3 `auto` mode (HIGH here, then
/// [`auto_priority_gate`] upgrades to REALTIME behind the NVENC-hang VRAM gate) is opt-in since
/// the 2026-08-12 field A/B: on AMD the REALTIME upgrade generated its own metronomic
/// content-starving stall class (~3.6 s period) that the VRAM gate cannot see, and pinning HIGH
/// removed it. Runs once per process; best-effort.
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default high; `auto` = the
/// 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() {
use std::sync::Once;
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
// 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
// hazard: REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC. So: probe HAGS once via
// D3DKMT; HAGS off ⇒ REALTIME unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM
// headroom, with a monitor thread that downgrades to HIGH the moment usage crosses
// [`VRAM_DOWNGRADE_PCT`] of the OS budget and restores REALTIME after it has stayed under
// [`VRAM_RESTORE_PCT`] for [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping
// on the boundary of the hazard window).
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. Two field-proven
// hazards bound it. (1) REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC — the VRAM
// gate below exists for that one: probe HAGS once via D3DKMT; HAGS off ⇒ REALTIME
// unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM headroom, with a monitor
// thread that downgrades to HIGH the moment usage crosses [`VRAM_DOWNGRADE_PCT`] of the OS
// budget and restores REALTIME after it has stayed under [`VRAM_RESTORE_PCT`] for
// [`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.
const VRAM_DOWNGRADE_PCT: u64 = 92;
-1
View File
@@ -10,7 +10,6 @@
//! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation).
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod hdr;
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.
//! 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")]
mod imp {
#![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
//! 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
/// 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
-1
View File
@@ -23,7 +23,6 @@
//! live session actually encodes on, for the console's "in use" display.
// Unsafe-proof program: every `unsafe {}` in this leaf carries a `// SAFETY:` proof.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result;
use serde::{Deserialize, Serialize};
@@ -15,9 +15,6 @@
//! `<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.
// 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 anyhow::{bail, Result};
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.
#![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 anyhow::{Context, Result};
-3
View File
@@ -6,9 +6,6 @@
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
//! 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 anyhow::{bail, Context, Result};
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
//! 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 punktfunk_core::input::{InputEvent, InputKind};
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
//! 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 punktfunk_core::input::{InputEvent, InputKind};
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.
#![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 punktfunk_core::input::{InputEvent, InputKind};
-1
View File
@@ -17,7 +17,6 @@
//! the decode chain there is Vulkan → D3D11VA → software.
// 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.
//
+3
View File
@@ -18,3 +18,6 @@ path = "src/main.rs"
[target.'cfg(target_os = "linux")'.dependencies]
serde = { version = "1", features = ["derive"] }
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"
// rationale had stopped being true.)
#![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;
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
//! 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 anyhow::{anyhow, bail, Context, Result};
use std::os::fd::{AsFd, AsRawFd};
@@ -20,8 +20,6 @@
//! each output's name / enabled / priority / current-mode size, then build a
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::time::{Duration, Instant};
+26 -3
View File
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
/// `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub edid_lock: bool,
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
///
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
edid_lock: false,
capture_monitor: None,
}
}
@@ -454,6 +465,7 @@ impl EffectivePolicy {
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
edid_lock: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy {
DisplayPolicy {
@@ -474,6 +486,7 @@ impl EffectivePolicy {
game_session,
ddc_power_off,
pnp_disable_monitors,
edid_lock,
capture_monitor,
}
}
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
self.get().pnp_disable_monitors
}
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn edid_lock(&self) -> bool {
self.get().edid_lock
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
@@ -1318,13 +1338,16 @@ mod tests {
GameSession::Dedicated,
true,
true,
true,
Some("DP-2".into()),
);
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
// preserved through the transform — arranging displays must not clear an unrelated setting.
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
// capture-monitor pin) are preserved through the transform — arranging displays must not
// clear an unrelated setting.
assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
assert!(p.edid_lock);
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
@@ -1405,7 +1428,7 @@ mod tests {
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
12,
13,
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
);
@@ -14,9 +14,6 @@
//! 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).
// 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::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
@@ -181,6 +178,10 @@ struct GroupState {
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Whether the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
edid_locked: bool,
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
@@ -1403,6 +1404,18 @@ impl VirtualDisplayManager {
if crate::policy::prefs().ddc_power_off() {
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
}
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
// emulation BEFORE the isolate deactivates the physicals — an awake
// sink still answers the live-EDID read the lock pins (asleep sinks
// fall back to the driver's stored emulation data). With emulation at
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
// HPD/DDC/link — the standby-sink stall class at its source
// (rationale + crash journal in `pf_win_display::adl_emul`). First
// member only, like the DDC leg: the connectors are host-wide.
if crate::policy::prefs().edid_lock() {
inner.group.edid_locked =
pf_win_display::adl_emul::lock_for_stream();
}
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
@@ -1961,6 +1974,15 @@ impl VirtualDisplayManager {
);
inner.group.ddc_panels_off = 0;
}
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
// physical paths do not depend on it (the pinned emulation IS the real monitor's
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
// was applied BEFORE the isolate, whose snapshot capture can have failed.
if inner.group.edid_locked {
pf_win_display::adl_emul::unlock_after_stream();
inner.group.edid_locked = false;
}
} else {
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
@@ -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 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
/// `dev` must be the live control handle.
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)> {
anyhow::bail!("fake driver has no control device")
}
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
unsafe fn add_monitor(
&self,
_dev: HANDLE,
@@ -125,9 +128,11 @@ mod tests {
) -> Result<AddedMonitor> {
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<()> {
Ok(())
}
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
unsafe fn ping(&self, _dev: HANDLE) -> Result<()> {
Ok(())
}
@@ -16,9 +16,6 @@
//! Only the driver-specific bits (GUID, IOCTL codes, request/reply structs, the version handshake) are
//! 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::mem::size_of;
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
//! 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
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof,
//! enforced (and unlike the encoder there is NO file-level
//! `unsafe_op_in_unsafe_fn` exemption every operation is individually fenced):
#![deny(clippy::undocumented_unsafe_blocks)]
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof — enforced by
//! the workspace `[workspace.lints]` tables, and (unlike the encoder) with NO file-level
//! `unsafe_op_in_unsafe_fn` exemption: every operation is individually fenced.
pub mod caps;
pub mod caps_av1;
-2
View File
@@ -88,8 +88,6 @@
//! the readback geometry (row pitch / crop) or intra decode; mismatches that
//! only appear on later frames point at inter prediction / DPB management.
#![deny(clippy::undocumented_unsafe_blocks)]
mod common;
use ash::vk;
-2
View File
@@ -39,8 +39,6 @@
//! so releases pass `false`), soak, and both vendors' DPB arrangements at once
//! (each box exercises only its own).
#![deny(clippy::undocumented_unsafe_blocks)]
mod common;
use ash::vk;
+689
View File
@@ -0,0 +1,689 @@
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
//! HPD-holding dummy plug).
//!
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
//! physical pins report.
//!
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
//! with its unlock (driver reinstall = the escape hatch of last resort).
//!
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
// mechanical.
#![allow(non_snake_case)]
use std::ffi::c_void;
use std::time::Instant;
use windows::core::{s, PCSTR};
use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
const ADL_OK: i32 = 0;
const ADL_MAX_PATH: usize = 256;
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
const ADL_EMUL_MODE_OFF: i32 = 0;
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
const ADL_QUERY_REAL_DATA: i32 = 0;
const ADL_QUERY_EMULATED_DATA: i32 = 1;
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
const AMD_VENDOR_ID: i32 = 1002;
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
/// whole consumer-vs-Pro question, so spell them out.
pub fn rc_str(rc: i32) -> &'static str {
match rc {
0 => "ADL_OK",
1..=4 => "ADL_OK_(warning-class)",
-1 => "ADL_ERR",
-2 => "ADL_ERR_NOT_INIT",
-3 => "ADL_ERR_INVALID_PARAM",
-5 => "ADL_ERR_INVALID_ADL_IDX",
-8 => "ADL_ERR_NOT_SUPPORTED",
-9 => "ADL_ERR_NULL_POINTER",
-10 => "ADL_ERR_DISABLED_ADAPTER",
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
_ => "?",
}
}
fn connector_type_str(t: i32) -> &'static str {
match t {
1 => "VGA",
2 => "DVI-D",
3 => "DVI-I",
8 => "HDMI-A",
9 => "HDMI-B",
10 => "DP",
11 => "eDP",
12 => "miniDP",
13 => "VIRTUAL",
14 => "USB-C",
_ => "unknown",
}
}
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
#[repr(C)]
struct AdapterInfo {
iSize: i32,
iAdapterIndex: i32,
strUDID: [u8; ADL_MAX_PATH],
iBusNumber: i32,
iDeviceNumber: i32,
iFunctionNumber: i32,
iVendorID: i32,
strAdapterName: [u8; ADL_MAX_PATH],
strDisplayName: [u8; ADL_MAX_PATH],
iPresent: i32,
// _WIN32 tail — this tool only builds for Windows.
iExist: i32,
strDriverPath: [u8; ADL_MAX_PATH],
strDriverPathExt: [u8; ADL_MAX_PATH],
strPNPString: [u8; ADL_MAX_PATH],
iOSDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLMSTRad {
iLinkNumber: i32,
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLDevicePort {
iConnectorIndex: i32,
aMSTRad: ADLMSTRad,
}
impl ADLDevicePort {
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
fn root(connector: i32) -> Self {
Self {
iConnectorIndex: connector,
aMSTRad: ADLMSTRad {
iLinkNumber: 0,
rad: [0; ADL_MAX_RAD_LINK_COUNT],
},
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionProperties {
iValidProperties: i32,
iBitrate: i32,
iNumberOfLanes: i32,
iColorDepth: i32,
iStereo3DCaps: i32,
iOutputBandwidth: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionData {
iConnectionType: i32,
aConnectionProperties: ADLConnectionProperties,
iNumberofPorts: i32,
iActiveConnections: i32,
iDataSize: i32,
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ADLConnectionState {
iEmulationStatus: i32,
iEmulationMode: i32,
iDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectorInfo {
iConnectorIndex: i32,
iConnectorId: i32,
iSlotIndex: i32,
iType: i32,
iOffset: i32,
iLength: i32,
}
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
type AdlContext = *mut c_void;
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
type FnBoardLayoutGet = unsafe extern "C" fn(
AdlContext,
i32,
*mut i32,
*mut i32,
*mut *mut c_void,
*mut i32,
*mut *mut ADLConnectorInfo,
) -> i32;
type FnConnStateGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
type FnConnDataGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
struct Adl {
create: FnMainCreate,
destroy: FnMainDestroy,
num_adapters: FnNumAdapters,
adapter_info: FnAdapterInfoGet,
edid_caps: FnEdidMgmtCaps,
board_layout: FnBoardLayoutGet,
conn_state: FnConnStateGet,
conn_data_get: FnConnDataGet,
conn_data_set: FnConnDataSet,
conn_data_remove: FnConnDataRemove,
emul_mode_set: FnEmulModeSet,
}
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
/// out-pointers and expects the app to own them.
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
let size = size.max(1) as usize;
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
unsafe {
std::alloc::alloc(std::alloc::Layout::from_size_align(size, 16).expect("tiny ADL alloc"))
as *mut c_void
}
}
impl Adl {
fn load() -> Option<Self> {
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
// name; a foreign-DLL search-path attack would require writing to System32.
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
// GetProcAddress yields the export's address or None.
let f = unsafe { GetProcAddress(lib, name) }?;
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
}
// SAFETY: `lib` is the live module handle from the successful load above.
unsafe {
Some(Self {
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
})
}
}
}
// ---- the library surface ----
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum EmulAction {
/// Read-only: caps + layout + per-connector state. Always safe.
Probe,
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
Lock,
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
Unlock,
}
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
/// tracing-logs them. The rc IS the deliverable of a field run.
pub struct OpRecord {
pub op: &'static str,
pub target: String,
pub took_ms: u128,
pub rc: i32,
pub extra: String,
}
impl OpRecord {
pub fn ok(&self) -> bool {
self.rc == ADL_OK
}
}
impl std::fmt::Display for OpRecord {
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let sep = if self.extra.is_empty() { "" } else { " " };
write!(
f,
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
self.op,
self.target,
self.took_ms,
self.ok(),
self.rc,
rc_str(self.rc),
self.extra
)
}
}
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
pub enum RunOutcome {
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
/// emulation lever does not exist on this box.
NoAdl,
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
InitFailed(Vec<OpRecord>),
/// The connector walk ran; every op's outcome is in the records.
Done(Vec<OpRecord>),
}
impl RunOutcome {
pub fn records(&self) -> &[OpRecord] {
match self {
RunOutcome::NoAdl => &[],
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
}
}
}
fn c_str(buf: &[u8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..len]).into_owned()
}
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
let mut recs: Vec<OpRecord> = Vec::new();
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
recs.push(OpRecord {
op,
target: target.to_owned(),
took_ms,
rc,
extra,
});
};
let Some(adl) = Adl::load() else {
return RunOutcome::NoAdl;
};
let mut ctx: AdlContext = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
// physical GPU), and a valid out-slot for the context.
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
rec(
"adl-init",
"atiadlxx",
t.elapsed().as_millis(),
rc,
String::new(),
);
if rc != ADL_OK {
return RunOutcome::InitFailed(recs);
}
let mut count = 0i32;
// SAFETY: live context; valid out-param.
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
if rc != ADL_OK || count <= 0 {
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
let mut infos: Vec<AdapterInfo> = (0..count)
.map(|_| {
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
// and ADL fills the array in place.
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
a
})
.collect();
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
// API's iInputSize contract requires.
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
if rc != ADL_OK {
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
let mut seen_buses: Vec<i32> = Vec::new();
for info in &infos {
if info.iPresent == 0
|| info.iVendorID != AMD_VENDOR_ID
|| seen_buses.contains(&info.iBusNumber)
{
continue;
}
seen_buses.push(info.iBusNumber);
let idx = info.iAdapterIndex;
let name = c_str(&info.strAdapterName);
let target = format!("adapter{idx}[{}]", name.trim());
let mut supported = 0i32;
let t = Instant::now();
// SAFETY: live context, adapter index from this enumeration, valid out-param.
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
rec(
"adl-edid-caps",
&target,
t.elapsed().as_millis(),
rc,
format!("supported={supported}"),
);
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
let mut slots: *mut c_void = std::ptr::null_mut();
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
// through `adl_malloc` (deliberately leaked, see there).
let rc = unsafe {
(adl.board_layout)(
ctx,
idx,
&mut valid,
&mut n_slots,
&mut slots,
&mut n_conn,
&mut connectors,
)
};
rec(
"adl-board-layout",
&target,
t.elapsed().as_millis(),
rc,
format!("connectors={n_conn} valid_flags={valid:#x}"),
);
let connector_list: &[ADLConnectorInfo] =
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
// (leaked) buffer outlives this borrow.
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
} else {
&[]
};
for c in connector_list {
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
continue;
}
let port = ADLDevicePort::root(c.iConnectorIndex);
let ctarget = format!(
"adapter{idx}.connector{}[{}]",
c.iConnectorIndex,
connector_type_str(c.iType)
);
let mut state = ADLConnectionState::default();
let t = Instant::now();
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
// enumerated; valid out-param.
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
rec(
"adl-conn-state",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
state.iEmulationStatus,
real,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
state.iEmulationMode,
state.iDisplayIndex,
),
);
if rc != ADL_OK {
continue;
}
match action {
EmulAction::Probe => {
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
// and ADL overwrites it.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port as above; REAL query fills `data` in place.
let rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
rec(
"adl-conn-data",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
}
EmulAction::Lock => {
if !real && connector_filter.is_none() {
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
}
// SAFETY: as in Probe — zeroed then driver-filled.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port; REAL query first — we pin exactly what the
// sink reports today, so the emulated display IS the user's monitor.
let mut rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
if rc != ADL_OK {
// Asleep sinks can refuse a live EDID read — fall back to whatever the
// driver already has as emulation data (Radeon-Pro-UI parity).
// SAFETY: same contract, emulated-data query.
rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
};
}
rec(
"adl-lock-read",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
if rc != ADL_OK || data.iDataSize <= 0 {
continue;
}
let t = Instant::now();
// SAFETY: live context/port; `data` passed by value per the ADL signature.
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
rec(
"adl-lock-set",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
rec(
"adl-lock-mode-always",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
EmulAction::Unlock => {
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
rec(
"adl-unlock-mode-off",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; removes emulation data set earlier (harmless
// where none exists — the rc says so).
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
rec(
"adl-unlock-remove",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
}
}
}
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
RunOutcome::Done(recs)
}
// ---- the `edid_lock` display-policy axis (host-side) ----
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
/// must unlock on its next start ([`startup_recover`]).
fn journal_path() -> std::path::PathBuf {
pf_paths::config_dir().join("edid-lock-active.json")
}
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
match outcome {
RunOutcome::NoAdl => tracing::info!(
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
emulation lever does not exist on this box"
),
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
for r in recs {
if r.ok() {
tracing::info!("{prefix}: {r}");
} else {
tracing::warn!("{prefix}: {r}");
}
}
}
}
}
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
/// some connectors (each rc is in the log).
pub fn lock_for_stream() -> bool {
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
// journal write would otherwise leave pinned connectors with no startup unlock owed.
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
tracing::warn!(
error = %format!("{e:#}"),
"edid_lock: crash journal write failed — continuing (the feature degrades to \
no-crash-journal)"
);
}
let outcome = run(EmulAction::Lock, None);
tracing_log("edid_lock", &outcome);
if matches!(outcome, RunOutcome::NoAdl) {
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
let _ = std::fs::remove_file(journal_path());
return false;
}
let locked = outcome
.records()
.iter()
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
.count();
tracing::info!(
connectors = locked,
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
);
true
}
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
pub fn unlock_after_stream() {
let outcome = run(EmulAction::Unlock, None);
tracing_log("edid_lock", &outcome);
let _ = std::fs::remove_file(journal_path());
}
/// Host-startup crash recovery: a previous host that died holding the lock left connector
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
/// journal marker exists, unlock everything and clear it — before any new session touches the
/// topology, mirroring `monitor_devnode::startup_recover`.
pub fn startup_recover() {
if !journal_path().exists() {
return;
}
tracing::warn!(
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
);
unlock_after_stream();
}
@@ -28,9 +28,6 @@
//! 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).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::{Mutex, Once, OnceLock};
use std::time::Instant;
+2 -2
View File
@@ -12,9 +12,9 @@
// `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`,
// `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")]
pub mod adl_emul;
#[cfg(target_os = "windows")]
pub mod display_events;
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
+16 -2
View File
@@ -217,18 +217,32 @@ fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
disabled
}
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
/// Re-enable `ids` (teardown / recovery) and clear the ones that actually re-enabled from the
/// journal. A FAILED re-enable must keep its journal entry: it is the only record that the
/// devnode is still disabled, and the next host start's [`startup_recover`] is the only thing
/// left that will retry it. (The old behavior cleared every requested id unconditionally — a
/// mid-life re-enable failure erased its own crash-recovery entry, leaving the operator's
/// monitor invisible to Windows AND to every display listing until they re-enabled it by hand
/// in Device Manager: the "my displays are gone until I restart everything" field class.)
pub fn enable_instances(ids: &[String]) -> u32 {
let mut ok = 0u32;
let mut reenabled: Vec<&String> = Vec::with_capacity(ids.len());
for id in ids {
if set_devnode(id, false) {
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
reenabled.push(id);
ok += 1;
} else {
tracing::warn!(
id,
"PnP-disable: monitor devnode re-enable FAILED — keeping its crash-journal \
entry so the next host start retries (until then this monitor stays disabled)"
);
}
}
let journal: Vec<String> = read_journal()
.into_iter()
.filter(|j| !ids.contains(j))
.filter(|j| !reenabled.contains(&j))
.collect();
write_journal(&journal);
ok
+284 -26
View File
@@ -8,13 +8,6 @@
//! 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.
// 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
// 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
@@ -1969,29 +1962,173 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
isolate_journal::clear();
}
/// Every display target that still EXISTS right now — `(adapter LUID low, high, target id)` keys
/// from a full `QDC_ALL_PATHS` sweep, counting a target present when the OS says a monitor is
/// attached (`targetAvailable`) OR an active path drives it (the flag reads FALSE transiently
/// right after a removal — same rule as [`target_inventory`]). `None` when the CCD query itself
/// fails, so the caller can fall back to trusting its snapshot verbatim.
fn available_target_keys() -> Option<Vec<(u32, i32, u32)>> {
let mut np = 0u32;
let mut nm = 0u32;
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live locals the
// OS fills with the counts it wants for these flags.
if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm) }.is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
// elements from the sizing call above, and are handed over with those same counts.
if unsafe {
QueryDisplayConfig(
QDC_ALL_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
}
.is_err()
{
return None;
}
paths.truncate(np as usize);
let mut keys: Vec<(u32, i32, u32)> = Vec::new();
for p in &paths {
let t = &p.targetInfo;
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
let present = t.targetAvailable.as_bool() || p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0;
if present && !keys.contains(&key) {
keys.push(key);
}
}
Some(keys)
}
/// Drop every snapshot path whose TARGET no longer exists (`avail` — the live
/// [`available_target_keys`] sweep) and rebuild the mode table with only the entries the
/// survivors reference, remapping their `modeInfoIdx` slots. Both halves matter:
/// `SetDisplayConfig(SDC_USE_SUPPLIED_DISPLAY_CONFIG)` validates the WHOLE submission, so one
/// stale path — or one orphaned mode entry left behind by a dropped path — fails the entire
/// restore with 0x57 ERROR_INVALID_PARAMETER. Returns `(paths, modes, dropped_path_count)`;
/// pure over its inputs so the remap arithmetic is unit-testable without a live CCD.
fn prune_saved_config_for_targets(
paths: &[DISPLAYCONFIG_PATH_INFO],
modes: &[DISPLAYCONFIG_MODE_INFO],
avail: &[(u32, i32, u32)],
) -> (
Vec<DISPLAYCONFIG_PATH_INFO>,
Vec<DISPLAYCONFIG_MODE_INFO>,
usize,
) {
let mut kept: Vec<DISPLAYCONFIG_PATH_INFO> = Vec::with_capacity(paths.len());
let mut new_modes: Vec<DISPLAYCONFIG_MODE_INFO> = Vec::with_capacity(modes.len());
// old mode index → new mode index, memoized: clone configs legitimately share a source mode
// entry between paths, and it must land in the rebuilt table exactly once.
let mut remap: Vec<Option<u32>> = vec![None; modes.len()];
let take =
|idx: u32, new_modes: &mut Vec<DISPLAYCONFIG_MODE_INFO>, remap: &mut Vec<Option<u32>>| {
if idx == DISPLAYCONFIG_PATH_MODE_IDX_INVALID {
return DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
}
match modes.get(idx as usize) {
// An out-of-range index could never have applied — un-pin the mode rather than
// shipping a table the whole submission fails on.
None => DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
Some(m) => match remap[idx as usize] {
Some(n) => n,
None => {
let n = new_modes.len() as u32;
new_modes.push(*m);
remap[idx as usize] = Some(n);
n
}
},
}
};
let mut dropped = 0usize;
for p in paths {
let t = &p.targetInfo;
if !avail.contains(&(t.adapterId.LowPart, t.adapterId.HighPart, t.id)) {
dropped += 1;
continue;
}
let mut p = *p;
// SAFETY: POD union reads (CCD header contract) — `modeInfoIdx` overlays a same-sized
// bitfield struct, both valid for every bit pattern; used only as bounds-checked indices.
let (src_idx, tgt_idx) = unsafe {
(
p.sourceInfo.Anonymous.modeInfoIdx,
p.targetInfo.Anonymous.modeInfoIdx,
)
};
p.sourceInfo.Anonymous.modeInfoIdx = take(src_idx, &mut new_modes, &mut remap);
p.targetInfo.Anonymous.modeInfoIdx = take(tgt_idx, &mut new_modes, &mut remap);
kept.push(p);
}
(kept, new_modes, dropped)
}
fn restore_displays_ccd_inner(saved: &SavedConfig) {
let (paths, modes) = saved;
if paths.is_empty() {
let (saved_paths, saved_modes) = saved;
if saved_paths.is_empty() {
return;
}
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
// slices, so pointer and length cannot disagree, and both outlive this synchronous
// call. `retry_set_display_config` binds it to the input desktop, which is the one
// precondition a caller of this global-state write could otherwise get wrong.
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
)
});
if rc == 0 {
tracing::info!("display isolate (CCD): restored original topology");
} else {
// Prune the snapshot against what is STILL ATTACHED before replaying it. A monitor unplugged
// mid-session leaves the snapshot referencing an absent target, and SetDisplayConfig rejects
// the WHOLE array with 0x57 ERROR_INVALID_PARAMETER — nothing restores, the desk stays dark,
// and the next session snapshots that wreckage (the poisoned-snapshot chain's first link;
// field 2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping between
// black/working at random). Dropping the stale paths lets the surviving displays restore
// normally; when NOTHING survives there is nothing to replay and the dark-desk backstop
// below is the whole answer.
let (kept, pruned_modes, dropped);
let (paths, modes): (&Vec<_>, &Vec<_>) = match available_target_keys() {
Some(avail) => {
(kept, pruned_modes, dropped) =
prune_saved_config_for_targets(saved_paths, saved_modes, &avail);
if dropped > 0 {
tracing::warn!(
dropped,
kept = kept.len(),
"display isolate (CCD): snapshot references target(s) that are no longer \
attached (unplugged mid-session?) pruned them so the survivors can restore \
(a verbatim replay fails whole with rc=0x57)"
);
}
(&kept, &pruned_modes)
}
// The availability query itself failed — replay verbatim, exactly the old behavior.
None => (saved_paths, saved_modes),
};
let mut apply_rc = 0i32; // 0 also when the replay was skipped (nothing left to apply)
if paths.is_empty() {
tracing::warn!(
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
sdc_access_denied_hint(rc)
"display isolate (CCD): nothing from the topology snapshot is still attached — \
skipping the replay (the dark-desk backstop decides what lights up)"
);
} else {
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
// slices, so pointer and length cannot disagree, and both outlive this synchronous
// call. `retry_set_display_config` binds it to the input desktop, which is the one
// precondition a caller of this global-state write could otherwise get wrong.
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
)
});
apply_rc = rc;
if rc == 0 {
tracing::info!("display isolate (CCD): restored original topology");
} else {
tracing::warn!(
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
sdc_access_denied_hint(rc)
);
}
}
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
@@ -2027,7 +2164,7 @@ fn restore_displays_ccd_inner(saved: &SavedConfig) {
return;
}
tracing::warn!(
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
"display isolate (CCD): no external physical display active after the restore (rc={apply_rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
);
force_extend_topology();
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
@@ -2135,3 +2272,124 @@ mod live_tests {
tracing::info!("live CCD query: {n} active display path(s)");
}
}
#[cfg(test)]
mod prune_saved_config_tests {
//! The snapshot-prune remap arithmetic (`prune_saved_config_for_targets`) — pure over its
//! inputs, so the 0x57-poisoned-restore fix is testable without a live CCD: a stale target's
//! path must vanish, its modes must not orphan (an orphaned entry fails the whole
//! SetDisplayConfig exactly like the stale path did), and clone-shared modes must land once.
use super::*;
fn path(
luid_low: u32,
target_id: u32,
src_mode: u32,
tgt_mode: u32,
) -> DISPLAYCONFIG_PATH_INFO {
let mut p = DISPLAYCONFIG_PATH_INFO::default();
p.targetInfo.adapterId.LowPart = luid_low;
p.targetInfo.id = target_id;
p.sourceInfo.adapterId.LowPart = luid_low;
p.sourceInfo.Anonymous.modeInfoIdx = src_mode;
p.targetInfo.Anonymous.modeInfoIdx = tgt_mode;
p
}
fn mode(marker: u32) -> DISPLAYCONFIG_MODE_INFO {
DISPLAYCONFIG_MODE_INFO {
id: marker,
..Default::default()
}
}
fn indices(p: &DISPLAYCONFIG_PATH_INFO) -> (u32, u32) {
// SAFETY: POD union reads — `modeInfoIdx` overlays a same-sized bitfield struct, both
// valid for every bit pattern (the same contract the production reads rely on).
unsafe {
(
p.sourceInfo.Anonymous.modeInfoIdx,
p.targetInfo.Anonymous.modeInfoIdx,
)
}
}
#[test]
fn everything_attached_survives_with_dense_indices() {
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
let avail = vec![(1, 0, 100), (1, 0, 200)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert_eq!(kept.len(), 2);
assert_eq!(new_modes.len(), 4);
assert_eq!(indices(&kept[0]), (0, 1));
assert_eq!(indices(&kept[1]), (2, 3));
assert_eq!(new_modes[3].id, 13, "mode entries follow their paths");
}
#[test]
fn a_gone_target_drops_its_path_and_modes() {
// Target 200 was unplugged mid-session (the field rc=0x57 case): its path AND its two
// mode entries must vanish, and the survivor's indices must be remapped dense.
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
let avail = vec![(1, 0, 100)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 1);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].targetInfo.id, 100);
assert_eq!(
new_modes.len(),
2,
"the dropped path's modes must not orphan"
);
assert_eq!((new_modes[0].id, new_modes[1].id), (10, 11));
assert_eq!(indices(&kept[0]), (0, 1));
}
#[test]
fn a_clone_shared_source_mode_lands_exactly_once() {
// Clone configs share one source mode entry between paths — the rebuilt table must
// contain it once, referenced by both survivors.
let paths = vec![path(1, 100, 0, 1), path(1, 200, 0, 2)];
let modes = vec![mode(10), mode(11), mode(12)];
let avail = vec![(1, 0, 100), (1, 0, 200)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert_eq!(new_modes.len(), 3);
let (a_src, _) = indices(&kept[0]);
let (b_src, _) = indices(&kept[1]);
assert_eq!(a_src, b_src, "shared source mode keeps one table entry");
}
#[test]
fn unpinned_and_corrupt_indices_stay_unpinned() {
// The INVALID sentinel must pass through, and an out-of-range index (a corrupt snapshot)
// must degrade to unpinned rather than shipping a table the whole apply fails on.
let paths = vec![path(1, 100, DISPLAYCONFIG_PATH_MODE_IDX_INVALID, 99)];
let modes = vec![mode(10)];
let avail = vec![(1, 0, 100)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert!(new_modes.is_empty());
assert_eq!(
indices(&kept[0]),
(
DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
DISPLAYCONFIG_PATH_MODE_IDX_INVALID
)
);
}
#[test]
fn different_adapters_do_not_alias_the_same_target_id() {
// Target ids are only unique per adapter LUID — a survivor on adapter 2 must not keep a
// stale path alive on adapter 1 just because the ids match.
let paths = vec![path(1, 100, 0, 1)];
let modes = vec![mode(10), mode(11)];
let avail = vec![(2, 0, 100)];
let (kept, _, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!((kept.len(), dropped), (0, 1));
}
}
-3
View File
@@ -14,9 +14,6 @@
//! wait, no harm, and `WaitOutcome::NoFence` tells us the driver doesn't fence (so zero-copy
//! 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::time::{Duration, Instant};
-3
View File
@@ -6,9 +6,6 @@
//! A worker death — the whole point of the isolation — surfaces as an `Err` with
//! [`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::egl::DmabufPlane;
use super::ipc;
-2
View File
@@ -18,8 +18,6 @@
//! driver — see [`super::egl`].)
#![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 std::os::raw::{c_uint, c_void};
-2
View File
@@ -5,8 +5,6 @@
//! and drive this layer.
#![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 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.
#![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 anyhow::{ensure, Context as _, Result};
-2
View File
@@ -5,8 +5,6 @@
//! [`super`].
#![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 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
//! 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::Serialize;
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
//! 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 anyhow::{anyhow, Context as _, Result};
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
//! 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 anyhow::{anyhow, bail, Context as _, Result};
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
//! 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::egl::{DmabufPlane, EglImporter};
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
//! `DeviceBuffer` that vocabulary's `FramePayload::Cuda` owns).
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. Each
// file keeps its own `#![deny(...)]` too; this crate-root deny is the catch-all gate.
// `unsafe_op_in_unsafe_fn` closes the gap the clippy lint leaves: operations inside an
// `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)]
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof, and
// `unsafe fn` bodies need explicit blocks (~45 functions' worth of raw driver calls used to sit
// outside that invariant). Both lints are enforced by the workspace `[workspace.lints]` tables.
/// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll).
#[cfg(target_os = "linux")]
+148 -29
View File
@@ -53,6 +53,19 @@ use std::os::raw::c_char;
use std::panic::AssertUnwindSafe;
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.
pub struct PunktfunkSession {
inner: Session,
@@ -471,8 +484,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
}
match s.inner.poll_frame() {
Ok(frame) => {
s.last_frame = Some(frame);
let f = s.last_frame.as_ref().unwrap();
let f = s.last_frame.insert(frame);
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value.
unsafe {
@@ -494,8 +506,10 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
/// Client: serialize and send one input event to the host.
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # 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]
pub unsafe extern "C" fn punktfunk_send_input(
s: *mut PunktfunkSession,
@@ -509,12 +523,11 @@ pub unsafe extern "C" fn punktfunk_send_input(
Some(s) => s,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let ev = match unsafe { ev.as_ref() } {
Some(e) => e,
None => return PunktfunkStatus::NullPointer,
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// for the one field where a reference formed too early would be UB instead.
let ev = match unsafe { read_input_event(ev) } {
Ok(e) => e,
Err(status) => return status,
};
match s.inner.send_input(ev) {
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
/// 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))
{
Ok(frame) => {
let mut slot = c.last.lock().unwrap();
*slot = Some(frame);
let f = slot.as_ref().unwrap();
let mut slot = lock_recover(&c.last);
let f = slot.insert(frame);
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value.
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))
{
Ok(pkt) => {
let mut slot = c.last_audio.lock().unwrap();
*slot = Some(pkt);
let p = slot.as_ref().unwrap();
let mut slot = lock_recover(&c.last_audio);
let p = slot.insert(pkt);
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value.
unsafe {
@@ -2467,7 +2503,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
Ok(pkt) => pkt,
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) {
// Nothing to hand out this call: a DTX silence marker with no loss owed before it.
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))
{
Ok(shape) => {
let mut slot = c.last_cursor_shape.lock().unwrap();
*slot = Some(shape);
let sh = slot.as_ref().unwrap();
let mut slot = lock_recover(&c.last_cursor_shape);
let sh = slot.insert(shape);
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value.
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).
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # 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")]
#[no_mangle]
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,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let ev = match unsafe { ev.as_ref() } {
Some(e) => e,
None => return PunktfunkStatus::NullPointer,
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// for the one field where a reference formed too early would be UB instead.
let ev = match unsafe { read_input_event(ev) } {
Ok(e) => e,
Err(status) => return status,
};
match c.inner.send_input(ev) {
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))
{
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);
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// 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
// 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.
*c.last_clip.lock().unwrap() = None;
*lock_recover(&c.last_clip) = None;
e.status()
}
}
@@ -4337,7 +4373,41 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
if !gap_out.is_null() {
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value.
unsafe { *gap_out = gap };
unsafe { *gap_out = gap > 0 };
}
PunktfunkStatus::Ok
})
}
/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
/// A client with a post-loss display freeze passes the width to
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
/// race — see the gate function's doc).
///
/// # Safety
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
c: *const PunktfunkConnection,
frame_index: u32,
gap_width_out: *mut u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
let gap = c.inner.note_frame_index(frame_index);
if !gap_width_out.is_null() {
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value.
unsafe { *gap_width_out = gap };
}
PunktfunkStatus::Ok
})
@@ -4688,6 +4758,31 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
});
}
/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
///
/// # Safety
/// `g` is a valid gate handle.
#[no_mangle]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
g: *mut ReanchorGate,
expected_drops: u64,
) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
// not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
// handles.
if let Some(g) = unsafe { g.as_mut() } {
g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
}
});
}
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
@@ -4809,6 +4904,30 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
mod tests {
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
/// 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).
+10 -4
View File
@@ -881,14 +881,20 @@ impl NativeClient {
///
/// Call it for EVERY received frame; it is cheap and idempotent, and the
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
/// a post-loss display freeze can (re-)arm it on the same signal.
/// loop stays the backstop for when the recovery frame itself is lost. Returns the gap WIDTH —
/// how many frames this arrival revealed as missing, `0` when none (contiguous or straggler),
/// whether or not the RFI was throttled — so a client with a post-loss display freeze can
/// (re-)arm it on the same signal AND pre-credit the reassembler's later `frames_dropped` climb
/// for the same loss ([`ReanchorGate::arm_expecting_drops`] — without the credit, a fast
/// LTR-RFI anchor lifts the freeze before the climb books the loss, and the stale climb then
/// re-freezes the healed stream).
///
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
/// signal and shares one throttle across RFI + keyframe requests.)
pub fn note_frame_index(&self, frame_index: u32) -> bool {
///
/// [`ReanchorGate::arm_expecting_drops`]: crate::reanchor::ReanchorGate::arm_expecting_drops
pub fn note_frame_index(&self, frame_index: u32) -> u32 {
// Decide (and update state) under the lock; fire the request after releasing it.
let (gap, ask) = self
.rfi
+33 -30
View File
@@ -32,14 +32,16 @@ pub(crate) enum RecoveryAsk {
impl RfiRecovery {
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
/// without a live session (see the tests below).
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
/// `gap` is how many frames this arrival revealed as missing — 0 for contiguous/straggler; the
/// embedder arms its post-loss display freeze on a non-zero gap, and the WIDTH lets it
/// pre-credit the reassembler's later `frames_dropped` climb for the same loss
/// ([`crate::reanchor::ReanchorGate::arm_expecting_drops`] — the double-arm race). `ask` is the
/// (throttled) recovery request to fire — an RFI naming the exact lost span, or a keyframe when
/// the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is hopeless there: no encoder holds
/// references that old, and a huge jump is more likely a resync — e.g. the first real AU after
/// an old host's speed test — than a real loss). Split out from the connection so the wrapping
/// arithmetic + [`RFI_THROTTLE`] are unit-testable without a live session (see the tests below).
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (u32, RecoveryAsk) {
match self.next_expected {
Some(exp) => {
// Wrapping split at the half-space: a small positive delta is a forward gap
@@ -47,10 +49,11 @@ impl RfiRecovery {
let ahead = frame_index.wrapping_sub(exp);
if ahead == 0 {
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
} else if ahead < u32::MAX / 2 {
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
// Forward gap: [exp, frame_index-1] lost (`ahead` frames). Advance past this
// frame so the same gap isn't re-detected, then fire a throttled recovery ask
// for the lost range.
self.next_expected = Some(frame_index.wrapping_add(1));
let send = self
.last_req
@@ -65,15 +68,15 @@ impl RfiRecovery {
} else {
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
};
(true, ask)
(ahead, ask)
} else {
// Straggler behind the delivery point — leave the expectation.
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
}
}
None => {
self.next_expected = Some(frame_index.wrapping_add(1));
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
}
}
}
@@ -96,7 +99,7 @@ mod rfi_recovery_tests {
fn first_frame_arms_without_a_gap() {
let mut r = RfiRecovery::default();
// The opening frame only seeds the expectation — there is no prior frame to be missing.
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
assert_eq!(r.observe(100, base()), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(101));
}
@@ -105,9 +108,9 @@ mod rfi_recovery_tests {
let mut r = RfiRecovery::default();
let t = base();
r.observe(100, t);
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(101, t), (0, RecoveryAsk::None));
assert_eq!(r.observe(102, t), (0, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(104));
}
@@ -117,7 +120,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(100, t); // expecting 101 next
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
assert_eq!(r.observe(105, t), (4, RecoveryAsk::Rfi(101, 104)));
// The expectation advances past the delivered frame so the same gap can't re-fire.
assert_eq!(r.next_expected, Some(106));
}
@@ -128,7 +131,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(100, t);
// Exactly one frame (101) lost → range is the single index [101, 101].
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
assert_eq!(r.observe(102, t), (1, RecoveryAsk::Rfi(101, 101)));
}
#[test]
@@ -137,16 +140,16 @@ mod rfi_recovery_tests {
let t0 = base();
r.observe(100, t0);
// First gap fires the request and stamps the throttle.
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
assert_eq!(r.observe(105, t0), (4, RecoveryAsk::Rfi(101, 104)));
// A second gap 50 ms later is still a gap, but the request is throttled away.
assert_eq!(
r.observe(110, t0 + Duration::from_millis(50)),
(true, RecoveryAsk::None)
(4, RecoveryAsk::None)
);
// Past the window, the request re-opens for the still-accurate lost span.
assert_eq!(
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
(true, RecoveryAsk::Rfi(111, 119))
(9, RecoveryAsk::Rfi(111, 119))
);
}
@@ -158,7 +161,7 @@ mod rfi_recovery_tests {
r.observe(105, t); // expecting 106 next
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(106));
}
@@ -167,9 +170,9 @@ mod rfi_recovery_tests {
let mut r = RfiRecovery::default();
let t = base();
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
assert_eq!(r.observe(u32::MAX, t), (0, RecoveryAsk::None)); // contiguous, wraps to 0
assert_eq!(r.next_expected, Some(0));
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
assert_eq!(r.observe(0, t), (0, RecoveryAsk::None)); // still contiguous across the wrap
assert_eq!(r.next_expected, Some(1));
}
@@ -179,7 +182,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
assert_eq!(r.observe(1, t), (2, RecoveryAsk::Rfi(u32::MAX, 0)));
assert_eq!(r.next_expected, Some(2));
}
@@ -192,14 +195,14 @@ mod rfi_recovery_tests {
// reference exists for an RFI, and the jump may be a phantom (an old host's
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
assert_eq!(r.observe(jump, t), (jump - 101, RecoveryAsk::Keyframe));
// The expectation still advances past the delivered frame (no re-fire on the next one).
assert_eq!(r.next_expected, Some(jump + 1));
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(jump + 1, t), (0, RecoveryAsk::None));
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
assert_eq!(
r.observe(jump + 10, t + Duration::from_millis(1)),
(true, RecoveryAsk::None)
(8, RecoveryAsk::None)
);
}
}
-1
View File
@@ -46,7 +46,6 @@
// `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.
#![deny(unsafe_code)]
#![deny(clippy::undocumented_unsafe_blocks)]
#![forbid(unsafe_op_in_unsafe_fn)]
pub mod abi;
+148 -6
View File
@@ -64,6 +64,26 @@ pub const REANCHOR_MARKS_TO_LIFT: u32 = 2;
/// floor fires, so a real stall still recovers.
pub const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
/// How long a frame-index-gap arm's expected `frames_dropped` climb stays pre-credited in
/// [`ReanchorGate::poll`]. One loss arms the gate through TWO signals: the frame-index gap the
/// instant the AU after the loss is delivered ([`ReanchorGate::arm_expecting_drops`]), and the
/// reassembler's `frames_dropped` climb once the lost frame ages out of its loss window (~120 ms
/// later, and only when at least one of its packets arrived). Without the credit, a *fast* recovery
/// — an LTR-RFI anchor typically lands within ~60 ms — lifts the freeze between the two signals,
/// and the stale climb then re-freezes a stream that is already bit-exact healed; the host swallows
/// the resulting keyframe request as an echo of the very RFI that healed it, so the picture stays
/// frozen until the [`REANCHOR_FREEZE_MAX`] overdue re-ask extracts a full IDR (the field
/// "H265 freezes on every loss, AV1 fine" signature — the slower IDR path usually lands after the
/// climb and dodged the race).
///
/// Sized to cover the reassembler's 120 ms loss window plus delivery jitter with a wide margin,
/// while staying short enough that a leftover credit (a straggler that filled the gap late, so no
/// climb ever came; or a whole-frame vanish the reassembler never saw a packet of) cannot mask a
/// genuinely unrelated future climb for long. A masked climb is also never silent in practice:
/// every unrecoverable loss reveals itself as a frame-index gap on the next delivered frame, which
/// re-arms (and re-credits) through [`ReanchorGate::arm_expecting_drops`] on its own.
pub const DROP_CREDIT_WINDOW: Duration = Duration::from_millis(1000);
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small positive
@@ -185,6 +205,14 @@ pub struct ReanchorGate {
/// a client stamps the decoder's decode-order watermark whenever this counter moves and
/// discards the local recovery of anything older. Every other client ignores it.
arms: u64,
/// `frames_dropped` climb still expected from losses that already armed via a frame-index gap
/// ([`Self::arm_expecting_drops`]). [`Self::poll`] consumes climbs against this before treating
/// them as a NEW loss, so the reassembler's delayed bookkeeping of a gap-armed (and possibly
/// already anchor-healed) loss cannot re-freeze the stream — see [`DROP_CREDIT_WINDOW`].
drop_credit: u64,
/// When the outstanding [`Self::drop_credit`] lapses ([`DROP_CREDIT_WINDOW`] after the latest
/// credited arm). `None` when no credit is outstanding.
drop_credit_expiry: Option<Instant>,
}
impl ReanchorGate {
@@ -199,6 +227,8 @@ impl ReanchorGate {
last_dropped: frames_dropped,
local_sei_since_arm: false,
arms: 0,
drop_credit: 0,
drop_credit_expiry: None,
}
}
@@ -230,6 +260,20 @@ impl ReanchorGate {
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
}
/// [`arm`](Self::arm) for a loss detected as a **frame-index gap**, where the caller knows how
/// many frames the gap skipped. On top of arming, it pre-credits the reassembler's
/// `frames_dropped` climb those same lost frames will produce up to ~120 ms later (its
/// loss-window age-out), so [`poll`](Self::poll) does not treat that delayed bookkeeping as a
/// SECOND loss. Without the credit a fast LTR-RFI anchor lifts the freeze between the two
/// signals and the stale climb re-freezes a healed stream — the double-arm race
/// ([`DROP_CREDIT_WINDOW`] tells the whole story). Use plain [`arm`](Self::arm) for every
/// non-gap loss signal (decoder wedge/demotion), which has no climb to credit.
pub fn arm_expecting_drops(&mut self, now: Instant, expected_drops: u64) {
self.arm(now);
self.drop_credit = self.drop_credit.saturating_add(expected_drops);
self.drop_credit_expiry = Some(now + DROP_CREDIT_WINDOW);
}
/// Fold the client's OWN recovery-point observation for one decoded frame, BEFORE handing that
/// frame to [`on_decoded`](Self::on_decoded). Returns `true` when it lifted the freeze.
///
@@ -333,16 +377,36 @@ impl ReanchorGate {
}
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed by
/// more than the outstanding gap-arm credit (a fresh unrecoverable loss — arm the freeze) or the
/// freeze has held a full [`REANCHOR_FREEZE_MAX`] window with no re-anchor (re-ask and keep
/// holding — NEVER resume to the concealed picture; a genuinely dead stream is the QUIC
/// idle-timeout watchdog's job, not the gate's).
///
/// A climb covered by [`arm_expecting_drops`](Self::arm_expecting_drops)' credit is the
/// reassembler's delayed bookkeeping of a loss this gate already armed for — it must neither
/// re-arm (an LTR-RFI anchor may have healed the stream in the meantime; re-freezing it is the
/// double-arm race) nor ask again (the gap already fired the precise RFI, and if THAT recovery
/// was lost the overdue backstop still re-asks at the [`REANCHOR_FREEZE_MAX`] deadline the
/// gap-arm set — which is also sooner than the deadline a re-arm here would push out to).
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
let mut want_keyframe = false;
if frames_dropped > self.last_dropped {
let climb = frames_dropped - self.last_dropped;
self.last_dropped = frames_dropped;
self.arm(now);
want_keyframe = true;
if self.drop_credit_expiry.is_some_and(|e| now >= e) {
self.drop_credit = 0;
self.drop_credit_expiry = None;
}
let credited = climb.min(self.drop_credit);
self.drop_credit -= credited;
if self.drop_credit == 0 {
self.drop_credit_expiry = None;
}
if climb > credited {
self.arm(now);
want_keyframe = true;
}
}
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
@@ -542,6 +606,84 @@ mod tests {
);
}
#[test]
fn an_rfi_anchor_is_not_refrozen_by_the_same_losss_drop_climb() {
// The double-arm race (field: "H265 freezes on every loss, AV1 fine"): a loss arms via
// the frame-index gap at T+10ms, the LTR-RFI anchor heals at T+60ms, and the reassembler
// books the SAME loss into frames_dropped at ~T+130ms. The credited arm must keep that
// stale climb from re-freezing the healed stream (and from re-asking — the host would
// swallow the ask as an RFI echo and the picture would freeze until a forced IDR).
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t + Duration::from_millis(10), 1); // gap of one lost frame + RFI
assert_eq!(
g.on_decoded(ANCHOR, false, t + Duration::from_millis(60)),
GateVerdict::Present,
"the anchor lifts"
);
assert!(
!g.poll(1, t + Duration::from_millis(130)),
"the credited climb must not ask again"
);
assert!(!g.is_holding(), "and must not re-freeze the healed stream");
assert_eq!(
g.on_decoded(0, false, t + Duration::from_millis(141)),
GateVerdict::Present,
"healthy P-frames keep presenting"
);
}
#[test]
fn a_climb_beyond_the_credit_is_a_fresh_loss_and_arms() {
// The credit covers exactly the gap's frames; a bigger climb means MORE loss than the gap
// accounted for (an interleaved partial-frame loss) — that part must still arm and ask.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 2);
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50)); // healed the credited loss
assert!(
g.poll(3, t + Duration::from_millis(130)),
"one uncredited drop → ask"
);
assert!(g.is_holding(), "and re-arm for the uncredited part");
}
#[test]
fn the_drop_credit_expires_so_a_late_climb_still_arms() {
// A straggler can fill the gap late (no climb ever comes) — the leftover credit must not
// linger and mask a genuinely NEW loss later. Past DROP_CREDIT_WINDOW the credit is void.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 1);
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50));
let late = t + DROP_CREDIT_WINDOW + Duration::from_millis(1);
assert!(
g.poll(1, late),
"an expired credit no longer absorbs climbs"
);
assert!(g.is_holding());
}
#[test]
fn a_credited_climb_keeps_the_unhealed_freezes_original_deadline() {
// When the recovery never arrives, consuming the climb must not silence the gate: the
// overdue backstop still re-asks — at the deadline the GAP arm set, which is sooner than
// the deadline a climb re-arm would have pushed out to.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 1); // RFI fired here; assume its anchor is lost in transit
assert!(
!g.poll(1, t + Duration::from_millis(130)),
"credited climb: no early re-ask"
);
assert!(g.is_holding(), "still frozen — nothing healed it");
assert!(
g.poll(1, t + REANCHOR_FREEZE_MAX + Duration::from_millis(1)),
"the overdue backstop still re-asks on the gap-arm's own deadline"
);
assert!(g.is_holding(), "and keeps holding, never resuming to gray");
}
#[test]
fn the_no_output_streak_trips_at_three() {
let mut g = ReanchorGate::new(0);
+35 -7
View File
@@ -11,24 +11,52 @@ profile="${1:-debug}"
build_flag=""
[ "$profile" = "release" ] && build_flag="--release"
echo ">> building punktfunk-core staticlib ($profile)"
cargo build -p punktfunk-core $build_flag >/dev/null
# PF_SAN=address instruments BOTH sides of the C boundary at once: the staticlib via
# -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"
[ -f "$staticlib" ] || { echo "missing $staticlib"; 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.
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)"
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}"
cflags=""
if [ -n "$san" ]; then
cc="${CC:-clang}"
cflags="-fsanitize=$san -fno-omit-frame-pointer"
fi
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"
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
//! 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 anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
@@ -21,9 +21,6 @@
//! endpoint of that name, and the probe restores the default playback/recording devices it
//! 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::{audio_control, SAMPLE_RATE};
use anyhow::{anyhow, bail, Context, Result};
@@ -24,9 +24,6 @@
//! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk
//! 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 anyhow::Result;
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
//! 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 anyhow::{anyhow, bail, Context, Result};
use std::collections::{HashSet, VecDeque};
@@ -27,18 +27,28 @@
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
//! ([`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`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
//!
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
//! existing, only the power request is released) and parks on the queue's condvar; the next
//! pushed frame resumes it within one device period. During a session the box is kept awake by
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
//! driver misbehaves when its render side pauses.
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use std::time::{Duration, Instant};
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
const CHANNELS: u32 = 2;
@@ -63,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
/// adaptive target drives the ring.
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
/// `drain_sleep`).
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
/// driver's capture side misbehaves while its render side is paused.
fn mic_always_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
}
pub struct WasapiVirtualMic {
queue: Arc<Mutex<VecDeque<u8>>>,
queue: Arc<MicQueue>,
stop: Arc<AtomicBool>,
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
alive: Arc<AtomicBool>,
@@ -97,7 +128,7 @@ impl WasapiVirtualMic {
channels == CHANNELS,
"virtual mic is stereo-only (got {channels})"
);
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
let stop = Arc::new(AtomicBool::new(false));
let alive = Arc::new(AtomicBool::new(true));
let ring = Arc::new(RingShared::default());
@@ -147,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
if !self.alive.load(Ordering::Acquire) {
return false;
}
let Ok(mut q) = self.queue.lock() else {
let (lock, wake) = &*self.queue;
let Ok(mut q) = lock.lock() else {
return false;
};
let was_empty = q.is_empty();
q.reserve(pcm.len() * 4);
for &s in pcm {
q.extend(s.to_le_bytes());
@@ -174,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
.overflow
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
}
drop(q);
if was_empty {
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
// re-checks the queue under the lock, so this cannot be a lost wakeup).
wake.notify_one();
}
true
}
@@ -182,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
}
fn discard(&self) {
if let Ok(mut q) = self.queue.lock() {
if let Ok(mut q) = self.queue.0.lock() {
q.clear();
}
}
@@ -202,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
if prime == 0 {
return None; // render loop hasn't run yet
}
let q = self.queue.lock().ok()?;
let q = self.queue.0.lock().ok()?;
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
}
@@ -390,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
}
fn render_thread(
queue: Arc<Mutex<VecDeque<u8>>>,
queue: Arc<MicQueue>,
stop: Arc<AtomicBool>,
shared: Arc<RingShared>,
ready: SyncSender<Result<String>>,
@@ -464,7 +503,40 @@ fn render_thread(
// the target).
let mut buf: Vec<u8> = Vec::new();
let mut primed = false;
while !stop.load(Ordering::Relaxed) {
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
// client left behind, while any genuinely new audio moves the length (a push appends a
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
// can never coincidentally return to its marked length) and restarts the window.
let always_on = mic_always_on();
let mut running = true;
let mut idle_mark: Option<(usize, Instant)> = None;
'render: while !stop.load(Ordering::Relaxed) {
if !running {
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
// empty→non-empty edge); the timeout only keeps `stop` responsive.
{
let (lock, wake) = &*queue;
let mut q = lock.lock().unwrap();
while q.is_empty() {
if stop.load(Ordering::Relaxed) {
break 'render;
}
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
q = guard;
}
}
// A resume failure means the endpoint died while we slept — propagate, so the
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
audio_client
.start_stream()
.context("resume render stream")?;
running = true;
idle_mark = None;
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
}
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
if h_event.wait_for_event(100).is_err() {
continue;
@@ -489,7 +561,7 @@ fn render_thread(
// Silence base; overwrite with queued mic PCM once the cushion is primed.
buf[..need].fill(0);
{
let mut q = queue.lock().unwrap();
let mut q = queue.0.lock().unwrap();
if !primed && q.len() >= prime {
primed = true;
}
@@ -503,6 +575,35 @@ fn render_thread(
shared.reprimes.fetch_add(1, Ordering::Relaxed);
}
}
if primed {
idle_mark = None;
} else if !always_on {
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
// unchanged queue length, stop the stream so the box can sleep. Decided under
// the queue lock, and only when nothing has arrived for the whole window (see
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
// (the pump's stale-gap discard would drop it before the next real frame
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
match idle_mark {
Some((len, since)) if len == q.len() => {
if since.elapsed() >= IDLE_STOP_AFTER {
q.clear();
audio_client
.stop_stream()
.context("idle-stop render stream")?;
running = false;
idle_mark = None;
tracing::debug!(
"virtual mic stream idle-stopped (releases the sleep-blocking \
audio power request; next mic frame resumes it)"
);
continue 'render;
}
}
_ => idle_mark = Some((q.len(), Instant::now())),
}
}
}
render_client
.write_to_device(space, &buf[..need], None)
@@ -17,9 +17,6 @@
//! 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).
// 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))]
use crate::audio::SAMPLE_RATE;
#[cfg(any(target_os = "linux", target_os = "windows"))]

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