Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
b6acbd096e | ||
|
|
d63e913f52 |
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -175,6 +175,46 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -260,7 +300,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -383,6 +423,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -693,6 +738,24 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -704,7 +767,83 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -724,7 +863,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -301,6 +306,10 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -316,7 +325,8 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -328,6 +338,7 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -335,6 +346,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -350,6 +362,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
if !note_cursor_id(cursor, id) {
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -367,9 +399,44 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -407,13 +407,21 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone): one device cycle + re-probe before giving up.
|
||||
anyhow::ensure!(
|
||||
driver::ensure_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
// `ensure_available` waits out a devnode that is merely coming up (the wake-from-sleep case:
|
||||
// the adapter re-enters D0 and re-registers its interface while a reconnecting client is
|
||||
// already knocking) and self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone) by reloading the adapter.
|
||||
//
|
||||
// `context`, not a replacement message: it reports WHY — how long it waited, whether a reload
|
||||
// ran, how many interface instances were seen and in what state. A flat "the driver is not
|
||||
// installed" is what a field report carried from a box whose driver was installed, started,
|
||||
// and simply mid-resume, and it pointed every reader at the wrong problem.
|
||||
use anyhow::Context as _;
|
||||
driver::ensure_available().context(
|
||||
"pf-vdisplay driver interface not available — the pf-vdisplay IddCx driver is not \
|
||||
installed, not loaded, or did not finish coming back up (the host installer bundles \
|
||||
it; reinstall or check the driver state)",
|
||||
)?;
|
||||
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -425,6 +425,20 @@ pub fn control_device_handle() -> Option<HANDLE> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
/// Retire the cached control handle from OUTSIDE the manager, for a caller that KNOWS the device
|
||||
/// died — the adapter-reload recovery in [`crate::driver`], which tears the driver stack down and
|
||||
/// back up. Without it the stale handle survives into the next session's `IOCTL_ADD` and is only
|
||||
/// recovered by the gone-classified retry one failed IOCTL later.
|
||||
///
|
||||
/// Takes the `device` mutex, so it must NOT be called from inside it (notably not from
|
||||
/// `VdisplayDriver::open`, which `ensure_device` invokes while holding it). No-op before any backend
|
||||
/// opened the device.
|
||||
pub(crate) fn invalidate_cached_device(why: &str) {
|
||||
if let Some(m) = VDM.get() {
|
||||
m.invalidate_device(&anyhow::anyhow!("{why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator
|
||||
/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only
|
||||
/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
@@ -143,31 +144,70 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
|
||||
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
|
||||
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
|
||||
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
|
||||
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
|
||||
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
|
||||
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
|
||||
/// installed and a retry is pointless.
|
||||
fn restart_vdisplay_device() -> bool {
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
/// woken host logged `cycled … status=OK` and then failed the session for a missing interface).
|
||||
enum AdapterCycle {
|
||||
/// The driver stack was genuinely reloaded. `how` names the lever that worked.
|
||||
Reloaded { how: &'static str, status: String },
|
||||
/// No punktfunk adapter devnode exists at all — the driver is not installed and retrying is
|
||||
/// pointless.
|
||||
NotInstalled,
|
||||
/// A devnode exists but could not be reloaded; carries the reason (already whitespace-collapsed).
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
|
||||
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
|
||||
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
|
||||
/// reloads; on-glass, only a device reload recovered it.
|
||||
///
|
||||
/// Two levers, in order. `Disable-PnpDevice` + `Enable-PnpDevice` is the one `reset-pf-vdisplay.ps1`
|
||||
/// uses — but that script stops the host service FIRST, precisely because the host holds the driver's
|
||||
/// control device open (its step 1), and a disable can be refused for a device in use. This runs
|
||||
/// INSIDE the host, so it structurally cannot take that step: the retired-but-never-closed handles in
|
||||
/// [`DeviceSlot`](super::manager) are still open on the very device being disabled. So a refusal is
|
||||
/// the expected case here, not the exotic one, and `pnputil /restart-device` — which reloads a device
|
||||
/// that is in use — is the fallback. Whichever runs, the failure paths re-enable, so a half-completed
|
||||
/// cycle can never leave the adapter DISABLED.
|
||||
///
|
||||
/// Best-effort + bounded (~6 s inside the script).
|
||||
fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
// reading `OK`, so the host logged a successful recovery it had never performed.
|
||||
//
|
||||
// `$LASTEXITCODE = 1` before the pnputil call for the same reason: no native command runs before
|
||||
// it, so an unlaunchable pnputil would otherwise leave the variable holding whatever it held and
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if ($ad) { \
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
|
||||
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
|
||||
Write-Output $st \
|
||||
} else { Write-Output 'ABSENT' }";
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
catch { Start-Sleep -Seconds 2; Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop }; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED cycle ' + (Get-PnpDevice -InstanceId $id).Status); exit \
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
match std::process::Command::new(&ps)
|
||||
let out = match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -178,22 +218,65 @@ fn restart_vdisplay_device() -> bool {
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if status == "ABSENT" {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%status,
|
||||
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
||||
);
|
||||
}
|
||||
status != "ABSENT"
|
||||
}
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
|
||||
false
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter reload could not spawn powershell");
|
||||
return AdapterCycle::Refused(format!("could not spawn powershell: {e}"));
|
||||
}
|
||||
};
|
||||
let outcome = classify_reload_output(&out);
|
||||
match &outcome {
|
||||
AdapterCycle::NotInstalled => {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to reload — driver not installed");
|
||||
}
|
||||
AdapterCycle::Reloaded { how, status } => tracing::warn!(
|
||||
how,
|
||||
%status,
|
||||
"pf-vdisplay: reloaded the adapter device (hostless-zombie recovery)"
|
||||
),
|
||||
AdapterCycle::Refused(why) => tracing::warn!(
|
||||
reason = %why,
|
||||
"pf-vdisplay: the adapter devnode exists but could NOT be reloaded — a session cannot \
|
||||
recover from this without a host-service restart or a reboot"
|
||||
),
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Parse [`reload_vdisplay_adapter`]'s script output. Split out to be testable without a box: the
|
||||
/// bug this whole change answers was a recovery that MISreported its own outcome, so the decoding of
|
||||
/// that outcome is worth pinning down.
|
||||
fn classify_reload_output(out: &str) -> AdapterCycle {
|
||||
let out = out.trim();
|
||||
let (verb, rest) = out.split_once(char::is_whitespace).unwrap_or((out, ""));
|
||||
match verb {
|
||||
"ABSENT" => AdapterCycle::NotInstalled,
|
||||
"RELOADED" => {
|
||||
let (how, status) = rest
|
||||
.trim()
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((rest.trim(), ""));
|
||||
// Held as `&'static str` so the two levers stay distinguishable in a field report:
|
||||
// `restart` means the disable was refused, i.e. something still holds the device open —
|
||||
// worth knowing when a reload does not fix the box.
|
||||
let how: &'static str = if how == "restart" {
|
||||
"pnputil /restart-device"
|
||||
} else {
|
||||
"disable+enable"
|
||||
};
|
||||
AdapterCycle::Reloaded {
|
||||
how,
|
||||
status: status.trim().to_string(),
|
||||
}
|
||||
}
|
||||
// Covers `REFUSED <reason>` and anything unrecognised, including an empty stdout (powershell
|
||||
// died before writing). All of them mean an un-reloaded devnode, which is the only thing
|
||||
// callers act on; the text rides along for the log.
|
||||
_ => AdapterCycle::Refused(if rest.trim().is_empty() {
|
||||
format!("unexpected adapter-reload output: {out:?}")
|
||||
} else {
|
||||
rest.trim().to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +408,55 @@ impl Drop for DevInfoList {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a device-interface enumeration found. The counts are what let [`ensure_available`] tell a
|
||||
/// devnode that is MID-TRANSITION (present, interface registered, not started yet — resuming from
|
||||
/// sleep, restarting, reloading) apart from one that is genuinely gone. Only the second is worth
|
||||
/// answering with device surgery; cycling the first only lengthens the outage it is waiting out.
|
||||
struct Probe {
|
||||
/// The control handle, if any interface instance opened.
|
||||
handle: Option<OwnedHandle>,
|
||||
/// Instances seen with `SPINT_ACTIVE` set — the owning device is started.
|
||||
active: u32,
|
||||
/// Instances seen with `SPINT_ACTIVE` clear — registered, but the owning device is not started.
|
||||
inactive: u32,
|
||||
/// The last enumeration/open failure, kept for the diagnostic.
|
||||
last_err: Option<anyhow::Error>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// No interface instance of ANY kind. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves; with none, the driver is not installed. Either way, waiting
|
||||
/// alone will not fix it.
|
||||
fn is_absent(&self) -> bool {
|
||||
self.handle.is_none() && self.active == 0 && self.inactive == 0
|
||||
}
|
||||
|
||||
/// Why no handle came back, NAMING what was seen — "0 interfaces" and "1 inactive interface" are
|
||||
/// completely different diagnoses (not installed vs. still coming up), and the old message
|
||||
/// collapsed both into "is the driver installed?". Call only on a miss; a hit reports as much.
|
||||
fn into_error(self) -> anyhow::Error {
|
||||
let seen = format!("{} active, {} inactive", self.active, self.inactive);
|
||||
if self.handle.is_some() {
|
||||
return anyhow::anyhow!("pf-vdisplay device interface opened ({seen})");
|
||||
}
|
||||
match self.last_err {
|
||||
Some(e) => e.context(format!("no openable pf-vdisplay device interface ({seen})")),
|
||||
None => anyhow::anyhow!(
|
||||
"no pf-vdisplay device interface found ({seen}) — is the pf-vdisplay driver \
|
||||
installed and its device started?"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume into the [`open_device`] result.
|
||||
fn into_result(mut self) -> Result<OwnedHandle> {
|
||||
match self.handle.take() {
|
||||
Some(h) => Ok(h),
|
||||
None => Err(self.into_error()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pf-vdisplay control device.
|
||||
///
|
||||
/// SAFE, and owning. It has no caller obligation — it takes no arguments and every precondition is
|
||||
@@ -333,26 +465,40 @@ impl Drop for DevInfoList {
|
||||
/// this file has already leaked from once (see the wrap-IMMEDIATELY comment in `open`). Returning an
|
||||
/// `OwnedHandle` makes the close a `Drop`, so there is exactly one way to get it wrong: not at all.
|
||||
fn open_device() -> Result<OwnedHandle> {
|
||||
probe_device().into_result()
|
||||
}
|
||||
|
||||
/// [`open_device`], reporting WHAT it found rather than only whether it succeeded.
|
||||
fn probe_device() -> Probe {
|
||||
let mut probe = Probe {
|
||||
handle: None,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
last_err: None,
|
||||
};
|
||||
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
|
||||
let hdev = DevInfoList(
|
||||
unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
let hdev = match unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")
|
||||
{
|
||||
Ok(h) => DevInfoList(h),
|
||||
Err(e) => {
|
||||
probe.last_err = Some(e);
|
||||
return probe;
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
|
||||
);
|
||||
};
|
||||
|
||||
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
|
||||
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
|
||||
// index — the old single-index read then failed every session with "driver not installed"
|
||||
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
|
||||
// is active only while its owning device is started); the first active + openable one wins.
|
||||
let mut inactive = 0u32;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for index in 0..64u32 {
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
@@ -367,9 +513,10 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
break; // ERROR_NO_MORE_ITEMS — no further candidates
|
||||
}
|
||||
if idata.Flags & SPINT_ACTIVE == 0 {
|
||||
inactive += 1;
|
||||
probe.inactive += 1;
|
||||
continue;
|
||||
}
|
||||
probe.active += 1;
|
||||
let mut required = 0u32;
|
||||
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
|
||||
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
|
||||
@@ -409,20 +556,18 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
})
|
||||
};
|
||||
match opened {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing else
|
||||
// holds it, so transferring it into the `OwnedHandle` gives it a single owner that
|
||||
// closes it exactly once on drop.
|
||||
Ok(h) => return Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }),
|
||||
Ok(h) => {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing
|
||||
// else holds it, so transferring it into the `OwnedHandle` gives it a single owner
|
||||
// that closes it exactly once on drop.
|
||||
probe.handle = Some(unsafe { OwnedHandle::from_raw_handle(h.0 as _) });
|
||||
return probe;
|
||||
}
|
||||
// A raced-away or wedged device — remember the error, try the next interface.
|
||||
Err(e) => last_err = Some(e),
|
||||
Err(e) => probe.last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
|
||||
pf-vdisplay driver installed and its device started?"
|
||||
)
|
||||
}))
|
||||
probe
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
@@ -435,29 +580,14 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
}
|
||||
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||
let device = match open_device() {
|
||||
Ok(d) => d,
|
||||
Err(first) => {
|
||||
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
|
||||
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
|
||||
// reloads the stack — kick it once and retry the open over a short arrival window.
|
||||
if !restart_vdisplay_device() {
|
||||
return Err(first); // no adapter devnode at all — genuinely not installed
|
||||
}
|
||||
let mut reopened = Err(first);
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
match open_device() {
|
||||
Ok(d) => {
|
||||
reopened = Ok(d);
|
||||
break;
|
||||
}
|
||||
Err(e) => reopened = Err(e),
|
||||
}
|
||||
}
|
||||
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
|
||||
}
|
||||
};
|
||||
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
|
||||
// copy of the recovery that used to live here. Session bring-up already ran the full
|
||||
// `ensure_available` before constructing the backend, so anything left for this open to
|
||||
// absorb is a race, not a wedge. `hw_cursor_capable` also lands here, mid client handshake,
|
||||
// where a reload's tens of seconds would be entirely the wrong trade for one capability bool
|
||||
// — and where reloading would deadlock besides, since `ensure_device` calls us holding the
|
||||
// manager's `device` mutex (see the `RECOVERY` ordering contract).
|
||||
let device = wait_for_interface(BRIEF_RETRY, false).0?;
|
||||
// `open_device` hands back an `OwnedHandle`, so every `?` below closes the device exactly
|
||||
// once by construction — the shape this used to reach by wrapping the raw handle here, and
|
||||
// which leaked whenever GET_INFO itself failed before that wrap was moved up.
|
||||
@@ -879,25 +1009,159 @@ pub fn is_available() -> bool {
|
||||
open_device().is_ok()
|
||||
}
|
||||
|
||||
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
|
||||
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
|
||||
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
|
||||
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
|
||||
/// uninstalled driver (no adapter devnode) fails fast without the wait.
|
||||
pub fn ensure_available() -> bool {
|
||||
if is_available() {
|
||||
return true;
|
||||
/// How often the interface is re-probed while waiting.
|
||||
const PROBE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long a devnode whose interface exists but is NOT-READY (no active instance, or `CreateFileW`
|
||||
/// refused) is given to come up on its own before the adapter is reloaded.
|
||||
///
|
||||
/// This is the wake-from-sleep window. Resuming re-enters D0 and re-registers the interface while
|
||||
/// the rest of the resume storm is still running, and a client reconnecting a second after wake
|
||||
/// arrives inside that gap — which the old code, probing exactly ONCE, answered by disabling and
|
||||
/// re-enabling a display adapter that was seconds from being ready anyway.
|
||||
const NOT_READY_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long a fully ABSENT interface is given before the adapter is reloaded. Short — a hostless
|
||||
/// devnode does not heal itself, and that is the case this recovery exists for — but non-zero, so a
|
||||
/// resume that briefly de-registers the interface is not met with device surgery either.
|
||||
const ABSENT_SETTLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the interface is given to ARRIVE after a reload.
|
||||
///
|
||||
/// Was 4 s, which a quiet box meets and a box still finishing a resume does not: PnP is contended
|
||||
/// right after wake. Field report 2026-08-02 — a woken host logged a successful adapter cycle and
|
||||
/// then failed the session 4 s later for a missing interface, and the client could not connect.
|
||||
const ARRIVAL_AFTER_RELOAD: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Hard ceiling on the whole wait, so display prep can never block for an unbounded sum of the
|
||||
/// windows above. Without it a devnode wedged NOT-READY costs the full grace, then the reload, then
|
||||
/// the full arrival window before failing — the pathological case paying nearly a minute per session.
|
||||
/// Patience for a device that is coming back is the point; patience for one that never will is not.
|
||||
const TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The budget a caller that must NOT stall gives the interface: no adapter reload, just a short
|
||||
/// re-probe to ride out a race. [`VdisplayDriver::open`] uses it — by the time the manager opens,
|
||||
/// session bring-up has already run the full [`ensure_available`] above, and the OTHER path that
|
||||
/// reaches it (`manager::hw_cursor_capable`, a best-effort capability answer during the client
|
||||
/// handshake) must never hold the Welcome for tens of seconds to decide one bool.
|
||||
const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Serializes the recovery so N sessions racing in after a wake perform ONE adapter reload between
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
/// wake from sleep.
|
||||
///
|
||||
/// Returns the reason on failure instead of a bare `false`: the caller used to replace it with a
|
||||
/// flat "the driver is not installed", which is what a field report showed on a box whose driver was
|
||||
/// installed, started, and merely mid-resume.
|
||||
pub fn ensure_available() -> Result<()> {
|
||||
// Poisoning carries no meaning here — the guard protects a `()`, not state a panic could leave
|
||||
// inconsistent — so a previous panic must not wedge every later session out of recovery.
|
||||
let (result, reloaded) = {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
);
|
||||
}
|
||||
if !restart_vdisplay_device() {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if is_available() {
|
||||
return true;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// Wait for an openable control interface, reloading the adapter if `reload` and the devnode looks
|
||||
/// genuinely hostless. Returns the handle (so the manager's own open can keep it) alongside whether
|
||||
/// a reload ran.
|
||||
///
|
||||
/// Two distinguishable states hide behind "cannot open the interface", and they want opposite
|
||||
/// treatment:
|
||||
///
|
||||
/// * **Not ready** — instances are registered but none is active (or the open is refused). The
|
||||
/// devnode is THERE and coming up: resuming from sleep, restarting, reloading. It heals itself;
|
||||
/// reloading the adapter underneath it only lengthens the outage.
|
||||
/// * **Absent** — no instance at all. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves (validated on-glass: PnP Status OK, no WUDFHost process, zero
|
||||
/// interface instances). Only a reload clears it.
|
||||
///
|
||||
/// So: probe, wait out a not-ready device, reload an absent one after a short settle, and give the
|
||||
/// interface a real arrival window afterwards. A reload is still attempted once at the end of
|
||||
/// `not_ready_grace`, so a devnode wedged not-ready (a failed start) recovers exactly as it did
|
||||
/// before. A genuinely uninstalled driver — no adapter devnode — still fails FAST, with no wait.
|
||||
fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedHandle>, bool) {
|
||||
let started = Instant::now();
|
||||
let mut deadline = started + not_ready_grace;
|
||||
let mut absent_since: Option<Instant> = None;
|
||||
let mut reloaded = false;
|
||||
loop {
|
||||
let mut probe = probe_device();
|
||||
if let Some(h) = probe.handle.take() {
|
||||
if reloaded || started.elapsed() > PROBE_INTERVAL {
|
||||
tracing::info!(
|
||||
waited_ms = started.elapsed().as_millis() as u64,
|
||||
reloaded,
|
||||
"pf-vdisplay: control interface available"
|
||||
);
|
||||
}
|
||||
return (Ok(h), reloaded);
|
||||
}
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
AdapterCycle::NotInstalled => {
|
||||
let e = Err(probe.into_error()).context(
|
||||
"no punktfunk virtual-display adapter devnode exists — the driver is not \
|
||||
installed",
|
||||
);
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Refused(why) => {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay adapter devnode could not be reloaded ({why})"
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Reloaded { .. } => {
|
||||
reloaded = true;
|
||||
absent_since = None;
|
||||
deadline = (Instant::now() + ARRIVAL_AFTER_RELOAD).min(started + TOTAL_BUDGET);
|
||||
}
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay control interface did not appear within {:?}{}",
|
||||
started.elapsed(),
|
||||
if reloaded {
|
||||
" (including an adapter reload)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
std::thread::sleep(PROBE_INTERVAL);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -906,6 +1170,96 @@ mod tests {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The recovery must not be able to claim success it did not achieve. This is the whole bug:
|
||||
/// the old script ran the cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status`,
|
||||
/// so a device whose disable had been REFUSED — untouched, still started — reported `OK`, and
|
||||
/// the host logged `cycled the adapter device … status=OK` while nothing had been cycled at all
|
||||
/// (field report 2026-08-02). A refusal must decode as a refusal, carrying its reason.
|
||||
#[test]
|
||||
fn a_refused_reload_is_not_reported_as_a_reload() {
|
||||
let refused =
|
||||
classify_reload_output("REFUSED This device cannot be disabled because it is in use.");
|
||||
match refused {
|
||||
AdapterCycle::Refused(why) => {
|
||||
assert!(why.contains("in use"), "the reason must survive: {why:?}")
|
||||
}
|
||||
other => panic!("a refused reload decoded as {}", variant(&other)),
|
||||
}
|
||||
// A bare device status — what the OLD script emitted on every path — must NEVER decode as a
|
||||
// successful reload now, however healthy it looks.
|
||||
for stale in ["OK", "Error", "Unknown"] {
|
||||
assert!(
|
||||
matches!(classify_reload_output(stale), AdapterCycle::Refused(_)),
|
||||
"{stale:?} is a device status, not a reload outcome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
#[test]
|
||||
fn reload_outcomes_decode() {
|
||||
assert!(matches!(
|
||||
classify_reload_output("ABSENT"),
|
||||
AdapterCycle::NotInstalled
|
||||
));
|
||||
match classify_reload_output("RELOADED cycle OK") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "disable+enable");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
match classify_reload_output("RELOADED restart OK\r\n") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "pnputil /restart-device");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
// powershell died before writing anything — an un-reloaded devnode, so `Refused`, not a
|
||||
// silent success.
|
||||
assert!(matches!(
|
||||
classify_reload_output(" "),
|
||||
AdapterCycle::Refused(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
/// the outage it is already recovering from.
|
||||
#[test]
|
||||
fn only_a_total_absence_counts_as_absent() {
|
||||
let probe = |active, inactive| Probe {
|
||||
handle: None,
|
||||
active,
|
||||
inactive,
|
||||
last_err: None,
|
||||
};
|
||||
assert!(probe(0, 0).is_absent(), "no instances at all = absent");
|
||||
assert!(
|
||||
!probe(0, 1).is_absent(),
|
||||
"a registered-but-inactive instance is a device coming up, not a missing one"
|
||||
);
|
||||
assert!(
|
||||
!probe(1, 0).is_absent(),
|
||||
"an active instance we merely failed to open is not a missing device"
|
||||
);
|
||||
// And the diagnostic names what was seen — the old message collapsed every one of these
|
||||
// into "is the driver installed?", which sent a field report down the wrong path.
|
||||
assert!(probe(0, 2).into_error().to_string().contains("2 inactive"));
|
||||
}
|
||||
|
||||
fn variant(c: &AdapterCycle) -> &'static str {
|
||||
match c {
|
||||
AdapterCycle::Reloaded { .. } => "Reloaded",
|
||||
AdapterCycle::NotInstalled => "NotInstalled",
|
||||
AdapterCycle::Refused(_) => "Refused",
|
||||
}
|
||||
}
|
||||
|
||||
/// Live hardware round trip — `#[ignore]`d (needs the pf-vdisplay driver installed); run with
|
||||
/// `cargo test -p pf-vdisplay -- --ignored live_create_drop`. Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
|
||||
@@ -73,9 +73,9 @@ pub struct StreamedAu {
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
|
||||
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
|
||||
/// whatever remains).
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte — flushes emit only whole
|
||||
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
|
||||
/// so `finish_streamed` always has something real to seal.
|
||||
pending: Vec<u8>,
|
||||
/// Sentinel blocks already emitted.
|
||||
blocks_out: u16,
|
||||
@@ -418,7 +418,18 @@ impl Packetizer {
|
||||
"streamed AU exceeds the negotiated max_frame_bytes",
|
||||
));
|
||||
}
|
||||
let k = whole.min(self.fec.max_data_per_block as usize);
|
||||
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
|
||||
// or the final block degenerates to a single zero-padded filler shard whose derived
|
||||
// base (`total_data − 1`) overlaps the block flushed just now — which the receiver's
|
||||
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
|
||||
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
|
||||
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
|
||||
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
|
||||
// it rides out in the final block, which has to exist regardless.
|
||||
let mut k = whole.min(self.fec.max_data_per_block as usize);
|
||||
if k > 1 && k == whole && au.pending.len() == whole * payload {
|
||||
k -= 1;
|
||||
}
|
||||
let sof = !au.opened;
|
||||
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
|
||||
let fi = au.frame_index;
|
||||
|
||||
@@ -467,14 +467,33 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
|
||||
// the limits' maximum — its real size doesn't exist yet.
|
||||
let buf_len = if sentinel {
|
||||
total_data_max * shard_bytes
|
||||
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
|
||||
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
|
||||
// a legacy one by its full-K position — and that is what the buffer must cover to place
|
||||
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
|
||||
// block's totals settle it.
|
||||
//
|
||||
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
|
||||
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
|
||||
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
|
||||
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
|
||||
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
|
||||
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
|
||||
// exhausted after ~3 concurrent frames — beyond which every packet of every further
|
||||
// frame was dropped outright. On a jittery link that is a permanent loss storm.
|
||||
let need_shards = if sentinel && slice_stream {
|
||||
frame_bytes / shard_bytes + data_shards
|
||||
} else if sentinel {
|
||||
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
|
||||
// index alone gives its end.
|
||||
(block_idx + 1).saturating_mul(lim.max_data_shards)
|
||||
} else {
|
||||
total_data * shard_bytes
|
||||
};
|
||||
total_data
|
||||
}
|
||||
.min(total_data_max);
|
||||
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
|
||||
// agree with its geometry.
|
||||
let buf_len = need_shards * shard_bytes;
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
@@ -602,6 +621,21 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
|
||||
// first and learns its real size from the final block's totals (or a later, higher
|
||||
// sentinel base) — reorder means either can come first, so the buffer is sized by
|
||||
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
|
||||
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
|
||||
// for exactly the reason it is checked at open — growth commits memory too.
|
||||
if buf_len > frame.buf.len() {
|
||||
let delta = buf_len - frame.buf.len();
|
||||
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += delta;
|
||||
frame.buf.resize(buf_len, 0);
|
||||
}
|
||||
let FrameBuf {
|
||||
buf,
|
||||
blocks,
|
||||
|
||||
@@ -941,8 +941,9 @@ fn slice_config() -> Config {
|
||||
|
||||
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
|
||||
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
|
||||
/// final (1, 63) with block_count 4.
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
|
||||
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
|
||||
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
|
||||
fn slice_chunks() -> Vec<Vec<u8>> {
|
||||
[320usize, 403, 100, 200]
|
||||
.iter()
|
||||
@@ -1007,7 +1008,8 @@ fn slice_streamed_wire_shape_and_roundtrip() {
|
||||
assert_eq!(src.len(), 1023);
|
||||
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
|
||||
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
|
||||
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
|
||||
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
|
||||
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_ne!(
|
||||
@@ -1478,15 +1480,24 @@ fn parts_flow_for_legacy_streamed_frames() {
|
||||
assert!(got.last().unwrap().complete);
|
||||
}
|
||||
|
||||
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
|
||||
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
|
||||
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
|
||||
/// in-flight budget still bounds the ones that claim a lot.
|
||||
///
|
||||
/// Both halves matter. A sentinel that claims little must cost little: sizing every
|
||||
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
|
||||
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
|
||||
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
|
||||
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
|
||||
/// amplification this budget exists for.
|
||||
#[test]
|
||||
fn streamed_open_amplification_is_budget_bounded() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
|
||||
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
|
||||
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
|
||||
for fi in 0..5u32 {
|
||||
for fi in 0..32u32 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
@@ -1498,10 +1509,35 @@ fn streamed_open_amplification_is_budget_bounded() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
0,
|
||||
"ordinary one-datagram opens must not exhaust the in-flight budget"
|
||||
);
|
||||
|
||||
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
|
||||
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
|
||||
// fifth must be refused.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
for fi in 0..5u32 {
|
||||
let mut h = base_header();
|
||||
h.user_flags = USER_FLAG_SLICE_STREAM;
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 4096 - 8 * 16;
|
||||
h.block_index = 1;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.frame_index = fi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the fifth max-sized open must be refused by the in-flight budget"
|
||||
"the fifth ceiling-claiming open must be refused by the in-flight budget"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1612,3 +1648,124 @@ fn streamed_second_final_with_different_totals_is_rejected() {
|
||||
.expect("frame completes under the first pinned totals");
|
||||
assert_eq!(got.data.len(), 160);
|
||||
}
|
||||
|
||||
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
|
||||
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
|
||||
fn prod_slice_config() -> Config {
|
||||
use crate::config::{FecConfig, ProtocolPhase, Role};
|
||||
Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 20,
|
||||
max_data_per_block: 200,
|
||||
},
|
||||
shard_payload: crate::config::mtu1500_shard_payload(),
|
||||
max_frame_bytes: 8 << 20,
|
||||
encrypt: false,
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
|
||||
fn streamed_packets_with(
|
||||
cfg: &Config,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
slice: bool,
|
||||
chunks: &[usize],
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let coder = coder_for(cfg.fec.scheme);
|
||||
let mut pk = Packetizer::new(cfg);
|
||||
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
|
||||
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
|
||||
let (mut pkts, mut src) = (Vec::new(), Vec::new());
|
||||
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
};
|
||||
for (c, &n) in chunks.iter().enumerate() {
|
||||
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
|
||||
src.extend_from_slice(&data);
|
||||
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
pk.finish_streamed(au, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
|
||||
///
|
||||
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
|
||||
/// block of one zero-padded FILLER shard. Its derived base (`total_data − 1`) overlapped the
|
||||
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
|
||||
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
|
||||
/// each costing a re-anchor freeze and a recovery keyframe.
|
||||
#[test]
|
||||
fn slice_streamed_exact_shard_multiple_completes() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let payload = cfg.shard_payload;
|
||||
for shards in [16usize, 29, 30, 64] {
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
|
||||
// Whatever the block split, the final block must carry real bytes — never a lone
|
||||
// zero-pad shard sitting on top of the previous block's range.
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
|
||||
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
|
||||
}
|
||||
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
|
||||
for extra in 0..3usize {
|
||||
let n = 30 * payload + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
|
||||
///
|
||||
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
|
||||
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
|
||||
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
|
||||
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
|
||||
/// normal reorder, plus a multi-megabyte zeroing per access unit.
|
||||
#[test]
|
||||
fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
|
||||
// holds several of at once.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,105 @@
|
||||
//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md):
|
||||
//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so
|
||||
//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host
|
||||
//! the client-side half of the controller's v2 error signal, plus the panel-grid learner every
|
||||
//! vsync-aware presenter paces against. Pure math, no features — shared so every presenter
|
||||
//! (Android today, iOS and the desktop session client next) computes the SAME statistic the host
|
||||
//! controller was tuned against, and so the controller's simulation tests can generate their
|
||||
//! synthetic reports through the identical code path.
|
||||
|
||||
/// Plausible panel periods: ~24 Hz to ~500 Hz. A spacing outside this is a clock glitch, not a
|
||||
/// display mode, and must never reach the estimate.
|
||||
const PANEL_PERIOD_RANGE_NS: std::ops::RangeInclusive<i64> = 2_000_000..=42_000_000;
|
||||
|
||||
/// Spacings within this of the estimate are the same grid — absorbs ordinary timeline jitter.
|
||||
const PANEL_GRID_TOLERANCE_NS: i64 = 200_000;
|
||||
|
||||
/// Consecutive WIDER observations required before the estimate grows. One stray wide sample is a
|
||||
/// scheduling hiccup; eight in a row (~66 ms at 120 Hz) is a display that really did slow down.
|
||||
const PANEL_WIDEN_STREAK: u8 = 8;
|
||||
|
||||
/// The panel's true refresh period, learned from observed vsync/frame-timeline spacing.
|
||||
///
|
||||
/// A presenter subdivides its release targets onto this grid, so an estimate FINER than the panel
|
||||
/// makes it aim at instants that never arrive and release faster than the display consumes —
|
||||
/// which is why the estimate has to be able to move both ways.
|
||||
///
|
||||
/// Seeding is the reason this is not simply "believe the last sample". The platform's *configured*
|
||||
/// mode is not the panel: under a per-uid frame-rate override a 120 Hz panel reports 60
|
||||
/// (`Display.getRefreshRate` returns the override — observed on-glass, A024), and the app's own
|
||||
/// choreographer callbacks arrive at the down-rated rate while the panel scans at its own. The
|
||||
/// mode TABLE is honest about what the panel *can* do, so it is the seed; the timeline spacing is
|
||||
/// honest about what it is *doing*, so it is the correction.
|
||||
///
|
||||
/// The asymmetry is deliberate. **Narrowing is immediate**: a finer real grid is always safe to
|
||||
/// subdivide onto, and it is the down-rate case the seed most often gets wrong. **Widening needs
|
||||
/// [`PANEL_WIDEN_STREAK`] consecutive agreeing observations** and then adopts the *narrowest* of
|
||||
/// them, because a wide sample is far more likely to be a missed callback than a mode change.
|
||||
///
|
||||
/// ⚠ 0.23.0 shipped this learner as narrow-only, seeded from the display mode the app *requests*
|
||||
/// (`preferredDisplayModeId` is a hint the system may refuse). A refused 120 Hz switch therefore
|
||||
/// left the presenter pacing a 60 Hz panel on an 8.33 ms grid with no way back — permanently.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PanelGrid {
|
||||
period_ns: i64,
|
||||
widen_streak: u8,
|
||||
/// Narrowest wider-than-estimate spacing seen during the current streak.
|
||||
widen_candidate: i64,
|
||||
}
|
||||
|
||||
impl PanelGrid {
|
||||
/// Seed from the display mode's refresh rate (`0` = unknown — the first plausible observation
|
||||
/// then sets the estimate outright).
|
||||
pub fn seeded(hz: i32) -> PanelGrid {
|
||||
PanelGrid {
|
||||
period_ns: if hz > 0 { 1_000_000_000 / hz as i64 } else { 0 },
|
||||
widen_streak: 0,
|
||||
widen_candidate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The learned period, or `0` while unknown.
|
||||
pub fn period_ns(&self) -> i64 {
|
||||
self.period_ns
|
||||
}
|
||||
|
||||
/// Fold one observed grid spacing. Returns `true` when [`period_ns`](Self::period_ns) changed.
|
||||
pub fn observe(&mut self, spacing_ns: i64) -> bool {
|
||||
if !PANEL_PERIOD_RANGE_NS.contains(&spacing_ns) {
|
||||
return false; // implausible — a clock glitch, not a display mode
|
||||
}
|
||||
if self.period_ns == 0 {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns < self.period_ns - PANEL_GRID_TOLERANCE_NS {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns > self.period_ns + PANEL_GRID_TOLERANCE_NS {
|
||||
self.widen_streak = self.widen_streak.saturating_add(1);
|
||||
self.widen_candidate = if self.widen_candidate == 0 {
|
||||
spacing_ns
|
||||
} else {
|
||||
self.widen_candidate.min(spacing_ns)
|
||||
};
|
||||
if self.widen_streak >= PANEL_WIDEN_STREAK {
|
||||
self.period_ns = self.widen_candidate;
|
||||
self.reset_streak();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.reset_streak(); // this sample agreed — the run of wider ones is broken
|
||||
false
|
||||
}
|
||||
|
||||
fn reset_streak(&mut self) {
|
||||
self.widen_streak = 0;
|
||||
self.widen_candidate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch
|
||||
/// mod the period (ns) and the coherence (‰).
|
||||
///
|
||||
@@ -90,3 +186,111 @@ mod tests {
|
||||
assert!(circular_latch(&[1_000; 16], 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_grid_tests {
|
||||
use super::*;
|
||||
|
||||
const P120: i64 = 8_333_333;
|
||||
const P60: i64 = 16_666_666;
|
||||
|
||||
#[test]
|
||||
fn seeds_from_the_mode_and_reports_unknown_without_one() {
|
||||
assert_eq!(PanelGrid::seeded(120).period_ns(), 8_333_333);
|
||||
assert_eq!(PanelGrid::seeded(0).period_ns(), 0);
|
||||
let mut g = PanelGrid::seeded(0);
|
||||
assert!(
|
||||
g.observe(P120),
|
||||
"the first plausible sample sets an unseeded grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrows_immediately_when_the_panel_is_faster_than_the_mode_said() {
|
||||
// The down-rate case: the mode table read 60, the timelines run at 120.
|
||||
let mut g = PanelGrid::seeded(60);
|
||||
assert!(g.observe(P120));
|
||||
assert_eq!(g.period_ns(), P120, "a finer real grid is adopted at once");
|
||||
}
|
||||
|
||||
/// The 0.23.0 bug: `preferredDisplayModeId` is a request, so a refused 120 Hz switch seeds a
|
||||
/// 120 Hz grid on a panel that is really running 60. The narrow-only learner could never
|
||||
/// climb back, and the presenter aimed at instants the panel never reached.
|
||||
#[test]
|
||||
fn widens_back_out_when_the_requested_mode_was_refused() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for i in 0..PANEL_WIDEN_STREAK - 1 {
|
||||
assert!(!g.observe(P60), "sample {i} must not widen on its own");
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
assert!(
|
||||
g.observe(P60),
|
||||
"a sustained run of wider spacings widens the grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_wide_sample_never_widens() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..40 {
|
||||
assert!(!g.observe(P60));
|
||||
assert!(!g.observe(P120)); // an agreeing sample breaks the run
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P120,
|
||||
"alternating samples must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_adopts_the_narrowest_of_the_run() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
// A run of wide spacings that includes some very wide outliers.
|
||||
let run = [
|
||||
P60,
|
||||
33_000_000,
|
||||
P60 + 400_000,
|
||||
41_000_000,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
];
|
||||
for s in run {
|
||||
g.observe(s);
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P60,
|
||||
"the estimate takes the narrowest of the run, never an outlier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_spacings_are_ignored_entirely() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..100 {
|
||||
assert!(!g.observe(0));
|
||||
assert!(!g.observe(-1));
|
||||
assert!(!g.observe(1_000_000)); // 1000 Hz — below the range floor
|
||||
assert!(!g.observe(100_000_000)); // 10 Hz — above the ceiling
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_narrow_glitch_self_heals() {
|
||||
// Narrowing is immediate, so a glitch DOES poison the estimate — the point is that it is
|
||||
// no longer permanent (0.23.0's learner had no way back).
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
assert!(g.observe(2_100_000), "a glitch narrows the estimate");
|
||||
assert_eq!(g.period_ns(), 2_100_000);
|
||||
for _ in 0..PANEL_WIDEN_STREAK {
|
||||
g.observe(P120);
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta
|
||||
// is an authoritative "pointer hidden" — the caller derives it from the backend that created
|
||||
// `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own
|
||||
// backend). See `pf_capture`'s `cursor_id0_hides` contract.
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
|
||||
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
|
||||
@@ -132,6 +137,7 @@ pub fn capture_virtual_output(
|
||||
want.hdr,
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
vout.expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -171,6 +177,9 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no
|
||||
// `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source.
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -285,6 +294,7 @@ pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> {
|
||||
vout,
|
||||
fmt,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")?;
|
||||
cap.set_active(true);
|
||||
|
||||
@@ -570,6 +570,7 @@ fn open_gs_mirror_source(
|
||||
vout,
|
||||
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")
|
||||
}
|
||||
@@ -782,6 +783,7 @@ fn open_gs_virtual_source(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
|
||||
@@ -1815,6 +1815,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
|
||||
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
|
||||
let mut phase_ctl = PhaseController::new();
|
||||
// Frame-driven wire-rate cap (see [`PaceBudget`]): a loop local like `phase_ctl`, and for the
|
||||
// same reason — it must survive every in-loop rebuild path so a mid-stream rebuild can't
|
||||
// reopen the overshoot. Bounded burst (CAP) is all a rebuild gap can buy.
|
||||
let mut pace = PaceBudget::new(std::time::Instant::now());
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
@@ -3117,8 +3121,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
|
||||
// Frame-driven mode (T1.1) re-anchors to the ACTUAL submit — arrivals are the clock, and a
|
||||
// fixed `+= interval` grid would drift against them and squeeze the pacing budget; the
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail).
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail). The rate-cap
|
||||
// charge lives under the same guard as the tail's gate: the legacy tick paces by its grid
|
||||
// alone, and charging it without ever accruing would bank unbounded debt that stalls the
|
||||
// loop if a rebuild later flips the capturer to arrival-wait.
|
||||
next = if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||
pace.charge();
|
||||
std::time::Instant::now() + interval
|
||||
} else {
|
||||
next + interval
|
||||
@@ -3477,9 +3485,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||
// to a full interval — ~half on average), sleep only to the rate floor and then
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor caps the encode
|
||||
// rate at ~1.11× target when the source runs faster (compositor Hz > session fps);
|
||||
// the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor leaves per-gap jitter
|
||||
// headroom; the `pace` budget pins the long-run AVERAGE at the pacing rate — the
|
||||
// floor alone let a source that always has a frame pending (an HZ_MULT-overdriven
|
||||
// display under uncapped content) settle at 0.9-interval spacing, 1.11× the
|
||||
// negotiated rate on the wire, frames the client's panel can only drop. The
|
||||
// +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// client liveness) at 1.5×interval cadence and bounds control-servicing latency.
|
||||
//
|
||||
// Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is
|
||||
@@ -3489,9 +3500,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// period becomes interval + encode (≈158 fps off a 240 Hz source; 360 Hz → ~200).
|
||||
// An async encoder (NVENC) returns from submit in ≈0, so t_cap ≈ post-submit and this
|
||||
// is a no-op for it — which is why H.26x already holds full rate. Arrival-anchoring
|
||||
// lets the synchronous encode overlap the interval; the ≥0.9×interval spacing from
|
||||
// the last grab still caps the rate at ~1.11× target.
|
||||
let earliest = t_cap + interval.mul_f32(0.9);
|
||||
// lets the synchronous encode overlap the interval; the budget, not the floor, is
|
||||
// what bounds the sustained rate.
|
||||
let earliest = std::cmp::max(
|
||||
t_cap + interval.mul_f32(0.9),
|
||||
pace.earliest(std::time::Instant::now(), interval),
|
||||
);
|
||||
if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) {
|
||||
std::thread::sleep(d);
|
||||
}
|
||||
@@ -4001,6 +4015,59 @@ fn pacing_hz(session_hz: u32, achieved_hz: u32) -> u32 {
|
||||
achieved_hz.min(session_hz).max(1)
|
||||
}
|
||||
|
||||
/// Long-run rate limiter for the frame-driven trigger (T1.1): pins the AVERAGE encode rate at the
|
||||
/// pacing rate while the 0.9×interval arrival floor keeps its jitter headroom.
|
||||
///
|
||||
/// The floor alone bounds only each gap, so a source that always has a frame pending — a display
|
||||
/// overdriven by `PUNKTFUNK_VDISPLAY_HZ_MULT`, uncapped content — settles at 0.9-interval spacing:
|
||||
/// 1.11× the negotiated rate on the wire (field report: 132 fps on a 120 fps session, frames the
|
||||
/// client's 120 Hz panel can only drop). Credit accrues at one frame per interval of real elapsed
|
||||
/// time (capped at [`Self::CAP`]) and every submitted frame spends one; a grab may run early only
|
||||
/// against banked credit, so per-gap jitter still passes while the average cannot exceed the
|
||||
/// pacing rate. A source at or below the pacing rate banks credit faster than it spends and is
|
||||
/// never delayed.
|
||||
struct PaceBudget {
|
||||
/// Banked frames, in `[-1.0, CAP]`. Transiently dips below 0 when a grab spent credit it had
|
||||
/// only partly banked; the owed fraction is repaid before the next grab.
|
||||
credit: f32,
|
||||
/// When credit last accrued (the previous [`Self::earliest`] call).
|
||||
last: std::time::Instant,
|
||||
}
|
||||
|
||||
impl PaceBudget {
|
||||
/// Burst allowance: at most this many frames may follow a stall back-to-back before the
|
||||
/// bucket re-gates. One frame of instant catch-up plus the floor's own headroom.
|
||||
const CAP: f32 = 1.25;
|
||||
|
||||
fn new(now: std::time::Instant) -> PaceBudget {
|
||||
PaceBudget {
|
||||
credit: Self::CAP,
|
||||
last: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accrue the elapsed credit and return the earliest instant the next grab may run: `now`
|
||||
/// once a full frame is banked, else the missing fraction of an interval out.
|
||||
fn earliest(
|
||||
&mut self,
|
||||
now: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
) -> std::time::Instant {
|
||||
let secs = interval.as_secs_f32();
|
||||
if secs > 0.0 {
|
||||
let accrued = now.duration_since(self.last).as_secs_f32() / secs;
|
||||
self.credit = (self.credit + accrued).min(Self::CAP);
|
||||
}
|
||||
self.last = now;
|
||||
now + interval.mul_f32((1.0 - self.credit).max(0.0))
|
||||
}
|
||||
|
||||
/// One frame submitted — spend its credit.
|
||||
fn charge(&mut self) {
|
||||
self.credit -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
@@ -4122,9 +4189,19 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
//
|
||||
// KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative
|
||||
// "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow
|
||||
// outlives every in-game/Big Picture hide (0.22.0 field report). Derived from the backend
|
||||
// (correct for pooled reuse too — a kept display only matches its own backend).
|
||||
let cursor_id0_hides = vd.name() == pf_vdisplay::Compositor::Kwin.id();
|
||||
let mut capturer = crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
plan.output_format(),
|
||||
plan.capture,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||
@@ -4270,6 +4347,105 @@ mod tests {
|
||||
assert_eq!(pacing_hz(60, 0), 1);
|
||||
}
|
||||
|
||||
/// Drive [`PaceBudget`] against a source that ALWAYS has a frame pending (the overdriven
|
||||
/// display + uncapped content case): each cycle grabs the instant the gate opens, the next
|
||||
/// `earliest` call runs right after (encode folded into the wait, like the loop). Returns the
|
||||
/// grab instants.
|
||||
fn grab_saturated(
|
||||
b: &mut PaceBudget,
|
||||
start: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
n: usize,
|
||||
) -> Vec<std::time::Instant> {
|
||||
let mut now = start;
|
||||
let mut grabs = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let gate = b.earliest(now, interval);
|
||||
let grab = gate.max(now);
|
||||
b.charge();
|
||||
grabs.push(grab);
|
||||
now = grab;
|
||||
}
|
||||
grabs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_pins_a_saturated_source_at_the_interval() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 120);
|
||||
// Whatever the initial credit bought, the total may exceed the on-rate schedule by at
|
||||
// most the burst cap — 120 grabs span no less than (120 - 1 - CAP) intervals.
|
||||
let span = grabs[119].duration_since(grabs[0]);
|
||||
assert!(
|
||||
span >= interval.mul_f32(120.0 - 1.0 - PaceBudget::CAP),
|
||||
"span {span:?} admits more than CAP frames of overshoot"
|
||||
);
|
||||
// And the steady state is EXACTLY the interval: past the warmup, consecutive grabs are
|
||||
// one interval apart (not 0.9 — the 132-fps bug).
|
||||
for w in grabs[20..].windows(2) {
|
||||
let gap = w[1].duration_since(w[0]);
|
||||
assert!(
|
||||
gap >= interval.mul_f32(0.999) && gap <= interval.mul_f32(1.001),
|
||||
"steady-state gap {gap:?} != interval {interval:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_never_delays_an_on_rate_or_slow_source() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// A source at half the pacing rate (a 60 fps game on a 120 fps session): every arrival
|
||||
// banks two frames of credit and spends one — the gate is always already open.
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval * 2;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"slow source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
// Exactly on-rate: still never gated (credit hovers at the cap, never below 1).
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"on-rate source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_burst_after_a_stall_is_capped() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// Settle into the gated steady state, then stall the source for 10 intervals.
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 20);
|
||||
let stall_end = grabs[19] + interval * 10;
|
||||
// However long the stall, the recovery may run ahead of the on-rate schedule by at most
|
||||
// CAP frames: the second post-stall grab is already re-gated.
|
||||
let after = grab_saturated(&mut b, stall_end, interval, 3);
|
||||
assert_eq!(after[0], stall_end, "first post-stall grab is immediate");
|
||||
assert!(
|
||||
after[1].duration_since(after[0]) >= interval.mul_f32(2.0 - PaceBudget::CAP),
|
||||
"second post-stall grab spent more than the burst cap"
|
||||
);
|
||||
assert!(
|
||||
after[2].duration_since(after[1]) >= interval.mul_f32(0.999),
|
||||
"third post-stall grab must be back on the interval grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_mode_multiplier_scales_only_the_refresh() {
|
||||
// Default (no env set in the test process) is 1× — the identity, which is what every
|
||||
|
||||
@@ -118,6 +118,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ mod linux {
|
||||
false,
|
||||
policy,
|
||||
vout.expect_exact_dims,
|
||||
compositor == pf_vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach the PipeWire capturer")?;
|
||||
cap.set_active(true);
|
||||
|
||||
Reference in New Issue
Block a user