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:
@@ -264,21 +264,23 @@ pub fn show(
|
|||||||
let page = adw::PreferencesPage::new();
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||||
let res_names: Vec<String> = RESOLUTIONS
|
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||||
.iter()
|
// `match_window` flag), then the explicit sizes.
|
||||||
.map(|&(w, h)| {
|
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
if w == 0 {
|
.chain(std::iter::once("Match window".to_string()))
|
||||||
"Native display".to_string()
|
.chain(
|
||||||
} else {
|
RESOLUTIONS
|
||||||
format!("{w} × {h}")
|
.iter()
|
||||||
}
|
.skip(1)
|
||||||
})
|
.map(|&(w, h)| format!("{w} × {h}")),
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
let res_row = ChoiceRow::new(
|
let res_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Resolution",
|
"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::<Vec<_>>(),
|
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
let hz_names: Vec<String> = REFRESH
|
let hz_names: Vec<String> = REFRESH
|
||||||
@@ -470,10 +472,15 @@ pub fn show(
|
|||||||
// Seed from the current settings.
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
let s = settings.borrow();
|
let s = settings.borrow();
|
||||||
let res_i = RESOLUTIONS
|
let res_i = if s.match_window {
|
||||||
.iter()
|
1
|
||||||
.position(|&(w, h)| w == s.width && h == s.height)
|
} else {
|
||||||
.unwrap_or(0);
|
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);
|
res_row.set_selected(res_i as u32);
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
hz_row.set_selected(hz_i as u32);
|
||||||
@@ -508,8 +515,14 @@ pub fn show(
|
|||||||
dialog.add(&page);
|
dialog.add(&page);
|
||||||
dialog.connect_closed(move |_| {
|
dialog.connect_closed(move |_| {
|
||||||
let mut s = settings.borrow_mut();
|
let mut s = settings.borrow_mut();
|
||||||
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||||
(s.width, s.height) = (w, h);
|
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.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
})),
|
})),
|
||||||
overlay: Some(Box::new(overlay)),
|
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 =
|
let result =
|
||||||
|
|||||||
@@ -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<Box<dyn FnMut(u32, u32)>> {
|
||||||
|
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<dyn FnMut(u32, u32)>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
/// 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
|
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||||
/// failure through the same contract when spawned with `--json-status`.
|
/// 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())),
|
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||||
#[cfg(not(feature = "ui"))]
|
#[cfg(not(feature = "ui"))]
|
||||||
overlay: None,
|
overlay: None,
|
||||||
|
window_size: window_size(&settings),
|
||||||
|
match_window: match_window(&settings),
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome =
|
let outcome =
|
||||||
|
|||||||
@@ -136,29 +136,37 @@ pub(crate) fn settings_page(
|
|||||||
let s = ctx.settings.lock().unwrap().clone();
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
|
|
||||||
// --- Display ---------------------------------------------------------------------------
|
// --- 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 (res_names, res_i) = {
|
||||||
let names: Vec<String> = RESOLUTIONS
|
let names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
.iter()
|
.chain(std::iter::once("Match window".to_string()))
|
||||||
.map(|&(w, h)| {
|
.chain(
|
||||||
if w == 0 {
|
RESOLUTIONS
|
||||||
"Native display".into()
|
.iter()
|
||||||
} else {
|
.skip(1)
|
||||||
format!("{w} \u{00D7} {h}")
|
.map(|&(w, h)| format!("{w} \u{00D7} {h}")),
|
||||||
}
|
)
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let i = RESOLUTIONS
|
let i = if s.match_window {
|
||||||
.iter()
|
1
|
||||||
.position(|&(w, h)| w == s.width && h == s.height)
|
} else {
|
||||||
.unwrap_or(0);
|
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)
|
(names, i)
|
||||||
};
|
};
|
||||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, 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(
|
.tooltip(
|
||||||
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
"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 (hz_names, hz_i) = {
|
||||||
let names: Vec<String> = REFRESH
|
let names: Vec<String> = REFRESH
|
||||||
|
|||||||
@@ -406,6 +406,19 @@ pub struct Settings {
|
|||||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||||
pub library_enabled: bool,
|
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 {
|
fn default_codec() -> String {
|
||||||
@@ -466,6 +479,9 @@ impl Default for Settings {
|
|||||||
stats_verbosity: None,
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
|
match_window: false,
|
||||||
|
last_window_w: 0,
|
||||||
|
last_window_h: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
RowId::Resolution => (
|
RowId::Resolution => (
|
||||||
Some("Stream"),
|
Some("Stream"),
|
||||||
"Resolution",
|
"Resolution",
|
||||||
if s.width == 0 {
|
if s.match_window {
|
||||||
|
"Match window".into()
|
||||||
|
} else if s.width == 0 {
|
||||||
"Native".into()
|
"Native".into()
|
||||||
} else {
|
} else {
|
||||||
format!("{} × {}", s.width, s.height)
|
format!("{} × {}", s.width, s.height)
|
||||||
@@ -259,7 +261,8 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
fn detail(id: RowId) -> &'static str {
|
fn detail(id: RowId) -> &'static str {
|
||||||
match id {
|
match id {
|
||||||
RowId::Resolution => {
|
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::Refresh => "Native follows the display this window is on.",
|
||||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
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;
|
let s = &mut *ctx.settings;
|
||||||
match id {
|
match id {
|
||||||
RowId::Resolution => {
|
RowId::Resolution => {
|
||||||
let cur = RESOLUTIONS
|
// The D1 tri-state as one picker: Native, Match window, then the explicit
|
||||||
.iter()
|
// sizes (RESOLUTIONS keeps its (0,0) = Native head; Match window is the
|
||||||
.position(|(w, h)| (*w, *h) == (s.width, s.height));
|
// virtual index 1, stored as the `match_window` flag with w/h cleared).
|
||||||
step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| {
|
let cur = if s.match_window {
|
||||||
(s.width, s.height) = RESOLUTIONS[i];
|
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 => {
|
RowId::Refresh => {
|
||||||
@@ -401,14 +413,26 @@ mod tests {
|
|||||||
device_name: "t",
|
device_name: "t",
|
||||||
t: 0.0,
|
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!(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));
|
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.
|
// Cycle from the last option wraps to the first.
|
||||||
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
||||||
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
||||||
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
||||||
|
assert!(!ctx.settings.match_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ pub struct SessionOpts {
|
|||||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||||
/// warning rather than killing the session. Browse mode requires one.
|
/// warning rather than killing the session. Browse mode requires one.
|
||||||
pub overlay: Option<Box<dyn Overlay>>,
|
pub overlay: Option<Box<dyn Overlay>>,
|
||||||
|
/// 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<Box<dyn FnMut(u32, u32)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Outcome {
|
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
|
/// 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.
|
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||||
last_stats: Option<Stats>,
|
last_stats: Option<Stats>,
|
||||||
|
/// 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<Instant>,
|
||||||
|
/// 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<Instant>,
|
||||||
|
/// 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<Mode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -217,6 +245,10 @@ impl StreamState {
|
|||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
last_stats: None,
|
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<Option<Outcome>
|
|||||||
.register_custom_event::<FrameWake>()
|
.register_custom_event::<FrameWake>()
|
||||||
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
||||||
let mut window = {
|
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 {
|
match opts.window_pos {
|
||||||
Some((x, y)) => b.position(x, y),
|
Some((x, y)) => b.position(x, y),
|
||||||
None => b.position_centered(),
|
None => b.position_centered(),
|
||||||
@@ -340,12 +375,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
let mut stream: Option<StreamState> = match &mut mode {
|
let mut stream: Option<StreamState> = match &mut mode {
|
||||||
ModeCtl::Single(build) => {
|
ModeCtl::Single(build) => {
|
||||||
let force_software = Arc::new(AtomicBool::new(false));
|
let force_software = Arc::new(AtomicBool::new(false));
|
||||||
let params = build(
|
let mut params = build(
|
||||||
&gamepad,
|
&gamepad,
|
||||||
native,
|
native,
|
||||||
force_software.clone(),
|
force_software.clone(),
|
||||||
presenter.vulkan_decode(),
|
presenter.vulkan_decode(),
|
||||||
);
|
);
|
||||||
|
if opts.match_window.is_some() {
|
||||||
|
apply_match_window(&mut params, &window);
|
||||||
|
}
|
||||||
Some(StreamState::new(
|
Some(StreamState::new(
|
||||||
params,
|
params,
|
||||||
force_software,
|
force_software,
|
||||||
@@ -423,6 +461,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
||||||
presenter.recreate_swapchain(&window)?;
|
presenter.recreate_swapchain(&window)?;
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
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 => {
|
WindowEvent::Exposed => {
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||||
@@ -616,7 +662,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
presenter.vulkan_decode(),
|
presenter.vulkan_decode(),
|
||||||
) {
|
) {
|
||||||
ActionOutcome::Handled => {}
|
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(
|
stream = Some(StreamState::new(
|
||||||
*params,
|
*params,
|
||||||
force_software,
|
force_software,
|
||||||
@@ -753,6 +802,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 --------------
|
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||||
if let Some(o) = overlay.as_mut() {
|
if let Some(o) = overlay.as_mut() {
|
||||||
let (pw, ph) = window.size_in_pixels();
|
let (pw, ph) = window.size_in_pixels();
|
||||||
@@ -1013,6 +1069,125 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
Ok(outcome)
|
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<Instant>,
|
||||||
|
sent_at: Option<Instant>,
|
||||||
|
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)
|
/// 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
|
/// 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
|
/// the host while captured instead of the local shell. SDL implements the grab there
|
||||||
@@ -1115,6 +1290,105 @@ fn stats_text(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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) {
|
fn sample() -> (Stats, PresentedWindow) {
|
||||||
(
|
(
|
||||||
Stats {
|
Stats {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ use punktfunk_core::quic::{
|
|||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::Session;
|
use punktfunk_core::Session;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -1085,6 +1085,24 @@ async fn serve_session(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||||
let (mut ctrl_send, mut ctrl_recv) = (send, recv);
|
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
|
// 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.
|
// `async move` captures a copy and it stays usable for the data-plane SessionContext below.
|
||||||
let codec = crate::encode::Codec::from_wire(welcome.codec);
|
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::<ProbeRequest>();
|
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||||
let (probe_result_tx, mut probe_result_rx) =
|
let (probe_result_tx, mut probe_result_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||||
|
// 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: <actually live> }` — 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::<Reconfigured>();
|
||||||
// Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes
|
// 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
|
// 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.
|
// 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();
|
let fec_target_ctl = fec_target.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut active = hello.mode;
|
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<std::time::Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = io::read_msg(&mut ctrl_recv) => {
|
msg = io::read_msg(&mut ctrl_recv) => {
|
||||||
let Ok(msg) = msg else { break }; // stream closed
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
if let Ok(req) = Reconfigure::decode(&msg) {
|
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(
|
&& crate::encode::validate_dimensions(
|
||||||
codec,
|
codec,
|
||||||
req.mode.width,
|
req.mode.width,
|
||||||
req.mode.height,
|
req.mode.height,
|
||||||
)
|
)
|
||||||
.is_ok();
|
.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 {
|
if ok {
|
||||||
active = req.mode;
|
active = req.mode;
|
||||||
|
last_accepted_switch = Some(now);
|
||||||
tracing::info!(mode = ?req.mode, "mode switch accepted");
|
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 };
|
let ack = Reconfigured { accepted: ok, mode: active };
|
||||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
@@ -1230,6 +1278,17 @@ async fn serve_session(
|
|||||||
break;
|
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,
|
codec,
|
||||||
probe_rx,
|
probe_rx,
|
||||||
probe_result_tx,
|
probe_result_tx,
|
||||||
|
reconfig_result_tx,
|
||||||
fec_target: fec_target_dp,
|
fec_target: fec_target_dp,
|
||||||
conn: conn_stream,
|
conn: conn_stream,
|
||||||
timing_conn,
|
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.
|
/// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration.
|
||||||
struct SendStats {
|
struct SendStats {
|
||||||
rec: Arc<StatsRecorder>,
|
rec: Arc<StatsRecorder>,
|
||||||
width: u32,
|
/// Live session mode, packed w:16|h:16|hz:16 ([`pack_mode`]) — the capture thread updates it
|
||||||
height: u32,
|
/// on an accepted mid-stream mode switch (mirroring `bitrate_kbps` below), so a stats capture
|
||||||
fps: u32,
|
/// registers the mode the stream is ACTUALLY running at, not the session-start latch (H3).
|
||||||
|
mode: Arc<AtomicU64>,
|
||||||
codec: &'static str,
|
codec: &'static str,
|
||||||
client: String,
|
client: String,
|
||||||
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
|
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
|
||||||
@@ -2937,6 +2998,30 @@ struct SendStats {
|
|||||||
bitrate_kbps: Arc<AtomicU32>,
|
bitrate_kbps: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn send_loop(
|
fn send_loop(
|
||||||
mut session: Session,
|
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.
|
// window's pacing/goodput/loss. Loss fields are deltas vs the previous window's snapshot.
|
||||||
if stats.rec.is_armed() {
|
if stats.rec.is_armed() {
|
||||||
let session_id = *sid.get_or_insert_with(|| {
|
let session_id = *sid.get_or_insert_with(|| {
|
||||||
stats.rec.register_session(
|
// Read the LIVE mode at registration time (H3): a capture armed after a
|
||||||
"native",
|
// mid-stream mode switch gets the mode the stream actually runs at.
|
||||||
stats.width,
|
let (w, h, hz) = unpack_mode(stats.mode.load(Ordering::Relaxed));
|
||||||
stats.height,
|
stats
|
||||||
stats.fps,
|
.rec
|
||||||
stats.codec,
|
.register_session("native", w, h, hz, stats.codec, &stats.client)
|
||||||
&stats.client,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
let sample = crate::stats_recorder::StatsSample {
|
let sample = crate::stats_recorder::StatsSample {
|
||||||
t_ms: 0, // stamped by push_sample from the capture's monotonic start
|
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<ProbeRequest>,
|
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||||
/// Speed-test results back to the control task.
|
/// Speed-test results back to the control task.
|
||||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||||
|
/// Mode-switch outcomes back to the control task (H2): a corrective
|
||||||
|
/// `Reconfigured { accepted: true, mode: <actually live> }` 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<Reconfigured>,
|
||||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||||
fec_target: Arc<AtomicU8>,
|
fec_target: Arc<AtomicU8>,
|
||||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||||
@@ -3351,6 +3438,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
codec: _,
|
codec: _,
|
||||||
probe_rx,
|
probe_rx,
|
||||||
probe_result_tx,
|
probe_result_tx,
|
||||||
|
reconfig_result_tx,
|
||||||
fec_target,
|
fec_target,
|
||||||
conn,
|
conn,
|
||||||
timing_conn,
|
timing_conn,
|
||||||
@@ -3409,7 +3497,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
);
|
);
|
||||||
crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone())
|
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)?;
|
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).
|
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||||
#[cfg(target_os = "windows")]
|
#[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
|
// 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.
|
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
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
|
// 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.
|
// recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate.
|
||||||
let send_stats = SendStats {
|
let send_stats = SendStats {
|
||||||
rec: stats.clone(),
|
rec: stats.clone(),
|
||||||
width: mode.width,
|
mode: live_mode.clone(),
|
||||||
height: mode.height,
|
|
||||||
fps: mode.refresh_hz,
|
|
||||||
codec: plan.codec.label(),
|
codec: plan.codec.label(),
|
||||||
client: client_label,
|
client: client_label,
|
||||||
bitrate_kbps: live_bitrate.clone(),
|
bitrate_kbps: live_bitrate.clone(),
|
||||||
@@ -3608,7 +3703,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
Ok((new_vd, pipe))
|
Ok((new_vd, pipe))
|
||||||
})();
|
})();
|
||||||
match rebuilt {
|
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 +
|
// Replace the pipeline first (drops the old capturer → old PipeWire stream +
|
||||||
// virtual output), then the factory (drops e.g. the old KWin connection).
|
// virtual output), then the factory (drops e.g. the old KWin connection).
|
||||||
capturer = new_cap;
|
capturer = new_cap;
|
||||||
@@ -3616,6 +3711,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
frame = new_frame;
|
frame = new_frame;
|
||||||
interval = new_interval;
|
interval = new_interval;
|
||||||
cur_node_id = new_node_id;
|
cur_node_id = new_node_id;
|
||||||
|
cur_display_gen = new_gen;
|
||||||
vd = new_vd;
|
vd = new_vd;
|
||||||
compositor = sw.compositor;
|
compositor = sw.compositor;
|
||||||
next = std::time::Instant::now();
|
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.
|
// healthy session — keep streaming the current mode and log instead.
|
||||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
|
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
|
||||||
Ok(next_pipe) => {
|
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;
|
cur_mode = new_mode;
|
||||||
next = std::time::Instant::now();
|
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
|
// The owed AUs died with the old encoder — drop their in-flight records
|
||||||
// and restart the encode-stall clock for the fresh one.
|
// and restart the encode-stall clock for the fresh one.
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
@@ -3668,6 +3793,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
||||||
"mode-switch rebuild failed — staying on the current 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) {
|
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.
|
// `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(
|
match crate::encode::open_video(
|
||||||
plan.codec,
|
plan.codec,
|
||||||
frame.format,
|
frame.format,
|
||||||
@@ -3840,7 +3978,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// appears — no reconnect.
|
// appears — no reconnect.
|
||||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
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
|
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||||
if crate::config::config().compositor.is_none() {
|
if crate::config::config().compositor.is_none() {
|
||||||
@@ -3900,6 +4038,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
frame = new_frame;
|
frame = new_frame;
|
||||||
interval = new_interval;
|
interval = new_interval;
|
||||||
cur_node_id = new_node_id;
|
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
|
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
|
last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild
|
||||||
next = std::time::Instant::now();
|
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
|
// 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.
|
// (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway.
|
||||||
u32,
|
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<u64>,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
/// 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.)
|
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let reused_gen = vout.reused_gen;
|
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
|
// 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`.
|
// this session's own node). Read before `capture_virtual_output` consumes `vout`.
|
||||||
let node_id = vout.node_id;
|
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);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ pub struct VirtualOutput {
|
|||||||
/// keep-alive pool is Linux).
|
/// keep-alive pool is Linux).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub reused_gen: Option<u64>,
|
pub reused_gen: Option<u64>,
|
||||||
|
/// 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<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualOutput {
|
impl VirtualOutput {
|
||||||
@@ -100,6 +107,8 @@ impl VirtualOutput {
|
|||||||
ownership: DisplayOwnership::Owned,
|
ownership: DisplayOwnership::Owned,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
reused_gen: None,
|
reused_gen: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pool_gen: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
keepalive: Box::new(()),
|
keepalive: Box::new(()),
|
||||||
ownership: DisplayOwnership::External,
|
ownership: DisplayOwnership::External,
|
||||||
reused_gen: None,
|
reused_gen: None,
|
||||||
|
pool_gen: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
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(()),
|
keepalive: Box::new(()),
|
||||||
ownership: DisplayOwnership::SessionManaged,
|
ownership: DisplayOwnership::SessionManaged,
|
||||||
reused_gen: None,
|
reused_gen: None,
|
||||||
|
pool_gen: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ impl VirtualDisplay for HyprlandDisplay {
|
|||||||
// `remote_fd.is_some()` — same as wlroots.
|
// `remote_fd.is_some()` — same as wlroots.
|
||||||
ownership: DisplayOwnership::Owned,
|
ownership: DisplayOwnership::Owned,
|
||||||
reused_gen: None,
|
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).
|
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
|
||||||
ownership: DisplayOwnership::Owned,
|
ownership: DisplayOwnership::Owned,
|
||||||
reused_gen: None,
|
reused_gen: None,
|
||||||
|
pool_gen: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ pub fn mark_failed(gen: u64) {
|
|||||||
let _ = gen;
|
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
|
/// 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
|
/// 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
|
/// (`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
|
// 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.
|
// `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);
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +837,20 @@ mod linux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn force_release(slot: Option<u64>) -> usize {
|
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 Some(r) = REG.get() else { return 0 };
|
||||||
let (released, restores) = {
|
let (released, restores) = {
|
||||||
let mut es = r.entries.lock().unwrap();
|
let mut es = r.entries.lock().unwrap();
|
||||||
@@ -847,10 +879,7 @@ mod linux {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
for e in released {
|
for e in released {
|
||||||
tracing::info!(
|
tracing::info!(backend = e.backend, "virtual display {why}");
|
||||||
backend = e.backend,
|
|
||||||
"virtual display released (mgmt /display/release)"
|
|
||||||
);
|
|
||||||
drop(e);
|
drop(e);
|
||||||
}
|
}
|
||||||
n
|
n
|
||||||
|
|||||||
Reference in New Issue
Block a user