feat(resize): mid-stream resolution resize — host hardening (H1–H5) + session-binary Match window (C1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:53:47 +02:00
parent ca61477c3c
commit d0d9bd5bfb
13 changed files with 644 additions and 70 deletions
@@ -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,
}
}
@@ -198,6 +198,7 @@ impl VirtualDisplay for HyprlandDisplay {
// `remote_fd.is_some()` — same as wlroots.
ownership: DisplayOwnership::Owned,
reused_gen: None,
pool_gen: None,
})
}
}
@@ -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,
})
}
}
+33 -4
View File
@@ -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<u64>) -> 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<u64>, 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