From d0d9bd5bfb2faa4641a9020f5355a477dd4aa0be Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 12:53:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(resize):=20mid-stream=20resolution=20resiz?= =?UTF-8?q?e=20=E2=80=94=20host=20hardening=20(H1=E2=80=93H5)=20+=20sessio?= =?UTF-8?q?n-binary=20Match=20window=20(C1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/midstream-resolution-resize.md Phase 0 + Phase 1. Host (Phase 0): - H1/H5: per-backend Reconfigure acceptance gate — reject for gamescope (all sub-modes; a resize must never relaunch the title) and under the per-client-mode identity policy (a resize would resolve a different display slot). Synthetic stays reconfigurable on purpose (the protocol test source; the C-ABI roundtrip test rides it). Plus a 500 ms host-side min-interval backstop against Reconfigure spam. - H2: rollback/corrective ack — the data plane reports the mode actually live after a failed rebuild (or a refresh the backend capped) through a reconfig_result channel; the control task forwards it as a second accepted Reconfigured so the client's mode slot self-corrects. - H3: live stats mode — SendStats reads a packed AtomicU64 (w|h|hz) updated on every switch instead of latching the session-start mode. - H4: registry::retire(gen) — a mode-switch rebuild force-releases the superseded Linux display, so linger/forever keep-alive policies don't accumulate kept monitors at stale modes. VirtualOutput carries pool_gen (fresh AND reused) and the Pipeline tuple threads it to the switch arm. Client (Phase 1, default off): - Settings: match_window policy + persisted last window size; exposed as the Resolution tri-state (Native / Match window / explicit) in the Skia console, GTK and WinUI settings pages. - pf-presenter: window opens at the persisted size; Hello mode follows the window's pixel size; D2 trigger discipline (400 ms debounce to resize-end, ≥1 s spacing, even-floor + ≥320×200 clamp, each distinct size requested at most once — covers rejects and host rollbacks) as a pure, unit-tested decision; HUD line + title refresh on a switch. - Session binary wires both --connect and --browse paths; the WinUI shell is session-always, so this covers Windows too. Verified: workspace tests + clippy green; synthetic --remode end-to-end; live session-binary run (window at persisted 1000×600 → Hello 1000×600@60). On-glass per-backend matrix (Mutter/KWin/gamescope-reject, keep-alive accumulation) still pending before any default flip. Co-Authored-By: Claude Fable 5 --- clients/linux/src/ui_settings.rs | 45 ++- clients/session/src/console.rs | 4 + clients/session/src/main.rs | 30 ++ clients/windows/src/app/settings.rs | 38 ++- crates/pf-client-core/src/trust.rs | 16 + crates/pf-console-ui/src/screens/settings.rs | 40 ++- crates/pf-presenter/src/run.rs | 280 +++++++++++++++++- crates/punktfunk-host/src/punktfunk1.rs | 211 +++++++++++-- crates/punktfunk-host/src/vdisplay.rs | 9 + .../src/vdisplay/linux/gamescope.rs | 2 + .../src/vdisplay/linux/hyprland.rs | 1 + .../src/vdisplay/linux/wlroots.rs | 1 + .../punktfunk-host/src/vdisplay/registry.rs | 37 ++- 13 files changed, 644 insertions(+), 70 deletions(-) diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index a2d18ba2..0fbe235d 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -264,21 +264,23 @@ pub fn show( let page = adw::PreferencesPage::new(); let stream = adw::PreferencesGroup::builder().title("Stream").build(); - let res_names: Vec = RESOLUTIONS - .iter() - .map(|&(w, h)| { - if w == 0 { - "Native display".to_string() - } else { - format!("{w} × {h}") - } - }) + // The D1 tri-state: Native, Match window (a virtual index 1, stored as the + // `match_window` flag), then the explicit sizes. + let res_names: Vec = std::iter::once("Native display".to_string()) + .chain(std::iter::once("Match window".to_string())) + .chain( + RESOLUTIONS + .iter() + .skip(1) + .map(|&(w, h)| format!("{w} × {h}")), + ) .collect(); let res_row = ChoiceRow::new( &dialog, inline, "Resolution", - "The host creates a virtual output at exactly this size", + "The host creates a virtual output at exactly this size — Match window follows \ + the stream window, including mid-stream resizes", &res_names.iter().map(String::as_str).collect::>(), ); let hz_names: Vec = REFRESH @@ -470,10 +472,15 @@ pub fn show( // Seed from the current settings. { let s = settings.borrow(); - let res_i = RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(0); + let res_i = if s.match_window { + 1 + } else { + RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .map(|i| if i == 0 { 0 } else { i + 1 }) + .unwrap_or(0) + }; res_row.set_selected(res_i as u32); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); hz_row.set_selected(hz_i as u32); @@ -508,8 +515,14 @@ pub fn show( dialog.add(&page); dialog.connect_closed(move |_| { let mut s = settings.borrow_mut(); - let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)]; - (s.width, s.height) = (w, h); + // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit. + let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len()); + s.match_window = res_i == 1; + (s.width, s.height) = if res_i <= 1 { + (0, 0) + } else { + RESOLUTIONS[res_i - 1] + }; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 4118286d..6201bf05 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -145,6 +145,10 @@ pub fn run(target: Option<&str>) -> u8 { trust::touch_last_used(&trust::hex(&fingerprint)); })), overlay: Some(Box::new(overlay)), + window_size: crate::session_main::window_size(&settings_at_start), + // Latched at console start (like the stats tier above): toggling Match window in + // the console's settings screen applies from the next console launch. + match_window: crate::session_main::match_window(&settings_at_start), }; let result = diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 972b8859..e4ba52b5 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -164,6 +164,34 @@ mod session_main { } } + /// The window's starting size under Match-window: the persisted last size, so the + /// first connect's mode already matches the glass; `None` (policy off / never + /// stored) = the 1280×720 default. + pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> { + (settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0) + .then_some((settings.last_window_w, settings.last_window_h)) + } + + /// The Match-window policy hook for the presenter loop + /// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the + /// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's + /// logical window size (load-modify-save, like the console settings screen) so the + /// next launch opens at it. + pub(crate) fn match_window( + settings: &trust::Settings, + ) -> Option> { + settings.match_window.then(|| { + Box::new(|w: u32, h: u32| { + let mut s = trust::Settings::load(); + if (s.last_window_w, s.last_window_h) != (w, h) { + s.last_window_w = w; + s.last_window_h = h; + s.save(); + } + }) as Box + }) + } + /// One JSON status line on stdout (the shell parses these; strings hand-escaped via /// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its /// failure through the same contract when spawned with `--json-status`. @@ -343,6 +371,8 @@ mod session_main { overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())), #[cfg(not(feature = "ui"))] overlay: None, + window_size: window_size(&settings), + match_window: match_window(&settings), }; let outcome = diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 11e131a7..8cde457d 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -136,29 +136,37 @@ pub(crate) fn settings_page( let s = ctx.settings.lock().unwrap().clone(); // --- Display --------------------------------------------------------------------------- + // The D1 tri-state: Native, Match window (a virtual index 1, stored as the + // `match_window` flag), then the explicit sizes. let (res_names, res_i) = { - let names: Vec = RESOLUTIONS - .iter() - .map(|&(w, h)| { - if w == 0 { - "Native display".into() - } else { - format!("{w} \u{00D7} {h}") - } - }) + let names: Vec = std::iter::once("Native display".to_string()) + .chain(std::iter::once("Match window".to_string())) + .chain( + RESOLUTIONS + .iter() + .skip(1) + .map(|&(w, h)| format!("{w} \u{00D7} {h}")), + ) .collect(); - let i = RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(0); + let i = if s.match_window { + 1 + } else { + RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .map(|i| if i == 0 { 0 } else { i + 1 }) + .unwrap_or(0) + }; (names, i) }; let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| { - (s.width, s.height) = RESOLUTIONS[i]; + s.match_window = i == 1; + (s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] }; }) .tooltip( "The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \ - resolves to the monitor this window is on at connect.", + resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \ + follows the stream window, including mid-stream resizes.", ); let (hz_names, hz_i) = { let names: Vec = REFRESH diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index f25e0993..ef52c3df 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -406,6 +406,19 @@ pub struct Settings { /// Experimental: the game-library browser ("Browse library…" on saved cards) — /// mirrors the Apple client's "Show game library" toggle, default off. pub library_enabled: bool, + /// Match-window resolution policy (design/midstream-resolution-resize.md D1): the + /// stream mode follows the session window — the connect asks for the window's pixel + /// size and a mid-session resize renegotiates the host's virtual display + encoder + /// (`Reconfigure`), so windowed sessions stream native-resolution pixels instead of + /// scaling. Overrides `width`/`height` while on; on fullscreen it degenerates to the + /// display's native mode. Default off (Auto-native stays the shipped default until + /// the per-backend validation matrix is green). + pub match_window: bool, + /// The session window's last logical size under `match_window`: the next launch + /// opens its window at this size, so the first connect's mode already matches what + /// the user will be looking at. `0` = never stored → the 1280×720 default. + pub last_window_w: u32, + pub last_window_h: u32, } fn default_codec() -> String { @@ -466,6 +479,9 @@ impl Default for Settings { stats_verbosity: None, fullscreen_on_stream: true, library_enabled: false, + match_window: false, + last_window_w: 0, + last_window_h: 0, } } } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index ca7b9865..2f60d867 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -176,7 +176,9 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { RowId::Resolution => ( Some("Stream"), "Resolution", - if s.width == 0 { + if s.match_window { + "Match window".into() + } else if s.width == 0 { "Native".into() } else { format!("{} × {}", s.width, s.height) @@ -259,7 +261,8 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { fn detail(id: RowId) -> &'static str { match id { RowId::Resolution => { - "The host creates a virtual display at exactly this size — no scaling." + "The host creates a virtual display at exactly this size — no scaling. \ + Match window follows this window, including mid-stream resizes." } RowId::Refresh => "Native follows the display this window is on.", RowId::Bitrate => "Automatic uses the host's default (20 Mbps).", @@ -303,11 +306,20 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { let s = &mut *ctx.settings; match id { RowId::Resolution => { - let cur = RESOLUTIONS - .iter() - .position(|(w, h)| (*w, *h) == (s.width, s.height)); - step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| { - (s.width, s.height) = RESOLUTIONS[i]; + // The D1 tri-state as one picker: Native, Match window, then the explicit + // sizes (RESOLUTIONS keeps its (0,0) = Native head; Match window is the + // virtual index 1, stored as the `match_window` flag with w/h cleared). + let cur = if s.match_window { + Some(1) + } else { + RESOLUTIONS + .iter() + .position(|(w, h)| (*w, *h) == (s.width, s.height)) + .map(|i| if i == 0 { 0 } else { i + 1 }) + }; + step_option(cur, RESOLUTIONS.len() + 1, delta, wrap).map(|i| { + s.match_window = i == 1; + (s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] }; }) } RowId::Refresh => { @@ -401,14 +413,26 @@ mod tests { device_name: "t", t: 0.0, }; - // Resolution starts at Native (index 0): left refuses, right steps. + // Resolution starts at Native (index 0): left refuses, right steps — first onto + // Match window (the D1 tri-state's middle option), then the explicit sizes. assert!(!adjust(RowId::Resolution, -1, false, &mut ctx)); assert!(adjust(RowId::Resolution, 1, false, &mut ctx)); + assert!(ctx.settings.match_window, "Native → Match window"); + assert_eq!((ctx.settings.width, ctx.settings.height), (0, 0)); + assert!(adjust(RowId::Resolution, 1, false, &mut ctx)); + assert!(!ctx.settings.match_window, "explicit size clears the policy"); assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720)); + // Stepping back from an explicit size returns to Match window, then Native. + assert!(adjust(RowId::Resolution, -1, false, &mut ctx)); + assert!(ctx.settings.match_window); + assert!(adjust(RowId::Resolution, -1, false, &mut ctx)); + assert!(!ctx.settings.match_window); + assert_eq!(ctx.settings.width, 0, "back to Native"); // Cycle from the last option wraps to the first. (ctx.settings.width, ctx.settings.height) = (3840, 2160); assert!(adjust(RowId::Resolution, 1, true, &mut ctx)); assert_eq!(ctx.settings.width, 0, "wrapped to Native"); + assert!(!ctx.settings.match_window); } #[test] diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index d39f741a..d2113ba8 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -52,6 +52,18 @@ pub struct SessionOpts { /// stay stdout-only). An overlay whose `init` fails degrades to `None` with a /// warning rather than killing the session. Browse mode requires one. pub overlay: Option>, + /// The window's starting logical size; `None` = the 1280×720 default. The binary + /// passes the persisted last-window size under the Match-window policy so the first + /// connect's mode already matches what the user will be looking at. + pub window_size: Option<(u32, u32)>, + /// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): + /// `Some` = the stream mode follows the window. At session start the params' mode + /// w/h are replaced by the window's physical pixel size; a mid-session resize sends + /// a debounced `Reconfigure` so the host's virtual display + encoder follow. The + /// callback receives the window's logical size at each resize-end — the binary + /// persists it for the next launch. `None` = never auto-resize (Auto-native / + /// Explicit keep today's behavior). + pub match_window: Option>, } pub enum Outcome { @@ -173,6 +185,22 @@ struct StreamState { /// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the /// OSD immediately instead of waiting up to 1 s for the next Stats event. last_stats: Option, + /// Match-window (D2) debounce state: the last resize event's stamp. `Some` = a + /// resize is pending; the tick fires the request once ~400 ms pass with no further + /// size events (never per drag-frame — each accepted switch is a full host rebuild). + resize_pending: Option, + /// When the last `Reconfigure` was sent — the ≥ 1 s spacing between requests (D2). + /// The accept ack round-trips in milliseconds (it precedes the host's rebuild), so + /// this spacing also serializes: at most ~one request is ever outstanding. + resize_sent_at: Option, + /// The last size actually requested. Each distinct size is requested at most once: + /// this both implements "don't re-request a rejected size until it changes" (D2) and + /// keeps a host-side rollback (accept ack, rebuild failed, corrective ack restored + /// the old mode) from looping request → rollback → request forever. + resize_requested: Option<(u32, u32)>, + /// The connector mode last shown in the HUD/title — a change (an accepted switch's + /// ack, or a corrective rollback) refreshes both. + shown_mode: Option, } impl StreamState { @@ -217,6 +245,10 @@ impl StreamState { hw_fails: 0, osd_text: String::new(), last_stats: None, + resize_pending: None, + resize_sent_at: None, + resize_requested: None, + shown_mode: None, } } @@ -270,7 +302,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .register_custom_event::() .map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?; let mut window = { - let mut b = video.window(&opts.window_title, 1280, 720); + // Match-window (D1): open at the persisted last size, so the first connect's + // mode already matches the glass. 1280×720 stays the fallback/default. + let (ww, wh) = opts.window_size.unwrap_or((1280, 720)); + let mut b = video.window(&opts.window_title, ww.max(320), wh.max(200)); match opts.window_pos { Some((x, y)) => b.position(x, y), None => b.position_centered(), @@ -340,12 +375,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let mut stream: Option = match &mut mode { ModeCtl::Single(build) => { let force_software = Arc::new(AtomicBool::new(false)); - let params = build( + let mut params = build( &gamepad, native, force_software.clone(), presenter.vulkan_decode(), ); + if opts.match_window.is_some() { + apply_match_window(&mut params, &window); + } Some(StreamState::new( params, force_software, @@ -423,6 +461,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => { presenter.recreate_swapchain(&window)?; presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; + // Match-window (D2): (re)stamp the debounce — the request fires + // once ~400 ms pass with no further size events, never per + // drag-frame (each accepted switch is a full host rebuild). + if opts.match_window.is_some() { + if let Some(st) = stream.as_mut() { + st.resize_pending = Some(Instant::now()); + } + } } WindowEvent::Exposed => { presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; @@ -616,7 +662,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result presenter.vulkan_decode(), ) { ActionOutcome::Handled => {} - ActionOutcome::Start(params) => { + ActionOutcome::Start(mut params) => { + if opts.match_window.is_some() { + apply_match_window(&mut params, &window); + } stream = Some(StreamState::new( *params, force_software, @@ -753,6 +802,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } + // --- Match-window (D2): debounced mode-follow + HUD/title refresh on a switch ---- + if let Some(persist) = opts.match_window.as_mut() { + if let Some(st) = stream.as_mut() { + resize_tick(st, &mut window, &opts.window_title, persist.as_mut()); + } + } + // --- Console UI: damage-driven overlay re-render for this iteration -------------- if let Some(o) = overlay.as_mut() { let (pw, ph) = window.size_in_pixels(); @@ -1013,6 +1069,125 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result Ok(outcome) } +/// Match-window (D1): replace the params' requested w/h with the window's physical pixel +/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a +/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the +/// display, so this degenerates to the display's native mode. +fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window) { + let (pw, ph) = window.size_in_pixels(); + params.mode.width = (pw & !1).max(320); + params.mode.height = (ph & !1).max(200); + tracing::info!( + w = params.mode.width, + h = params.mode.height, + "match-window: requesting the window's pixel size" + ); +} + +/// Match-window (D2) per-iteration tick: refresh the HUD line + window title when the +/// live mode moves (an accepted switch's ack, or a corrective rollback), then fire the +/// debounced `Reconfigure` once ~400 ms pass with no further resize events. The shared +/// trigger discipline: +/// * physical pixels, even-floored, clamped ≥ 320×200; the current refresh is kept; +/// * ≥ 1 s between requests (the accept ack round-trips in milliseconds — it precedes +/// the host's rebuild — so the spacing also keeps at most ~one request outstanding); +/// * each distinct size is requested at most ONCE (`resize_requested`): a rejected +/// size isn't re-asked until the window changes, and a host-side rollback (accepted, +/// rebuild failed, corrective ack restored the old mode) can't loop. +fn resize_tick( + st: &mut StreamState, + window: &mut sdl3::video::Window, + title_base: &str, + persist: &mut dyn FnMut(u32, u32), +) { + let Some(c) = &st.connector else { + return; // not connected yet — the pending stamp survives until we are + }; + // HUD/title follow the live mode slot (updated by any accepted ack). + let m = c.mode(); + if st.shown_mode.is_some_and(|prev| prev != m) { + st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz); + tracing::info!(mode = %st.mode_line, "stream mode switched"); + let _ = window.set_title(&format!("{title_base} · {}", st.mode_line)); + } + st.shown_mode = Some(m); + + match resize_decision( + Instant::now(), + &mut st.resize_pending, + st.resize_sent_at, + st.resize_requested, + (m.width, m.height), + window.size_in_pixels(), + ) { + ResizeAction::Wait => {} + ResizeAction::Settled(target) => { + // The debounce settled: persist the window's LOGICAL size for the next + // launch (its window is created in logical units) even when no request goes + // out (e.g. resized back to the streamed size). + let (lw, lh) = window.size(); + persist(lw, lh); + let Some((w, h)) = target else { return }; + tracing::info!(w, h, "window resized — requesting mode switch"); + if c + .request_mode(Mode { + width: w, + height: h, + refresh_hz: m.refresh_hz, + }) + .is_err() + { + tracing::warn!("mode-switch request dropped — control channel closed"); + } + st.resize_requested = Some((w, h)); + st.resize_sent_at = Some(Instant::now()); + } + } +} + +/// What one [`resize_decision`] tick decided. +#[derive(Debug, PartialEq, Eq)] +enum ResizeAction { + /// Nothing to do yet (no resize pending, still debouncing, or spacing defers — the + /// pending stamp is kept so a later tick retries). + Wait, + /// The debounce settled (pending cleared, the caller persists the window size), with + /// the mode to request — `None` when the size needs no switch (equal to the streamed + /// mode, or this exact size was already requested once). + Settled(Option<(u32, u32)>), +} + +/// The D2 trigger discipline as a pure decision (unit-tested — CI can't open windows): +/// debounce to resize-end, ≥ 1 s between requests, physical pixels even-floored and +/// clamped ≥ 320×200, skip when equal to the streamed mode, and each distinct size +/// requested at most once (covers rejected sizes AND host-side rollbacks). +fn resize_decision( + now: Instant, + pending: &mut Option, + sent_at: Option, + requested: Option<(u32, u32)>, + current: (u32, u32), + pixel_size: (u32, u32), +) -> ResizeAction { + const DEBOUNCE: Duration = Duration::from_millis(400); + const SPACING: Duration = Duration::from_secs(1); + let Some(since) = *pending else { + return ResizeAction::Wait; + }; + if now.duration_since(since) < DEBOUNCE { + return ResizeAction::Wait; + } + if sent_at.is_some_and(|at| now.duration_since(at) < SPACING) { + return ResizeAction::Wait; // keep the pending stamp — a later tick retries + } + *pending = None; + let target = ((pixel_size.0 & !1).max(320), (pixel_size.1 & !1).max(200)); + if current == target || requested == Some(target) { + return ResizeAction::Settled(None); + } + ResizeAction::Settled(Some(target)) +} + /// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor) /// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach /// the host while captured instead of the local shell. SDL implements the grab there @@ -1115,6 +1290,105 @@ fn stats_text( mod tests { use super::*; + #[test] + fn resize_decision_follows_the_d2_discipline() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + + // No resize pending → nothing to do. + let mut pending = None; + assert_eq!( + resize_decision(t0, &mut pending, None, None, (1280, 720), (1000, 600)), + ResizeAction::Wait + ); + + // Still debouncing (a drag in progress) → wait, pending kept. + let mut pending = Some(t0); + assert_eq!( + resize_decision( + t0 + ms(399), + &mut pending, + None, + None, + (1280, 720), + (1000, 600) + ), + ResizeAction::Wait + ); + assert!(pending.is_some(), "pending survives the wait"); + + // Debounce settled → request the even-floored, clamped pixel size. + assert_eq!( + resize_decision( + t0 + ms(400), + &mut pending, + None, + None, + (1280, 720), + (1001, 601) + ), + ResizeAction::Settled(Some((1000, 600))), + "odd pixels floor to even" + ); + assert!(pending.is_none(), "pending consumed"); + + // Spacing: a request went out < 1 s ago → wait WITHOUT dropping the pending + // stamp, so a later tick retries. + let mut pending = Some(t0); + assert_eq!( + resize_decision( + t0 + ms(900), + &mut pending, + Some(t0), + Some((1000, 600)), + (1280, 720), + (800, 500) + ), + ResizeAction::Wait + ); + assert!(pending.is_some()); + assert_eq!( + resize_decision( + t0 + ms(1000), + &mut pending, + Some(t0), + Some((1000, 600)), + (1280, 720), + (800, 500) + ), + ResizeAction::Settled(Some((800, 500))) + ); + + // Equal to the streamed mode → settle (persist) but no request. + let mut pending = Some(t0); + assert_eq!( + resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (1280, 720)), + ResizeAction::Settled(None) + ); + + // A size already requested once (rejected, or rolled back host-side) is never + // re-asked — no request → rollback → request loop. + let mut pending = Some(t0); + assert_eq!( + resize_decision( + t0 + ms(400), + &mut pending, + None, + Some((1000, 600)), + (1280, 720), + (1000, 600) + ), + ResizeAction::Settled(None) + ); + + // Tiny windows clamp to the host's floor. + let mut pending = Some(t0); + assert_eq!( + resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (100, 80)), + ResizeAction::Settled(Some((320, 200))) + ); + } + fn sample() -> (Stats, PresentedWindow) { ( Stats { diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 48972d45..00ff6b2d 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -39,7 +39,7 @@ use punktfunk_core::quic::{ use punktfunk_core::transport::UdpTransport; use punktfunk_core::Session; use rand::RngCore; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1085,6 +1085,24 @@ async fn serve_session( .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; let (mut ctrl_send, mut ctrl_recv) = (send, recv); + // Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for: + // * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's + // game-mode session, attach doesn't own the display — a resize must never relaunch the title + // (design/midstream-resolution-resize.md H1/D3). The client keeps scaling client-side. + // * an `identity: per-client-mode` policy: the mode is part of the display-identity slot key, + // so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the + // in-place reconfigure, on KWin a differently-named output — defeating the policy's + // per-resolution identity. Honest downgrade: reject, client scales (H5). + // The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip + // is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip + // test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it. + // Captured once at session setup; the control task answers `accepted: false` when gated. + let live_reconfig_ok = { + let per_client_mode_identity = crate::vdisplay::policy::prefs() + .configured_effective() + .is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode); + compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode_identity + }; // Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's // `async move` captures a copy and it stays usable for the data-plane SessionContext below. let codec = crate::encode::Codec::from_wire(welcome.codec); @@ -1110,6 +1128,13 @@ async fn serve_session( let (probe_tx, probe_rx) = std::sync::mpsc::channel::(); let (probe_result_tx, mut probe_result_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept + // ack is written BEFORE the rebuild, so a failed rebuild (host stays at the old mode) or a + // backend that honored a different refresh must CORRECT the client's mode slot with a second + // `Reconfigured { accepted: true, mode: }` — the client handler treats any + // accepted ack as "the active mode is now X" and fixes itself; old clients just log it. + let (reconfig_result_tx, mut reconfig_result_rx) = + tokio::sync::mpsc::unbounded_channel::(); // Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes // it here; the data-plane send loop reads + applies it per frame. Disabled (pinned) when // PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report. @@ -1118,23 +1143,46 @@ async fn serve_session( let fec_target_ctl = fec_target.clone(); tokio::spawn(async move { let mut active = hello.mode; + // Host-side switch rate limit (a backstop against a hostile/broken client spamming + // Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already + // coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s). + const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); + let mut last_accepted_switch: Option = None; loop { tokio::select! { msg = io::read_msg(&mut ctrl_recv) => { let Ok(msg) = msg else { break }; // stream closed if let Ok(req) = Reconfigure::decode(&msg) { - let ok = req.mode.refresh_hz > 0 + let now = std::time::Instant::now(); + let valid = req.mode.refresh_hz > 0 && crate::encode::validate_dimensions( codec, req.mode.width, req.mode.height, ) .is_ok(); + let too_soon = last_accepted_switch + .is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL); + let ok = if !live_reconfig_ok { + // Backend can't live-reconfigure (gamescope / synthetic / + // per-client-mode identity — see the gate above): honest downgrade, + // the client keeps scaling client-side. + tracing::info!(mode = ?req.mode, + "mode switch rejected (backend cannot live-reconfigure)"); + false + } else if !valid { + tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)"); + false + } else if too_soon { + tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)"); + false + } else { + true + }; if ok { active = req.mode; + last_accepted_switch = Some(now); tracing::info!(mode = ?req.mode, "mode switch accepted"); - } else { - tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)"); } let ack = Reconfigured { accepted: ok, mode: active }; if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { @@ -1230,6 +1278,17 @@ async fn serve_session( break; } } + correction = reconfig_result_rx.recv() => { + // H2 rollback/correction ack: the data plane reports the mode ACTUALLY live + // after a rebuild that failed (stayed at the old mode) or that the backend + // honored at a different refresh. Track it so a later rejection's + // `mode: active` echo is truthful too. + let Some(ack) = correction else { break }; // data plane gone + active = ack.mode; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + } } } }); @@ -1539,6 +1598,7 @@ async fn serve_session( codec, probe_rx, probe_result_tx, + reconfig_result_tx, fec_target: fec_target_dp, conn: conn_stream, timing_conn, @@ -2927,9 +2987,10 @@ pub(crate) fn boost_thread_priority(critical: bool) { /// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration. struct SendStats { rec: Arc, - width: u32, - height: u32, - fps: u32, + /// Live session mode, packed w:16|h:16|hz:16 ([`pack_mode`]) — the capture thread updates it + /// on an accepted mid-stream mode switch (mirroring `bitrate_kbps` below), so a stats capture + /// registers the mode the stream is ACTUALLY running at, not the session-start latch (H3). + mode: Arc, codec: &'static str, client: String, /// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive @@ -2937,6 +2998,30 @@ struct SendStats { bitrate_kbps: Arc, } +/// Pack a `(width, height, refresh_hz)` mode into one atomic word (w:16|h:16|hz:16) for the live +/// stats-mode slot — one store/load instead of three racy ones. Every dimension fits: the codec +/// max dimension caps w/h well under 2^16 (`validate_dimensions`), refresh likewise. +fn pack_mode(width: u32, height: u32, refresh_hz: u32) -> u64 { + ((width as u64 & 0xffff) << 32) | ((height as u64 & 0xffff) << 16) | (refresh_hz as u64 & 0xffff) +} + +/// Unpack a [`pack_mode`] word back into `(width, height, refresh_hz)`. +fn unpack_mode(packed: u64) -> (u32, u32, u32) { + ( + ((packed >> 32) & 0xffff) as u32, + ((packed >> 16) & 0xffff) as u32, + (packed & 0xffff) as u32, + ) +} + +/// Recover the integer refresh rate a pipeline was actually built at from its frame interval +/// (`interval` is constructed as `1/effective_hz` in `build_pipeline`, so the round-trip is exact). +/// This is the backend-honored rate — it differs from the requested mode when e.g. KWin caps a +/// virtual output at 60 Hz. +fn interval_hz(interval: std::time::Duration) -> u32 { + (1.0 / interval.as_secs_f64()).round() as u32 +} + #[allow(clippy::too_many_arguments)] fn send_loop( mut session: Session, @@ -3075,14 +3160,12 @@ fn send_loop( // window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot. if stats.rec.is_armed() { let session_id = *sid.get_or_insert_with(|| { - stats.rec.register_session( - "native", - stats.width, - stats.height, - stats.fps, - stats.codec, - &stats.client, - ) + // Read the LIVE mode at registration time (H3): a capture armed after a + // mid-stream mode switch gets the mode the stream actually runs at. + let (w, h, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed)); + stats + .rec + .register_session("native", w, h, hz, stats.codec, &stats.client) }); let sample = crate::stats_recorder::StatsSample { t_ms: 0, // stamped by push_sample from the capture's monotonic start @@ -3293,6 +3376,10 @@ struct SessionContext { probe_rx: std::sync::mpsc::Receiver, /// Speed-test results back to the control task. probe_result_tx: tokio::sync::mpsc::UnboundedSender, + /// Mode-switch outcomes back to the control task (H2): a corrective + /// `Reconfigured { accepted: true, mode: }` when a rebuild failed (stayed at + /// the old mode) or the backend honored a different refresh than requested. + reconfig_result_tx: tokio::sync::mpsc::UnboundedSender, /// Adaptive-FEC target the control task updates from the client's loss reports. fec_target: Arc, /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). @@ -3351,6 +3438,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { codec: _, probe_rx, probe_result_tx, + reconfig_result_tx, fec_target, conn, timing_conn, @@ -3409,7 +3497,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { ); crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) }); - let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) = + let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) = build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). #[cfg(target_os = "windows")] @@ -3457,13 +3545,20 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive // bitrate change (bitrate_rx below) updates it so the console shows the actual target. let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); + // Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so + // a stats capture armed after a resize registers the real mode. Seeded with the refresh the + // initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual + // output at 60 Hz. + let live_mode = Arc::new(AtomicU64::new(pack_mode( + mode.width, + mode.height, + interval_hz(interval), + ))); // The send thread emits the web-console stats sample (it owns `session.stats()`); clone the // recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate. let send_stats = SendStats { rec: stats.clone(), - width: mode.width, - height: mode.height, - fps: mode.refresh_hz, + mode: live_mode.clone(), codec: plan.codec.label(), client: client_label, bitrate_kbps: live_bitrate.clone(), @@ -3608,7 +3703,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { Ok((new_vd, pipe)) })(); match rebuilt { - Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => { + Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen))) => { // Replace the pipeline first (drops the old capturer → old PipeWire stream + // virtual output), then the factory (drops e.g. the old KWin connection). capturer = new_cap; @@ -3616,6 +3711,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { frame = new_frame; interval = new_interval; cur_node_id = new_node_id; + cur_display_gen = new_gen; vd = new_vd; compositor = sw.compositor; next = std::time::Instant::now(); @@ -3655,9 +3751,38 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // healthy session — keep streaming the current mode and log instead. match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) { Ok(next_pipe) => { - (capturer, enc, frame, interval, cur_node_id) = next_pipe; + let old_display_gen = cur_display_gen; + // The destructuring assignment drops the OLD capturer (→ its display lease) as + // each binding is replaced — the new pipeline is already up (create-before-drop). + (capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe; cur_mode = new_mode; next = std::time::Instant::now(); + // H4: the old display's lease drop above is indistinguishable from a disconnect + // to the keep-alive machinery — under linger/forever policies every resize would + // ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a + // no-op when it was already torn down under `immediate`, or off Linux). + if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { + crate::vdisplay::registry::retire(g); + } + // H2/H3: the backend may have honored a different refresh than requested (KWin + // caps virtual outputs it can't drive faster). Publish the ACTUAL mode to the + // live stats slot, and correct the client's mode slot when it differs from the + // accept ack it already got. + let actual = punktfunk_core::Mode { + width: new_mode.width, + height: new_mode.height, + refresh_hz: interval_hz(interval), + }; + live_mode.store( + pack_mode(actual.width, actual.height, actual.refresh_hz), + Ordering::Relaxed, + ); + if actual != new_mode { + let _ = reconfig_result_tx.send(Reconfigured { + accepted: true, + mode: actual, + }); + } // The owed AUs died with the old encoder — drop their in-flight records // and restart the encode-stall clock for the fresh one. inflight.clear(); @@ -3668,6 +3793,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { Err(e) => { tracing::error!(error = %format!("{e:#}"), ?new_mode, "mode-switch rebuild failed — staying on the current mode"); + // H2 rollback: the control task acked the switch BEFORE this rebuild, so the + // client's mode slot already flipped to `new_mode`. A second accepted ack + // carrying the still-live mode corrects it (any accepted ack means "the active + // mode is now X" client-side; old clients just log it). Refresh from the OLD + // pipeline's interval — the still-running one — in case its build was capped. + let _ = reconfig_result_tx.send(Reconfigured { + accepted: true, + mode: punktfunk_core::Mode { + width: cur_mode.width, + height: cur_mode.height, + refresh_hz: interval_hz(interval), + }, + }); } } } @@ -3682,7 +3820,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { } if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) { // `interval` was built as 1/effective_hz, so the round-trip recovers the integer rate. - let hz = (1.0 / interval.as_secs_f64()).round() as u32; + let hz = interval_hz(interval); match crate::encode::open_video( plan.codec, frame.format, @@ -3840,7 +3978,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // appears — no reconnect. const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; - let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop { + let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop { // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // retargeting (then we stick to the pinned backend and just rebuild it). if crate::config::config().compositor.is_none() { @@ -3900,6 +4038,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { frame = new_frame; interval = new_interval; cur_node_id = new_node_id; + cur_display_gen = new_display_gen; enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild next = std::time::Instant::now(); @@ -4174,6 +4313,11 @@ type Pipeline = ( // session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node // (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway. u32, + // The display's registry pool generation (Linux keep-alive pool only; `None` on Windows — the + // manager leases in place — and for non-poolable outputs). A mode-switch rebuild uses it to + // `registry::retire` the superseded old display, so linger/forever keep-alive policies don't + // accumulate kept monitors at stale modes (design/midstream-resolution-resize.md H4). + Option, ); /// Build the pipeline, retrying *transient* failures with bounded exponential backoff. @@ -4326,6 +4470,12 @@ fn build_pipeline( // gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.) #[cfg(target_os = "linux")] let reused_gen = vout.reused_gen; + // The display's pool generation (fresh AND reused), threaded out so a mode-switch rebuild can + // `registry::retire` the display this pipeline supersedes (H4). `None` off Linux / non-poolable. + #[cfg(target_os = "linux")] + let pool_gen = vout.pool_gen; + #[cfg(not(target_os = "linux"))] + let pool_gen = None; // The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to // this session's own node). Read before `capture_virtual_output` consumes `vout`. let node_id = vout.node_id; @@ -4390,13 +4540,26 @@ fn build_pipeline( ); } let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); - Ok((capturer, enc, frame, interval, node_id)) + Ok((capturer, enc, frame, interval, node_id, pool_gen)) } #[cfg(test)] mod tests { use super::*; + #[test] + fn live_mode_pack_roundtrips_and_interval_recovers_hz() { + // The live-stats mode slot (H3): pack → unpack is exact for real modes. + for (w, h, hz) in [(1280u32, 720u32, 60u32), (3840, 2160, 144), (320, 200, 24)] { + assert_eq!(unpack_mode(pack_mode(w, h, hz)), (w, h, hz)); + } + // `interval` is built as 1/effective_hz — the round-trip recovers the integer rate. + for hz in [24u32, 30, 60, 75, 90, 120, 144, 165, 240] { + let interval = std::time::Duration::from_secs_f64(1.0 / hz as f64); + assert_eq!(interval_hz(interval), hz); + } + } + #[test] fn pad_snapshot_replaces_state_and_seq_gates() { use punktfunk_core::input::{gamepad, GamepadSnapshot}; diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index b4963f40..a8a2aba1 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -79,6 +79,13 @@ pub struct VirtualOutput { /// keep-alive pool is Linux). #[cfg(target_os = "linux")] pub reused_gen: Option, + /// The registry pool generation of this display (fresh AND reused — unlike `reused_gen`), so a + /// mid-stream mode-switch rebuild can [`registry::retire`](crate::vdisplay::registry::retire) the + /// display it supersedes instead of leaving it to accumulate under a linger/forever keep-alive + /// policy (`design/midstream-resolution-resize.md` H4). `None` for non-poolable outputs. + /// Linux-only (the keep-alive pool is Linux). + #[cfg(target_os = "linux")] + pub pool_gen: Option, } impl VirtualOutput { @@ -100,6 +107,8 @@ impl VirtualOutput { ownership: DisplayOwnership::Owned, #[cfg(target_os = "linux")] reused_gen: None, + #[cfg(target_os = "linux")] + pool_gen: None, } } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 583972bd..c428e043 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -241,6 +241,7 @@ impl VirtualDisplay for GamescopeDisplay { keepalive: Box::new(()), ownership: DisplayOwnership::External, reused_gen: None, + pool_gen: None, }); } check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions @@ -366,6 +367,7 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput { keepalive: Box::new(()), ownership: DisplayOwnership::SessionManaged, reused_gen: None, + pool_gen: None, } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs index ed468522..f6d524e8 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs @@ -198,6 +198,7 @@ impl VirtualDisplay for HyprlandDisplay { // `remote_fd.is_some()` — same as wlroots. ownership: DisplayOwnership::Owned, reused_gen: None, + pool_gen: None, }) } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs index 096cff34..a67e5709 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs @@ -135,6 +135,7 @@ impl VirtualDisplay for WlrootsDisplay { // `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach). ownership: DisplayOwnership::Owned, reused_gen: None, + pool_gen: None, }) } } diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 5ce780c9..d2232aa7 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -176,6 +176,21 @@ pub fn mark_failed(gen: u64) { let _ = gen; } +/// Force-release a **superseded** kept display by its generation stamp +/// (`design/midstream-resolution-resize.md` H4): after a mid-stream mode-switch rebuild, the old +/// display's lease drop is indistinguishable from a disconnect, so under a `linger`/`forever` +/// keep-alive policy every resize would accumulate kept monitors at stale modes. The mode-switch +/// arm calls this once the new pipeline is up and the old capturer is dropped. Only a KEPT +/// (lingering/pinned) entry is released — an Active one is refused, like `/display/release` — and +/// a gen that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows +/// reconfigures the same monitor in place — nothing is superseded). +pub fn retire(gen: u64) { + #[cfg(target_os = "linux")] + linux::retire(gen); + #[cfg(not(target_os = "linux"))] + let _ = gen; +} + /// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch /// tore it down), so `/display/state` must stop listing it and its keepalive must be reaped /// (`design/gamemode-and-dedicated-sessions.md` A4). Called from the session-switch watcher / a @@ -386,6 +401,9 @@ mod linux { // A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can // `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node. out.reused_gen = reused.then_some(gen); + // H4: every pooled display carries its gen, so a mode-switch rebuild can `retire` the entry + // this output's successor supersedes. + out.pool_gen = Some(gen); out } @@ -819,6 +837,20 @@ mod linux { } pub(super) fn force_release(slot: Option) -> usize { + release_kept(slot, "released (mgmt /display/release)") + } + + /// H4 — force-release a display superseded by a mid-stream mode switch. Same machinery as + /// [`force_release`] (kept entries only — an Active entry is refused, and a gen already torn + /// down under `immediate` is a no-op), distinct log line. + pub(super) fn retire(gen: u64) { + release_kept(Some(gen), "retired (superseded by a mode switch)"); + } + + /// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by gen — running / + /// handing off group topology restores, with keepalive drops outside the lock. The shared core + /// of [`force_release`] (mgmt) and [`retire`] (mode-switch supersede). + fn release_kept(slot: Option, why: &'static str) -> usize { let Some(r) = REG.get() else { return 0 }; let (released, restores) = { let mut es = r.entries.lock().unwrap(); @@ -847,10 +879,7 @@ mod linux { restore(); } for e in released { - tracing::info!( - backend = e.backend, - "virtual display released (mgmt /display/release)" - ); + tracing::info!(backend = e.backend, "virtual display {why}"); drop(e); } n