Files
punktfunk/crates/punktfunk-host/src/session_status.rs
T
enricobuehlerandClaude Fable 5 b7a00137eb
ci / docs-site (push) Successful in 56s
ci / web (push) Successful in 1m0s
android / android (push) Failing after 1m6s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 48s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
ci / bench (push) Successful in 8m13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m40s
deb / build-publish (push) Successful in 11m0s
release / apple (push) Successful in 9m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10m19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m57s
docker / deploy-docs (push) Successful in 35s
deb / build-publish-host (push) Successful in 14m3s
apple / screenshots (push) Successful in 6m27s
arch / build-publish (push) Successful in 17m49s
windows-host / package (push) Successful in 18m15s
flatpak / build-publish (push) Failing after 8m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m37s
ci / rust (push) Successful in 25m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m52s
feat(host): hold a logind sleep/idle inhibitor while clients stream
Remote INPUT resets the compositor's idle timers, but a passive
(video-only) viewer sends none — observed live: a SteamOS Game-Mode
host s2idled mid-day and dropped off the network (in a VM with GPU
passthrough it never woke again). Refcounted across planes: the native
LiveSessionGuard and the GameStream video worker each hold a share;
first hold takes the logind sleep:idle block inhibitor on a dedicated
plain thread (zbus blocking must not run on a tokio worker), last drop
releases. Best-effort: no logind → warn once and stream on. No-op off
Linux (macOS/Windows manage their own assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:09:47 +02:00

185 lines
7.3 KiB
Rust

//! Plane-neutral live-session status for the management `/status` (web-console Dashboard) view.
//!
//! The GameStream media pipeline records its session in `AppState.{launch, stream, streaming}`
//! (consumed by RTSP/media), but the native punktfunk/1 plane never touches `AppState` — by design
//! it is handed only the shared stats recorder ([`crate::native::serve`]). So a native session,
//! which is the DEFAULT plane (GameStream is opt-in, `--gamestream`), was invisible on the Dashboard:
//! `GET /status` reported `video_streaming: false` and no session/stream card while a client was
//! actively streaming (the Stats page worked because it shares the recorder — hence the confusing
//! "stats move but the dashboard says idle").
//!
//! This module is the small shared surface the native video loop ([`crate::native::virtual_stream`])
//! publishes a live snapshot to, keyed per session so CONCURRENT native sessions each get an entry
//! (the native server admits up to `max_sessions`, unbounded by default). The loop registers on
//! stream start and the returned [`LiveSessionGuard`] removes the entry on ANY scope exit (return,
//! `?`, panic — RAII). `/status` reads [`snapshot`]/[`count`]; the Dashboard's session-control
//! buttons reach a native session through [`stop_all`] (stop) and [`force_idr_all`] (request IDR),
//! so surfacing the session doesn't leave those buttons dead.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::encode::Codec;
/// A single live native session. Holds the SAME live Arc handles the video loop already maintains,
/// so a mid-stream mode switch / adaptive-bitrate change is reflected on the Dashboard with no
/// second update path — plus the flags the mgmt API flips to control the session.
struct LiveSession {
id: u64,
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
mode: Arc<AtomicU64>,
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
bitrate_kbps: Arc<AtomicU32>,
codec: Codec,
/// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`).
stop: Arc<AtomicBool>,
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
/// drains it alongside a client's decode-recovery keyframe request.
force_idr: Arc<AtomicBool>,
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
client: String,
/// Whether the session negotiated HDR — carried on the lifecycle events.
hdr: bool,
/// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written
/// once by the session's [`crate::bringup::Trace`] (latency plan P0.1).
ttff_ms: Arc<AtomicU32>,
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
last_resize_ms: Arc<AtomicU32>,
}
/// A resolved read of one live session, for the `/status` view.
#[derive(Clone, Copy)]
pub struct SessionSnapshot {
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate_kbps: u32,
pub codec: Codec,
/// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1).
pub time_to_first_frame_ms: u32,
/// Most recent mid-stream resize total, ms; 0 = no resize this session.
pub last_resize_ms: u32,
}
fn registry() -> &'static Mutex<Vec<LiveSession>> {
static REG: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(Vec::new()))
}
fn next_id() -> u64 {
static ID: AtomicU64 = AtomicU64::new(1);
ID.fetch_add(1, Ordering::Relaxed)
}
/// Resolves one session's [`crate::events::SessionRef`] (mode read live) for the lifecycle events.
fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
crate::events::SessionRef {
id: s.id,
client: s.client.clone(),
mode: crate::events::mode_str(width, height, fps),
hdr: s.hdr,
}
}
/// Registers a live native session; the returned guard removes it on drop (session end).
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
/// so every exit path (return, `?`, panic-unwind) pairs them.
#[allow(clippy::too_many_arguments)]
pub fn register(
mode: Arc<AtomicU64>,
bitrate_kbps: Arc<AtomicU32>,
codec: Codec,
stop: Arc<AtomicBool>,
force_idr: Arc<AtomicBool>,
client: String,
hdr: bool,
ttff_ms: Arc<AtomicU32>,
last_resize_ms: Arc<AtomicU32>,
) -> LiveSessionGuard {
let id = next_id();
let session = LiveSession {
id,
mode,
bitrate_kbps,
codec,
stop,
force_idr,
client,
hdr,
ttff_ms,
last_resize_ms,
};
crate::events::emit(crate::events::EventKind::SessionStarted {
session: session_ref(&session),
});
registry().lock().unwrap().push(session);
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 {
fn drop(&mut self) {
let mut reg = registry().lock().unwrap();
if let Some(pos) = reg.iter().position(|s| s.id == self.id) {
let session = reg.remove(pos);
drop(reg); // emit outside the registry lock — the bus takes its own
crate::events::emit(crate::events::EventKind::SessionEnded {
session: session_ref(&session),
});
}
}
}
/// The number of live native sessions.
pub fn count() -> usize {
registry().lock().unwrap().len()
}
/// A resolved snapshot of every live native session (mode/bitrate read live), newest last.
pub fn snapshot() -> Vec<SessionSnapshot> {
registry()
.lock()
.unwrap()
.iter()
.map(|s| {
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
SessionSnapshot {
width,
height,
fps,
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
codec: s.codec,
time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed),
last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed),
}
})
.collect()
}
/// Signals every live native session to tear down (mgmt `DELETE /session`). Best-effort: the video
/// + send loops observe the flag and exit, ending the stream; the guard then clears the entry.
pub fn stop_all() {
for s in registry().lock().unwrap().iter() {
s.stop.store(true, Ordering::SeqCst);
}
}
/// Requests a forced keyframe on every live native session (mgmt `POST /session/idr`). The encode
/// loop drains the flag exactly like a client decode-recovery request.
pub fn force_idr_all() {
for s in registry().lock().unwrap().iter() {
s.force_idr.store(true, Ordering::Relaxed);
}
}