feat(vdisplay): ship keep_alive=forever (gaming-rig) — Windows MgrState::Pinned
Completes the last §6A-era preset. The Linux registry already resolved forever→Pinned (pure
lifecycle machine); the blockers were the Windows manager, the mgmt reject, and the console tag:
- Windows manager: new `MgrState::Pinned { mon }` — the last-released monitor under keep_alive=forever
is kept indefinitely (like Lingering but the linger timer never fires). A reconnect preempts +
recreates it (same as Lingering — a reused IddCx swap-chain is dead), snapshot reports "pinned",
and `force_release` (POST /display/release, the §8 escape hatch) frees a pinned monitor. release()
branches on the new `keep_alive_forever()`; all MgrState matches made exhaustive over Pinned.
- mgmt PUT /display/settings: stop rejecting keep_alive=forever (now honored on both platforms with a
release path). OpenAPI regenerated.
- web: un-disable the gaming-rig preset (DISABLED_PRESETS now empty) — one-click applies.
Linux paths + web/tsc/openapi green; 47 vdisplay tests pass. The Windows manager.rs is #[cfg(windows)]
(not compilable on the Linux dev box) — build-verified + on-glass validation on .173 to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -268,7 +268,7 @@
|
|||||||
"display"
|
"display"
|
||||||
],
|
],
|
||||||
"summary": "Set the display-management policy",
|
"summary": "Set the display-management policy",
|
||||||
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` is rejected until the\ndisplay-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release\npath yet).",
|
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).",
|
||||||
"operationId": "setDisplaySettings",
|
"operationId": "setDisplaySettings",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "An option value is not yet supported (e.g. keep_alive forever)",
|
"description": "Malformed policy body",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -1065,9 +1065,8 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
|||||||
/// Set the display-management policy
|
/// Set the display-management policy
|
||||||
///
|
///
|
||||||
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
|
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
|
||||||
/// running session keeps the display it opened on. `keep_alive: forever` is rejected until the
|
/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is
|
||||||
/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release
|
/// honored (the display is Pinned; free it via `POST /display/release`).
|
||||||
/// path yet).
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
path = "/display/settings",
|
path = "/display/settings",
|
||||||
@@ -1076,7 +1075,7 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
|||||||
request_body = crate::vdisplay::policy::DisplayPolicy,
|
request_body = crate::vdisplay::policy::DisplayPolicy,
|
||||||
responses(
|
responses(
|
||||||
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
||||||
(status = BAD_REQUEST, description = "An option value is not yet supported (e.g. keep_alive forever)", body = ApiError),
|
(status = BAD_REQUEST, description = "Malformed policy body", body = ApiError),
|
||||||
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
|
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
|
||||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
)
|
)
|
||||||
@@ -1084,17 +1083,8 @@ async fn get_display_settings() -> Json<DisplaySettingsState> {
|
|||||||
async fn set_display_settings(
|
async fn set_display_settings(
|
||||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
use crate::vdisplay::policy::KeepAlive;
|
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
|
||||||
// Reject options this build can't honor yet, so the console can't promise a behavior that won't
|
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
|
||||||
// happen. `keep_alive: forever` (directly or via the `gaming-rig` preset) needs the Pinned
|
|
||||||
// lifecycle + a release path; until then it would strand physical monitors dark.
|
|
||||||
if policy.effective().keep_alive == KeepAlive::Forever {
|
|
||||||
return api_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"keep_alive `forever` (and the `gaming-rig` preset) is not available yet — it arrives \
|
|
||||||
with the display-lifecycle stage. Use a fixed duration for now.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
||||||
return api_error(
|
return api_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ enum MgrState {
|
|||||||
Idle,
|
Idle,
|
||||||
Active { mon: Monitor, refs: u32 },
|
Active { mon: Monitor, refs: u32 },
|
||||||
Lingering { mon: Monitor, until: Instant },
|
Lingering { mon: Monitor, until: Instant },
|
||||||
|
/// `keep_alive = forever` (gaming-rig): the monitor is kept indefinitely after the last session
|
||||||
|
/// leaves — like `Lingering` but the linger timer never tears it down. A reconnect preempts +
|
||||||
|
/// recreates it (same as `Lingering`, since a reused IddCx swap-chain is dead); only the mgmt
|
||||||
|
/// `/display/release` (or host shutdown) frees it. The physical screens stay off (exclusive) for
|
||||||
|
/// the box's life — the §8 release-now escape hatch (`force_release`) is the way back.
|
||||||
|
Pinned { mon: Monitor },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||||
@@ -386,22 +392,28 @@ impl VirtualDisplayManager {
|
|||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let dev = self.ensure_device()?;
|
let dev = self.ensure_device()?;
|
||||||
|
|
||||||
// IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the
|
// IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client
|
||||||
// prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black
|
// RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it
|
||||||
// screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a
|
// hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and
|
||||||
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
|
// create a fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
|
||||||
//
|
//
|
||||||
// ONLY Lingering, NOT Active: an Active monitor still has a lease held — that's the build-retry
|
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
|
||||||
// path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session,
|
// build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a
|
||||||
// NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every
|
// concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn
|
||||||
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at
|
// REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool
|
||||||
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
// and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
|
||||||
if matches!(*state, MgrState::Lingering { .. }) {
|
if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
|
||||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle)
|
let taken = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||||
{
|
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
|
||||||
|
other => {
|
||||||
|
*state = other;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mon) = taken {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
old_target = mon.target_id,
|
old_target = mon.target_id,
|
||||||
"IDD-push reconnect — preempting the lingering monitor, recreating a fresh one"
|
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value
|
// SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value
|
||||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||||
@@ -457,12 +469,14 @@ impl VirtualDisplayManager {
|
|||||||
return Ok(self.output_for(mon));
|
return Ok(self.output_for(mon));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
|
// Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a
|
||||||
|
// kept Lingering/Pinned monitor was already preempted → Idle above; this arm is the defensive
|
||||||
|
// reuse path if a race left one here — it must stay exhaustive over `Pinned` regardless.)
|
||||||
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||||
MgrState::Lingering { mut mon, .. } => {
|
MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"virtual monitor reused (reconnect within the linger window)"
|
"virtual monitor reused (reconnect to a kept monitor)"
|
||||||
);
|
);
|
||||||
if mon.mode != mode {
|
if mon.mode != mode {
|
||||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||||
@@ -747,7 +761,9 @@ impl VirtualDisplayManager {
|
|||||||
fn release(&self, gen: u64) {
|
fn release(&self, gen: u64) {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let stale = match &*state {
|
let stale = match &*state {
|
||||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
MgrState::Active { mon, .. }
|
||||||
|
| MgrState::Lingering { mon, .. }
|
||||||
|
| MgrState::Pinned { mon } => mon.gen != gen,
|
||||||
MgrState::Idle => true,
|
MgrState::Idle => true,
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
@@ -758,6 +774,14 @@ impl VirtualDisplayManager {
|
|||||||
mon,
|
mon,
|
||||||
refs: refs - 1,
|
refs: refs - 1,
|
||||||
},
|
},
|
||||||
|
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
|
||||||
|
// else linger for the policy window before the timer tears it down.
|
||||||
|
MgrState::Active { mon, .. } if keep_alive_forever() => {
|
||||||
|
tracing::info!(
|
||||||
|
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
|
||||||
|
);
|
||||||
|
MgrState::Pinned { mon }
|
||||||
|
}
|
||||||
MgrState::Active { mon, .. } => {
|
MgrState::Active { mon, .. } => {
|
||||||
let ms = linger_ms();
|
let ms = linger_ms();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -918,7 +942,7 @@ fn resolve_render_pin() -> Option<LUID> {
|
|||||||
pub(crate) struct ManagedInfo {
|
pub(crate) struct ManagedInfo {
|
||||||
pub backend: &'static str,
|
pub backend: &'static str,
|
||||||
pub mode: (u32, u32, u32),
|
pub mode: (u32, u32, u32),
|
||||||
/// `"active"` | `"lingering"`.
|
/// `"active"` | `"lingering"` | `"pinned"`.
|
||||||
pub state: &'static str,
|
pub state: &'static str,
|
||||||
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
||||||
pub expires_in_ms: Option<u64>,
|
pub expires_in_ms: Option<u64>,
|
||||||
@@ -939,6 +963,8 @@ impl VirtualDisplayManager {
|
|||||||
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
||||||
(mon, "lingering", 0u32, Some(ms))
|
(mon, "lingering", 0u32, Some(ms))
|
||||||
}
|
}
|
||||||
|
// Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows "Pinned".
|
||||||
|
MgrState::Pinned { mon } => (mon, "pinned", 0u32, None),
|
||||||
};
|
};
|
||||||
Some(ManagedInfo {
|
Some(ManagedInfo {
|
||||||
backend: self.driver.name(),
|
backend: self.driver.name(),
|
||||||
@@ -950,20 +976,28 @@ impl VirtualDisplayManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
|
/// Force-tear-down a kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) —
|
||||||
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
|
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
|
||||||
/// is refused (stopping a live session is session management, not display management). Returns
|
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused
|
||||||
/// `true` if a lingering monitor was released.
|
/// (stopping a live session is session management, not display management). Returns `true` if a kept
|
||||||
|
/// monitor was released.
|
||||||
pub(crate) fn force_release(&self) -> bool {
|
pub(crate) fn force_release(&self) -> bool {
|
||||||
let Some(dev) = self.device_handle() else {
|
let Some(dev) = self.device_handle() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock().unwrap();
|
||||||
if matches!(&*st, MgrState::Lingering { .. }) {
|
if matches!(&*st, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
|
||||||
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
|
let mon = match std::mem::replace(&mut *st, MgrState::Idle) {
|
||||||
|
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
|
||||||
|
other => {
|
||||||
|
*st = other;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mon) = mon {
|
||||||
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
|
// 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
|
// (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,
|
// `DeviceSlot`). `mon` was moved out of the kept state under the `state` lock,
|
||||||
// so it is exclusively owned here — no aliasing.
|
// so it is exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown(dev, mon) };
|
unsafe { self.teardown(dev, mon) };
|
||||||
return true;
|
return true;
|
||||||
@@ -996,16 +1030,10 @@ fn linger_ms() -> u64 {
|
|||||||
return match eff.keep_alive.linger() {
|
return match eff.keep_alive.linger() {
|
||||||
Linger::Immediate => 0,
|
Linger::Immediate => 0,
|
||||||
Linger::For(d) => d.as_millis() as u64,
|
Linger::For(d) => d.as_millis() as u64,
|
||||||
// Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to
|
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
|
||||||
// the default rather than silently keeping the monitor — and thus the physical screens —
|
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
|
||||||
// dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.)
|
// check) — fall back to the default rather than a huge linger.
|
||||||
Linger::Forever => {
|
Linger::Forever => 10_000,
|
||||||
tracing::warn!(
|
|
||||||
"display policy: keep_alive=forever not yet honored — lingering 10 s \
|
|
||||||
(Pinned lands in the display-lifecycle stage)"
|
|
||||||
);
|
|
||||||
10_000
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||||
@@ -1014,6 +1042,17 @@ fn linger_ms() -> u64 {
|
|||||||
.unwrap_or(10_000)
|
.unwrap_or(10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
|
||||||
|
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
|
||||||
|
/// lingering. Unconfigured hosts are never forever (default is a short linger).
|
||||||
|
fn keep_alive_forever() -> bool {
|
||||||
|
use crate::vdisplay::policy::{prefs, Linger};
|
||||||
|
prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
||||||
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
||||||
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
||||||
|
|||||||
@@ -572,9 +572,10 @@ const apiErrorMessage = (err: unknown): string | undefined => {
|
|||||||
return err ? String(err) : undefined;
|
return err ? String(err) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `gaming-rig` expands to `keep_alive: forever`, which the host still rejects (Windows has no
|
/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty
|
||||||
* Pinned state yet) — surface it, but disabled, rather than let the one-click apply 400. */
|
* now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and
|
||||||
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
|
* freed via Release. */
|
||||||
|
const DISABLED_PRESETS: ReadonlySet<string> = new Set<string>();
|
||||||
|
|
||||||
const PRESET_LABEL: Record<string, () => string> = {
|
const PRESET_LABEL: Record<string, () => string> = {
|
||||||
custom: m.display_preset_custom,
|
custom: m.display_preset_custom,
|
||||||
|
|||||||
Reference in New Issue
Block a user