docs(unsafe): audit all 49 unsafe impl — one proof was wrong, four were missing
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s

`unsafe impl Send`/`Sync` is the highest-risk unsafe category here and the one this program had
never looked at: a wrong one is cross-thread UB that is invisible at every call site, with no
`unsafe` block to catch a reviewer's eye. 49 of them (41 Send, 8 Sync). Two results.

**`MappedView`'s `Sync` proof was factually wrong.** It read "only exposes accessors that are safe
under concurrent use" — they are not. `read_u8`/`write_u8`/`read_u16` are plain unaligned accesses
through `&self`, and `&MappedView` really is shared across threads: `ChannelState::data()` hands out
`&'static MappedView`, and pf-xusb, pf-mouse and pf-gamepad all dispatch
`WdfIoQueueDispatchParallel` with `NumberOfPresentedRequests = u32::MAX`. The struct's own doc had
the right story — consistency is the channel protocol's job — but the `unsafe impl` stated a
different, stronger claim, which is the one a reviewer checking that line would rely on.

The impl is still sound, for a reason worth writing down: these bytes are mapped into ANOTHER
PROCESS that writes them concurrently, so Rust-level exclusivity over them is unachievable no matter
what this type does. Sync fields go through the atomic accessors; the plain ones cover only
protocol-fenced bytes. The proof now says that, and states the rule it implies for accessors added
later — plain path only for bytes the protocol already fences.

**Four `Send` impls carried a one-line aside instead of a proof** — the pf-client-core decoders and
`DrmFrameGuard`. All four are sound, and each now says why, including the two facts that make them
work and were nowhere stated: libav permits a codec context to be used from a thread other than its
creator provided use is serialised (`&mut self` is that serialisation), and D3D11's immediate
context is thread-AGNOSTIC rather than thread-safe — it wants serialised use, not one fixed thread.
Each also records that it is deliberately not `Sync`, which is the invariant a future `impl Sync`
would silently break.

Every one of the 49 now carries reasoning. Verified: Linux .21 fmt + both CI clippy steps rc=0;
Windows .47 `-p pf-client-core` clippy `-D warnings` rc=0 (it compiles the `video_d3d11` proof the
Linux run cannot see). The pf-umdf-util edit is comment-only — confirmed by diff, since that crate
needs the WDK, which .47 does not have.
This commit is contained in:
2026-07-29 00:37:14 +02:00
parent e2fd1c586b
commit d2b6f5b65f
5 changed files with 40 additions and 6 deletions
+5 -1
View File
@@ -222,7 +222,11 @@ pub struct DmabufPlane {
/// Owns the mapped DRM-PRIME `AVFrame` (which in turn references the VAAPI surface).
/// Dropping it releases the surface back to the decoder pool and closes the fds.
pub struct DrmFrameGuard(pub(crate) *mut ffmpeg::ffi::AVFrame);
// An AVFrame is plain refcounted data; freeing it from the GTK main thread is fine.
// SAFETY: the guard owns one `AVFrame` and frees it exactly once in `Drop`. libav's buffer
// refcounts are atomic and its hwframe pool is internally locked, so releasing the frame — and with
// it the VAAPI surface, back to the decoder's pool — from a different thread than the one that
// mapped it is sound. That is the whole point here: the guard is handed to GTK and dropped on the
// main thread while the pump thread keeps decoding. Moved, never shared; deliberately NOT `Sync`.
unsafe impl Send for DrmFrameGuard {}
impl Drop for DrmFrameGuard {
+6 -2
View File
@@ -492,8 +492,12 @@ pub(crate) struct D3d11vaDecoder {
hdr10_out: bool,
}
// Single-owner pointers + COM interfaces, only touched from the session pump thread (the
// decode loop); the presenter reaches the shared textures exclusively via their NT handles.
// SAFETY: the libav pointers are this decoder's own allocations (freed once in `Drop`) and the COM
// interfaces it holds are reference-counted with interlocked counts, so moving the whole struct to
// another thread and releasing it there is sound. D3D11's immediate context is not thread-SAFE but
// it is thread-AGNOSTIC: it requires serialised use, which `&mut self` on every method gives, not
// use from one fixed thread. The presenter never touches these objects — it reaches the shared
// textures through their NT handles on its own device. Moved, never shared; deliberately NOT `Sync`.
unsafe impl Send for D3d11vaDecoder {}
impl D3d11vaDecoder {
+7 -1
View File
@@ -45,7 +45,13 @@ pub(crate) struct VaapiDecoder {
frame: *mut ffmpeg::ffi::AVFrame,
}
// Single-owner pointers, only touched from the session pump thread.
// SAFETY: the three raw pointers (`ctx`, `packet`, `frame`) are allocations this decoder makes in
// its constructor and frees exactly once in `Drop`; nothing else holds them, and `hw_device` is an
// owning `AvBuffer` whose refcount is atomic. `Send` only permits MOVING that ownership to another
// thread, which libav supports — a codec context may be used from a thread other than the one that
// created it, provided use is serialised, and `&mut self` on every method is that serialisation.
// Deliberately NOT `Sync`: two threads holding `&VaapiDecoder` could call into libav concurrently
// on one context, which libav does not allow.
#[cfg(target_os = "linux")]
unsafe impl Send for VaapiDecoder {}
+6 -1
View File
@@ -39,7 +39,12 @@ pub(crate) struct VulkanDecoder {
_ctx_storage: Box<VkCtxStorage>,
}
// Single-owner pointers, only touched from the session pump thread.
// SAFETY: `ctx`/`packet`/`frame` are allocations this decoder owns from its constructor to `Drop`,
// `hw_device` is an owning `AvBuffer` (atomic refcount), and `_ctx_storage` is a `Box` that merely
// has to outlive them. `Send` only moves that ownership between threads, which libav permits for a
// codec context used serially — `&mut self` on every method provides that. The presenter reaches
// decoded images through the `AVFrame` guard's own references and the shared `QueueLock`, not
// through this struct. Deliberately NOT `Sync`.
unsafe impl Send for VulkanDecoder {}
struct VkCtxStorage {