diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 75d30fd3..0e56e12a 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -60,6 +60,9 @@ pub fn start( // Same scheduling posture as the native path's capture/encode thread (Linux nice -10 / // Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux. crate::native::boost_thread_priority(true); + // A GameStream viewer may be video-only too — hold the suspend/idle inhibitor for + // this stream's lifetime (plane parity with the native LiveSessionGuard). + let _sleep = crate::sleep_inhibit::hold(); tracing::info!(?cfg, "video stream starting"); // Lifecycle events + the script-facing marker file, plane parity with the native loop // (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 1654a6d8..afb8d9d7 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -72,6 +72,7 @@ mod send_pacing; mod service; mod session_plan; mod session_status; +mod sleep_inhibit; mod spike; mod stats_recorder; // The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 1524d9c0..2d882563 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -114,12 +114,18 @@ pub fn register( session: session_ref(&session), }); registry().lock().unwrap().push(session); - LiveSessionGuard { id } + LiveSessionGuard { + id, + _sleep: crate::sleep_inhibit::hold(), + } } /// Removes its session from the registry when dropped (any scope exit of the native video loop). pub struct LiveSessionGuard { id: u64, + /// While any native session lives, the box must not auto-suspend under a passive viewer + /// ([`crate::sleep_inhibit`]) — refcounted, released with the guard. + _sleep: crate::sleep_inhibit::StreamHold, } impl Drop for LiveSessionGuard { diff --git a/crates/punktfunk-host/src/sleep_inhibit.rs b/crates/punktfunk-host/src/sleep_inhibit.rs new file mode 100644 index 00000000..6563e6a7 --- /dev/null +++ b/crates/punktfunk-host/src/sleep_inhibit.rs @@ -0,0 +1,100 @@ +//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host +//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a +//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer +//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and +//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted +//! across planes (native sessions + GameStream media): the first hold acquires, the last drop +//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on. +//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions. + +use std::sync::{Mutex, OnceLock}; + +/// RAII share of the host-wide inhibitor — hold one per live session/stream. +pub struct StreamHold(()); + +struct State { + count: u32, + /// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open. + #[cfg(target_os = "linux")] + fd: Option, +} + +fn state() -> &'static Mutex { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| { + Mutex::new(State { + count: 0, + #[cfg(target_os = "linux")] + fd: None, + }) + }) +} + +/// Take a share; the underlying inhibitor is acquired on the 0→1 edge. +pub fn hold() -> StreamHold { + let mut st = state().lock().unwrap_or_else(|e| e.into_inner()); + st.count += 1; + #[cfg(target_os = "linux")] + if st.count == 1 && st.fd.is_none() { + st.fd = acquire(); + } + StreamHold(()) +} + +impl Drop for StreamHold { + fn drop(&mut self) { + let mut st = state().lock().unwrap_or_else(|e| e.into_inner()); + st.count = st.count.saturating_sub(1); + #[cfg(target_os = "linux")] + if st.count == 0 && st.fd.take().is_some() { + tracing::info!("released the sleep/idle inhibitor (no live sessions)"); + } + } +} + +/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on +/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either. +/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates. +#[cfg(target_os = "linux")] +fn acquire() -> Option { + let fd = std::thread::spawn(|| -> Option { + use ashpd::zbus; + // zbus's blocking API is configured out by ashpd's feature set — drive the async API on + // a private current-thread runtime instead (still on this plain thread; see fn doc). + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()?; + let attempt: zbus::Result = rt.block_on(async { + let conn = zbus::Connection::system().await?; + let reply = conn + .call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + "Inhibit", + &("sleep:idle", "Punktfunk", "a client is streaming", "block"), + ) + .await?; + reply.body().deserialize() + }); + match attempt { + Ok(fd) => Some(fd), + Err(e) => { + tracing::warn!( + error = %e, + "could not take a logind sleep/idle inhibitor — the box may auto-suspend \ + under a passive (video-only) viewer" + ); + None + } + } + }) + .join() + .ok() + .flatten(); + if fd.is_some() { + tracing::info!("holding a logind sleep/idle inhibitor while clients stream"); + } + fd +}