Files
punktfunk/crates/pf-presenter/src/vk/present_timing.rs
T
enricobuehler 65cd388a52 fix(presenter,core): close the last of the proof-lint hole — the Vulkan contract, stated once
pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.

The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.

punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.

⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.

All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
2026-07-29 08:48:42 +02:00

136 lines
5.4 KiB
Rust

//! True on-glass present timing via `VK_KHR_present_wait` (latency plan T0.2).
//!
//! The render loop's `displayed` stamp is taken when `vkQueuePresentKHR` *returns* — CPU
//! submit time, excluding the presentation engine's queue and the vblank latch, so the
//! HUD's `display` stage under-reports by up to a refresh (and hides a silent-FIFO
//! standing queue entirely). When the device offers `VK_KHR_present_id` +
//! `VK_KHR_present_wait`, each present carries a monotonically increasing id and a
//! dedicated waiter thread blocks in `vkWaitForPresentKHR` — which completes when the
//! image is actually visible — stamping the REAL on-glass time off the render loop.
//!
//! Lifecycle: `vkWaitForPresentKHR` requires the swapchain to stay alive for the call's
//! duration, so [`PresentTimer::drain`] must run before any `vkDestroySwapchainKHR`
//! (recreate and teardown both do). Waits carry a 250 ms cap: presentation ids complete
//! in submission order (a MAILBOX-replaced image's id completes with the present that
//! replaced it), so a wait only outlives that cap when the pipeline is already wedged —
//! the timeout keeps the drain bounded rather than wedging a resize with it.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use ash::vk;
/// One presented frame's identity + the true on-glass stamp the waiter filled in.
pub(crate) struct PresentedSample {
/// The frame's capture stamp (host clock) — the e2e anchor.
pub pts_ns: u64,
/// Decode-complete stamp (client clock) — the display-stage anchor.
pub decoded_ns: u64,
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
pub displayed_ns: u64,
}
struct Job {
swapchain: vk::SwapchainKHR,
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
}
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
pub(crate) struct PresentTimer {
tx: Option<mpsc::Sender<Job>>,
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
pending: Arc<AtomicUsize>,
results: Arc<Mutex<Vec<PresentedSample>>>,
join: Option<std::thread::JoinHandle<()>>,
}
impl PresentTimer {
pub(crate) fn spawn(wait_d: ash::khr::present_wait::Device) -> Self {
let (tx, rx) = mpsc::channel::<Job>();
let pending = Arc::new(AtomicUsize::new(0));
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
let (pending_t, results_t) = (pending.clone(), results.clone());
let join = std::thread::Builder::new()
.name("pf-present-wait".into())
.spawn(move || {
while let Ok(job) = rx.recv() {
// 250 ms cap — see the module doc's lifecycle note.
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are
// owned by this type and live for the call, and every builder struct is a
// local that outlives it.
let r = unsafe {
wait_d.wait_for_present(job.swapchain, job.present_id, 250_000_000)
};
if r.is_ok() {
let displayed_ns = pf_client_core::session::now_ns();
results_t.lock().unwrap().push(PresentedSample {
pts_ns: job.pts_ns,
decoded_ns: job.decoded_ns,
displayed_ns,
});
}
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
// (or the loop is about to find out) — never poison the window.
pending_t.fetch_sub(1, Ordering::AcqRel);
}
})
.expect("spawn pf-present-wait");
PresentTimer {
tx: Some(tx),
pending,
results,
join: Some(join),
}
}
/// Hand a successfully submitted present to the waiter.
pub(crate) fn enqueue(
&self,
swapchain: vk::SwapchainKHR,
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
) {
if let Some(tx) = &self.tx {
self.pending.fetch_add(1, Ordering::AcqRel);
if tx
.send(Job {
swapchain,
present_id,
pts_ns,
decoded_ns,
})
.is_err()
{
self.pending.fetch_sub(1, Ordering::AcqRel);
}
}
}
/// Block until no wait references any swapchain — REQUIRED before
/// `vkDestroySwapchainKHR`. Bounded by the waiter's own 250 ms wait cap.
pub(crate) fn drain(&self) {
while self.pending.load(Ordering::Acquire) > 0 {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
/// Take the window's completed samples (called at the 1 s stats fold).
pub(crate) fn take_samples(&self) -> Vec<PresentedSample> {
std::mem::take(&mut *self.results.lock().unwrap())
}
}
impl Drop for PresentTimer {
fn drop(&mut self) {
// Close the channel, let in-flight waits finish (bounded), then join.
self.tx.take();
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}