Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef320662b | |||
| 04309d0ad9 |
+13
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.7.4"
|
||||
"version": "0.8.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2240,6 +2240,10 @@
|
||||
"type": "object",
|
||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||
"properties": {
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/components/schemas/Identity"
|
||||
},
|
||||
@@ -2399,6 +2403,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
"enum": [
|
||||
"auto",
|
||||
"dedicated"
|
||||
]
|
||||
},
|
||||
"GpuState": {
|
||||
"type": "object",
|
||||
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.os.Build
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -83,31 +82,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Host-gone watchdog. When the host suspends/sleeps (or crashes, or drops off the network) it
|
||||
// stops answering the QUIC keep-alive and the connection idle-times out (~8 s) — no more frames
|
||||
// arrive and the decoder would otherwise sit frozen on its last decoded frame until the user
|
||||
// manually backed out. Poll the native session-liveness flag (one atomic load, independent of the
|
||||
// stats HUD) and, the moment the session is dead, drop back to the menu so the user can
|
||||
// Wake-on-LAN the host instead of being stranded on a frozen picture. Mirrors the Apple client's
|
||||
// onSessionEnd → sessionEnded() → disconnect(). The 1 s cadence + the ~8 s idle timeout is a
|
||||
// deliberately generous window: the keep-alive holds a merely-quiet connection (a static desktop)
|
||||
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
|
||||
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
|
||||
LaunchedEffect(handle) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
onDisconnect()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
||||
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
|
||||
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
||||
|
||||
@@ -56,15 +56,6 @@ object NativeBridge {
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
external fun nativeHostFingerprint(handle: Long): String
|
||||
|
||||
/**
|
||||
* Has the underlying QUIC session ended? `true` once the connection closed — a host suspend /
|
||||
* crash / network drop idle-timed it out (~8 s), or the host closed it — from then on no frame
|
||||
* ever arrives and the video sits frozen on its last one. The stream watchdog polls this (~1 Hz)
|
||||
* to leave a dead stream and return to the menu, where the user can Wake-on-LAN the host, instead
|
||||
* of stranding them on a frozen frame. `false` on a `0` handle. Cheap (one atomic load); UI-safe.
|
||||
*/
|
||||
external fun nativeSessionEnded(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
|
||||
@@ -192,28 +192,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
|
||||
/// `true` once the connection closed (a host suspend / crash / network drop idle-timed it out, or the
|
||||
/// host closed it) — from then on no more frames arrive and the video sits frozen on its last one.
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(h.client.is_session_ended())
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
|
||||
@@ -432,7 +432,6 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -472,7 +471,6 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
|
||||
@@ -1090,7 +1090,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
break;
|
||||
}
|
||||
if started.elapsed() > std::time::Duration::from_secs(cap_secs)
|
||||
|| last_rx.elapsed() > std::time::Duration::from_secs(8)
|
||||
|| last_rx.elapsed() > std::time::Duration::from_secs(45)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -582,17 +582,6 @@ impl NativeClient {
|
||||
self.frames_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
|
||||
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
|
||||
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
|
||||
/// / drop did. Once `true`, every `next_*` plane returns [`PunktfunkError::Closed`] and no more
|
||||
/// frames will ever arrive. A client watchdog polls this so it can leave a frozen stream and
|
||||
/// return to the menu (where the user can wake the host) instead of sitting on the last decoded
|
||||
/// frame forever — the poll-friendly counterpart to reacting to a `Closed` in a plane loop.
|
||||
pub fn is_session_ended(&self) -> bool {
|
||||
self.shutdown.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Register the calling thread as latency-critical so a later
|
||||
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
|
||||
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
|
||||
|
||||
@@ -129,6 +129,14 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
|
||||
/// reconnect can resume. Shared so host + every client agree on the code.
|
||||
pub const QUIT_CLOSE_CODE: u32 = 0x51;
|
||||
|
||||
/// QUIC application error code the **host** closes the control connection with when a **dedicated game
|
||||
/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher
|
||||
/// client can distinguish "the game ended" from an error and return to its library cleanly rather than
|
||||
/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
|
||||
/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
|
||||
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
|
||||
@@ -246,15 +246,34 @@ fn open_gs_virtual_source(
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// A client is (re)connecting → cancel any pending TV-session restore (review #3).
|
||||
crate::vdisplay::cancel_pending_tv_restore();
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
// A4: fold any compositor-instance change (idle-time Game↔Desktop switch) into the epoch
|
||||
// before acquiring, so a GameStream reconnect never reuses a dead-instance node.
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
// Dedicated game session (B0): a GameStream app whose launch RESOLVES to a command (library
|
||||
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
|
||||
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
|
||||
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
|
||||
let has_launch = crate::library::resolve_session_launch(
|
||||
app.and_then(|a| a.library_id.as_deref()),
|
||||
app.and_then(|a| a.cmd.as_deref()),
|
||||
)
|
||||
.is_some();
|
||||
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
|
||||
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
|
||||
crate::vdisplay::Compositor::Gamescope
|
||||
} else {
|
||||
let c = crate::vdisplay::compositor_for_kind(active.kind)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
crate::vdisplay::apply_input_env(c);
|
||||
crate::vdisplay::apply_input_env(c, false);
|
||||
c
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||
|
||||
@@ -1043,6 +1043,7 @@ fn display_settings_state() -> DisplaySettingsState {
|
||||
"mode_conflict".into(),
|
||||
"identity".into(),
|
||||
"layout".into(),
|
||||
"game_session".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1248,7 +1249,10 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
||||
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
||||
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
||||
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
||||
let policy = store.get().effective().with_manual_layout(req.positions);
|
||||
let policy = store
|
||||
.get()
|
||||
.effective()
|
||||
.with_manual_layout(req.positions, store.game_session());
|
||||
if let Err(e) = store.set(policy) {
|
||||
return api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -285,6 +285,9 @@ pub(crate) async fn serve(
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
let _restore_worker = crate::vdisplay::start_restore_worker();
|
||||
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
|
||||
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
|
||||
crate::vdisplay::restore_takeover_on_startup();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
@@ -826,8 +829,23 @@ async fn serve_session(
|
||||
let compositor = match source {
|
||||
Punktfunk1Source::Virtual => {
|
||||
let pref = hello.compositor;
|
||||
// Dedicated game session (B0): a launching client under `game_session=dedicated`
|
||||
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
||||
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let has_resolvable_launch = hello
|
||||
.launch
|
||||
.as_deref()
|
||||
.and_then(crate::library::launch_command)
|
||||
.is_some();
|
||||
#[cfg(target_os = "windows")]
|
||||
let has_resolvable_launch = false;
|
||||
let dedicated =
|
||||
crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || resolve_compositor(pref))
|
||||
tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
|
||||
.await
|
||||
.context("resolve compositor task")??,
|
||||
)
|
||||
@@ -2223,17 +2241,24 @@ fn pick_compositor(
|
||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||
/// async reactor (`spawn_blocking`).
|
||||
fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Compositor> {
|
||||
fn resolve_compositor(
|
||||
pref: CompositorPref,
|
||||
dedicated_launch: bool,
|
||||
) -> Result<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
|
||||
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = pref;
|
||||
let _ = (pref, dedicated_launch);
|
||||
Ok(Compositor::Kwin)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
|
||||
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
|
||||
// own cancel — review #3). No-op when nothing is pending.
|
||||
crate::vdisplay::cancel_pending_tv_restore();
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = crate::config::config().compositor.is_some();
|
||||
@@ -2244,6 +2269,10 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
||||
// every backend (video capture + input) this connect opens against the active session —
|
||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||
// reuses a node id from the dead instance.
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
tracing::info!(
|
||||
active = ?active.kind,
|
||||
@@ -2252,6 +2281,18 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
||||
);
|
||||
crate::vdisplay::compositor_for_kind(active.kind)
|
||||
};
|
||||
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
|
||||
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
||||
if dedicated_launch && !overridden {
|
||||
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
||||
);
|
||||
return Ok(Compositor::Gamescope);
|
||||
}
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
|
||||
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
||||
@@ -2259,7 +2300,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
||||
if !overridden {
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||
crate::vdisplay::apply_input_env(chosen);
|
||||
crate::vdisplay::apply_input_env(chosen, false);
|
||||
}
|
||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||
match Compositor::from_pref(pref) {
|
||||
@@ -2886,6 +2927,11 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
||||
break;
|
||||
}
|
||||
let active = vdisplay::detect_active_session();
|
||||
// A4: bump the session epoch + invalidate the old backend the moment the compositor instance
|
||||
// changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't
|
||||
// signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives
|
||||
// the in-place rebuild.
|
||||
vdisplay::observe_session_instance(&active);
|
||||
let cur = active.kind;
|
||||
if cur == current {
|
||||
pending = None; // back to the current backend before debounce elapsed — no switch
|
||||
@@ -3049,7 +3095,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
||||
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
|
||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -3196,8 +3242,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
|
||||
kind: sw.kind,
|
||||
env: sw.env,
|
||||
compositor_pid: None,
|
||||
});
|
||||
crate::vdisplay::apply_input_env(sw.compositor);
|
||||
// A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
|
||||
// switched-to backend's normal sub-mode.
|
||||
crate::vdisplay::apply_input_env(sw.compositor, false);
|
||||
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
|
||||
// point at the old session, so input would silently not land until a reconnect.
|
||||
// Settle it (env push + KWin portal restart) before the injector reopens against it.
|
||||
@@ -3223,13 +3272,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
Ok((new_vd, pipe))
|
||||
})();
|
||||
match rebuilt {
|
||||
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval))) => {
|
||||
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => {
|
||||
// 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;
|
||||
enc = new_enc;
|
||||
frame = new_frame;
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
vd = new_vd;
|
||||
compositor = sw.compositor;
|
||||
next = std::time::Instant::now();
|
||||
@@ -3264,7 +3314,7 @@ 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) = next_pipe;
|
||||
(capturer, enc, frame, interval, cur_node_id) = next_pipe;
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
}
|
||||
@@ -3331,6 +3381,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// bounded retry is exhausted; the consecutive cap stops a flapping source from looping the
|
||||
// client through endless cold IDRs.
|
||||
Err(e) => {
|
||||
// B2: a DEDICATED gamescope game session whose gamescope node is gone = the game
|
||||
// exited (gamescope is a single-app compositor — it dies with its app). End the session
|
||||
// CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its
|
||||
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
|
||||
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
|
||||
// Bazzite/desktop capture loss still rebuilds in place.
|
||||
#[cfg(target_os = "linux")]
|
||||
if launch.is_some()
|
||||
&& crate::vdisplay::launch_is_nested(compositor)
|
||||
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
||||
{
|
||||
tracing::info!(
|
||||
"dedicated game session: the game exited — ending the session cleanly"
|
||||
);
|
||||
quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone
|
||||
conn.close(
|
||||
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
|
||||
b"game exited",
|
||||
);
|
||||
break;
|
||||
}
|
||||
capture_rebuilds += 1;
|
||||
if capture_rebuilds > MAX_CAPTURE_REBUILDS {
|
||||
return Err(e).context("capture lost — rebuild attempts exhausted");
|
||||
@@ -3348,14 +3419,18 @@ 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) = loop {
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id) = 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() {
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
// A4: fold any compositor-instance change into the epoch/invalidation before we
|
||||
// rebuild, so the rebuild's acquire won't reuse a dead-instance node.
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
crate::vdisplay::apply_input_env(c);
|
||||
// Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
|
||||
crate::vdisplay::apply_input_env(c, false);
|
||||
if c != compositor {
|
||||
if matches!(
|
||||
c,
|
||||
@@ -3402,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
enc = new_enc;
|
||||
frame = new_frame;
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||
next = std::time::Instant::now();
|
||||
tracing::info!(
|
||||
@@ -3592,6 +3668,10 @@ type Pipeline = (
|
||||
Box<dyn crate::encode::Encoder>,
|
||||
crate::capture::CapturedFrame,
|
||||
std::time::Duration,
|
||||
// The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS
|
||||
// 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,
|
||||
);
|
||||
|
||||
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
|
||||
@@ -3709,6 +3789,14 @@ fn build_pipeline(
|
||||
// `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger.
|
||||
let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone())
|
||||
.context("create virtual output")?;
|
||||
// A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down
|
||||
// so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the
|
||||
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
|
||||
#[cfg(target_os = "linux")]
|
||||
let reused_gen = vout.reused_gen;
|
||||
// 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;
|
||||
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
|
||||
// virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame
|
||||
// clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
|
||||
@@ -3733,7 +3821,17 @@ fn build_pipeline(
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
let frame = capturer.next_frame().context("first frame")?;
|
||||
let frame = match capturer.next_frame().context("first frame") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(g) = reused_gen {
|
||||
crate::vdisplay::registry::mark_failed(g);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
||||
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
|
||||
let enc = crate::encode::open_video(
|
||||
@@ -3760,7 +3858,7 @@ fn build_pipeline(
|
||||
);
|
||||
}
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||
Ok((capturer, enc, frame, interval))
|
||||
Ok((capturer, enc, frame, interval, node_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -21,6 +21,29 @@ pub use punktfunk_core::Mode;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
|
||||
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
|
||||
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
|
||||
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
|
||||
/// not pretend to keep.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DisplayOwnership {
|
||||
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
|
||||
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
|
||||
/// default — a backend that says nothing is registry-owned.
|
||||
#[default]
|
||||
Owned,
|
||||
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
|
||||
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
|
||||
/// row.
|
||||
External,
|
||||
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
|
||||
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
|
||||
/// module's until Part A3 hands the registry a real keepalive + restore duty).
|
||||
SessionManaged,
|
||||
}
|
||||
|
||||
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
|
||||
/// tears the output down (releases the compositor-side resource).
|
||||
///
|
||||
@@ -44,6 +67,41 @@ pub struct VirtualOutput {
|
||||
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
|
||||
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
|
||||
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
|
||||
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
|
||||
pub ownership: DisplayOwnership,
|
||||
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
|
||||
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
|
||||
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
|
||||
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
|
||||
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
|
||||
/// keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub reused_gen: Option<u64>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
|
||||
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
|
||||
pub fn owned(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id,
|
||||
#[cfg(target_os = "linux")]
|
||||
remote_fd: None,
|
||||
preferred_mode,
|
||||
#[cfg(target_os = "windows")]
|
||||
win_capture: None,
|
||||
keepalive,
|
||||
ownership: DisplayOwnership::Owned,
|
||||
#[cfg(target_os = "linux")]
|
||||
reused_gen: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pluggable virtual-output creation, per compositor.
|
||||
@@ -101,6 +159,110 @@ pub trait VirtualDisplay: Send {
|
||||
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
|
||||
/// backends never have a sibling.
|
||||
fn set_first_in_group(&mut self, _first: bool) {}
|
||||
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
|
||||
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
|
||||
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
|
||||
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
|
||||
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
|
||||
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
|
||||
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
|
||||
fn poolable_now(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The resolved launch command carried on this backend instance (set via
|
||||
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
|
||||
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
|
||||
/// running game A must never be handed to a session that asked to launch game B. Default `None`
|
||||
/// (backends that never nest a command); only gamescope reports its `cmd`.
|
||||
fn launch_command(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
|
||||
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
|
||||
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
|
||||
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
|
||||
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
|
||||
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
|
||||
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
|
||||
///
|
||||
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
|
||||
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
|
||||
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
|
||||
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
|
||||
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
|
||||
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
|
||||
pub fn session_epoch() -> u64 {
|
||||
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
|
||||
/// instance (kind change, or same-kind new PID). Returns the new value.
|
||||
pub fn bump_session_epoch() -> u64 {
|
||||
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
||||
}
|
||||
|
||||
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
|
||||
/// genuine instance change from a stable re-detect.
|
||||
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
|
||||
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
|
||||
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
|
||||
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
|
||||
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
|
||||
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
|
||||
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
|
||||
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
|
||||
pub fn observe_session_instance(active: &ActiveSession) {
|
||||
let cur = (active.kind, active.compositor_pid);
|
||||
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(prev) = *last {
|
||||
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
|
||||
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
|
||||
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
|
||||
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
|
||||
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
|
||||
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
|
||||
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
|
||||
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
|
||||
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
|
||||
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
|
||||
if is_desktop_kind(prev.0) {
|
||||
if let Some(old) = compositor_for_kind(prev.0) {
|
||||
registry::invalidate_backend(old.id());
|
||||
}
|
||||
}
|
||||
let epoch = bump_session_epoch();
|
||||
tracing::info!(
|
||||
from = ?prev.0,
|
||||
to = ?cur.0,
|
||||
epoch,
|
||||
"desktop compositor instance changed — session epoch bumped"
|
||||
);
|
||||
}
|
||||
}
|
||||
*last = Some(cur);
|
||||
}
|
||||
|
||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||
fn is_desktop_kind(kind: ActiveKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
||||
)
|
||||
}
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
@@ -241,6 +403,10 @@ pub struct SessionEnv {
|
||||
pub struct ActiveSession {
|
||||
pub kind: ActiveKind,
|
||||
pub env: SessionEnv,
|
||||
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
|
||||
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
|
||||
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
|
||||
pub compositor_pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl ActiveSession {
|
||||
@@ -253,6 +419,7 @@ impl ActiveSession {
|
||||
dbus_session_bus_address: default_bus(&default_runtime_dir()),
|
||||
..Default::default()
|
||||
},
|
||||
compositor_pid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +471,9 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||
let mut kind = ActiveKind::None;
|
||||
let mut best = 0u8;
|
||||
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
||||
// session epoch (A4), not just a kind change.
|
||||
let mut winning_pid: Option<u32> = None;
|
||||
if let Ok(entries) = std::fs::read_dir("/proc") {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
@@ -328,9 +498,22 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
"sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4),
|
||||
_ => continue,
|
||||
};
|
||||
let pid = name.parse::<u32>().ok();
|
||||
if prio > best {
|
||||
best = prio;
|
||||
kind = k;
|
||||
winning_pid = pid;
|
||||
} else if prio == best {
|
||||
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
|
||||
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
|
||||
// `/proc` enumeration order — which `observe_session_instance` would misread as a
|
||||
// compositor restart and tear a live display down (re-review low-severity note).
|
||||
if let (Some(p), Some(w)) = (pid, winning_pid) {
|
||||
if p < w {
|
||||
kind = k;
|
||||
winning_pid = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +541,7 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
dbus_session_bus_address: dbus,
|
||||
xdg_current_desktop,
|
||||
},
|
||||
compositor_pid: winning_pid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +702,7 @@ pub enum GamescopeMode {
|
||||
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_gamescope_mode(
|
||||
dedicated_launch: bool,
|
||||
force_managed: bool,
|
||||
attach_env: bool,
|
||||
node_env: bool,
|
||||
@@ -529,6 +714,11 @@ fn pick_gamescope_mode(
|
||||
GamescopeMode::Managed
|
||||
} else if attach_env || node_env {
|
||||
GamescopeMode::Attach
|
||||
} else if dedicated_launch {
|
||||
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
||||
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
||||
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
||||
GamescopeMode::Spawn
|
||||
} else if session_env || managed_infra {
|
||||
GamescopeMode::Managed
|
||||
} else if foreign_gamescope {
|
||||
@@ -548,7 +738,7 @@ fn pick_gamescope_mode(
|
||||
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||
/// forces managed over all of it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_input_env(chosen: Compositor) {
|
||||
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = match chosen {
|
||||
Compositor::Gamescope => "gamescope",
|
||||
@@ -562,6 +752,7 @@ pub fn apply_input_env(chosen: Compositor) {
|
||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||
if chosen == Compositor::Gamescope {
|
||||
let mode = pick_gamescope_mode(
|
||||
dedicated_launch,
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||
@@ -593,7 +784,34 @@ pub fn apply_input_env(chosen: Compositor) {
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply_input_env(_chosen: Compositor) {}
|
||||
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
||||
|
||||
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||
use policy::GameSession;
|
||||
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
||||
return false;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if gamescope::is_available() {
|
||||
true
|
||||
} else {
|
||||
tracing::info!(
|
||||
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false // Windows: a launching session opens into the one desktop (no gamescope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||
@@ -616,6 +834,27 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||
gamescope::launch_into_session(cmd)
|
||||
}
|
||||
|
||||
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||
/// exit (review #4/#8). Always `false` off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
||||
gamescope::game_session_exited(node_id)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cancel_pending_tv_restore() {
|
||||
gamescope::cancel_pending_restore();
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn cancel_pending_tv_restore() {}
|
||||
|
||||
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||
@@ -750,6 +989,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
std::sync::Arc::new(())
|
||||
}
|
||||
|
||||
/// Recover a stranded TV takeover from a crashed previous host instance
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
|
||||
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_takeover_on_startup() {
|
||||
gamescope::restore_takeover_on_startup();
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_on_startup() {}
|
||||
|
||||
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
|
||||
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
|
||||
// it), so no cfg gate. See `design/display-management.md`.
|
||||
@@ -878,21 +1127,33 @@ mod tests {
|
||||
fn gamescope_mode_ladder() {
|
||||
use GamescopeMode::*;
|
||||
let pick = pick_gamescope_mode;
|
||||
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||
assert_eq!(pick(false, false, false, false, false, false), Spawn);
|
||||
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
||||
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||
assert_eq!(pick(false, false, false, false, true, false), Managed);
|
||||
assert_eq!(pick(false, false, false, false, true, true), Managed);
|
||||
assert_eq!(
|
||||
pick(false, false, false, false, false, true, false),
|
||||
Managed
|
||||
);
|
||||
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
||||
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||
assert_eq!(pick(false, false, false, false, false, true), Attach);
|
||||
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
||||
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||
assert_eq!(pick(false, false, false, true, false, false), Managed);
|
||||
assert_eq!(
|
||||
pick(false, false, false, false, true, false, false),
|
||||
Managed
|
||||
);
|
||||
// Explicit attach/node wins over infra…
|
||||
assert_eq!(pick(false, true, false, false, true, false), Attach);
|
||||
assert_eq!(pick(false, false, true, true, true, false), Attach);
|
||||
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
||||
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
||||
// …and force-managed wins over everything.
|
||||
assert_eq!(pick(true, true, true, false, false, false), Managed);
|
||||
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
||||
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
||||
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
||||
// …but the explicit operator overrides still win over dedicated.
|
||||
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
||||
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
||||
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! Input uses gamescope's own libei/EIS socket (`LIBEI_SOCKET`), relayed to the libei backend (see
|
||||
//! `inject/libei.rs`) — wired and live-validated.
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -62,6 +62,18 @@ static PENDING_RESTORE: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::ne
|
||||
/// instead of triggering a stop/relaunch.
|
||||
const RESTORE_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Per-spawn instance counter (A5): each bare-spawn gets a unique id addressing its own log so two
|
||||
/// coexisting gamescopes (a kept lingering spawn + a fresh one) never parse each other's node id.
|
||||
static SPAWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// This spawn instance's log path, under `$XDG_RUNTIME_DIR` (per-user, tmpfs; falls back to `/tmp`
|
||||
/// only if unset). Replaces the shared `/tmp/punktfunk-gamescope.log` so concurrent spawns don't
|
||||
/// clobber each other's `stream available on node ID:` line.
|
||||
fn spawn_log_path(inst: u64) -> std::path::PathBuf {
|
||||
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::Path::new(&base).join(format!("punktfunk-gamescope-{inst}.log"))
|
||||
}
|
||||
|
||||
/// systemd --user transient unit name for the host-managed gamescope-session-plus session.
|
||||
const SESSION_UNIT: &str = "punktfunk-gamescope";
|
||||
/// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts).
|
||||
@@ -82,6 +94,80 @@ const STEAMOS_SESSION_TARGET: &str = "gamescope-session.target";
|
||||
/// restart the physical session.
|
||||
static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// Persisted takeover state (`design/gamemode-and-dedicated-sessions.md` A3): the takeover mechanics
|
||||
/// ([`STOPPED_AUTOLOGIN`] / [`STEAMOS_TOOK_OVER`]) are process memory, so a host **crash** mid-stream
|
||||
/// would strand the box out of gaming mode with no restore. Mirroring the statics to a file lets
|
||||
/// [`restore_takeover_on_startup`] put the TV back after a restart.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
struct TakeoverState {
|
||||
/// Autologin `gamescope-session-plus@*.service` units we stopped (to restart on restore).
|
||||
stopped_autologin: Vec<String>,
|
||||
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
||||
steamos: bool,
|
||||
}
|
||||
|
||||
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
||||
/// on reboot, which is correct: a reboot restarts the autologin itself).
|
||||
fn takeover_state_path() -> std::path::PathBuf {
|
||||
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::Path::new(&base).join("punktfunk-session-takeover.json")
|
||||
}
|
||||
|
||||
/// Persist the current takeover mechanics so a host crash doesn't strand the box out of gaming mode.
|
||||
/// Best-effort (a write failure just loses crash-restore, not correctness).
|
||||
fn persist_takeover() {
|
||||
let state = TakeoverState {
|
||||
stopped_autologin: STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
};
|
||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(&state) {
|
||||
let _ = std::fs::write(takeover_state_path(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the persisted takeover file (after a completed restore, or when there's nothing to restore).
|
||||
fn clear_takeover() {
|
||||
let _ = std::fs::remove_file(takeover_state_path());
|
||||
}
|
||||
|
||||
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
|
||||
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
|
||||
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
|
||||
/// client reconnecting right after the restart keeps the streamed session instead of bouncing the
|
||||
/// box back to gaming mode). No-op when no takeover file exists (a clean start). Call once from
|
||||
/// `serve` alongside [`start_restore_worker`].
|
||||
pub fn restore_takeover_on_startup() {
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
};
|
||||
let Ok(state) = serde_json::from_slice::<TakeoverState>(&bytes) else {
|
||||
clear_takeover();
|
||||
return;
|
||||
};
|
||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
units = ?state.stopped_autologin,
|
||||
steamos = state.steamos,
|
||||
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
||||
);
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
||||
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
||||
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Instant::now() + Duration::from_secs(15));
|
||||
}
|
||||
|
||||
impl GamescopeDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(GamescopeDisplay::default())
|
||||
@@ -97,6 +183,32 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
self.cmd = cmd;
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); managed
|
||||
// (`PUNKTFUNK_GAMESCOPE_SESSION`) and attach (`PUNKTFUNK_GAMESCOPE_NODE`) report
|
||||
// `SessionManaged`/`External`, so the registry must not reuse a kept spawn for them (same
|
||||
// backend name). Mirrors [`crate::vdisplay::launch_is_nested`]; read under the env lock the
|
||||
// sub-mode ladder writes these keys under.
|
||||
crate::vdisplay::with_env_lock(|| {
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
||||
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_command(&self) -> Option<String> {
|
||||
// The registry keys keep-alive reuse on (backend, mode, launch): a kept bare-spawn running
|
||||
// game A must never be reused for a session launching game B (A2).
|
||||
self.cmd.clone()
|
||||
}
|
||||
|
||||
fn kept_display_alive(&mut self, node_id: u32) -> bool {
|
||||
// The nested gamescope dies when its game exits (independently of any compositor), leaving a
|
||||
// dead pooled node. Before the registry reuses that node on a reconnect, confirm it still
|
||||
// exists on the daemon; a `false` makes the registry recreate instead of handing back a corpse
|
||||
// (which would then burn a ~10 s first-frame retry before `mark_failed` recovered it).
|
||||
gamescope_node_present(node_id)
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
|
||||
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
|
||||
@@ -121,26 +233,51 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
};
|
||||
point_injector_at_eis();
|
||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
ownership: DisplayOwnership::External,
|
||||
reused_gen: None,
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
let proc = GamescopeProc(spawn(
|
||||
// B1: a dedicated STEAM launch needs Steam's single instance free. If the box autologged into
|
||||
// game mode (Bazzite) its Steam holds the instance, and a nested second Steam would see the
|
||||
// first and exit (crashing the spawn) — so free the autologin session first. Its restore is the
|
||||
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||
stop_autologin_sessions();
|
||||
}
|
||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||
// shared log. The nested-command's LIBEI relay stays on the global path (per-instance input
|
||||
// isolation is `design/gamescope-multiuser.md` scope, not addressed here).
|
||||
let inst = SPAWN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let log = spawn_log_path(inst);
|
||||
let child = spawn(
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz.max(1),
|
||||
self.cmd.as_deref(),
|
||||
)?);
|
||||
&log,
|
||||
)?;
|
||||
let child_pid = child.id();
|
||||
let proc = GamescopeProc {
|
||||
child,
|
||||
log: log.clone(),
|
||||
};
|
||||
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
|
||||
// alive meanwhile, and killed if we give up).
|
||||
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| {
|
||||
// alive meanwhile, and killed if we give up). Discovery reads THIS spawn's log, and the
|
||||
// fallback is scoped to this spawn's process tree.
|
||||
let node_id = wait_for_node(Duration::from_secs(15), &log, child_pid).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"gamescope PipeWire node did not appear within 15s — gamescope may have failed to \
|
||||
start or headless capture is unsupported on this GPU/driver (see /tmp/punktfunk-gamescope.log)"
|
||||
start or headless capture is unsupported on this GPU/driver (see {})",
|
||||
log.display()
|
||||
)
|
||||
})?;
|
||||
tracing::info!(
|
||||
@@ -150,12 +287,12 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(proc),
|
||||
})
|
||||
Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
Box::new(proc),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +329,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope session: reusing the running session (same mode — no Steam restart)"
|
||||
);
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
});
|
||||
return Ok(managed_output(node_id, mode));
|
||||
}
|
||||
tracing::warn!("gamescope session: tracked session has no live node — relaunching");
|
||||
*guard = None;
|
||||
@@ -218,12 +350,23 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope session: launched gamescope-session-plus at the client's mode"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
Ok(managed_output(node_id, mode))
|
||||
}
|
||||
|
||||
/// The [`VirtualOutput`] for a managed / SteamOS-takeover session: a box-level session whose restore
|
||||
/// lifecycle is (at Part A1) the gamescope module's own machinery (`schedule_restore_tv_session`), so
|
||||
/// it is [`DisplayOwnership::SessionManaged`] — the registry passes it through (no pooling), and the
|
||||
/// capturer's unit keepalive tears nothing down on drop. (Part A3 replaces the unit keepalive with a
|
||||
/// real `ManagedSessionHandle` and flips this to `Owned`.)
|
||||
fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
ownership: DisplayOwnership::SessionManaged,
|
||||
reused_gen: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// SteamOS detection: its session launcher is present and Bazzite's session-plus is NOT (so the
|
||||
@@ -483,12 +626,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope (SteamOS): reusing the headless session (same mode — no Steam restart)"
|
||||
);
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
});
|
||||
return Ok(managed_output(node_id, mode));
|
||||
}
|
||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||
}
|
||||
@@ -497,9 +635,11 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
systemctl_user(&["restart", STEAMOS_SESSION_TARGET]);
|
||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
// gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower
|
||||
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop.
|
||||
let node_id = wait_for_node(Duration::from_secs(30)).ok_or_else(|| {
|
||||
// (Big Picture cold start) and is awaited by the caller's first-frame retry loop. The managed
|
||||
// session logs to journald (not a per-spawn file), so poll `find_gamescope_node` directly.
|
||||
let node_id = poll_managed_node(Duration::from_secs(30)).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"SteamOS headless gamescope node did not appear within 30s after restarting \
|
||||
{STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`"
|
||||
@@ -518,12 +658,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope (SteamOS): took over gamescope-session.target headless at the client's mode"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
Ok(managed_output(node_id, mode))
|
||||
}
|
||||
|
||||
/// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s
|
||||
@@ -670,11 +805,31 @@ fn running_autologin_gamescope_unit() -> Option<String> {
|
||||
.map(|u| u.to_string())
|
||||
}
|
||||
|
||||
/// Tear a gamescope `systemd --user` unit down with **SIGKILL** rather than the default SIGTERM stop
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` A3 / `session-aware-host-followups.md` #1): the
|
||||
/// hypothesis — validated as the fix on the F44 repro box `.181` — is that gamescope's SIGTERM
|
||||
/// teardown handler (the one that SIGSEGVs, exit 139) LEAKS the NVIDIA GPU context, after which every
|
||||
/// subsequent gamescope fails `vkCreateDevice` with `VK_ERROR_INITIALIZATION_FAILED` (-3) until a
|
||||
/// reboot. SIGKILL skips that handler so the driver reclaims the context cleanly via normal process
|
||||
/// exit. Follow with `stop` + `reset-failed` to clear the unit's state so a relaunch is clean.
|
||||
fn kill_unit(unit: &str) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "kill", "--signal=SIGKILL", unit])
|
||||
.status();
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", unit])
|
||||
.status();
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "reset-failed", unit])
|
||||
.status();
|
||||
}
|
||||
|
||||
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||
/// is autologged in (e.g. a box that boots headless).
|
||||
/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
|
||||
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
||||
fn stop_autologin_sessions() {
|
||||
let Ok(out) = Command::new("systemctl")
|
||||
.args([
|
||||
@@ -694,12 +849,10 @@ fn stop_autologin_sessions() {
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Some(unit) = line.split_whitespace().next() {
|
||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", unit])
|
||||
.status();
|
||||
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
tracing::info!(
|
||||
unit,
|
||||
"freed Steam: stopped the autologin gaming session for this stream"
|
||||
"freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
|
||||
);
|
||||
stopped.push(unit.to_string());
|
||||
}
|
||||
@@ -707,15 +860,57 @@ fn stop_autologin_sessions() {
|
||||
}
|
||||
if !stopped.is_empty() {
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
}
|
||||
}
|
||||
|
||||
/// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we
|
||||
/// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`])
|
||||
/// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing
|
||||
/// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the
|
||||
/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite /
|
||||
/// headless box). Idempotent / safe to call on every session end.
|
||||
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
||||
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
||||
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
||||
/// managed path already clears `PENDING_RESTORE`) — so without this, a dedicated Steam reconnect within
|
||||
/// the linger window would restart the autologin *underneath* the live session (review finding #3).
|
||||
/// Called from the connect path (native `resolve_compositor`, GameStream `open_gs_virtual_source`).
|
||||
/// No-op when nothing is pending; the stopped-unit list stays armed for a later real disconnect.
|
||||
pub fn cancel_pending_restore() {
|
||||
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if g.is_some() {
|
||||
*g = None;
|
||||
tracing::info!(
|
||||
"gamescope: client (re)connected — cancelled the pending TV-session restore"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The delay before restoring the TV's autologin session after the last client disconnects — the
|
||||
/// display-management **keep-alive policy**, replacing the hardcoded [`RESTORE_DEBOUNCE`]
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` A3). The managed gamescope session is a single
|
||||
/// box-level singleton (not a registry pool entry — A1), so its keep-alive lives here rather than in
|
||||
/// the registry, but reads the same policy the pooled backends do:
|
||||
/// * `off` → restore immediately (0 s);
|
||||
/// * `duration(s)` → restore after `s`;
|
||||
/// * `forever` → **`None`**: never auto-restore — the managed session is HELD until host stop or a
|
||||
/// manual return to gaming mode (the `gaming-rig` "the TV model" story, now truthful on gamescope);
|
||||
/// * unconfigured → the historical 5 s [`RESTORE_DEBOUNCE`] (bit-for-bit today's behavior).
|
||||
fn restore_delay() -> Option<Duration> {
|
||||
use crate::vdisplay::policy::{self, Linger};
|
||||
match policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.keep_alive.linger())
|
||||
{
|
||||
Some(Linger::Immediate) => Some(Duration::from_secs(0)),
|
||||
Some(Linger::For(d)) => Some(d),
|
||||
Some(Linger::Forever) => None,
|
||||
None => Some(RESTORE_DEBOUNCE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Client disconnected: **schedule** a policy-timed restore of the TV's autologin gaming session(s) we
|
||||
/// stopped on connect ([`restore_delay`], via [`start_restore_worker`]) — unless a client reconnects
|
||||
/// first, which cancels it and reuses the warm managed session. Debouncing means at most one gamescope
|
||||
/// stop/relaunch per quiet period instead of one per disconnect — the per-connect churn is what leaked
|
||||
/// GPU context on F44. Under `keep_alive=forever` ([`restore_delay`] `None`) NO restore is scheduled:
|
||||
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
|
||||
/// box). Idempotent / safe to call on every session end.
|
||||
pub fn schedule_restore_tv_session() {
|
||||
let nothing_to_restore = STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
@@ -725,13 +920,25 @@ pub fn schedule_restore_tv_session() {
|
||||
if nothing_to_restore {
|
||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Instant::now() + RESTORE_DEBOUNCE);
|
||||
match restore_delay() {
|
||||
None => {
|
||||
// keep_alive=forever → pin the managed session; leave PENDING_RESTORE unset.
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
tracing::info!(
|
||||
secs = RESTORE_DEBOUNCE.as_secs(),
|
||||
"gamescope: scheduled debounced TV-session restore (cancelled if a client reconnects)"
|
||||
"gamescope: keep-alive=forever — managed session held (no TV-restore scheduled; \
|
||||
return to gaming mode or restart the host to free it)"
|
||||
);
|
||||
}
|
||||
Some(delay) => {
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Instant::now() + delay);
|
||||
tracing::info!(
|
||||
secs = delay.as_secs(),
|
||||
"gamescope: scheduled TV-session restore (keep-alive policy; cancelled on reconnect)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||
@@ -745,6 +952,7 @@ fn do_restore_tv_session() {
|
||||
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *took {
|
||||
*took = false;
|
||||
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
remove_steamos_dropin();
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
@@ -770,6 +978,7 @@ fn do_restore_tv_session() {
|
||||
if units.is_empty() {
|
||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
||||
}
|
||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||
@@ -923,12 +1132,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead
|
||||
/// session's socket name can't be reconnected.
|
||||
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
|
||||
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
|
||||
fn stop_session(unit_name: &str) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", unit_name])
|
||||
.status();
|
||||
kill_unit(unit_name);
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
}
|
||||
|
||||
@@ -949,13 +1156,36 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
|
||||
/// the game is the gamescope focus with no Steam client window to navigate
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
|
||||
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
|
||||
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||
fn is_steam_launch(cmd: &str) -> bool {
|
||||
let mut it = cmd.split_whitespace();
|
||||
it.next() == Some("steam") && cmd.contains("steam://")
|
||||
}
|
||||
|
||||
fn shape_dedicated_command(app: &str) -> String {
|
||||
let mut it = app.split_whitespace();
|
||||
if it.next() == Some("steam") {
|
||||
let rest: Vec<&str> = it.collect();
|
||||
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
|
||||
return format!("steam -silent {}", rest.join(" "));
|
||||
}
|
||||
}
|
||||
app.to_string()
|
||||
}
|
||||
|
||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
|
||||
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
|
||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
|
||||
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
|
||||
@@ -970,6 +1200,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
})
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
|
||||
// gamescope focus with no Steam client window to navigate.
|
||||
let app = shape_dedicated_command(&app);
|
||||
let relay = ei_socket_file();
|
||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||
let mut cmd = Command::new("gamescope");
|
||||
@@ -990,14 +1223,14 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||
if let Ok(log) = std::fs::File::create("/tmp/punktfunk-gamescope.log") {
|
||||
if let Ok(log2) = log.try_clone() {
|
||||
cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2));
|
||||
if let Ok(logf) = std::fs::File::create(log) {
|
||||
if let Ok(log2) = logf.try_clone() {
|
||||
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
||||
}
|
||||
} else {
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
}
|
||||
tracing::info!(w, h, hz, %app, "spawning gamescope (headless)");
|
||||
tracing::info!(w, h, hz, %app, log = %log.display(), "spawning gamescope (headless)");
|
||||
cmd.spawn()
|
||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
||||
}
|
||||
@@ -1006,22 +1239,59 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
||||
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
||||
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
||||
fn wait_for_node(timeout: Duration) -> Option<u32> {
|
||||
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
||||
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
||||
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
||||
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
||||
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
||||
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
||||
/// node stays absent across the window.
|
||||
pub fn game_session_exited(node_id: u32) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_millis(1500);
|
||||
loop {
|
||||
if gamescope_node_present(node_id) {
|
||||
return false; // OUR node is (still) present → not an exit (transient loss)
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return true; // our node stayed gone across the window → the game exited
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||
fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = node_from_log() {
|
||||
if let Some(id) = find_gamescope_node() {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return find_gamescope_node(); // last-resort fallback
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored).
|
||||
fn node_from_log() -> Option<u32> {
|
||||
let log = std::fs::read_to_string("/tmp/punktfunk-gamescope.log").ok()?;
|
||||
fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = node_from_log(log) {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
||||
// node isn't picked by mistake.
|
||||
return find_gamescope_node_scoped(Some(child_pid));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
||||
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
||||
let log = std::fs::read_to_string(log).ok()?;
|
||||
for line in log.lines().rev() {
|
||||
if let Some(pos) = line.find("stream available on node ID:") {
|
||||
let tail = &line[pos + "stream available on node ID:".len()..];
|
||||
@@ -1034,6 +1304,27 @@ fn node_from_log() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
||||
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
||||
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
||||
fn gamescope_node_present(node_id: u32) -> bool {
|
||||
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
||||
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
||||
return true;
|
||||
};
|
||||
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
return true;
|
||||
};
|
||||
dump.as_array()
|
||||
.map(|objs| {
|
||||
objs.iter().any(|o| {
|
||||
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
||||
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
||||
})
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||
///
|
||||
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
||||
@@ -1041,10 +1332,18 @@ fn node_from_log() -> Option<u32> {
|
||||
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
||||
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
||||
fn find_gamescope_node() -> Option<u32> {
|
||||
find_gamescope_node_scoped(None)
|
||||
}
|
||||
|
||||
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
||||
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
||||
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
||||
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
||||
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
||||
let out = Command::new("pw-dump").output().ok()?;
|
||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let nodes = dump.as_array()?;
|
||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String)> {
|
||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||
return None;
|
||||
}
|
||||
@@ -1060,20 +1359,40 @@ fn find_gamescope_node() -> Option<u32> {
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((id, name, class))
|
||||
// PipeWire records the owning process id as a string or an int depending on version.
|
||||
let pid = props
|
||||
.and_then(|p| p.get("application.process.id"))
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
.map(|n| n as u32)
|
||||
});
|
||||
Some((id, name, class, pid))
|
||||
};
|
||||
// Preferred: a Video/Source node named (or containing) "gamescope".
|
||||
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
||||
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
||||
// per-instance log is the primary addressing (design §7 risk note).
|
||||
let in_scope = |pid: Option<u32>| -> bool {
|
||||
match scope {
|
||||
None => true,
|
||||
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
||||
}
|
||||
};
|
||||
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, class)) = node_props(obj) {
|
||||
if class == "Video/Source" && (name == "gamescope" || name.contains("gamescope")) {
|
||||
if let Some((id, name, class, pid)) = node_props(obj) {
|
||||
if class == "Video/Source"
|
||||
&& (name == "gamescope" || name.contains("gamescope"))
|
||||
&& in_scope(pid)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: a node literally named "gamescope" with no usable class tag.
|
||||
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, _)) = node_props(obj) {
|
||||
if name == "gamescope" {
|
||||
if let Some((id, name, _, pid)) = node_props(obj) {
|
||||
if name == "gamescope" && in_scope(pid) {
|
||||
tracing::warn!(
|
||||
node_id = id,
|
||||
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
||||
@@ -1168,22 +1487,62 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Owns the spawned gamescope process; killing it tears the virtual output down.
|
||||
struct GamescopeProc(Child);
|
||||
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||
/// output down.
|
||||
struct GamescopeProc {
|
||||
child: Child,
|
||||
log: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for GamescopeProc {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
|
||||
// now-dead session's socket between sessions (the stale path is the "Connection refused").
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
// Drop this spawn's per-instance log (A5) so `$XDG_RUNTIME_DIR` doesn't accumulate them.
|
||||
let _ = std::fs::remove_file(&self.log);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, MIN_GAMESCOPE};
|
||||
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
|
||||
|
||||
#[test]
|
||||
fn steam_launch_detection() {
|
||||
assert!(is_steam_launch("steam steam://rungameid/570"));
|
||||
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
|
||||
assert!(!is_steam_launch("vkcube"));
|
||||
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
|
||||
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedicated_command_shaping() {
|
||||
// Steam URI → -silent inserted so the game is the gamescope focus.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam steam://rungameid/570"),
|
||||
"steam -silent steam://rungameid/570"
|
||||
);
|
||||
// Idempotent: an already-silent command is left alone.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -silent steam://rungameid/570"),
|
||||
"steam -silent steam://rungameid/570"
|
||||
);
|
||||
// Non-Steam launches and operator custom commands are untouched.
|
||||
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
|
||||
assert_eq!(
|
||||
shape_dedicated_command("lutris lutris:rungameid/42"),
|
||||
"lutris lutris:rungameid/42"
|
||||
);
|
||||
// A bare `steam` with no URI is left alone (not a game launch).
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -bigpicture"),
|
||||
"steam -bigpicture"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_banner() {
|
||||
|
||||
@@ -212,12 +212,11 @@ impl VirtualDisplay for KwinDisplay {
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||
Ok(VirtualOutput {
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, achieved_hz)),
|
||||
keepalive: Box::new(StopGuard { stop }),
|
||||
})
|
||||
Some((mode.width, mode.height, achieved_hz)),
|
||||
Box::new(StopGuard { stop }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,11 @@ impl VirtualDisplay for MutterDisplay {
|
||||
h = mode.height,
|
||||
"Mutter virtual monitor ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
Box::new(StopGuard(stop)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
|
||||
//! interface routed to xdpw (`scripts/headless/portals.conf`).
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::process::Command;
|
||||
@@ -130,6 +130,11 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
_stop: StopGuard(stop),
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
// portal fd can't be re-opened per attach, so the registry passes it through on
|
||||
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,22 @@ pub struct Layout {
|
||||
pub positions: BTreeMap<String, Position>,
|
||||
}
|
||||
|
||||
/// How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is
|
||||
/// served (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes
|
||||
/// — a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers
|
||||
/// it. Linux-only in effect (a launching Windows session opens into the one desktop).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GameSession {
|
||||
/// Today's routing: the launch rides whatever session the box is in (managed Steam session on
|
||||
/// Bazzite/SteamOS, bare spawn on plain distros, spawned into the live desktop on KWin/Mutter/wlroots).
|
||||
#[default]
|
||||
Auto,
|
||||
/// A launching session always gets its OWN headless gamescope at the client's mode, nesting just
|
||||
/// the game — no Steam Big Picture, no game mode. Degrades to `auto` when gamescope is unavailable.
|
||||
Dedicated,
|
||||
}
|
||||
|
||||
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
|
||||
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
@@ -202,6 +218,11 @@ pub struct DisplayPolicy {
|
||||
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
|
||||
#[serde(default = "default_max_displays")]
|
||||
pub max_displays: u32,
|
||||
/// How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).
|
||||
/// Orthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`
|
||||
/// so existing `display-settings.json` files are untouched.
|
||||
#[serde(default)]
|
||||
pub game_session: GameSession,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
@@ -224,6 +245,7 @@ impl Default for DisplayPolicy {
|
||||
identity: Identity::default(),
|
||||
layout: Layout::default(),
|
||||
max_displays: 4,
|
||||
game_session: GameSession::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +301,11 @@ impl EffectivePolicy {
|
||||
/// transform, factored out pure so arranging displays stays orthogonal to the other axes and is
|
||||
/// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new
|
||||
/// layout — rule; a named preset would ignore them.)
|
||||
pub fn with_manual_layout(&self, positions: BTreeMap<String, Position>) -> DisplayPolicy {
|
||||
pub fn with_manual_layout(
|
||||
&self,
|
||||
positions: BTreeMap<String, Position>,
|
||||
game_session: GameSession,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
version: 1,
|
||||
preset: Preset::Custom,
|
||||
@@ -292,6 +318,8 @@ impl EffectivePolicy {
|
||||
positions,
|
||||
},
|
||||
max_displays: self.max_displays,
|
||||
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
|
||||
game_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +426,13 @@ impl DisplayPolicyStore {
|
||||
self.configured().map(|p| p.effective())
|
||||
}
|
||||
|
||||
/// The game-session routing axis (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to
|
||||
/// the preset — read directly off the stored policy (or the default `Auto` when unconfigured), so a
|
||||
/// preset selection never resets it.
|
||||
pub fn game_session(&self) -> GameSession {
|
||||
self.get().game_session
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||
@@ -560,7 +595,9 @@ mod tests {
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||
let p = eff.with_manual_layout(positions);
|
||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated);
|
||||
// The orthogonal game-session axis is preserved through the layout transform.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
// …every other behavior axis is preserved verbatim…
|
||||
|
||||
@@ -164,6 +164,28 @@ pub fn release(slot: Option<u64>) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
|
||||
/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry
|
||||
/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if
|
||||
/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too).
|
||||
pub fn mark_failed(gen: u64) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux::mark_failed(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
|
||||
/// per-connect re-detect that finds the previous backend's compositor gone. No-op off Linux.
|
||||
pub fn invalidate_backend(backend: &str) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux::invalidate_backend(backend);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = backend;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Linux keep-alive pool
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -202,6 +224,13 @@ mod linux {
|
||||
/// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the
|
||||
/// group's last member drops. `None` for extend/primary and non-first / non-exclusive members.
|
||||
topology_restore: Option<Restore>,
|
||||
/// The launch command this display was created with (`design/gamemode-and-dedicated-sessions.md`
|
||||
/// A2): keep-alive reuse requires an exact match, so a kept spawn running game A never serves a
|
||||
/// session launching game B. `None` = a plain desktop / no nested command.
|
||||
launch: Option<String>,
|
||||
/// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps
|
||||
/// entries whose epoch is stale (their compositor instance was replaced under them).
|
||||
epoch: u64,
|
||||
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
|
||||
/// — its entry was reused + re-stamped — is a no-op).
|
||||
gen: u64,
|
||||
@@ -210,6 +239,18 @@ mod linux {
|
||||
/// A per-group topology-restore action (see [`Entry::topology_restore`]).
|
||||
type Restore = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// The result of the keep-alive reuse lookup (A2 validated reuse): a live kept display was reused,
|
||||
/// a dead one was pulled out (recreate), or nothing matched.
|
||||
enum ReuseOutcome {
|
||||
/// A live kept display — the session-facing output to return.
|
||||
Reused(VirtualOutput),
|
||||
/// A dead kept display, removed from the pool, plus its group restore (run before the corpse's
|
||||
/// keepalive drops); the caller falls through to a fresh create.
|
||||
Dead(Entry, Option<Restore>),
|
||||
/// No matching kept display.
|
||||
Miss,
|
||||
}
|
||||
|
||||
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
|
||||
/// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown
|
||||
/// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the
|
||||
@@ -245,6 +286,19 @@ mod linux {
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a pooled entry's session `epoch` still match the current one for reuse / expiry purposes?
|
||||
/// The session epoch tracks the box's **active-session (desktop) compositor** instance (KWin /
|
||||
/// Mutter / wlroots) — whose PipeWire node dies with the compositor, so a stale-epoch kept output
|
||||
/// is a corpse. A **gamescope** spawn is the exact opposite: an independent nested session (its own
|
||||
/// group), whose node lives with its own child process, wholly unrelated to whatever desktop /
|
||||
/// game-mode compositor the epoch tracks. So gamescope entries are EXEMPT from the epoch — a desktop
|
||||
/// switch, or a game-mode gamescope restart, must never invalidate a kept dedicated game session
|
||||
/// (review findings #2/#5/#6/#7/#10). Their liveness is the `kept_display_alive` node probe + the B2
|
||||
/// game-exit path + `mark_failed`, not the epoch.
|
||||
fn epoch_matches(backend: &str, entry_epoch: u64, cur_epoch: u64) -> bool {
|
||||
backend == "gamescope" || entry_epoch == cur_epoch
|
||||
}
|
||||
|
||||
/// The linger resolution for Linux: the console policy's `keep_alive` when configured, else
|
||||
/// **Immediate** (today's behavior — a Linux disconnect tears the output down at once).
|
||||
fn linger() -> Linger {
|
||||
@@ -262,9 +316,17 @@ mod linux {
|
||||
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) {
|
||||
let mut expired = Vec::new();
|
||||
let mut restores = Vec::new();
|
||||
// A4 backstop: also reap a KEPT (non-Active) DESKTOP display whose session epoch is stale — its
|
||||
// compositor instance was replaced (a Game↔Desktop switch / same-kind restart), so its node id
|
||||
// now means nothing. gamescope spawns are exempt (`epoch_matches` — independent nested sessions).
|
||||
// An Active entry is left to its own session's capture-loss rebuild (which, under the bumped
|
||||
// epoch, won't reuse it); `invalidate_backend` clears a whole desktop backend on a known switch.
|
||||
let cur_epoch = crate::vdisplay::session_epoch();
|
||||
let mut i = 0;
|
||||
while i < entries.len() {
|
||||
if entries[i].life.poll_expiry(now) {
|
||||
let dead_epoch = !epoch_matches(entries[i].backend, entries[i].epoch, cur_epoch)
|
||||
&& !matches!(entries[i].life, lifecycle::State::Active { .. });
|
||||
if entries[i].life.poll_expiry(now) || dead_epoch {
|
||||
let mut e = entries.remove(i);
|
||||
let backend = e.backend;
|
||||
if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) {
|
||||
@@ -312,13 +374,18 @@ mod linux {
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
gen: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
reused: bool,
|
||||
) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
// The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive.
|
||||
let mut out = VirtualOutput::owned(
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode,
|
||||
keepalive: Box::new(DisplayLease { gen, quit }),
|
||||
}
|
||||
Box::new(DisplayLease { gen, quit }),
|
||||
);
|
||||
// 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);
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn acquire(
|
||||
@@ -328,6 +395,10 @@ mod linux {
|
||||
) -> Result<VirtualOutput> {
|
||||
ensure_timer();
|
||||
let backend = vd.name();
|
||||
// A2 reuse key: the launch command this acquire carries (a kept spawn running game A must never
|
||||
// be reused for a session launching game B). A4 reuse key: the current session epoch.
|
||||
let launch = vd.launch_command();
|
||||
let cur_epoch = crate::vdisplay::session_epoch();
|
||||
let r = reg();
|
||||
|
||||
// Reap expired first (run any group restores + drop outside the lock).
|
||||
@@ -340,28 +411,94 @@ mod linux {
|
||||
}
|
||||
drop(expired);
|
||||
|
||||
// Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session
|
||||
// re-attaches a fresh PipeWire consumer to the still-live `node_id`.
|
||||
{
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
if let Some(e) = es.iter_mut().find(|e| {
|
||||
// Reuse: a kept (lingering/pinned) display of the same backend + mode + launch + epoch. A
|
||||
// reconnecting session re-attaches a fresh PipeWire consumer to the still-live `node_id`. Gated
|
||||
// on `vd.poolable_now()` (A1): a gamescope managed/attach acquire must NOT reuse a kept bare-spawn
|
||||
// (they share the backend name `"gamescope"`); its `create` builds a `SessionManaged`/`External`
|
||||
// output that passes through below.
|
||||
if vd.poolable_now() {
|
||||
// Reuse a kept display, matching backend + mode + launch (+ epoch for the desktop backends;
|
||||
// gamescope spawns are independent nested sessions, exempt from the active-session epoch —
|
||||
// see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump`
|
||||
// for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so:
|
||||
// 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it;
|
||||
// 2. probe liveness OUTSIDE the lock;
|
||||
// 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed
|
||||
// it meanwhile — then we just miss and create fresh).
|
||||
let candidate = {
|
||||
let es = r.entries.lock().unwrap();
|
||||
es.iter()
|
||||
.find(|e| {
|
||||
matches!(
|
||||
e.life,
|
||||
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
|
||||
) && e.backend == backend
|
||||
&& e.mode == mode
|
||||
&& e.launch == launch
|
||||
&& epoch_matches(e.backend, e.epoch, cur_epoch)
|
||||
})
|
||||
.map(|e| (e.gen, e.node_id))
|
||||
};
|
||||
if let Some((cand_gen, node_id)) = candidate {
|
||||
let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block)
|
||||
let reuse = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
// Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept
|
||||
// (a concurrent reconnect adopted it) — we then miss and create fresh.
|
||||
match es.iter().position(|e| {
|
||||
e.gen == cand_gen
|
||||
&& matches!(
|
||||
e.life,
|
||||
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
|
||||
)
|
||||
}) {
|
||||
// Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known.
|
||||
e.life.acquire();
|
||||
Some(idx) if alive => {
|
||||
es[idx].life.acquire();
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
e.gen = gen;
|
||||
let out = output_for(e.node_id, e.preferred_mode, gen, quit);
|
||||
es[idx].gen = gen;
|
||||
let preferred_mode = es[idx].preferred_mode;
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id = e.node_id,
|
||||
node_id,
|
||||
"virtual display reused (keep-alive reconnect)"
|
||||
);
|
||||
return Ok(out);
|
||||
ReuseOutcome::Reused(output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
gen,
|
||||
quit.clone(),
|
||||
true,
|
||||
))
|
||||
}
|
||||
Some(idx) => {
|
||||
// Dead kept display: remove it, hand off its group restore, create fresh.
|
||||
let mut dead = es.remove(idx);
|
||||
let restore = hand_off_restore(
|
||||
&mut es,
|
||||
dead.backend,
|
||||
dead.topology_restore.take(),
|
||||
);
|
||||
ReuseOutcome::Dead(dead, restore)
|
||||
}
|
||||
None => ReuseOutcome::Miss, // adopted/removed by another thread
|
||||
}
|
||||
};
|
||||
match reuse {
|
||||
ReuseOutcome::Reused(out) => return Ok(out),
|
||||
ReuseOutcome::Dead(dead, restore) => {
|
||||
// Outside the lock: re-enable physicals (if the group emptied) then drop the
|
||||
// corpse's keepalive (may block) — then fall through to a fresh create below.
|
||||
if let Some(rst) = restore {
|
||||
rst();
|
||||
}
|
||||
tracing::info!(
|
||||
backend,
|
||||
"virtual display: kept display was dead — recreating (validated reuse)"
|
||||
);
|
||||
drop(dead);
|
||||
}
|
||||
ReuseOutcome::Miss => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,13 +518,18 @@ mod linux {
|
||||
// the group arrangement (manual per-slot positions) + the state slot.
|
||||
let identity_slot = vd.last_identity_slot();
|
||||
|
||||
// wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the
|
||||
// portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The
|
||||
// poolable backends put their node on the default daemon (remote_fd = None).
|
||||
if real.remote_fd.is_some() {
|
||||
// Pool ONLY a registry-owned display on the default PipeWire daemon
|
||||
// (design/gamemode-and-dedicated-sessions.md A1). Pass through, unchanged (capturer owns the
|
||||
// keepalive, teardown on drop), everything else:
|
||||
// * `External`/`SessionManaged` — gamescope attach / managed session: the gamescope module
|
||||
// owns their lifecycle (its own restore machinery), so the registry must not keep them
|
||||
// (the stale-node reuse wedge). Their unit keepalive tears nothing down on drop.
|
||||
// * `remote_fd = Some` — wlroots' sandboxed xdpw portal fd can't be re-opened per attach.
|
||||
if real.ownership != crate::vdisplay::DisplayOwnership::Owned || real.remote_fd.is_some() {
|
||||
tracing::debug!(
|
||||
backend,
|
||||
"virtual display not poolable (portal fd) — keep-alive off for this backend"
|
||||
ownership = ?real.ownership,
|
||||
"virtual display not registry-poolable — keep-alive off (owner keeps it / portal fd)"
|
||||
);
|
||||
return Ok(real);
|
||||
}
|
||||
@@ -410,6 +552,8 @@ mod linux {
|
||||
backend,
|
||||
identity_slot,
|
||||
topology_restore,
|
||||
launch: launch.clone(),
|
||||
epoch: cur_epoch,
|
||||
gen,
|
||||
};
|
||||
|
||||
@@ -455,7 +599,7 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
Ok(output_for(node_id, preferred_mode, gen, quit))
|
||||
Ok(output_for(node_id, preferred_mode, gen, quit, false))
|
||||
}
|
||||
|
||||
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
|
||||
@@ -704,6 +848,71 @@ mod linux {
|
||||
n
|
||||
}
|
||||
|
||||
/// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off /
|
||||
/// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op).
|
||||
pub(super) fn mark_failed(gen: u64) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let (torn, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
return; // already gone — the subsequent stale-gen lease drop no-ops too
|
||||
};
|
||||
let mut e = es.remove(idx);
|
||||
let backend = e.backend;
|
||||
let restore = hand_off_restore(&mut es, backend, e.topology_restore.take());
|
||||
(e, restore)
|
||||
};
|
||||
if let Some(rst) = restore {
|
||||
rst(); // outside the lock, before the keepalive drops
|
||||
}
|
||||
tracing::warn!(
|
||||
backend = torn.backend,
|
||||
"virtual display: reused kept display was dead on first frame — torn down (A2 mark_failed)"
|
||||
);
|
||||
drop(torn); // keepalive Drop outside the lock (may block)
|
||||
}
|
||||
|
||||
/// A4 — invalidate every kept display of `backend` (its compositor instance is gone). Removes them
|
||||
/// all (any lifecycle state — a dead compositor's Active entries are doomed too; their sessions
|
||||
/// rebuild), runs/hands off group restores, drops keepalives outside the lock (they hit dead
|
||||
/// sockets and fail fast). Mirrors `force_release`'s shape but selects by backend, not slot/state.
|
||||
pub(super) fn invalidate_backend(backend: &str) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let (removed, restores) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let mut restores = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < es.len() {
|
||||
if es[i].backend == backend {
|
||||
let mut e = es.remove(i);
|
||||
let b = e.backend;
|
||||
if let Some(rst) = hand_off_restore(&mut es, b, e.topology_restore.take()) {
|
||||
restores.push(rst);
|
||||
}
|
||||
out.push(e);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
(out, restores)
|
||||
};
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
for restore in restores {
|
||||
restore();
|
||||
}
|
||||
tracing::info!(
|
||||
backend,
|
||||
count = removed.len(),
|
||||
"virtual displays invalidated — compositor instance gone (A4 session switch)"
|
||||
);
|
||||
for e in removed {
|
||||
drop(e); // outside the lock
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
|
||||
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
|
||||
struct DisplayLease {
|
||||
@@ -744,6 +953,8 @@ mod linux {
|
||||
backend,
|
||||
identity_slot: None,
|
||||
topology_restore: restore,
|
||||
launch: None,
|
||||
epoch: 0,
|
||||
gen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use windows::Win32::System::Threading::{
|
||||
CreateMutexW, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
|
||||
};
|
||||
|
||||
use super::{Mode, VirtualOutput};
|
||||
use super::{DisplayOwnership, Mode, VirtualOutput};
|
||||
use crate::win_display::{
|
||||
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
|
||||
set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
||||
@@ -531,6 +531,9 @@ impl VirtualDisplayManager {
|
||||
mgr: self,
|
||||
gen: mon.gen,
|
||||
}),
|
||||
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
|
||||
// (which delegates to it via `vd.create`) treats it as Owned.
|
||||
ownership: DisplayOwnership::Owned,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
|
||||
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
|
||||
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
|
||||
not required).
|
||||
not required), **`design/gamemode-and-dedicated-sessions.md`** (PLANNED — reconciles this layer
|
||||
with session-mobile Bazzite/SteamOS hosts: display **ownership classes** so the registry stops
|
||||
pooling gamescope managed/attach outputs it doesn't own, validated reuse + invalidation, the
|
||||
§5.1 "policy replaces the managed 5 s debounce" promise actually implemented, and the dedicated
|
||||
per-launch gamescope game sessions built on it).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
@@ -590,7 +594,12 @@ out per-host instead of lying:
|
||||
|
||||
The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the
|
||||
registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity,
|
||||
conflict = join-only. That's just codifying reality.
|
||||
conflict = join-only. That's just codifying reality. **Gap (2026-07-05):** the shipped registry
|
||||
does NOT implement this row — it pools every `remote_fd == None` output, including the
|
||||
managed/attach/SteamOS paths' unit-keepalive outputs, which double-owns the managed session
|
||||
against the gamescope module's own restore worker (stale-node reuse wedge on game-mode
|
||||
reconnect). The fix — explicit display **ownership classes** + registry-owned managed-session
|
||||
restore — is designed in `design/gamemode-and-dedicated-sessions.md` Part A.
|
||||
|
||||
## 8. Management API, web console, tray
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
# Game-mode integration & dedicated game sessions — design
|
||||
|
||||
> **Status: IMPLEMENTED (2026-07-05), Linux on-glass validation pending.** Parts A (A1–A5) and B
|
||||
> (B0–B2) landed; `cargo build`/`test --workspace`/`clippy -D warnings`/`fmt` green, OpenAPI
|
||||
> regenerated. It reconciles the shipped display-management layer (`design/display-management.md`,
|
||||
> Stages 0–5 merged `95b3496`) with **session-mobile hosts** — Bazzite/SteamOS boxes that flip
|
||||
> between Steam Game Mode (gamescope) and a KDE/GNOME desktop — and adds **dedicated game sessions**:
|
||||
> a `game_session=dedicated` policy that serves every library launch from its own host-spawned
|
||||
> headless gamescope at the client's exact mode, booting straight into the game with no Steam Big
|
||||
> Picture and no game mode.
|
||||
>
|
||||
> ## Implementation status (as-built — read the deviations)
|
||||
>
|
||||
> - **A1 (ownership classes) — DONE.** `DisplayOwnership {Owned, External, SessionManaged}` on
|
||||
> `VirtualOutput`; the registry pools only `Owned` outputs on the default daemon. gamescope
|
||||
> spawn = `Owned`, attach = `External`, managed/SteamOS = `SessionManaged`, KWin/Mutter/Windows =
|
||||
> `Owned`, wlroots = `Owned` but gated out by `remote_fd`. Plus `VirtualDisplay::poolable_now()`
|
||||
> so a managed/attach acquire never reuses a kept bare-spawn. **This alone removes the reported
|
||||
> Bazzite game-mode-reconnect stale-node wedge** (managed sessions are no longer pooled).
|
||||
> - **A2 (validated reuse) — DONE.** Reuse keys on `(backend, mode, launch, epoch)`;
|
||||
> `VirtualDisplay::kept_display_alive()` (gamescope checks its node is still present) tears a dead
|
||||
> kept display down and recreates; `VirtualOutput::reused_gen` + `registry::mark_failed(gen)` on a
|
||||
> reused-display first-frame failure so the retry loop creates fresh instead of re-wedging.
|
||||
> - **A3 (managed restore) — DONE, but DEVIATES from the plan below.** The managed/SteamOS session
|
||||
> is a **single-instance box singleton** (one Steam per uid), so making it a registry-`Owned` pool
|
||||
> entry (as §4-A3 sketches) collides with its single-instance nature on a mode-change relaunch
|
||||
> (two `ManagedSessionHandle` drops fighting over the shared `SESSION_UNIT`/autologin). Since A1
|
||||
> already fixed the wedge, A3 instead **keeps the managed session's own single-instance lifecycle**
|
||||
> (`MANAGED_SESSION`/`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` + the restore worker) and makes only
|
||||
> its **restore policy-driven**: `schedule_restore_tv_session` reads `restore_delay()` = the
|
||||
> `keep_alive` policy (`off`→0s, `duration`→N s, **`forever`→never** = gaming-rig held, unconfigured
|
||||
> →5s bit-for-bit). Plus **crash-restore persistence** (`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json`
|
||||
> → `restore_takeover_on_startup`) and the **SIGKILL-teardown** experiment in `stop_session`
|
||||
> (followups.md #1/#7). *Not done:* `/display/release` freeing a `forever`-held managed session
|
||||
> (managed isn't a registry entry) — a forever managed box returns to gaming mode by manual switch
|
||||
> or host restart (documented; acceptable for a dedicated couch appliance).
|
||||
> - **A4 (session epoch) — DONE.** `session_epoch()`/`bump_session_epoch()` + `ActiveSession.compositor_pid`;
|
||||
> `observe_session_instance()` (called from the per-connect resolve, the mid-stream watcher, the
|
||||
> capture-loss re-detect, and the GameStream acquire) bumps the epoch + `registry::invalidate_backend`
|
||||
> on a compositor-instance change (kind change OR same-kind restart). `take_expired` also reaps
|
||||
> kept dead-epoch entries.
|
||||
> - **A5 (addressed discovery) — PARTIAL.** Per-spawn **log** (`$XDG_RUNTIME_DIR/punktfunk-gamescope-<inst>.log`)
|
||||
> + **scoped node discovery** (`find_gamescope_node_scoped` by `application.process.id` /
|
||||
> `descends_from`) — fixes concurrent bare-spawn VIDEO-node ambiguity (the load-bearing correctness
|
||||
> part). **DEFERRED:** the per-instance **EIS input relay** — the injector-coupling rework the map
|
||||
> warned about ("EIS setup timed out"); input for concurrent gamescopes stays on the global relay,
|
||||
> which is exactly `design/gamescope-multiuser.md` scope. Noted there.
|
||||
> - **B0 (dedicated routing) — DONE.** `GameSession {Auto, Dedicated}` — a **top-level** `DisplayPolicy`
|
||||
> field (NOT in `EffectivePolicy`, so presets don't clobber it). `pick_gamescope_mode` gains a leading
|
||||
> `dedicated_launch` that forces `Spawn` (below explicit operator env, above managed-infra/foreign);
|
||||
> `wants_dedicated_game_session()` (launch present ∧ policy dedicated ∧ gamescope available, else
|
||||
> honest `auto` fallback) → `resolve_compositor(pref, dedicated)` forces gamescope; threaded through
|
||||
> `apply_input_env(chosen, dedicated_launch)`. Steam URIs are shaped `steam -silent steam://…`
|
||||
> (`shape_dedicated_command`). GameStream shares the dispatch.
|
||||
> - **B1 (Steam single-instance takeover) — FOCUSED.** A dedicated **Steam** spawn frees the box's
|
||||
> autologin/game-mode Steam first (`is_steam_launch` → `stop_autologin_sessions`, restored on session
|
||||
> end via the A3 machinery). *Not done:* force-releasing a *kept dedicated Steam* session for a
|
||||
> *different* Steam game (two concurrent dedicated Steam sessions) — rare; documented degradation.
|
||||
> - **B2 (game-exit clean end) — DONE.** A dedicated gamescope session whose node vanishes
|
||||
> (`dedicated_game_exited()` — gamescope is single-app, dies with its game) ends the session with the
|
||||
> new `APP_EXITED_CLOSE_CODE` (0x52) so launcher clients return to their library, instead of the 40 s
|
||||
> capture-loss rebuild timeout.
|
||||
> - **B3 (docs/console) — this block + docs-site + CLAUDE.md; the web console toggle is the remaining
|
||||
> surface.** The `game_session` axis is in the API + OpenAPI now.
|
||||
>
|
||||
> **Adversarial review pass (2026-07-05):** a multi-agent review confirmed 11 findings, all fixed:
|
||||
> (1) `registry::acquire` held the pool mutex across the `pw-dump` liveness probe → the probe now runs
|
||||
> OUTSIDE the lock (snapshot candidate under lock → probe → re-lock + re-find by gen); (2–5,7,10) the
|
||||
> session epoch / `invalidate_backend` wrongly tore down **independent gamescope spawns** on any Gaming
|
||||
> pid flap (the CRITICAL "dedicated Steam on a game-mode box self-destructs" — B1 stopping the autologin
|
||||
> flipped the winning PID) → `observe_session_instance` now acts ONLY on **desktop** compositor changes,
|
||||
> and `epoch_matches` exempts gamescope from the epoch (reuse + reap); (3) a dedicated-Steam reconnect
|
||||
> didn't cancel the pending TV restore (reuse skips `create`) → `cancel_pending_tv_restore()` on every
|
||||
> connect; (4,8) B2 game-exit used the UNSCOPED node scan → now scoped to the session's own `node_id`
|
||||
> (threaded through the pipeline); (9) an unknown launch id → blank gamescope → dedicated now gated on
|
||||
> the launch RESOLVING to a command. All green after fixes (`test --workspace` 360).
|
||||
>
|
||||
> **Remaining before this is "shipped":** Linux on-glass validation (`.116` Bazzite-KWin game-mode
|
||||
> reconnect under presets, `.21`/`.116` dedicated launch, gaming-rig-forever held-managed, crash-restore,
|
||||
> SIGKILL-teardown F44 check), and the two documented deferrals (A5 per-instance EIS relay →
|
||||
> gamescope-multiuser; B1 concurrent-dedicated-Steam release).
|
||||
>
|
||||
> The **PLAN as originally written follows** — it still reads as "PLANNED"; the status block above is
|
||||
> the authority on what actually shipped and where it deviates.
|
||||
|
||||
Companion docs: `design/display-management.md` (the policy/registry layer this reconciles — its
|
||||
§7 capability matrix and §5.1 gamescope semantics are amended here),
|
||||
`design/session-aware-host-followups.md` (open session-switch limitations #1/#7 are resolved by
|
||||
Part A3 below), `design/gamescope-multiuser.md` (per-session input/audio isolation — adjacent;
|
||||
Part B deliberately stops short of it and cross-references instead).
|
||||
|
||||
## 1. The two problems
|
||||
|
||||
**(A) The display-management rewrite does not compose with Bazzite game mode / KDE switching.**
|
||||
The registry (`vdisplay/registry.rs`) assumes it owns every display it pools: it holds the
|
||||
backend keepalive, decides linger/pin/teardown from the policy, and reuses kept displays by
|
||||
`(backend, mode)`. On a session-mobile box that assumption is false twice over — the gamescope
|
||||
managed/attach paths hand it outputs whose lifecycle is owned by *other* machinery
|
||||
(`MANAGED_SESSION` + the debounced TV-restore worker), and the compositor under **every** Linux
|
||||
backend can be killed and replaced at any moment by a Game↔Desktop switch. The result is pool
|
||||
entries that lie: they linger or pin while the session behind them is torn down, then get reused
|
||||
as dead nodes and wedge the whole retry budget.
|
||||
|
||||
**(B) Library launches deserve a first-class "just the game" mode.** Today a library launch on a
|
||||
Bazzite box rides the managed `gamescope-session-plus` Steam session (Big Picture at the client's
|
||||
mode, launch forwarded into the running Steam) — the game appears, but inside game mode's whole
|
||||
UX, with Steam BPM boot time and game-mode ownership of the box. The bare-spawn path that nests
|
||||
*just* the resolved command in a headless gamescope already exists and works — but the sub-mode
|
||||
ladder makes it unreachable exactly where users want it most (any box with session-plus/SteamOS
|
||||
infra picks managed). The ask: a host option so a library launch **always** gets a dedicated
|
||||
gamescope session at the client's requested mode — game boots directly; non-Steam titles
|
||||
instantly, Steam titles without any UI to navigate.
|
||||
|
||||
These are one design because both reduce to the same question: **who owns a gamescope session's
|
||||
lifecycle, and how does the registry know?** Part A answers it for what exists; Part B builds the
|
||||
new launch mode on the answer.
|
||||
|
||||
## 2. Failure inventory (code-anchored)
|
||||
|
||||
What actually goes wrong today, in dependency order:
|
||||
|
||||
1. **The registry pools externally-owned outputs.** `registry::linux::acquire` pools every output
|
||||
with `remote_fd == None`. The gamescope **managed**, **SteamOS-takeover**, and **attach** paths
|
||||
all return `remote_fd: None` with `keepalive: Box::new(())` (`gamescope.rs::create_managed_session`
|
||||
/ `create_managed_session_steamos` / the attach arm) — a unit keepalive that keeps *nothing*
|
||||
alive. The pool entry claims ownership of a display it cannot hold or tear down.
|
||||
2. **Two lifecycle owners for the managed session → stale-node reuse wedge.** The real managed
|
||||
lifecycle is `MANAGED_SESSION` + `PENDING_RESTORE` + `RESTORE_DEBOUNCE` (hardcoded 5 s) + the
|
||||
restore worker, which stops the session and restarts the TV autologin. With any `keep_alive`
|
||||
policy configured, a disconnect leaves a registry entry Lingering (e.g. 300 s) while the restore
|
||||
worker kills the session at 5 s. A reconnect inside the linger window **reuses the dead
|
||||
`node_id`**; capture fails "no PipeWire frame within 10s"; the capturer drop returns the entry to
|
||||
Lingering; `build_pipeline_with_retry` re-acquires the **same corpse** on every attempt — all 8
|
||||
retries wedge (~90 s). This is the `.41`-class "game-mode reconnect broken" symptom, now
|
||||
*manufacturable on any Bazzite box by clicking a console preset*.
|
||||
3. **`gaming-rig` (keep_alive=forever) is a lie on gamescope.** Design §5.1 promised "the policy
|
||||
duration replaces the hardcoded 5 s debounce; forever = the TV session is never auto-restored".
|
||||
Never implemented: the restore worker doesn't read the policy, so `forever` pins an entry whose
|
||||
session the worker restores away regardless.
|
||||
4. **A session switch leaves zombie entries.** The mid-stream watcher rebuild and the per-connect
|
||||
re-detect drop the old backend's lease → the old entry lingers/pins **for a compositor that no
|
||||
longer exists** (KWin dies on the switch to game mode; the game-mode gamescope dies on the switch
|
||||
to desktop). `/display/state` lies; expiry drops keepalives into dead compositors; a
|
||||
Desktop→Game→Desktop bounce brings up a *new* KWin whose node-id space has no relation to the
|
||||
kept entry's — reuse hands out a number that now means nothing.
|
||||
5. **Discovery is ambient and ambiguous.** One shared spawn log (`/tmp/punktfunk-gamescope.log`),
|
||||
name-based node discovery (`find_gamescope_node` returns *any* `gamescope` `Video/Source`), and
|
||||
one global EIS relay file (`punktfunk-gamescope-ei`). Correct only while at most one gamescope
|
||||
exists per uid. A kept spawn + live game mode, two concurrent spawns, or (Part B) a dedicated
|
||||
session next to game mode can capture the wrong node and inject into the wrong session.
|
||||
6. **Stock Bazzite restarts the systemd user manager on every Game↔Desktop switch** (observed on
|
||||
`.41`: user-manager PID churn), killing the host, the compositor, and the pool together. Inherent
|
||||
— keep-alive **cannot** span a switch on a stock setup (the headless-appliance setup —
|
||||
`enable-linger` + `multi-user.target` — keeps the host alive and can). Separately, the takeover
|
||||
bookkeeping (`STOPPED_AUTOLOGIN`, `STEAMOS_TOOK_OVER`, the SteamOS drop-in) is process memory
|
||||
only: a host crash mid-stream strands the box out of game mode with no restore on restart.
|
||||
|
||||
## 3. Design principles
|
||||
|
||||
- **One owner per display.** Every `VirtualOutput` declares who owns its lifecycle; the registry
|
||||
pools only what it owns. This extends the CLAUDE.md invariant ("display lifecycle is owned by
|
||||
the registry; sessions hold leases") with its honest converse: *what the registry does not own,
|
||||
it must not pretend to keep.*
|
||||
- **Keep-alive is an optimization, never a failure mode** (display-management §3, now enforced):
|
||||
reuse validates liveness; a failed reuse invalidates and creates fresh; the retry budget is
|
||||
never spent twice on the same corpse.
|
||||
- **The compositor instance, not the host process, is the unit of session truth.** A session
|
||||
epoch invalidates every display created under a previous compositor instance — kind changes
|
||||
*and* same-kind restarts.
|
||||
- **Addressed, not ambient, discovery.** Every spawned gamescope gets its own log, its own node
|
||||
resolution, its own EIS relay. Global singletons are what break the moment two sessions coexist.
|
||||
- **Launch identity is part of display identity.** A kept display running game A must never be
|
||||
handed to a session asking for game B.
|
||||
|
||||
## 4. Part A — ownership & session mobility
|
||||
|
||||
### A1. Ownership classes (the structural fix — ship first)
|
||||
|
||||
`VirtualOutput` gains an ownership declaration:
|
||||
|
||||
```rust
|
||||
pub enum DisplayOwnership {
|
||||
/// The registry owns lifecycle: pool, linger, pin, tear down. (KWin, Mutter, gamescope
|
||||
/// SPAWN, Windows manager-delegated.)
|
||||
Owned,
|
||||
/// Someone else's display, mirrored: no keep-alive, no topology, no reuse — codifies the
|
||||
/// display-management §7 "attach = unmanaged pass-through" row the code never implemented.
|
||||
/// (gamescope ATTACH; wlroots stays gated on remote_fd as today.)
|
||||
External,
|
||||
/// A box-level session the gamescope module manages (managed session-plus / SteamOS
|
||||
/// takeover). Pass-through at A1 (capturer owns the lease as pre-Stage-1); A3 converts it
|
||||
/// to Owned by giving the registry the real keepalive + restore duty.
|
||||
SessionManaged,
|
||||
}
|
||||
```
|
||||
|
||||
The registry pools `Owned` only; `External`/`SessionManaged` pass through unchanged (today's
|
||||
pre-registry behavior — teardown-on-capturer-drop is a no-op for their unit keepalives, and the
|
||||
existing gamescope-module machinery keeps doing what it does). `remote_fd == Some` keeps its gate.
|
||||
|
||||
Effect: failures 1–3 stop being reachable from the console — a preset can no longer create lying
|
||||
entries for game-mode sessions. Smallest possible diff; everything else layers on it. Until A3
|
||||
lands, keep-alive on gamescope managed/attach honestly reports **unsupported** in
|
||||
`/display/state` capabilities instead of pretending.
|
||||
|
||||
### A2. Validated reuse, invalidation, launch key
|
||||
|
||||
- **Launch key.** `Entry` gains `launch: Option<String>` (the resolved launch command the display
|
||||
was created with; `None` = plain desktop). Reuse requires `(backend, mode, launch)` equality.
|
||||
Without this, a kept spawn running game A is reused for a session that asked to launch game B —
|
||||
latent today (unconfigured Linux lingers Immediate), live the moment keep-alive + launches
|
||||
combine, load-bearing for Part B.
|
||||
- **Liveness probe at reuse.** New trait method, default honest:
|
||||
`fn kept_display_alive(&mut self, node_id: u32) -> bool { true }` — gamescope-spawn checks the
|
||||
child (`try_wait`) *and* the node; KWin/Mutter check the node still exists on the default
|
||||
daemon (one cheap PipeWire registry roundtrip — no capture attach). A dead entry is torn down
|
||||
(its group restore handed off / run) and the acquire falls through to a fresh create.
|
||||
- **Invalidation on reuse failure.** `acquire` marks the returned `VirtualOutput` as reused (a
|
||||
flag on the lease/gen); when `build_pipeline`'s first-frame fails on a **reused** display, it
|
||||
calls `registry::mark_failed(gen)` before returning the error — the entry is torn down, so the
|
||||
retry loop's next `acquire` **creates fresh** instead of re-wedging on the same corpse. This is
|
||||
the direct fix for failure 2's 8×10 s wedge shape, and it holds for every future way a kept
|
||||
display can silently die.
|
||||
|
||||
### A3. One owner for the managed session (registry-owned restore)
|
||||
|
||||
The managed/SteamOS paths become `Owned` by giving the registry the two things it lacks:
|
||||
|
||||
- **A real keepalive.** `create_managed_session*` returns a `ManagedSessionHandle` whose `Drop`
|
||||
performs today's `do_restore_tv_session` duty: stop the transient unit / remove the SteamOS
|
||||
drop-in, then restart the autologin **iff no desktop session is active** (the existing guard
|
||||
moves in verbatim — a user who switched to KDE mid-linger is never yanked back to game mode).
|
||||
- **The policy as the debounce.** The registry linger *is* the restore delay: unconfigured
|
||||
default = 5 s (bit-for-bit today's `RESTORE_DEBOUNCE`), a configured duration replaces it,
|
||||
`forever` = never auto-restore — `gaming-rig` finally means on a Bazzite couch box what its
|
||||
story says, released via `/display/release` or the tray. A reconnect inside the linger is a
|
||||
registry **reuse** (the same warm-session fast path `PENDING_RESTORE`-cancel gives today, now
|
||||
with A2 validation); a different requested mode tears down + relaunches through the registry
|
||||
(gamescope's honest "reconfigure = recreate").
|
||||
- **Retire the parallel machinery.** `PENDING_RESTORE`, `RESTORE_DEBOUNCE`,
|
||||
`start_restore_worker`, and the `restore_managed_session()` call sites go away — the registry
|
||||
linger timer is the one timer. `MANAGED_SESSION` survives only as the module's mode/unit cache.
|
||||
`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` stay as the *mechanics* the handle's Drop consumes.
|
||||
- **Persist the takeover.** The stopped-unit list + SteamOS-drop-in marker are written to
|
||||
`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json` at takeover, cleared at restore. On host
|
||||
startup, a leftover file (crash / service restart mid-stream) schedules a restore after a short
|
||||
reconnect grace — with the same active-desktop guard. A crashed host no longer strands the TV.
|
||||
- **Teardown signal.** Adopt the parked follow-ups doc #1 experiment here: the handle's stop uses
|
||||
`systemctl --user kill --signal=SIGKILL <unit>` (+ `stop`/`reset-failed` to clear unit state)
|
||||
instead of plain SIGTERM stop, testing the hypothesis that skipping gamescope's crashy SIGTERM
|
||||
teardown avoids the F44 GPU-context leak. A3's validation pass is the natural place to measure
|
||||
it; fall back to SIGTERM if SIGKILL misbehaves. Follow-ups #7 (restore-guard/keep-warm
|
||||
interaction) dissolves: "keep warm" is now just `keep_alive: forever`.
|
||||
|
||||
### A4. Session epoch & backend invalidation
|
||||
|
||||
- **`vdisplay::session_epoch()`** — a host-global counter bumped whenever session detection
|
||||
observes a different compositor **instance**: an `ActiveKind` change *or* a new compositor PID
|
||||
for the same kind (the Desktop→Game→Desktop bounce). Entries stamp their creation epoch; reuse
|
||||
requires an epoch match; the linger timer reaps entries from dead epochs (their keepalive Drops
|
||||
hit dead sockets and fail fast; the registry already drops outside the lock).
|
||||
- **Watcher hook.** A `SessionSwitch` (and a per-connect re-detect that finds the previous
|
||||
backend's compositor gone) additionally calls `registry::invalidate_backend(old)` so
|
||||
`/display/state` is honest immediately rather than at the next expiry poll.
|
||||
- **Stock-Bazzite user-manager restarts** stay inherent: the host dies with the switch, the pool
|
||||
dies with the host, and that is documented ("keep-alive spans a Game↔Desktop switch only on the
|
||||
headless-appliance setup"). The persisted takeover file (A3) is what survives.
|
||||
|
||||
### A5. Addressed discovery (Part-B prerequisite)
|
||||
|
||||
- **Per-spawn log**: `$XDG_RUNTIME_DIR/punktfunk-gamescope-<gen>.log`; the spawned instance's
|
||||
node id is parsed from *its* log only.
|
||||
- **Scoped node discovery**: PipeWire node props carry `application.process.id` — a spawn's node
|
||||
must belong to our child's PID tree (`descends_from`, already written); managed/attach
|
||||
discovery conversely **excludes** nodes owned by our spawned children. `find_gamescope_node`
|
||||
grows a scope parameter instead of "first node named gamescope".
|
||||
- **Per-instance EIS relay**: `punktfunk-gamescope-ei-<gen>`, path carried on `VirtualOutput`
|
||||
(the gamescope-multiuser doc's item 1), with the injector service resolving the session's own
|
||||
relay for gamescope sessions (a narrow slice of its item 2 — the shared injector stays for the
|
||||
portal backends, where shared input is correct and where per-session churn caused the historic
|
||||
"EIS setup timed out" wedge).
|
||||
|
||||
### Part A validation (on-glass, `.116` Bazzite KWin/AMD + Deck `.253`; `.21` for spawn-on-GNOME)
|
||||
|
||||
1. Game-mode reconnect: connect → disconnect → reconnect inside game mode, under *every* preset
|
||||
incl. `gaming-rig` (the failure-2/3 repro — must reuse the warm session or cleanly recreate,
|
||||
never wedge the retry budget).
|
||||
2. Game↔Desktop switch mid-linger both directions; Desktop→Game→Desktop bounce (epoch test);
|
||||
`/display/state` never lists a display whose compositor is dead.
|
||||
3. `gaming-rig` on Bazzite: TV stays off until `/display/release`; a KDE switch mid-linger is not
|
||||
yanked back to game mode.
|
||||
4. Kill -9 the host mid-takeover → restart → TV session restored (persisted-takeover test).
|
||||
5. Two gamescopes coexisting (kept spawn + game mode): capture and input land in the right one.
|
||||
|
||||
## 5. Part B — dedicated game sessions for library launches
|
||||
|
||||
### 5.1 What it is
|
||||
|
||||
A session that carries a launch id (native `Hello.launch`, the GTK `--browse`/`--launch` flows,
|
||||
Decky pins, GameStream apps with a library id) can be served by a **dedicated gamescope
|
||||
session**: a host-spawned headless gamescope at exactly the client's WxH@Hz whose nested command
|
||||
is the resolved game. No Steam Big Picture, no game mode, no desktop involvement. Session end
|
||||
returns the client to its launcher (already shipped behavior); the game survives disconnects per
|
||||
`keep_alive` — the Apollo-style detach/reattach the design always wanted, now per-game.
|
||||
|
||||
### 5.2 Policy surface
|
||||
|
||||
One new axis in `display-settings.json` (same store/PUT/console pattern; serde-defaulted so
|
||||
existing files are untouched):
|
||||
|
||||
```json5
|
||||
// How a session that LAUNCHES a game is served:
|
||||
// "auto" – today's routing: the launch rides whatever session the box is in (managed
|
||||
// Steam session on Bazzite/SteamOS, bare spawn on plain distros, spawned into
|
||||
// the live desktop on KWin/Mutter/wlroots)
|
||||
// "dedicated" – a launching session always gets its own headless gamescope at the client's
|
||||
// mode, nesting just the game
|
||||
"game_session": "auto"
|
||||
```
|
||||
|
||||
Sessions **without** a launch id are untouched by this axis — desktop streaming routes exactly as
|
||||
today. `dedicated` degrades honestly: no gamescope binary → log + fall back to `auto`. Console: a
|
||||
toggle on the Virtual displays card (it is a display-lifecycle decision) with one story line.
|
||||
Deliberate non-options for v1: per-entry overrides (schema keys allow a later
|
||||
`"per_entry": {"<id>": …}` overlay) and a client-requested Hello byte ("launch dedicated") — host
|
||||
policy first, protocol growth when a client actually wants to differ per connect.
|
||||
|
||||
### 5.3 Routing & command shaping
|
||||
|
||||
- **Sub-mode ladder.** `pick_gamescope_mode` (pure, unit-tested) gains a leading
|
||||
`dedicated_launch: bool` input that forces `Spawn`, outranking managed-infra/foreign-attach
|
||||
(explicit operator `MANAGED`/`ATTACH`/`NODE` envs still win — they are debug/CI overrides).
|
||||
`resolve_compositor` computes it: launch id present ∧ policy `dedicated` ∧ gamescope available
|
||||
→ chosen = `Gamescope`, spawn sub-mode; `launch_is_nested` then routes the command into the
|
||||
spawn as today.
|
||||
- **Non-Steam entries** (custom / lutris / heroic): the resolved command nests directly — truly
|
||||
instant (gamescope up in ~1 s, then the game's own boot).
|
||||
- **Steam entries** (`steam steam://rungameid/<id>`): Steam is single-instance per uid, so:
|
||||
1. Command shape becomes `steam -silent steam://rungameid/<id>` in dedicated mode — `-silent`
|
||||
suppresses the Steam main window so the game is the gamescope focus. **Empirical validation
|
||||
item** (behavior of `-silent` under a fresh nested Steam); fallback is the plain URI form
|
||||
(a briefly-visible Steam client, still no BPM navigation).
|
||||
2. If another same-uid Steam is live (game mode autologin, a kept managed session, a kept
|
||||
dedicated Steam session): **take Steam over first** — force-release kept entries whose
|
||||
`launch` is a Steam title, stop the autologin via the A3-owned takeover (persisted state,
|
||||
policy-driven restore). This reuses the exact machinery game mode streaming already needs;
|
||||
`dedicated` adds no new churn class.
|
||||
- **Game exit ends the session.** When the nested command exits (user quits the game), the
|
||||
gamescope child dies; today's capture-loss path would rebuild an empty session. Dedicated
|
||||
sessions instead end cleanly: the rebuild path consults the keepalive child (`try_wait`) and a
|
||||
confirmed child exit becomes a typed session end (host closes QUIC with a new
|
||||
`APP_EXITED_CLOSE_CODE`, sibling of `QUIT_CLOSE_CODE` 0x51, so launchers can distinguish "game
|
||||
ended" from an error) with `force_immediate` release. Clients need no change to *work* (they
|
||||
already return to the launcher on session end); the typed code is polish they can adopt.
|
||||
- **Mid-stream `Reconfigure`** on a dedicated session = teardown + respawn (gamescope cannot
|
||||
live-resize its output; the game restarts) — the same honest §7 caveat as managed, documented.
|
||||
|
||||
### 5.4 Lifecycle composition (where Part A pays off)
|
||||
|
||||
- Dedicated outputs are `Owned` (A1) with the child as a real keepalive → they pool naturally.
|
||||
- Reuse keys on `(backend, mode, launch)` (A2): reconnect to the same game re-attaches to the
|
||||
still-running session instantly; a different game never falsely reuses. `keep_alive` then reads
|
||||
exactly as the presets promise: `off` = game dies with the disconnect; a duration = the detach
|
||||
window; `forever` = the game runs until released (`gaming-rig`, per-game).
|
||||
- Each dedicated session is already its own registry **group** (`group_key` — no topology or
|
||||
restore interaction with the desktop); `max_displays` bounds how many can be kept; Steam
|
||||
titles are additionally bounded to one by the single-instance takeover above.
|
||||
- Input rides the per-instance EIS relay (A5); uinput gamepads are per-session already. Audio and
|
||||
mic stay the host-wide shared services — one *active* dedicated session is the designed case;
|
||||
concurrent independent-audio sessions are exactly `design/gamescope-multiuser.md` scope, not
|
||||
re-solved here.
|
||||
- Admission (`mode_conflict`) applies unchanged across clients; a second client asking for a
|
||||
different dedicated game under `separate` gets its own spawn (non-Steam) or the single-instance
|
||||
takeover rules (Steam) — the honest per-backend gating pattern.
|
||||
|
||||
### 5.5 GameStream
|
||||
|
||||
Same dispatch (the launch path was unified in the 2026-07-01 rebuild): an apps.json/library-id
|
||||
launch under `dedicated` spawns the same way; serverinfo/RTSP negotiate the client's mode as
|
||||
today. Moonlight's quit-app (`h_cancel`) maps to a `force_immediate` release — killing the game
|
||||
on explicit quit — which folds into the already-deferred "GameStream quit-code" follow-up from
|
||||
display-management §5.1.
|
||||
|
||||
### 5.6 Client experience & latency honesty
|
||||
|
||||
No client changes are required: GTK `--browse`/`--launch`, Decky pins, and the Apple/Android
|
||||
library grids just launch, and session end already returns to the launcher. Boot-time
|
||||
expectations, stated plainly in docs: non-Steam titles are gamescope-spawn (~1 s) + game boot;
|
||||
Steam titles pay the Steam client's own cold boot inside the fresh session (~10–25 s class)
|
||||
before the game process starts — "no UI to navigate", not "zero seconds". A pre-warmed parked
|
||||
Steam (`steam -silent` held inside a background headless gamescope at host start) would close
|
||||
that gap but fights game mode over the single instance — explicitly out of scope for v1, noted
|
||||
as the one candidate v2 if Steam cold boot proves to be the complaint. **Stretch** (needs a small
|
||||
mgmt surface, not v1): launchers showing "Resume" for a game the host reports as kept
|
||||
(`/display/state` already exposes the entries; adding `launch` to `DisplayInfo` is the only
|
||||
schema growth).
|
||||
|
||||
## 6. Staging & dependencies
|
||||
|
||||
| Stage | Contents | Depends on | Validation |
|
||||
|---|---|---|---|
|
||||
| **A1** | `DisplayOwnership`, pool `Owned` only, honest capabilities | — | unit + `.116` game-mode reconnect under presets no longer wedges |
|
||||
| **A2** | launch key, `kept_display_alive`, `mark_failed` on reused-display capture failure | A1 | probe-driven: kill a kept display's session → reconnect creates fresh on attempt 2 |
|
||||
| **A3** | `ManagedSessionHandle`, policy-as-debounce, retire restore worker, persisted takeover, SIGKILL-teardown experiment | A1 | `.116`/Deck: gaming-rig semantics, crash-restore, desktop-guard |
|
||||
| **A4** | session epoch, `invalidate_backend` on switch | A1 | `.116`: switch matrix incl. same-kind bounce; `/display/state` honesty |
|
||||
| **A5** | per-spawn log, scoped node discovery, per-instance EIS relay | — (parallel) | two-gamescope coexistence test |
|
||||
| **B0** | `game_session` policy + ladder input + steam `-silent` shaping | A1 (correctness), A5 (if game mode may be live) | `.21`/plain box: non-Steam + Steam dedicated launch on glass |
|
||||
| **B1** | Steam takeover integration (autologin stop / kept-Steam release) | A3 | `.116`/Deck: dedicated launch from game mode, TV restore per policy |
|
||||
| **B2** | reuse-by-launch reattach + game-exit-ends-session (`APP_EXITED` close) | A2 | disconnect → game keeps running → reattach; quit game → launcher |
|
||||
| **B3** | docs (virtual-displays + steamos-host pages), console toggle polish, "Resume" stretch | B0–B2 | — |
|
||||
|
||||
Every stage lands green (`cargo test/clippy/fmt`, OpenAPI drift) and independently shippable,
|
||||
per the display-management discipline. A1+A2 alone fix the user-visible breakage; A3 makes the
|
||||
presets truthful; B0 is the first user-visible new capability.
|
||||
|
||||
## 7. Risks & open questions
|
||||
|
||||
- **`steam -silent` inside a fresh nested gamescope** — the load-bearing empirical unknown for
|
||||
B0's Steam polish (the launch itself works either way; only the cosmetic Steam-window flash is
|
||||
at stake). Validate first on `.21`/`.116` desktop mode.
|
||||
- **Bare spawn vs session-plus environment** — session-plus wraps Steam in MangoApp/runtime/
|
||||
controller-config scaffolding a bare `gamescope -- steam` lacks. The historic "nested Steam
|
||||
crashes" finding was Steam-vs-Steam single-instance (both dying), *not* a missing-scaffolding
|
||||
failure — with Steam taken over first, a bare spawn should hold, but this is exactly what B1's
|
||||
on-glass pass must prove per box (Bazzite, Deck).
|
||||
- **PipeWire `application.process.id` availability** across gamescope versions (A5's scoped
|
||||
discovery) — fall back to log-derived ids (per-spawn logs make those unambiguous already).
|
||||
- **Keepalive drops into dead compositors** (A4 reaping): Wayland conns fail fast; Mutter's D-Bus
|
||||
`Stop` can block — the registry already drops outside the lock, keep it that way and bound the
|
||||
damage to one reaper tick.
|
||||
- **Epoch granularity**: detecting "new compositor instance, same kind" needs the compositor PID
|
||||
in `ActiveSession` — cheap (the `/proc` scan already visits it), but the watcher must debounce
|
||||
PID flaps during a switch (its existing 3 s debounce covers it).
|
||||
- **Injector rework caution** (A5): per-session EIS injectors only for gamescope; the shared
|
||||
service stays for portal backends — re-learning the "EIS setup timed out" lesson is the failure
|
||||
mode to guard in review.
|
||||
- **Windows**: entirely untouched — `game_session` is Linux-only for now (`launch_title` on
|
||||
Windows opens via the shell into the one desktop); the policy field documents that honestly
|
||||
rather than pretending.
|
||||
@@ -20,6 +20,14 @@ virtual output primary** — `apply_session_env` defaults `PUNKTFUNK_KWIN_VIRTUA
|
||||
`PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` on for the auto desktop path. Both shipped in `3363576`; details in
|
||||
git history.)
|
||||
|
||||
> **Update 2026-07-05:** items **#1** (SIGKILL teardown / keep-warm) and **#7** (restore-guard /
|
||||
> keep-warm interaction) are picked up by `design/gamemode-and-dedicated-sessions.md` **Part A3**
|
||||
> — the managed session's restore becomes registry-owned and policy-driven (`keep_alive` replaces
|
||||
> the hardcoded 5 s debounce; "keep warm" = `keep_alive: forever`), with the SIGKILL-teardown
|
||||
> experiment folded into that stage's validation. That doc also covers the display-management
|
||||
> registry's game-mode conflicts (ownership classes, stale-node reuse) and dedicated per-launch
|
||||
> gamescope game sessions.
|
||||
|
||||
## Still parked
|
||||
|
||||
### 1. F44 gamescope teardown corrupts the GPU context
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Zero-copy capture hardening — issue handoff
|
||||
|
||||
> **Status: HANDOFF — issue description only (2026-07-06).** This document describes a reproduced
|
||||
> host **SIGSEGV** in the Linux zero-copy capture path. It deliberately does **not** prescribe a fix —
|
||||
> the next agent plans the implementation. Everything below is observed fact + root-cause analysis;
|
||||
> the "Considerations / open questions" section frames the solution space without committing to one.
|
||||
>
|
||||
> **This is a pre-existing capture-layer issue, NOT a regression from the gamemode/dedicated-sessions
|
||||
> work** (`design/gamemode-and-dedicated-sessions.md`). The crashing code
|
||||
> (`crates/punktfunk-host/src/linux/zerocopy/{cuda,egl}.rs`, `capture/linux/pipewire.rs`) is untouched
|
||||
> by that branch; it merely surfaced during on-glass validation of it.
|
||||
|
||||
## 1. What happened
|
||||
|
||||
On **`.181`** (Bazzite F44, RTX 4090, NVIDIA open driver 610.43.02 — see
|
||||
`[[gamemode-onglass-181-2026-07-06]]`), streaming a game at the client's mode worked smoothly with
|
||||
**zero-copy enabled** (`PUNKTFUNK_ZEROCOPY=1`). When the user then **switched the box from Steam Game
|
||||
Mode to the KDE/Plasma desktop mid-stream**, the host process **crashed (SIGSEGV, core dumped)** and
|
||||
the client saw "session ended". systemd auto-restarted the host ~3 s later.
|
||||
|
||||
The host's own logic on the switch was **correct** up to the crash — the session watcher detected
|
||||
`Gaming → DesktopKde`, bumped the session epoch, rebuilt the backend to KWin, brought up the virtual
|
||||
output at 5120×1440@240, set it sole desktop, and began PipeWire capture. The crash came **after**
|
||||
that, the first time the capture thread mapped a KWin frame into CUDA.
|
||||
|
||||
## 2. The crash (backtrace)
|
||||
|
||||
`coredumpctl` for the host process, crashing thread:
|
||||
|
||||
```
|
||||
Signal: 11 (SEGV)
|
||||
#0 libnvidia-eglcore.so.610.43.02 ← SIGSEGV inside the NVIDIA driver
|
||||
#1 libnvidia-eglcore.so.610.43.02
|
||||
#2 libnvidia-eglcore.so.610.43.02
|
||||
#3 libnvidia-eglcore.so.610.43.02
|
||||
#4 libEGL_nvidia.so.0
|
||||
#5 libcuda.so.1
|
||||
#6 libcuda.so.1
|
||||
#7 cuGraphicsMapResources (libcuda.so.1)
|
||||
#8 punktfunk_host::zerocopy::cuda::RegisteredTexture::copy_mapped_plane
|
||||
#9 punktfunk_host::zerocopy::egl::EglImporter::import_inner
|
||||
#10 punktfunk_host::capture::linux::pipewire::pipewire_thread::{{closure}} (the PipeWire on_process callback)
|
||||
#11 pipewire::stream::…::on_process
|
||||
#12 libpipewire-0.3.so.0 (impl_node_process_input → process_node → node_on_fd_events → pw_main_loop_run)
|
||||
```
|
||||
|
||||
The fault is **inside `libnvidia-eglcore`, reached through `cuGraphicsMapResources`** — i.e. CUDA was
|
||||
asked to map a GL/EGL-imported resource, and the driver dereferenced GPU state that was no longer
|
||||
valid.
|
||||
|
||||
## 3. The code path
|
||||
|
||||
This is the **tiled-dmabuf zero-copy path** used for compositors that hand out **tiled** buffers
|
||||
(KWin, and the NVIDIA tiled path generally): PipeWire dmabuf → **EGL/GL import** (`EglImporter`,
|
||||
`crates/punktfunk-host/src/linux/zerocopy/egl.rs`) → **register as a CUDA graphics resource** and
|
||||
**map per frame** (`RegisteredTexture::copy_mapped_plane` / `copy_mapped_to`,
|
||||
`crates/punktfunk-host/src/linux/zerocopy/cuda.rs:810-848` and the sibling `copy_mapped_plane` below
|
||||
it). The crash is at the `cuGraphicsMapResources(1, &mut self.resource, …)` call
|
||||
(`cuda.rs:824-828`), driven per frame from the PipeWire `on_process` callback
|
||||
(`capture/linux/pipewire.rs`, the closure at backtrace frame #10).
|
||||
|
||||
The relevant `// SAFETY:` proof (`cuda.rs:814-823`) reasons that `self.resource` is a valid
|
||||
`CUgraphicsResource` from a successful `register_gl`, the GL+CUDA contexts are current, and the
|
||||
map/unmap pair is balanced with the copy synced before unmap. **That reasoning is sound for a
|
||||
well-behaved compositor that keeps the imported dmabuf alive across the map** — it does not cover the
|
||||
case where the **producer invalidates the underlying buffer/texture out from under an in-flight
|
||||
map**.
|
||||
|
||||
**Not affected on this box:** the **gamescope** path (LINEAR dmabuf → **Vulkan bridge** → CUDA,
|
||||
`linux/zerocopy/vulkan.rs`) did **not** crash — game-mode streaming was smooth. And the **non-zero-copy
|
||||
SHM/CPU path** (`PUNKTFUNK_ZEROCOPY=0`, the NVENC default) has no EGL/CUDA import at all, so there is
|
||||
nothing for the driver to fault on. So the fault is specific to the **EGL/GL→CUDA tiled import**, not
|
||||
zero-copy in general.
|
||||
|
||||
## 4. Root cause
|
||||
|
||||
`cuGraphicsMapResources` faulted **inside the driver** on GPU state that had become invalid — almost
|
||||
certainly because the **KWin compositor freed/recycled (or crashed with) the dmabuf** that our
|
||||
`EglImporter` had imported and registered, while our capture thread was mapping it for the next frame.
|
||||
Strong corroborating evidence: in the **same 8-second window**, the box logged core dumps for
|
||||
`plasmashell` (×2), `Xwayland`, `gamescope`, and `mangoapp`. So the F44 Game↔Desktop transition on
|
||||
this box is itself highly unstable, and the KWin buffer our zero-copy path held a handle to almost
|
||||
certainly went away mid-map.
|
||||
|
||||
**Why this is a real host-robustness gap, not just "the box is flaky":** a **SIGSEGV inside a
|
||||
closed-source driver (`libnvidia-eglcore`) is not catchable from Rust** — it is not a `Result`, not a
|
||||
Rust panic, not something a `catch_unwind` can contain. So *any* time the producer's buffer can vanish
|
||||
between "we hold a CUDA graphics resource for it" and "we map it" (compositor crash, buffer-pool
|
||||
recycle, output/mode teardown, hot-unplug), the driver can take the whole host process down. A capture
|
||||
pipeline that must survive a compositor going away (which the host already tries to do — it has a
|
||||
capture-loss → rebuild path) cannot rely on `cuGraphicsMapResources` returning an error on a stale
|
||||
resource; the driver may just crash instead.
|
||||
|
||||
## 5. Trigger conditions (what invalidates the imported buffer)
|
||||
|
||||
The observed trigger was a **compositor crash during a Game→Desktop switch**, but the same class of
|
||||
fault can be reached by anything that frees/recycles the imported dmabuf or its GL texture while a map
|
||||
is in flight or a `RegisteredTexture` still references it:
|
||||
- compositor crash / restart (observed);
|
||||
- normal PipeWire buffer-pool recycle / renegotiation (format change, buffer count change) where a
|
||||
registered texture outlives the buffer it wrapped;
|
||||
- virtual-output teardown / mode change (e.g. the mid-stream `Reconfigure`, the session-switch
|
||||
rebuild) racing an in-flight map;
|
||||
- output removal / disconnect.
|
||||
The next agent should establish **which of these are actually reachable** in the current code (the
|
||||
per-frame registration/lifetime in `EglImporter`/`RegisteredTexture` vs. the PipeWire buffer
|
||||
lifecycle) versus only the compositor-crash case.
|
||||
|
||||
## 6. Scope
|
||||
|
||||
- **Affected:** the EGL/GL→CUDA tiled-import path (`zerocopy::egl` + `zerocopy::cuda`), driven from
|
||||
`capture/linux/pipewire.rs`. On NVIDIA this is used for tiled dmabufs (KWin desktop capture is the
|
||||
concrete case here; the NVIDIA tiled path in general).
|
||||
- **Likely also worth auditing (same class):** the **Vulkan bridge** path (`zerocopy::vulkan.rs`, LINEAR
|
||||
dmabuf → Vulkan → CUDA) — it did not crash here, but it imports external dmabufs into the GPU too and
|
||||
may have the same "producer freed the buffer mid-use" exposure; confirm whether it validates/owns the
|
||||
buffer lifetime differently.
|
||||
- **Not affected:** the SHM/CPU capture path (no GPU import).
|
||||
|
||||
## 7. What is NOT the cause (to save the next agent time)
|
||||
|
||||
- **Not the gamemode/dedicated-sessions branch.** That branch's switch logic worked correctly
|
||||
(epoch bump, watcher rebuild to KWin, virtual output up); the crash is downstream in pre-existing
|
||||
capture code it doesn't touch.
|
||||
- **Not a `.desktop`/KWin authorization problem.** The KWin virtual output was created and set sole
|
||||
desktop successfully — auth was fine.
|
||||
- **Not the gamescope "out of buffers" issue** from the same validation session (that was a separate
|
||||
gamescope-3.16.19 PipeWire-node limitation on SHM). This is a hard driver SEGV on the GPU-import path.
|
||||
|
||||
## 8. Observed mitigation (already available, not the fix)
|
||||
|
||||
Setting **`PUNKTFUNK_ZEROCOPY=0`** (SHM/CPU path — the NVENC default anyway; the box had it forced
|
||||
`=1`) removes the EGL/CUDA import, so this crash cannot occur and a compositor going away degrades to a
|
||||
graceful capture-loss rebuild. Cost: a CPU copy per frame (higher latency than the zero-copy stream the
|
||||
user measured). This is an operational workaround, **not** a code fix, and it forfeits zero-copy on the
|
||||
desktop path.
|
||||
|
||||
## 9. Considerations / open questions for the implementation plan (do not treat as a prescription)
|
||||
|
||||
These frame the solution space; the next agent decides.
|
||||
- **A driver SIGSEGV is uncatchable in-process.** Any design that "handles" this by wrapping the FFI
|
||||
call in error handling will not work — the process is already dead. So the fix has to be about
|
||||
**never handing the driver a resource that can be stale**, or **isolating the GPU-import work** so a
|
||||
driver crash doesn't take the streaming host down. Both directions are open:
|
||||
- *Prevent-the-stale-resource* directions to evaluate: strict per-frame import/register/map/unmap
|
||||
lifetime tied to the exact PipeWire buffer being processed (so no `RegisteredTexture` outlives its
|
||||
buffer); detecting compositor/output teardown and stopping capture **before** the next map;
|
||||
reconciling the EGL texture / CUDA resource lifetime with PipeWire's buffer-recycle events.
|
||||
Establish whether the current code can ever map a resource whose buffer PipeWire has already
|
||||
recycled/removed.
|
||||
- *Isolate-the-crash* directions to evaluate: whether the GPU import belongs in a **separate process**
|
||||
(like the Windows two-process/DDA isolation model) so a driver SEGV is contained and the session
|
||||
can rebuild — heavier, but the only thing that truly survives an unpreventable driver fault.
|
||||
- **Per-backend / per-buffer-type routing.** The Vulkan-bridge path did not crash; the SHM path is
|
||||
safe. A plan might route tiled dmabufs (KWin) away from the fragile EGL/CUDA path, or only enable the
|
||||
EGL/CUDA path where the producer's buffer lifetime is guaranteed. Decide whether the fix is
|
||||
"harden the EGL/CUDA path" vs. "don't use it for producers that can pull buffers."
|
||||
- **Interaction with the existing capture-loss rebuild.** The host already rebuilds on capture loss;
|
||||
the goal is to reach that path on producer teardown **instead of** the driver crash. Understand why
|
||||
the crash beats the capture-loss detection today (the map happens in the PipeWire `on_process`
|
||||
callback before any loss is observed).
|
||||
- **Reproducibility.** This was observed on a box whose KDE was *itself* crashing (F44 plasmashell/
|
||||
Xwayland). To isolate "our zero-copy path is fragile" from "the compositor crashed," reproduce on a
|
||||
**stable** KDE/NVIDIA box — force a buffer invalidation (output teardown / renegotiation / a scripted
|
||||
compositor restart) mid-capture and confirm the same `cuGraphicsMapResources` fault without the
|
||||
surrounding compositor chaos. That also tells you whether the non-crash triggers in §5 are real.
|
||||
- **Keep the SAFETY-proof discipline.** `zerocopy/{cuda,egl,vulkan}.rs` are part of the unsafe-audited
|
||||
set (`#![deny(clippy::undocumented_unsafe_blocks)]`, every `unsafe` carries a `// SAFETY:`). Any fix
|
||||
updates those proofs to reflect the new lifetime/validity guarantees.
|
||||
|
||||
## 10. Reproduction environment / artifacts
|
||||
|
||||
- Box: `bazzite@192.168.1.181` (sudo `bazzite`), Bazzite F44 (`bazzite-deck-nvidia:testing`), RTX 4090,
|
||||
NVIDIA open 610.43.02. See `[[gamemode-onglass-181-2026-07-06]]` for deploy/access details.
|
||||
- Trigger: stream a game-mode session with `PUNKTFUNK_ZEROCOPY=1`, then switch the box Game Mode →
|
||||
KDE desktop mid-stream (the session watcher rebuilds to KWin → tiled EGL/CUDA capture → crash).
|
||||
- The coredump was present under `coredumpctl` on the box at the time of writing (may age out); the
|
||||
backtrace in §2 is captured above.
|
||||
|
||||
## 11. Related
|
||||
|
||||
- `design/gamemode-and-dedicated-sessions.md` (the branch this surfaced under — not the cause).
|
||||
- `design/session-aware-host-followups.md` (Game↔Desktop switch behavior; F44 GPU instability #1).
|
||||
- `design/gpu-contention-investigation.md` / `design/host-latency-plan.md` (zero-copy path context).
|
||||
- `crates/punktfunk-host/src/linux/zerocopy/{egl,cuda,vulkan}.rs`, `capture/linux/pipewire.rs`.
|
||||
- CLAUDE.md: "GPU **zero-copy** on all paths (tiled dmabuf → EGL/GL → CUDA; LINEAR dmabuf → **Vulkan
|
||||
bridge** → CUDA)" and the `PUNKTFUNK_ZEROCOPY` semantics (ON for VAAPI/AMD/Intel with a CPU downgrade;
|
||||
OFF/opt-in for NVENC).
|
||||
@@ -106,6 +106,26 @@ Per-backend support:
|
||||
each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more
|
||||
are streaming.
|
||||
|
||||
### Dedicated game sessions
|
||||
|
||||
**Dedicated game sessions** control how a session that *launches a game from your library* is served
|
||||
(Linux hosts):
|
||||
|
||||
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session on a
|
||||
Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or spawned into your live KDE /
|
||||
GNOME / Sway desktop.
|
||||
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution and
|
||||
refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate,
|
||||
no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles
|
||||
start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**,
|
||||
the game keeps running when you disconnect and you re-attach straight back into it; when you quit the
|
||||
game, the session ends cleanly and your client returns to its library.
|
||||
|
||||
Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto**
|
||||
routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box
|
||||
that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores
|
||||
it when the session ends. (GameStream / Moonlight launches follow the same routing.)
|
||||
|
||||
## Persistent scaling
|
||||
|
||||
Set your display **scaling** once and have it stick across reconnects. This works by giving each
|
||||
@@ -149,3 +169,13 @@ an empty extension. Use **Primary** or **Exclusive** so your desktop actually la
|
||||
|
||||
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
|
||||
see [requirements](/docs/requirements).
|
||||
|
||||
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting
|
||||
and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of
|
||||
landing on a dead stream — and switching between game mode and the KDE / GNOME desktop mid-stream
|
||||
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
|
||||
library; a game mode / desktop session keeps streaming.
|
||||
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **gaming-rig**
|
||||
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
|
||||
instantly — return to game mode on the box (or restart the host) to hand the TV back.
|
||||
|
||||
@@ -283,6 +283,16 @@
|
||||
#define QUIT_CLOSE_CODE 81
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code the **host** closes the control connection with when a **dedicated game
|
||||
// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher
|
||||
// client can distinguish "the game ended" from an error and return to its library cleanly rather than
|
||||
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
|
||||
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
|
||||
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
#define APP_EXITED_CLOSE_CODE 82
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_game_session": "Dedizierte Spiel-Sitzungen",
|
||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dediziert",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_game_session": "Dedicated game sessions",
|
||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||
"display_game_session_auto": "Auto",
|
||||
"display_game_session_dedicated": "Dedicated",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ApiDisplayInfo,
|
||||
DisplayPolicy,
|
||||
EffectivePolicy,
|
||||
GameSession,
|
||||
Identity,
|
||||
KeepAlive,
|
||||
LayoutMode,
|
||||
@@ -141,6 +142,8 @@ const DisplayForm: FC<{
|
||||
identity: effective.identity,
|
||||
layout: effective.layout,
|
||||
max_displays: effective.max_displays,
|
||||
// Game-session is orthogonal to the preset — carry it through the Custom switch.
|
||||
game_session: draft.game_session ?? "auto",
|
||||
});
|
||||
} else {
|
||||
apply({ ...draft, preset: id as Preset });
|
||||
@@ -330,6 +333,24 @@ const DisplayForm: FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the
|
||||
Custom block and applies immediately on change (like a preset click). */}
|
||||
<div className="border-t pt-4">
|
||||
<Choice
|
||||
label={m.display_game_session()}
|
||||
help={m.display_game_session_help()}
|
||||
value={draft.game_session ?? "auto"}
|
||||
options={["auto", "dedicated"]}
|
||||
labels={GAME_SESSION_LABEL}
|
||||
disabled={busy}
|
||||
onPick={(v) => {
|
||||
const next = { ...draft, game_session: v as GameSession };
|
||||
setDraft(next);
|
||||
apply(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* What's in force right now */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||||
@@ -339,6 +360,9 @@ const DisplayForm: FC<{
|
||||
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
|
||||
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
|
||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||
{(draft.game_session ?? "auto") === "dedicated" && (
|
||||
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||
@@ -611,6 +635,11 @@ const LAYOUT_LABEL: Record<string, () => string> = {
|
||||
manual: m.display_layout_manual,
|
||||
};
|
||||
|
||||
const GAME_SESSION_LABEL: Record<string, () => string> = {
|
||||
auto: m.display_game_session_auto,
|
||||
dedicated: m.display_game_session_dedicated,
|
||||
};
|
||||
|
||||
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
|
||||
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||||
const fn = key == null ? undefined : map[key];
|
||||
|
||||
Reference in New Issue
Block a user