feat(vdisplay): lifecycle state machine + display state/release API (Stage 1)
Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:
- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
Lingering{until}/Pinned) with acquire/release/expiry/force-release
transitions. No I/O, no OS types — the platform-neutral distillation of the
Windows manager's model. Unit + a 200k-iteration seeded property walk
(no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
keep-alive (a per-session pool) lands in a following increment (needs GPU-box
validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
(tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
per-row + release-all buttons and a linger countdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/release": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Release kept virtual displays",
|
||||
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
|
||||
"operationId": "releaseDisplay",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDisplayRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The number of kept displays released",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReleaseDisplayResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -230,6 +272,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Live virtual displays",
|
||||
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
|
||||
"operationId": "getDisplayState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The live/kept virtual displays",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DisplayStateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/gpus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1693,6 +1767,59 @@
|
||||
"av1"
|
||||
]
|
||||
},
|
||||
"ApiDisplayInfo": {
|
||||
"type": "object",
|
||||
"description": "One live or kept virtual display.",
|
||||
"required": [
|
||||
"slot",
|
||||
"backend",
|
||||
"mode",
|
||||
"state",
|
||||
"sessions"
|
||||
],
|
||||
"properties": {
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
|
||||
},
|
||||
"client": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Short client label, when the owner tracks it."
|
||||
},
|
||||
"expires_in_ms": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
|
||||
"minimum": 0
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "`WIDTHxHEIGHT@HZ`."
|
||||
},
|
||||
"sessions": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Live sessions holding the display.",
|
||||
"minimum": 0
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Stable-enough id for the `/display/release` `slot` argument.",
|
||||
"minimum": 0
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "`active` | `lingering` | `pinned`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiError": {
|
||||
"type": "object",
|
||||
"description": "Error envelope for every non-2xx response.",
|
||||
@@ -2076,6 +2203,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisplayStateResponse": {
|
||||
"type": "object",
|
||||
"description": "The host's managed virtual displays right now.",
|
||||
"required": [
|
||||
"displays"
|
||||
],
|
||||
"properties": {
|
||||
"displays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ApiDisplayInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"EffectivePolicy": {
|
||||
"type": "object",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||
@@ -2795,6 +2937,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
"properties": {
|
||||
"slot": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayResult": {
|
||||
"type": "object",
|
||||
"description": "Result of a `/display/release`.",
|
||||
"required": [
|
||||
"released"
|
||||
],
|
||||
"properties": {
|
||||
"released": {
|
||||
"type": "integer",
|
||||
"description": "Number of kept displays torn down.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeStatus": {
|
||||
"type": "object",
|
||||
"description": "Live host status (changes as clients launch/end sessions).",
|
||||
|
||||
@@ -158,6 +158,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(set_gpu_preference))
|
||||
.routes(routes!(get_display_settings))
|
||||
.routes(routes!(set_display_settings))
|
||||
.routes(routes!(get_display_state))
|
||||
.routes(routes!(release_display))
|
||||
.routes(routes!(get_status))
|
||||
.routes(routes!(get_local_summary))
|
||||
.routes(routes!(list_paired_clients))
|
||||
@@ -1095,6 +1097,104 @@ async fn set_display_settings(
|
||||
Json(display_settings_state()).into_response()
|
||||
}
|
||||
|
||||
/// One live or kept virtual display.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct ApiDisplayInfo {
|
||||
/// Stable-enough id for the `/display/release` `slot` argument.
|
||||
slot: u64,
|
||||
/// Backend name (`pf-vdisplay`, `kwin`, …).
|
||||
backend: String,
|
||||
/// `WIDTHxHEIGHT@HZ`.
|
||||
mode: String,
|
||||
/// `active` | `lingering` | `pinned`.
|
||||
state: String,
|
||||
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
|
||||
expires_in_ms: Option<u64>,
|
||||
/// Live sessions holding the display.
|
||||
sessions: u32,
|
||||
/// Short client label, when the owner tracks it.
|
||||
client: Option<String>,
|
||||
}
|
||||
|
||||
/// The host's managed virtual displays right now.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct DisplayStateResponse {
|
||||
displays: Vec<ApiDisplayInfo>,
|
||||
}
|
||||
|
||||
/// Request body for `releaseDisplay`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
struct ReleaseDisplayRequest {
|
||||
/// Slot to release (see `state`); omit to release **all** kept displays.
|
||||
#[serde(default)]
|
||||
slot: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result of a `/display/release`.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct ReleaseDisplayResult {
|
||||
/// Number of kept displays torn down.
|
||||
released: usize,
|
||||
}
|
||||
|
||||
/// Live virtual displays
|
||||
///
|
||||
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
|
||||
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
|
||||
/// `design/display-management.md`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/state",
|
||||
tag = "display",
|
||||
operation_id = "getDisplayState",
|
||||
responses(
|
||||
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||
let snap = crate::vdisplay::registry::snapshot();
|
||||
Json(DisplayStateResponse {
|
||||
displays: snap
|
||||
.displays
|
||||
.into_iter()
|
||||
.map(|d| ApiDisplayInfo {
|
||||
slot: d.slot,
|
||||
backend: d.backend,
|
||||
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
|
||||
state: d.state,
|
||||
expires_in_ms: d.expires_in_ms,
|
||||
sessions: d.sessions,
|
||||
client: d.client,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Release kept virtual displays
|
||||
///
|
||||
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
|
||||
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
|
||||
/// Active (streaming) displays are never torn down here (that is session control).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/display/release",
|
||||
tag = "display",
|
||||
operation_id = "releaseDisplay",
|
||||
request_body = ReleaseDisplayRequest,
|
||||
responses(
|
||||
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn release_display(
|
||||
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
|
||||
) -> Json<ReleaseDisplayResult> {
|
||||
let released = crate::vdisplay::registry::release(req.slot);
|
||||
tracing::info!(slot = ?req.slot, released, "management API: display release");
|
||||
Json(ReleaseDisplayResult { released })
|
||||
}
|
||||
|
||||
/// Live host status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -2650,7 +2750,10 @@ mod tests {
|
||||
let (status, body) = send(&app, put).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
body["error"].as_str().unwrap_or_default().contains("forever"),
|
||||
body["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("forever"),
|
||||
"the rejection names the unsupported option"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -752,6 +752,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
#[path = "vdisplay/policy.rs"]
|
||||
pub(crate) mod policy;
|
||||
|
||||
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||
// property-tested; the registry executes the side effects its transitions dictate.
|
||||
#[path = "vdisplay/lifecycle.rs"]
|
||||
pub(crate) mod lifecycle;
|
||||
|
||||
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
|
||||
// later), for the management API's /display/state + /display/release.
|
||||
#[path = "vdisplay/registry.rs"]
|
||||
pub(crate) mod registry;
|
||||
|
||||
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
|
||||
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
|
||||
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Neutral **facade over the per-OS virtual-display lifecycle owners**, for the management API's
|
||||
//! `/display/state` + `/display/release` (design: `design/display-management.md` §7).
|
||||
//!
|
||||
//! Windows already owns its display lifecycle in [`super::manager::VirtualDisplayManager`] (one
|
||||
//! shared IddCx monitor, refcounted, lingering); this facade reads and controls it. Linux keep-alive
|
||||
//! (a per-session output pool driven by [`super::lifecycle`]) lands in a following increment — it
|
||||
//! needs on-glass validation on a GPU box, which the current headless VM can't provide — so until
|
||||
//! then the Linux side reports no managed displays and release is a no-op.
|
||||
//!
|
||||
//! The lifecycle *state machine* ([`super::lifecycle::State`]) is the platform-neutral core both
|
||||
//! sides converge on; Windows adopts it when its manager is refactored onto it (that unification is
|
||||
//! deferred so the on-glass-validated Windows path stays untouched this stage).
|
||||
|
||||
/// One live or kept virtual display, for the mgmt snapshot.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DisplayInfo {
|
||||
/// A stable-enough id for the `/display/release` slot argument (the backend's generation stamp).
|
||||
pub slot: u64,
|
||||
/// Backend name (`"pf-vdisplay"`, `"kwin"`, …).
|
||||
pub backend: String,
|
||||
/// `(width, height, refresh_hz)`.
|
||||
pub mode: (u32, u32, u32),
|
||||
/// `"active"` | `"lingering"` | `"pinned"`.
|
||||
pub state: String,
|
||||
/// Milliseconds until a lingering display is torn down (`None` when active/pinned).
|
||||
pub expires_in_ms: Option<u64>,
|
||||
/// Live sessions holding the display.
|
||||
pub sessions: u32,
|
||||
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
|
||||
pub client: Option<String>,
|
||||
}
|
||||
|
||||
/// The live display set for the mgmt `/display/state` endpoint.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Snapshot {
|
||||
pub displays: Vec<DisplayInfo>,
|
||||
}
|
||||
|
||||
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
|
||||
/// safe per management request.
|
||||
pub fn snapshot() -> Snapshot {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let displays = super::manager::snapshot()
|
||||
.map(|i| DisplayInfo {
|
||||
slot: i.gen,
|
||||
backend: i.backend.to_string(),
|
||||
mode: i.mode,
|
||||
state: i.state.to_string(),
|
||||
expires_in_ms: i.expires_in_ms,
|
||||
sessions: i.sessions,
|
||||
client: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
Snapshot { displays }
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Linux keep-alive pool: not yet (needs GPU-box validation) — no managed displays to report.
|
||||
Snapshot::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot`
|
||||
/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are
|
||||
/// refused (releasing a display with live sessions is session management). Returns the number
|
||||
/// released.
|
||||
pub fn release(_slot: Option<u64>) -> usize {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one
|
||||
// lingering monitor if present. (Multi-monitor gives `slot` meaning later.)
|
||||
usize::from(super::manager::force_release())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -892,6 +892,81 @@ fn resolve_render_pin() -> Option<LUID> {
|
||||
crate::win_adapter::resolve_render_adapter_luid()
|
||||
}
|
||||
|
||||
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
||||
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
||||
/// maps it into the wire shape.
|
||||
pub(crate) struct ManagedInfo {
|
||||
pub backend: &'static str,
|
||||
pub mode: (u32, u32, u32),
|
||||
/// `"active"` | `"lingering"`.
|
||||
pub state: &'static str,
|
||||
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
||||
pub expires_in_ms: Option<u64>,
|
||||
/// Live sessions holding the monitor.
|
||||
pub sessions: u32,
|
||||
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
|
||||
pub gen: u64,
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
/// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle.
|
||||
pub(crate) fn snapshot(&self) -> Option<ManagedInfo> {
|
||||
let st = self.state.lock().unwrap();
|
||||
let (mon, state, sessions, expires_in_ms) = match &*st {
|
||||
MgrState::Idle => return None,
|
||||
MgrState::Active { mon, refs } => (mon, "active", *refs, None),
|
||||
MgrState::Lingering { mon, until } => {
|
||||
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
||||
(mon, "lingering", 0u32, Some(ms))
|
||||
}
|
||||
};
|
||||
Some(ManagedInfo {
|
||||
backend: self.driver.name(),
|
||||
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
|
||||
state,
|
||||
expires_in_ms,
|
||||
sessions,
|
||||
gen: mon.gen,
|
||||
})
|
||||
}
|
||||
|
||||
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
|
||||
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
|
||||
/// is refused (stopping a live session is session management, not display management). Returns
|
||||
/// `true` if a lingering monitor was released.
|
||||
pub(crate) fn force_release(&self) -> bool {
|
||||
let Some(dev) = self.device_handle() else {
|
||||
return false;
|
||||
};
|
||||
let mut st = self.state.lock().unwrap();
|
||||
if matches!(&*st, MgrState::Lingering { .. }) {
|
||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
|
||||
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
|
||||
// (cached handles are never closed — a dead one is retired, kept alive; see
|
||||
// `DeviceSlot`). `mon` was moved out of the `Lingering` state under the `state` lock,
|
||||
// so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
|
||||
/// session has ever run) or it is Idle. Safe to call per management request.
|
||||
pub(crate) fn snapshot() -> Option<ManagedInfo> {
|
||||
VDM.get().and_then(VirtualDisplayManager::snapshot)
|
||||
}
|
||||
|
||||
/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is
|
||||
/// uninitialised).
|
||||
pub(crate) fn force_release() -> bool {
|
||||
VDM.get()
|
||||
.map(VirtualDisplayManager::force_release)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
||||
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
||||
/// else the 10 s default.
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
|
||||
"display_live": "Aktive Displays",
|
||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||
"display_state_active": "Aktiv",
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
|
||||
"display_live": "Live displays",
|
||||
"display_none_live": "No virtual displays right now.",
|
||||
"display_state_active": "Active",
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
getGetDisplayStateQueryKey,
|
||||
getGetDisplaySettingsQueryKey,
|
||||
useGetDisplaySettings,
|
||||
useGetDisplayState,
|
||||
useReleaseDisplay,
|
||||
useSetDisplaySettings,
|
||||
} from "@/api/gen/display/display";
|
||||
import type { ApiDisplayInfo } from "@/api/gen/model";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import type {
|
||||
DisplayPolicy,
|
||||
@@ -70,11 +74,100 @@ export const DisplaySection: FC = () => {
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
<LiveDisplays />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
||||
* for lingering/pinned ones (active displays can't be released — that's session control).
|
||||
*/
|
||||
const LiveDisplays: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
|
||||
const release = useReleaseDisplay();
|
||||
const displays = state.data?.displays ?? [];
|
||||
const kept = displays.filter((d) => d.state !== "active");
|
||||
|
||||
const doRelease = (slot?: number) =>
|
||||
release.mutate(
|
||||
{ data: { slot: slot ?? null } },
|
||||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h4 className="text-sm font-medium">{m.display_live()}</h4>
|
||||
{kept.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={release.isPending}
|
||||
onClick={() => doRelease()}
|
||||
>
|
||||
{m.display_release_all()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{displays.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{displays.map((d) => (
|
||||
<DisplayRow
|
||||
key={d.slot}
|
||||
d={d}
|
||||
busy={release.isPending}
|
||||
onRelease={() => doRelease(d.slot)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
|
||||
d,
|
||||
busy,
|
||||
onRelease,
|
||||
}) => {
|
||||
const active = d.state === "active";
|
||||
const stateLabel =
|
||||
d.state === "active"
|
||||
? m.display_state_active()
|
||||
: d.state === "pinned"
|
||||
? m.display_state_pinned()
|
||||
: m.display_state_lingering();
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{d.mode}</span>
|
||||
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
|
||||
{active && d.sessions > 0 && (
|
||||
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{d.backend}
|
||||
{d.expires_in_ms != null
|
||||
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
|
||||
: ""}
|
||||
</code>
|
||||
</div>
|
||||
{!active && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
|
||||
{m.display_release_btn()}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||||
const apiErrorMessage = (err: unknown): string | undefined => {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user