Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 61547b512a fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 2m20s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m6s
android / android (pull_request) Successful in 3m43s
ci / web (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust (pull_request) Successful in 6m30s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:54:07 +02:00
enricobuehlerandClaude Opus 5 166c93c079 fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
android / android (pull_request) Canceled after 1m21s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 1m32s
ci / rust-arm64 (pull_request) Canceled after 1m28s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 1m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:52:39 +02:00
enricobuehlerandClaude Opus 5 8749bd1396 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
ci / web (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m42s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
android / android (pull_request) Successful in 2m50s
ci / docs-site (pull_request) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 12m58s
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:35:43 +02:00
enricobuehlerandClaude Opus 5 b6acbd096e fix(host/vdisplay): waking the PC stops failing the first session
android / android (push) Failing after 2s
apple / swift (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 1m49s
ci / web (push) Failing after 1s
ci / docs-site (push) Successful in 1m32s
ci / rust (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m55s
arch / build-publish (push) Successful in 8m58s
deb / build-publish (push) Successful in 6m38s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
deb / build-publish-host (push) Successful in 4m57s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
windows-host / package (push) Successful in 12m4s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 55s
windows-host / canary-manifest (push) Successful in 1m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 10m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10m55s
deb / build-publish-client-arm64 (push) Failing after 11m12s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
A woken Windows host refused every connection with "pf-vdisplay driver
interface not found", on a box where the driver was installed and running.

Resuming re-enters D0 and re-registers the IddCx control interface while
the rest of the resume storm is still going. A client reconnecting a
second after wake lands inside that gap. `ensure_available` probed
exactly ONCE, so it read the gap as a dead driver and answered a device
that was seconds from ready by disabling and re-enabling it — then gave
the interface 4 s to come back, which a contended post-resume PnP does
not meet. The session failed, and the log blamed a missing install.

The recovery also could not tell whether it had recovered anything. It
ran the whole cycle under `SilentlyContinue` and reported
`(Get-PnpDevice).Status` — the DEVICE's status, not the cycle's outcome —
so a disable that was REFUSED left the adapter untouched, started, and
reading `OK`. That is the reporter's `cycled the adapter device …
status=OK` line: a recovery that never happened, announcing success. And
a refusal is the expected case here, not the exotic one:
reset-pf-vdisplay.ps1 stops the host service first precisely because the
host holds the driver's control device open, a step an in-process cycle
structurally cannot take.

- Distinguish a devnode MID-TRANSITION (interface registered, not started
  yet, or the open refused) from one genuinely ABSENT. Wait the first
  out; only the second earns a reload. `Probe` carries the counts.
- Report what the reload DID, not what the device looks like afterwards:
  every failable step is `-ErrorAction Stop` in a `try`, and
  `pnputil /restart-device` is the fallback for the in-use device that
  `Disable-PnpDevice` refuses. Failure paths re-enable, so a half-cycle
  can never strand the adapter DISABLED.
- Give the interface 15 s to arrive after a reload, not 4 — under a 30 s
  hard ceiling so a permanently wedged devnode still fails predictably.
- Serialize recovery: N sessions racing in after a wake perform ONE
  reload, not N interleaved ones. The lock is taken only where no manager
  lock is held, so the order stays one-way.
- Retire the manager's cached control handle when a reload runs, instead
  of letting the next session discover it via a failed IOCTL.
- Surface the real reason. `ensure_available` returns `Result`, so the
  log names how long it waited, whether a reload ran, and how many
  interface instances were seen in what state — the detail that would
  have identified this from the field report's log alone.

`VdisplayDriver::open` now shares the wait (brief, no reload) instead of
carrying a second, drifted copy of it — that path is also reached by
`hw_cursor_capable` mid-handshake, where a reload would be the wrong
trade for one capability bool.

Windows-gated, so verified with scripts/xcheck.sh (check + clippy -D
warnings, --all-targets) and cargo fmt; on-glass wake test still owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:12:54 +02:00
11 changed files with 1120 additions and 174 deletions
@@ -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() {
+8 -3
View File
@@ -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,
}
+156 -20
View File
@@ -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` (decodedrelease) / `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
}
}
+32 -22
View File
@@ -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
+15 -7
View File
@@ -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]
+15 -4
View File
@@ -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;
+41 -7
View File
@@ -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,
+167 -10
View File
@@ -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"
);
}
}
+206 -2
View File
@@ -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");
}
}