diff --git a/api/openapi.json b/api/openapi.json index 24ed735..4bc886f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -268,7 +268,7 @@ "display" ], "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", "requestBody": { "content": { @@ -292,7 +292,7 @@ } }, "400": { - "description": "An option value is not yet supported (e.g. keep_alive forever)", + "description": "Malformed policy body", "content": { "application/json": { "schema": { diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 8d8db35..00f4b4f 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -1065,9 +1065,8 @@ async fn get_display_settings() -> Json { /// Set the display-management policy /// /// 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 -/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release -/// path yet). +/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is +/// honored (the display is Pinned; free it via `POST /display/release`). #[utoipa::path( put, path = "/display/settings", @@ -1076,7 +1075,7 @@ async fn get_display_settings() -> Json { request_body = crate::vdisplay::policy::DisplayPolicy, responses( (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 = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), ) @@ -1084,17 +1083,8 @@ async fn get_display_settings() -> Json { async fn set_display_settings( ApiJson(policy): ApiJson, ) -> Response { - use crate::vdisplay::policy::KeepAlive; - // Reject options this build can't honor yet, so the console can't promise a behavior that won't - // 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.", - ); - } + // `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux + // registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch). if let Err(e) = crate::vdisplay::policy::prefs().set(policy) { return api_error( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 11ff0b0..e2b20e5 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -131,6 +131,12 @@ enum MgrState { Idle, Active { mon: Monitor, refs: u32 }, 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 @@ -386,22 +392,28 @@ impl VirtualDisplayManager { let mut state = self.state.lock().unwrap(); let dev = self.ensure_device()?; - // IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the - // prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black - // screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a - // fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. + // IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client + // RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it + // hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and + // 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 - // path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session, - // NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every - // retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at - // 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). - if matches!(*state, MgrState::Lingering { .. }) { - if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle) - { + // ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the + // build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a + // concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn + // REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool + // and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). + if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) { + 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!( 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 // `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)); } - // 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) { - MgrState::Lingering { mut mon, .. } => { + MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => { tracing::info!( backend = self.driver.name(), - "virtual monitor reused (reconnect within the linger window)" + "virtual monitor reused (reconnect to a kept monitor)" ); if mon.mode != mode { // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live @@ -747,7 +761,9 @@ impl VirtualDisplayManager { fn release(&self, gen: u64) { let mut state = self.state.lock().unwrap(); 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, }; if stale { @@ -758,6 +774,14 @@ impl VirtualDisplayManager { mon, 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, .. } => { let ms = linger_ms(); tracing::info!( @@ -918,7 +942,7 @@ fn resolve_render_pin() -> Option { pub(crate) struct ManagedInfo { pub backend: &'static str, pub mode: (u32, u32, u32), - /// `"active"` | `"lingering"`. + /// `"active"` | `"lingering"` | `"pinned"`. pub state: &'static str, /// Milliseconds until a lingering monitor is torn down (`None` when active). pub expires_in_ms: Option, @@ -939,6 +963,8 @@ impl VirtualDisplayManager { let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64; (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 { backend: self.driver.name(), @@ -950,20 +976,28 @@ impl VirtualDisplayManager { }) } - /// 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. + /// Force-tear-down a kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) — + /// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8 + /// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused + /// (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 { 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) { + if matches!(&*st, MgrState::Lingering { .. } | MgrState::Pinned { .. }) { + 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()` // (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. unsafe { self.teardown(dev, mon) }; return true; @@ -996,16 +1030,10 @@ fn linger_ms() -> u64 { return match eff.keep_alive.linger() { Linger::Immediate => 0, Linger::For(d) => d.as_millis() as u64, - // Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to - // the default rather than silently keeping the monitor — and thus the physical screens — - // dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.) - Linger::Forever => { - tracing::warn!( - "display policy: keep_alive=forever not yet honored — lingering 10 s \ - (Pinned lands in the display-lifecycle stage)" - ); - 10_000 - } + // `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so + // this arm is only reached defensively (e.g. a caller that resolves ms without the pin + // check) — fall back to the default rather than a huge linger. + Linger::Forever => 10_000, }; } std::env::var("PUNKTFUNK_MONITOR_LINGER_MS") @@ -1014,6 +1042,17 @@ fn linger_ms() -> u64 { .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 /// [`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 diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index b6777e9..e6862ba 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -572,9 +572,10 @@ const apiErrorMessage = (err: unknown): string | undefined => { return err ? String(err) : undefined; }; -/** `gaming-rig` expands to `keep_alive: forever`, which the host still rejects (Windows has no - * Pinned state yet) — surface it, but disabled, rather than let the one-click apply 400. */ -const DISABLED_PRESETS: ReadonlySet = new Set(["gaming-rig"]); +/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty + * now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and + * freed via Release. */ +const DISABLED_PRESETS: ReadonlySet = new Set(); const PRESET_LABEL: Record string> = { custom: m.display_preset_custom,