refactor(host/W6.2): extract virtual-display orchestration into the pf-vdisplay crate
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
|
||||
//!
|
||||
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
|
||||
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
|
||||
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
|
||||
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
|
||||
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
|
||||
//!
|
||||
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
|
||||
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
|
||||
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
|
||||
//! only tracks the discriminant + refcount + deadline and reports what to do.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::policy::Linger;
|
||||
|
||||
/// The lifecycle state of one virtual-display slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum State {
|
||||
/// No display exists.
|
||||
#[default]
|
||||
Idle,
|
||||
/// A display exists with `refs` live sessions holding it.
|
||||
Active { refs: u32 },
|
||||
/// The last session left; the display is kept until `until`, then torn down.
|
||||
Lingering { until: Instant },
|
||||
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
|
||||
/// explicit release.
|
||||
Pinned,
|
||||
}
|
||||
|
||||
/// What acquiring a slot means for the backend.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Acquire {
|
||||
/// The slot was empty — the backend must CREATE a fresh display.
|
||||
Create,
|
||||
/// The slot was already Active — another session JOINS the live display (refcount++).
|
||||
Join,
|
||||
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
|
||||
Reuse,
|
||||
}
|
||||
|
||||
/// What releasing a hold on a slot means for the backend.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
/// Another session still holds the display — nothing to do.
|
||||
Decref,
|
||||
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
|
||||
Linger,
|
||||
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
|
||||
Pin,
|
||||
/// The last session left and keep-alive is off — tear the display down now.
|
||||
Teardown,
|
||||
/// A release with no live hold (stale/duplicate) — no-op.
|
||||
Noop,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
|
||||
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
|
||||
pub fn has_display(self) -> bool {
|
||||
!matches!(self, State::Idle)
|
||||
}
|
||||
|
||||
/// Number of live sessions holding the display (0 unless Active).
|
||||
pub fn refs(self) -> u32 {
|
||||
match self {
|
||||
State::Active { refs } => refs,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session acquires the slot. Transitions the state and reports whether the backend must
|
||||
/// create a fresh display, join the live one, or reuse the kept one.
|
||||
pub fn acquire(&mut self) -> Acquire {
|
||||
match *self {
|
||||
State::Idle => {
|
||||
*self = State::Active { refs: 1 };
|
||||
Acquire::Create
|
||||
}
|
||||
State::Active { refs } => {
|
||||
*self = State::Active { refs: refs + 1 };
|
||||
Acquire::Join
|
||||
}
|
||||
State::Lingering { .. } | State::Pinned => {
|
||||
*self = State::Active { refs: 1 };
|
||||
Acquire::Reuse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
|
||||
/// decide the kept state. Returns what the registry should do.
|
||||
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
|
||||
match *self {
|
||||
State::Active { refs } if refs > 1 => {
|
||||
*self = State::Active { refs: refs - 1 };
|
||||
Release::Decref
|
||||
}
|
||||
State::Active { .. } => match linger {
|
||||
Linger::Immediate => {
|
||||
*self = State::Idle;
|
||||
Release::Teardown
|
||||
}
|
||||
Linger::For(d) => {
|
||||
*self = State::Lingering { until: now + d };
|
||||
Release::Linger
|
||||
}
|
||||
Linger::Forever => {
|
||||
*self = State::Pinned;
|
||||
Release::Pin
|
||||
}
|
||||
},
|
||||
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
|
||||
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
|
||||
// this is the defensive backstop.
|
||||
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
|
||||
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
|
||||
pub fn poll_expiry(&mut self, now: Instant) -> bool {
|
||||
match *self {
|
||||
State::Lingering { until } if now >= until => {
|
||||
*self = State::Idle;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
|
||||
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
|
||||
/// a display that still has live sessions is session management, not display management. Idle → `false`.
|
||||
pub fn force_release(&mut self) -> bool {
|
||||
match *self {
|
||||
State::Lingering { .. } | State::Pinned => {
|
||||
*self = State::Idle;
|
||||
true
|
||||
}
|
||||
State::Active { .. } | State::Idle => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn create_join_reuse_and_teardown() {
|
||||
let mut s = State::default();
|
||||
assert_eq!(s.acquire(), Acquire::Create);
|
||||
assert_eq!(s, State::Active { refs: 1 });
|
||||
// A concurrent session joins.
|
||||
assert_eq!(s.acquire(), Acquire::Join);
|
||||
assert_eq!(s.refs(), 2);
|
||||
// One leaves — still active.
|
||||
let now = Instant::now();
|
||||
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
|
||||
assert_eq!(s.refs(), 1);
|
||||
// The last leaves with keep-alive off — teardown.
|
||||
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
|
||||
assert_eq!(s, State::Idle);
|
||||
assert!(!s.has_display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linger_then_reuse_within_window() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
assert_eq!(
|
||||
s.release(t0, Linger::For(Duration::from_secs(10))),
|
||||
Release::Linger
|
||||
);
|
||||
assert!(s.has_display());
|
||||
// A tick before the deadline does nothing.
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
|
||||
// A reconnect inside the window reuses the kept display.
|
||||
assert_eq!(s.acquire(), Acquire::Reuse);
|
||||
assert_eq!(s, State::Active { refs: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linger_expires_to_teardown() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
s.release(t0, Linger::For(Duration::from_secs(10)));
|
||||
// Past the deadline → teardown.
|
||||
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
|
||||
assert_eq!(s, State::Idle);
|
||||
// A second tick is idempotent (nothing to tear down).
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_never_expires_but_force_releases() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
|
||||
assert_eq!(s, State::Pinned);
|
||||
// No amount of ticking tears a pinned display down.
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
|
||||
assert!(s.has_display());
|
||||
// Only an explicit release does.
|
||||
assert!(s.force_release());
|
||||
assert_eq!(s, State::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_release_refuses_active() {
|
||||
let mut s = State::default();
|
||||
s.acquire();
|
||||
assert!(
|
||||
!s.force_release(),
|
||||
"an active display can't be force-released"
|
||||
);
|
||||
assert_eq!(s.refs(), 1);
|
||||
// Idle also can't.
|
||||
let mut idle = State::default();
|
||||
assert!(!idle.force_release());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_release_is_noop() {
|
||||
let mut s = State::default();
|
||||
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
|
||||
assert_eq!(s, State::Idle);
|
||||
}
|
||||
|
||||
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
|
||||
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
|
||||
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
|
||||
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
|
||||
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
|
||||
#[test]
|
||||
fn property_no_leaks_no_double_free_no_underflow() {
|
||||
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
|
||||
let mut rng: u64 = 0x1234_5678_9abc_def0;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(rng >> 33) as u32
|
||||
};
|
||||
|
||||
let base = Instant::now();
|
||||
let mut logical_ms: u64 = 0;
|
||||
let mut s = State::default();
|
||||
// Shadow model.
|
||||
let mut resource_alive = false;
|
||||
let mut live_holds: u32 = 0;
|
||||
|
||||
for _ in 0..200_000 {
|
||||
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
|
||||
logical_ms += (next() % 2000) as u64;
|
||||
let now = base + Duration::from_millis(logical_ms);
|
||||
|
||||
match next() % 5 {
|
||||
0 => {
|
||||
// acquire
|
||||
let before_alive = resource_alive;
|
||||
let a = s.acquire();
|
||||
match a {
|
||||
Acquire::Create => {
|
||||
assert!(!before_alive, "Create while a resource was alive")
|
||||
}
|
||||
Acquire::Join | Acquire::Reuse => {
|
||||
assert!(before_alive, "Join/Reuse with no live resource")
|
||||
}
|
||||
}
|
||||
resource_alive = true;
|
||||
live_holds += 1;
|
||||
}
|
||||
1 | 2 => {
|
||||
// release (weighted 2/5 so refs actually drain)
|
||||
let linger = match next() % 3 {
|
||||
0 => Linger::Immediate,
|
||||
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
|
||||
_ => Linger::Forever,
|
||||
};
|
||||
let held_before = live_holds;
|
||||
let r = s.release(now, linger);
|
||||
match r {
|
||||
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
|
||||
Release::Decref => {
|
||||
assert!(held_before >= 2, "Decref must leave the display held");
|
||||
live_holds -= 1;
|
||||
}
|
||||
Release::Teardown => {
|
||||
assert_eq!(held_before, 1, "Teardown only on the last hold");
|
||||
live_holds = 0;
|
||||
resource_alive = false;
|
||||
}
|
||||
Release::Linger | Release::Pin => {
|
||||
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
|
||||
live_holds = 0;
|
||||
// resource stays alive (kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// expiry tick
|
||||
if s.poll_expiry(now) {
|
||||
assert_eq!(live_holds, 0, "expiry tore down a held display");
|
||||
resource_alive = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// force release
|
||||
if s.force_release() {
|
||||
assert_eq!(live_holds, 0, "force-release tore down a held display");
|
||||
resource_alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant after every step: the machine's own view of "a display exists" matches the
|
||||
// shadow, and the refcount matches the live-hold count.
|
||||
assert_eq!(
|
||||
s.has_display(),
|
||||
resource_alive,
|
||||
"has_display drifted from the shadow model"
|
||||
);
|
||||
assert_eq!(
|
||||
s.refs(),
|
||||
live_holds,
|
||||
"refs drifted from the live-hold count"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user