cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / crates / sigil-host / src / source / mod.rs
mod.rs 41.2K
1use std::borrow::Cow;2use std::process::{ExitStatus, Stdio};3use std::sync::Arc;4use std::time::{Duration, Instant};56use anyhow::{Context, Result, bail, ensure};7use serde_json::Value;8use sigil_protocol::PointerSurfaceDimensions;9use tokio::io::{AsyncRead, AsyncReadExt};10use tokio::process::Command;11use tokio::sync::{oneshot, watch};12use tracing::warn;1314use crate::clock::SessionClock;15#[cfg(test)]16use crate::config::VaapiRateControl;17use crate::config::{GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig};1819mod annexb;20mod external;21mod gstreamer_properties;22mod in_process;23mod preflight;2425#[cfg(test)]26use external::gamescope_pipeline_args;27use external::{28 forward_annex_b_stream, spawn_gamescope_pipewire_with_target,29 spawn_gamescope_pipewire_with_target_and_shutdown,30};31pub(crate) use gstreamer_properties::probe_encoder_properties;32#[cfg(test)]33pub(crate) use in_process::EncoderControlTestHarness;34#[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]35use in_process::spawn_gamescope_pipewire_in_process_with_target;36pub use in_process::{EncoderControl, EncoderControlStatus};37use preflight::preflight_gamescope_static;38pub(crate) use preflight::{preflight_gstreamer_element, validate_executable};3940const MAX_ENCODE_BUFFER: usize = 16 * 1024 * 1024;41const MAX_CURRENT_GOP_BYTES: usize = 32 * 1024 * 1024;42const MAX_PW_DUMP_OUTPUT: usize = 8 * 1024 * 1024;43const MAX_DIAGNOSTIC_OUTPUT: usize = 512 * 1024;44const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5);4546#[derive(Clone, Debug)]47pub struct EncodedFrame {48 pub sequence: u64,49 pub width: u16,50 pub height: u16,51 /// Session-monotonic timestamp when the complete encoded access unit became52 /// observable to the daemon, using the same epoch as audio. Raw PipeWire53 /// capture PTS is not preserved by the current stdout bridge, so this must54 /// not be presented as capture age.55 pub capture_timestamp_micros: u64,56 /// Post-encode observation PTS in the shared session clock. This preserves57 /// real gaps in damage-driven video output.58 pub presentation_timestamp_micros: i64,59 pub observed_at: Instant,60 pub keyframe: bool,61 pub codec_config: bool,62 /// This configured keyframe begins a decoder generation that must not use63 /// reference state from the preceding encoded resolution.64 pub discontinuity: bool,65 pub data: Arc<[u8]>,66}6768#[derive(Clone, Debug)]69pub(crate) struct EncodedGop {70 pub frames: Vec<EncodedFrame>,71 pub(crate) payload_bytes: usize,72}7374pub struct EncodedSource {75 pub frames: watch::Receiver<Option<EncodedFrame>>,76 /// A bounded snapshot of every access unit from the latest IDR through the77 /// current frame. A new subscriber can therefore reconstruct the current78 /// static image without receiving an orphan inter-frame.79 pub(crate) current_gop: watch::Receiver<Option<EncodedGop>>,80 pub task: tokio::task::JoinHandle<Result<()>>,81 pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,82 pub encoder_control: Option<EncoderControl>,83}8485pub fn spawn_test_pattern(config: HostConfig, session_clock: SessionClock) -> EncodedSource {86 let (sender, receiver) = watch::channel(None);87 let (current_gop_sender, current_gop) = watch::channel(None);88 let task = tokio::spawn(async move {89 run_test_pattern(config, session_clock, sender, current_gop_sender).await90 });91 EncodedSource {92 frames: receiver,93 current_gop,94 task,95 pointer_surface_dimensions: None,96 encoder_control: None,97 }98}99100#[derive(Clone, Debug, PartialEq, Eq)]101pub struct GamescopePreflight {102 /// PipeWire object.serial, resolved from exact configured properties.103 pub target_object: String,104 /// Native Gamescope surface used by the relative-pointer coordinate space.105 pub pointer_surface_dimensions: PointerSurfaceDimensions,106 /// Bounded encoded mode after applying an optional explicit size override.107 pub video_mode: CaptureVideoMode,108}109110#[derive(Clone, Copy, Debug, PartialEq, Eq)]111pub struct CaptureVideoMode {112 pub width: u16,113 pub height: u16,114 pub framerate: u32,115}116117impl CaptureVideoMode {118 fn resolve(config: &HostConfig, native: PointerSurfaceDimensions) -> Result<Self> {119 let configured = config.configured_dimensions();120 let (width, height) =121 configured.unwrap_or((u32::from(native.width), u32::from(native.height)));122 ensure!(123 (64..=7_680).contains(&width) && (64..=4_320).contains(&height),124 "resolved Gamescope encoded dimensions {width}x{height} are outside H.264 bounds"125 );126 ensure!(127 width.is_multiple_of(2) && height.is_multiple_of(2),128 "resolved Gamescope encoded dimensions {width}x{height} must be even"129 );130 if configured.is_some() {131 ensure!(132 u64::from(width) * u64::from(native.height)133 == u64::from(height) * u64::from(native.width),134 "configured Gamescope encoded dimensions {width}x{height} must preserve native {}x{} aspect ratio",135 native.width,136 native.height137 );138 }139 Ok(Self {140 width: u16::try_from(width).context("resolved Gamescope width exceeds metadata")?,141 height: u16::try_from(height).context("resolved Gamescope height exceeds metadata")?,142 framerate: config.framerate,143 })144 }145}146147pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<GamescopePreflight> {148 config.validate()?;149 preflight_gamescope_static(config).await?;150 resolve_gamescope_pipewire_target(config).await151}152153async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<GamescopePreflight> {154 config.validate()?;155 let pipewire = gamescope_config(config)?;156 let mut command = Command::new(&pipewire.pw_dump_path);157 command.env("LC_ALL", "C");158 let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;159 ensure!(160 output.status.success(),161 "pw-dump failed: {}",162 diagnostic(&output.stderr)163 );164 resolve_pipewire_node(&output.stdout, config)165}166167pub struct ProbeEncodedSource {168 pub source: EncodedSource,169 pub shutdown: Option<oneshot::Sender<()>>,170}171172pub fn spawn_gamescope_pipewire_for_probe(173 config: HostConfig,174 session_clock: SessionClock,175 preflight: GamescopePreflight,176) -> Result<ProbeEncodedSource> {177 match gamescope_config(&config)?.encoder_backend {178 GamescopeEncoderBackend::ExternalGstLaunch => {179 let (shutdown, shutdown_receiver) = oneshot::channel();180 let source = spawn_gamescope_pipewire_with_target_and_shutdown(181 config,182 session_clock,183 preflight,184 Some(shutdown_receiver),185 )?;186 Ok(ProbeEncodedSource {187 source,188 shutdown: Some(shutdown),189 })190 }191 GamescopeEncoderBackend::InProcessGstreamer => {192 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]193 {194 let source = spawn_gamescope_pipewire_in_process_with_target(195 config,196 session_clock,197 preflight,198 )?;199 Ok(ProbeEncodedSource {200 source,201 shutdown: None,202 })203 }204 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]205 {206 let _ = (session_clock, preflight);207 bail!(208 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"209 )210 }211 }212 }213}214215/// Start capture after `serve` has completed the static executable/GstVA216/// preflight. The PipeWire node is still resolved immediately before each217/// session because its object serial can change when Gamescope restarts.218pub async fn spawn_gamescope_pipewire_after_static_preflight(219 config: HostConfig,220 session_clock: SessionClock,221) -> Result<EncodedSource> {222 let preflight = resolve_gamescope_pipewire_target(&config).await?;223 spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)224}225226fn spawn_gamescope_pipewire_with_preflight(227 config: HostConfig,228 session_clock: SessionClock,229 preflight: GamescopePreflight,230) -> Result<EncodedSource> {231 match gamescope_config(&config)?.encoder_backend {232 GamescopeEncoderBackend::ExternalGstLaunch => {233 spawn_gamescope_pipewire_with_target(config, session_clock, preflight)234 }235 GamescopeEncoderBackend::InProcessGstreamer => {236 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]237 {238 spawn_gamescope_pipewire_in_process_with_target(config, session_clock, preflight)239 }240 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]241 {242 let _ = (session_clock, preflight);243 bail!(244 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"245 )246 }247 }248 }249}250251async fn run_test_pattern(252 config: HostConfig,253 session_clock: SessionClock,254 sender: watch::Sender<Option<EncodedFrame>>,255 current_gop_sender: watch::Sender<Option<EncodedGop>>,256) -> Result<()> {257 let (width, height) = config.test_pattern_dimensions()?;258 let input = format!(259 "testsrc2=size={}x{}:rate={}",260 width, height, config.framerate261 );262 let gop = interactive_gop_frames(config.framerate).to_string();263264 let mut command = Command::new(&config.ffmpeg_path);265 command266 .arg("-hide_banner")267 .arg("-loglevel")268 .arg("warning")269 .arg("-re")270 .arg("-f")271 .arg("lavfi")272 .arg("-i")273 .arg(input)274 .arg("-an")275 .arg("-c:v")276 .arg("libx264")277 .arg("-preset")278 .arg("ultrafast")279 .arg("-tune")280 .arg("zerolatency")281 .arg("-g")282 .arg(&gop)283 .arg("-keyint_min")284 .arg(&gop)285 .arg("-sc_threshold")286 .arg("0")287 .arg("-bf")288 .arg("0")289 .arg("-pix_fmt")290 .arg("yuv420p")291 .arg("-bsf:v")292 .arg("h264_metadata=aud=insert")293 .arg("-f")294 .arg("h264")295 .arg("pipe:1")296 .stdin(Stdio::null())297 .stdout(Stdio::piped())298 .stderr(Stdio::piped())299 .kill_on_drop(true);300301 let mut child = command302 .spawn()303 .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;304 let mut stdout = child.stdout.take().context("ffmpeg stdout was not piped")?;305 let stderr = child.stderr.take().context("ffmpeg stderr was not piped")?;306 let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "ffmpeg"));307308 let result = forward_annex_b_stream(309 session_clock,310 &mut stdout,311 &sender,312 &current_gop_sender,313 interactive_gop_frames(config.framerate) as usize,314 u16::try_from(width).context("configured width exceeds encoder metadata")?,315 u16::try_from(height).context("configured height exceeds encoder metadata")?,316 )317 .await;318 let _ = child.kill().await;319 let status = child.wait().await.context("waiting for ffmpeg")?;320 stderr_task.abort();321 match result {322 Ok(()) => Ok(()),323 Err(error) => Err(error).with_context(|| format!("ffmpeg exited with {status}")),324 }325}326327struct AccessUnitPublisher<'a> {328 session_clock: SessionClock,329 sender: &'a watch::Sender<Option<EncodedFrame>>,330 current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,331 max_gop_frames: usize,332 width: u16,333 height: u16,334 sequence: u64,335}336337impl<'a> AccessUnitPublisher<'a> {338 fn new(339 session_clock: SessionClock,340 sender: &'a watch::Sender<Option<EncodedFrame>>,341 current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,342 max_gop_frames: usize,343 width: u16,344 height: u16,345 ) -> Self {346 Self {347 session_clock,348 sender,349 current_gop_sender,350 max_gop_frames,351 width,352 height,353 sequence: 0,354 }355 }356357 fn publish(&mut self, access_unit: Vec<u8>) -> Result<bool> {358 self.publish_with_metadata(access_unit, self.width, self.height, false)359 }360361 fn publish_with_metadata(362 &mut self,363 access_unit: Vec<u8>,364 width: u16,365 height: u16,366 discontinuity: bool,367 ) -> Result<bool> {368 ensure!(!access_unit.is_empty(), "encoded access unit is empty");369 ensure!(370 access_unit.len() <= MAX_ENCODE_BUFFER,371 "encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"372 );373 let observed_at = Instant::now();374 let capture_timestamp_micros = self.session_clock.micros_at(observed_at);375 let presentation_timestamp_micros =376 i64::try_from(capture_timestamp_micros).unwrap_or(i64::MAX);377 let keyframe = is_h264_keyframe(&access_unit);378 let codec_config = has_h264_codec_config(&access_unit);379 let frame = EncodedFrame {380 sequence: self.sequence,381 width,382 height,383 capture_timestamp_micros,384 presentation_timestamp_micros,385 observed_at,386 keyframe,387 codec_config,388 discontinuity,389 data: Arc::from(access_unit),390 };391 self.sequence = self392 .sequence393 .checked_add(1)394 .context("encoded frame sequence exhausted")?;395 publish_encoded_frame(396 self.sender,397 self.current_gop_sender,398 frame,399 self.max_gop_frames,400 MAX_CURRENT_GOP_BYTES,401 );402 self.width = width;403 self.height = height;404 Ok(keyframe && codec_config)405 }406407 fn receivers_closed(&self) -> bool {408 self.sender.is_closed() && self.current_gop_sender.is_closed()409 }410}411412fn publish_encoded_frame(413 sender: &watch::Sender<Option<EncodedFrame>>,414 current_gop_sender: &watch::Sender<Option<EncodedGop>>,415 frame: EncodedFrame,416 max_gop_frames: usize,417 max_gop_bytes: usize,418) {419 if !current_gop_sender.is_closed() {420 current_gop_sender.send_if_modified(|current_gop| {421 update_current_gop(current_gop, frame.clone(), max_gop_frames, max_gop_bytes)422 });423 }424 sender.send_replace(Some(frame.clone()));425}426427fn update_current_gop(428 current_gop: &mut Option<EncodedGop>,429 frame: EncodedFrame,430 max_gop_frames: usize,431 max_gop_bytes: usize,432) -> bool {433 let independently_decodable = frame.keyframe && frame.codec_config;434 if independently_decodable {435 let payload_bytes = frame.data.len();436 if max_gop_frames == 0 || payload_bytes > max_gop_bytes {437 return current_gop.take().is_some();438 }439 *current_gop = Some(EncodedGop {440 frames: vec![frame],441 payload_bytes,442 });443 return true;444 }445446 let Some(gop) = current_gop.as_mut() else {447 return false;448 };449 let contiguous = gop450 .frames451 .last()452 .and_then(|previous| previous.sequence.checked_add(1))453 == Some(frame.sequence);454 let payload_bytes = gop.payload_bytes.saturating_add(frame.data.len());455 if !contiguous || gop.frames.len() >= max_gop_frames || payload_bytes > max_gop_bytes {456 *current_gop = None;457 return true;458 }459460 gop.frames.push(frame);461 gop.payload_bytes = payload_bytes;462 true463}464465pub(crate) async fn log_stderr_chunks<R>(mut stderr: R, target: &'static str)466where467 R: AsyncRead + Unpin,468{469 const LOG_INTERVAL: Duration = Duration::from_secs(1);470 const MAX_LOG_BYTES_PER_INTERVAL: usize = 16 * 1024;471472 let mut chunk = [0_u8; 4096];473 let mut interval_started = tokio::time::Instant::now();474 let mut logged_bytes = 0_usize;475 let mut suppressed_bytes = 0_u64;476 loop {477 match stderr.read(&mut chunk).await {478 Ok(0) => return,479 Ok(count) => {480 if interval_started.elapsed() >= LOG_INTERVAL {481 if suppressed_bytes > 0 {482 warn!(483 target: "capture-process",484 process = target,485 suppressed_bytes,486 "capture diagnostics rate limited"487 );488 }489 interval_started = tokio::time::Instant::now();490 logged_bytes = 0;491 suppressed_bytes = 0;492 }493 if logged_bytes.saturating_add(count) <= MAX_LOG_BYTES_PER_INTERVAL {494 warn!(target: "capture-process", process = target, "{}", String::from_utf8_lossy(&chunk[..count]).trim_end());495 logged_bytes = logged_bytes.saturating_add(count);496 } else {497 suppressed_bytes = suppressed_bytes.saturating_add(count as u64);498 }499 }500 Err(error) => {501 warn!(target: "capture-process", process = target, %error, "failed reading stderr");502 return;503 }504 }505 }506}507508fn gamescope_config(config: &HostConfig) -> Result<&GamescopePipewireConfig> {509 config510 .gamescope_pipewire511 .as_ref()512 .context("gamescope_pipewire configuration is missing")513}514515fn interactive_gop_frames(framerate: u32) -> u32 {516 // A dropped encoded reference requires the client to wait for the next517 // independently decodable IDR. Bound that recovery to roughly 500 ms until518 // the in-process encoder can service explicit keyframe requests.519 framerate.div_ceil(2).max(1)520}521522pub(crate) async fn resolve_pipewire_node_by_properties(523 pw_dump_path: &std::path::Path,524 node_name: &str,525 media_class: &str,526 match_properties: &std::collections::BTreeMap<String, String>,527) -> Result<String> {528 let mut command = Command::new(pw_dump_path);529 command.env("LC_ALL", "C");530 let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;531 ensure!(532 output.status.success(),533 "pw-dump failed: {}",534 diagnostic(&output.stderr)535 );536 resolve_pipewire_node_exact(&output.stdout, node_name, media_class, match_properties)537}538539fn resolve_pipewire_node(output: &[u8], config: &HostConfig) -> Result<GamescopePreflight> {540 let pipewire = gamescope_config(config)?;541 let target_object = resolve_pipewire_node_exact(542 output,543 &pipewire.node_name,544 &pipewire.media_class,545 &pipewire.match_properties,546 )?;547 let pointer_surface_dimensions = resolve_pointer_surface_dimensions(548 output,549 &target_object,550 &pipewire.node_name,551 &pipewire.media_class,552 &pipewire.match_properties,553 )?;554 let video_mode = CaptureVideoMode::resolve(config, pointer_surface_dimensions)?;555 Ok(GamescopePreflight {556 target_object,557 pointer_surface_dimensions,558 video_mode,559 })560}561562pub(crate) fn resolve_pipewire_node_exact(563 output: &[u8],564 node_name: &str,565 media_class: &str,566 match_properties: &std::collections::BTreeMap<String, String>,567) -> Result<String> {568 let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;569 let objects = objects570 .as_array()571 .context("pw-dump output is not an array")?;572 let mut matches = Vec::new();573574 for object in objects {575 if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {576 continue;577 }578 let Some(properties) = object579 .get("info")580 .and_then(|info| info.get("props"))581 .and_then(Value::as_object)582 else {583 continue;584 };585 if !pipewire_node_matches(properties, node_name, media_class, match_properties) {586 continue;587 }588 let serial = property_string(properties.get("object.serial"))589 .context("matching PipeWire node has no object.serial")?;590 ensure!(591 !serial.is_empty() && serial.bytes().all(|byte| byte.is_ascii_digit()),592 "matching PipeWire node has invalid object.serial"593 );594 matches.push(serial.into_owned());595 }596597 ensure!(598 matches.len() == 1,599 "expected exactly one PipeWire node matching node.name={:?}, media.class={:?}, and configured properties; found {}",600 node_name,601 media_class,602 matches.len()603 );604 Ok(matches.remove(0))605}606607fn resolve_pointer_surface_dimensions(608 output: &[u8],609 target_object: &str,610 node_name: &str,611 media_class: &str,612 match_properties: &std::collections::BTreeMap<String, String>,613) -> Result<PointerSurfaceDimensions> {614 let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;615 let objects = objects616 .as_array()617 .context("pw-dump output is not an array")?;618 let mut matched_node_count = 0_usize;619 let mut native_size = None;620621 for object in objects {622 if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {623 continue;624 }625 let Some(info) = object.get("info").and_then(Value::as_object) else {626 continue;627 };628 let Some(properties) = info.get("props").and_then(Value::as_object) else {629 continue;630 };631 if !pipewire_node_matches(properties, node_name, media_class, match_properties)632 || property_string(properties.get("object.serial")).as_deref() != Some(target_object)633 {634 continue;635 }636 matched_node_count += 1;637 let formats = info638 .get("params")639 .and_then(|params| params.get("EnumFormat"))640 .and_then(Value::as_array)641 .context("matching Gamescope PipeWire node has no EnumFormat array")?;642 for format in formats {643 if format.get("mediaType").and_then(Value::as_str) != Some("video")644 || format.get("mediaSubtype").and_then(Value::as_str) != Some("raw")645 {646 continue;647 }648 let size = format649 .get("size")650 .context("Gamescope raw EnumFormat has no native size")?;651 let size = size.get("default").unwrap_or(size);652 let width = size653 .get("width")654 .and_then(Value::as_u64)655 .and_then(|value| u16::try_from(value).ok())656 .context("Gamescope raw EnumFormat native width is not a u16")?;657 let height = size658 .get("height")659 .and_then(Value::as_u64)660 .and_then(|value| u16::try_from(value).ok())661 .context("Gamescope raw EnumFormat native height is not a u16")?;662 let dimensions = PointerSurfaceDimensions::new(width, height)663 .context("Gamescope raw EnumFormat native size is outside pointer bounds")?;664 if native_size.is_some_and(|existing| existing != dimensions) {665 bail!("matching Gamescope PipeWire node advertises ambiguous native sizes");666 }667 native_size = Some(dimensions);668 }669 }670671 ensure!(672 matched_node_count == 1,673 "expected exactly one matched Gamescope PipeWire node while resolving its native size; found {matched_node_count}"674 );675 native_size.context("matching Gamescope PipeWire node has no raw native EnumFormat size")676}677678fn pipewire_node_matches(679 properties: &serde_json::Map<String, Value>,680 node_name: &str,681 media_class: &str,682 match_properties: &std::collections::BTreeMap<String, String>,683) -> bool {684 property_string(properties.get("node.name")).as_deref() == Some(node_name)685 && property_string(properties.get("media.class")).as_deref() == Some(media_class)686 && match_properties.iter().all(|(key, expected)| {687 property_string(properties.get(key)).is_some_and(|actual| actual == *expected)688 })689}690691fn property_string(value: Option<&Value>) -> Option<Cow<'_, str>> {692 match value? {693 Value::String(value) => Some(Cow::Borrowed(value)),694 Value::Number(value) => Some(Cow::Owned(value.to_string())),695 _ => None,696 }697}698699struct BoundedOutput {700 status: ExitStatus,701 stdout: Vec<u8>,702 stderr: Vec<u8>,703}704705async fn run_bounded_command(mut command: Command, stdout_limit: usize) -> Result<BoundedOutput> {706 command707 .stdin(Stdio::null())708 .stdout(Stdio::piped())709 .stderr(Stdio::piped())710 .kill_on_drop(true);711 let program = command.as_std().get_program().to_owned();712 let mut child = command713 .spawn()714 .with_context(|| format!("starting {}", program.to_string_lossy()))?;715 let stdout = child.stdout.take().context("child stdout was not piped")?;716 let stderr = child.stderr.take().context("child stderr was not piped")?;717718 let collect = async {719 let (stdout, stderr) = tokio::join!(720 read_bounded(stdout, stdout_limit),721 read_bounded(stderr, MAX_DIAGNOSTIC_OUTPUT)722 );723 let status = child724 .wait()725 .await726 .context("waiting for preflight command")?;727 Ok::<_, anyhow::Error>(BoundedOutput {728 status,729 stdout: stdout?,730 stderr: stderr?,731 })732 };733 match tokio::time::timeout(PREFLIGHT_TIMEOUT, collect).await {734 Ok(result) => result,735 Err(_) => {736 let _ = child.kill().await;737 let _ = child.wait().await;738 bail!(739 "preflight command {} exceeded {} seconds",740 program.to_string_lossy(),741 PREFLIGHT_TIMEOUT.as_secs()742 )743 }744 }745}746747async fn read_bounded<R>(reader: R, limit: usize) -> Result<Vec<u8>>748where749 R: AsyncRead + Unpin,750{751 let mut bytes = Vec::new();752 reader753 .take(limit.saturating_add(1) as u64)754 .read_to_end(&mut bytes)755 .await756 .context("reading bounded command output")?;757 ensure!(758 bytes.len() <= limit,759 "command output exceeded {limit} bytes"760 );761 Ok(bytes)762}763764fn diagnostic(bytes: &[u8]) -> String {765 String::from_utf8_lossy(bytes).trim().to_owned()766}767768fn is_h264_keyframe(data: &[u8]) -> bool {769 h264_nal_types(data).any(|nal_type| nal_type == 5)770}771772fn has_h264_codec_config(data: &[u8]) -> bool {773 let mut has_sps = false;774 let mut has_pps = false;775 for nal_type in h264_nal_types(data) {776 has_sps |= nal_type == 7;777 has_pps |= nal_type == 8;778 }779 has_sps && has_pps780}781782fn h264_nal_types(data: &[u8]) -> impl Iterator<Item = u8> + '_ {783 let mut index = 0;784 std::iter::from_fn(move || {785 while index + 4 <= data.len() {786 let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {787 Some(index + 4)788 } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {789 Some(index + 3)790 } else {791 None792 };793 index += 1;794 if let Some(nal_offset) = nal_offset795 && let Some(byte) = data.get(nal_offset)796 {797 return Some(byte & 0x1f);798 }799 }800 None801 })802}803804#[cfg(test)]805mod tests {806 use super::*;807 use crate::config::{GamescopeEncoderBackend, InputMode, VideoSource};808 use std::collections::BTreeMap;809 use std::path::PathBuf;810811 pub(super) fn gamescope_config() -> HostConfig {812 HostConfig {813 identity_path: PathBuf::from("/tmp/host.key"),814 state_path: PathBuf::from("/tmp/state"),815 source: VideoSource::GamescopePipewire,816 width: Some(1280),817 height: Some(800),818 framerate: 60,819 codec: "h264".into(),820 input_mode: InputMode::Disabled,821 uinput: None,822 ffmpeg_path: PathBuf::from("/usr/bin/ffmpeg"),823 gamescope_pipewire: Some(GamescopePipewireConfig {824 encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,825 xwayland_display: None,826 node_name: "gamescope".into(),827 media_class: "Video/Source".into(),828 match_properties: BTreeMap::from([(829 "device.bus-path".into(),830 "pci-0000:04:00.0".into(),831 )]),832 pw_dump_path: PathBuf::from("/usr/bin/pw-dump"),833 gst_launch_path: PathBuf::from("/usr/bin/gst-launch-1.0"),834 gst_inspect_path: PathBuf::from("/usr/bin/gst-inspect-1.0"),835 vaapi_encoder: "vah264enc".into(),836 vaapi_render_node: PathBuf::from("/dev/dri/renderD128"),837 rate_control: VaapiRateControl::Cbr,838 bitrate_kbps: Some(12_000),839 quantizer: None,840 }),841 audio: None,842 }843 }844845 pub(super) fn configured_video_mode() -> CaptureVideoMode {846 CaptureVideoMode {847 width: 1_280,848 height: 800,849 framerate: 60,850 }851 }852853 fn gamescope_dump(serial: u64, width: u16, height: u16) -> Vec<u8> {854 serde_json::to_vec(&serde_json::json!([{855 "type": "PipeWire:Interface:Node",856 "info": {857 "props": {858 "node.name": "gamescope",859 "media.class": "Video/Source",860 "device.bus-path": "pci-0000:04:00.0",861 "object.serial": serial,862 },863 "params": {864 "EnumFormat": [{865 "mediaType": "video",866 "mediaSubtype": "raw",867 "format": "NV12",868 "size": { "width": width, "height": height },869 }],870 },871 },872 }]))873 .unwrap()874 }875876 #[test]877 fn detects_h264_keyframes() {878 assert!(is_h264_keyframe(&[0, 0, 0, 1, 5, 1, 2]));879 assert!(!is_h264_keyframe(&[0, 0, 1, 7, 1, 2]));880 assert!(!is_h264_keyframe(&[0, 0, 0, 1, 1, 1, 2]));881 }882883 #[test]884 fn codec_config_requires_sps_and_pps() {885 assert!(has_h264_codec_config(&[886 0, 0, 0, 1, 7, 1, 2, 0, 0, 1, 8, 3, 4887 ]));888 assert!(!has_h264_codec_config(&[0, 0, 1, 7, 1, 2]));889 assert!(!has_h264_codec_config(&[0, 0, 1, 8, 1, 2]));890 }891892 #[test]893 fn publisher_carries_exact_resolution_and_discontinuity_into_new_gop() {894 let (frame_sender, frame_receiver) = watch::channel(None);895 let (current_gop_sender, current_gop_receiver) = watch::channel(None);896 let mut publisher = AccessUnitPublisher::new(897 SessionClock::start(),898 &frame_sender,899 &current_gop_sender,900 30,901 1280,902 800,903 );904 let configured_idr = vec![0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1, 0, 0, 0, 1, 5, 1];905906 assert!(907 publisher908 .publish_with_metadata(configured_idr, 960, 600, true)909 .unwrap()910 );911 let frame = frame_receiver.borrow().as_ref().unwrap().clone();912 assert_eq!((frame.width, frame.height), (960, 600));913 assert!(frame.discontinuity);914 let current_gop = current_gop_receiver.borrow();915 let first = &current_gop.as_ref().unwrap().frames[0];916 assert_eq!((first.width, first.height), (960, 600));917 assert!(first.keyframe && first.codec_config && first.discontinuity);918 }919920 #[test]921 fn current_gop_retains_only_a_bounded_contiguous_decodable_chain() {922 let (frame_sender, frame_receiver) = watch::channel(None);923 let (current_gop_sender, current_gop_receiver) = watch::channel(None);924 let frame = |sequence, keyframe, codec_config, payload_len| EncodedFrame {925 sequence,926 width: 1280,927 height: 800,928 capture_timestamp_micros: sequence,929 presentation_timestamp_micros: sequence as i64,930 observed_at: Instant::now(),931 keyframe,932 codec_config,933 discontinuity: false,934 data: Arc::from(vec![sequence as u8; payload_len]),935 };936937 for encoded in [938 frame(10, true, true, 1),939 frame(11, false, false, 1),940 frame(12, false, false, 1),941 ] {942 publish_encoded_frame(&frame_sender, &current_gop_sender, encoded, 4, 8);943 }944945 assert_eq!(frame_receiver.borrow().as_ref().unwrap().sequence, 12);946 assert_eq!(947 current_gop_receiver948 .borrow()949 .as_ref()950 .unwrap()951 .frames952 .iter()953 .map(|frame| frame.sequence)954 .collect::<Vec<_>>(),955 vec![10, 11, 12]956 );957958 publish_encoded_frame(959 &frame_sender,960 &current_gop_sender,961 frame(40, true, true, 1),962 4,963 8,964 );965 assert_eq!(966 current_gop_receiver.borrow().as_ref().unwrap().frames[0].sequence,967 40968 );969970 publish_encoded_frame(971 &frame_sender,972 &current_gop_sender,973 frame(42, false, false, 1),974 4,975 8,976 );977 assert!(current_gop_receiver.borrow().is_none());978979 publish_encoded_frame(980 &frame_sender,981 &current_gop_sender,982 frame(50, true, true, 3),983 4,984 4,985 );986 publish_encoded_frame(987 &frame_sender,988 &current_gop_sender,989 frame(51, false, false, 2),990 4,991 4,992 );993 assert!(current_gop_receiver.borrow().is_none());994995 for encoded in [996 frame(60, true, true, 1),997 frame(61, false, false, 1),998 frame(62, false, false, 1),999 ] {1000 publish_encoded_frame(&frame_sender, &current_gop_sender, encoded, 2, 8);1001 }1002 assert!(current_gop_receiver.borrow().is_none());1003 }10041005 #[test]1006 fn recovery_gop_is_bounded_to_half_a_second() {1007 assert_eq!(interactive_gop_frames(60), 30);1008 assert_eq!(interactive_gop_frames(59), 30);1009 assert_eq!(interactive_gop_frames(1), 1);1010 }10111012 #[test]1013 fn resolves_exactly_one_pipewire_node_by_properties() {1014 let config = gamescope_config();1015 let dump = br#"[1016 {"type":"PipeWire:Interface:Node","info":{"props":{1017 "node.name":"gamescope","media.class":"Video/Source",1018 "device.bus-path":"pci-0000:04:00.0","object.serial":7311019 },"params":{"EnumFormat":[1020 {"mediaType":"video","mediaSubtype":"raw","format":"BGRx",1021 "size":{"width":2560,"height":1600}},1022 {"mediaType":"video","mediaSubtype":"raw","format":"NV12",1023 "size":{"width":2560,"height":1600}}1024 ]}}},1025 {"type":"PipeWire:Interface:Node","info":{"props":{1026 "node.name":"gamescope","media.class":"Audio/Source",1027 "device.bus-path":"pci-0000:04:00.0","object.serial":7321028 }}}1029 ]"#;1030 assert_eq!(1031 resolve_pipewire_node(dump, &config).unwrap(),1032 GamescopePreflight {1033 target_object: "731".into(),1034 pointer_surface_dimensions: PointerSurfaceDimensions::new(2_560, 1_600).unwrap(),1035 video_mode: configured_video_mode(),1036 }1037 );10381039 let duplicate = br#"[1040 {"type":"PipeWire:Interface:Node","info":{"props":{1041 "node.name":"gamescope","media.class":"Video/Source",1042 "device.bus-path":"pci-0000:04:00.0","object.serial":7311043 }}},1044 {"type":"PipeWire:Interface:Node","info":{"props":{1045 "node.name":"gamescope","media.class":"Video/Source",1046 "device.bus-path":"pci-0000:04:00.0","object.serial":"732"1047 }}}1048 ]"#;1049 assert!(resolve_pipewire_node(duplicate, &config).is_err());10501051 let mut native_config = config.clone();1052 native_config.width = None;1053 native_config.height = None;1054 let native = resolve_pipewire_node(dump, &native_config).unwrap();1055 assert_eq!(1056 native.video_mode,1057 CaptureVideoMode {1058 width: 2_560,1059 height: 1_600,1060 framerate: 60,1061 }1062 );1063 assert_eq!(native.pointer_surface_dimensions.width, 2_560);1064 assert_eq!(native.pointer_surface_dimensions.height, 1_600);10651066 let mut distorted = config.clone();1067 distorted.width = Some(1_920);1068 distorted.height = Some(1_080);1069 assert!(1070 resolve_pipewire_node(dump, &distorted)1071 .unwrap_err()1072 .to_string()1073 .contains("must preserve native 2560x1600 aspect ratio")1074 );10751076 let portrait_dump = std::str::from_utf8(dump)1077 .unwrap()1078 .replace("2560", "WIDTH")1079 .replace("1600", "2560")1080 .replace("WIDTH", "1600");1081 let portrait = resolve_pipewire_node(portrait_dump.as_bytes(), &native_config).unwrap();1082 assert_eq!(1083 (portrait.video_mode.width, portrait.video_mode.height),1084 (1_600, 2_560)1085 );1086 }10871088 #[test]1089 fn unconfigured_gamescope_mode_follows_each_resolved_session() {1090 let mut config = gamescope_config();1091 config.width = None;1092 config.height = None;10931094 for (serial, width, height) in [1095 (731, 2_560, 1_600),1096 (900, 1_280, 800),1097 (901, 1_920, 1_080),1098 (902, 1_366, 768),1099 ] {1100 let resolved =1101 resolve_pipewire_node(&gamescope_dump(serial, width, height), &config).unwrap();1102 assert_eq!(resolved.target_object, serial.to_string());1103 assert_eq!(1104 resolved.pointer_surface_dimensions,1105 PointerSurfaceDimensions::new(width, height).unwrap()1106 );1107 assert_eq!(1108 resolved.video_mode,1109 CaptureVideoMode {1110 width,1111 height,1112 framerate: 60,1113 }1114 );1115 assert_eq!(config.configured_dimensions(), None);1116 }1117 }11181119 #[test]1120 fn resolves_pinned_upstream_gamescope_pipewire_contract() {1121 // Sanitized from the standard interface emitted by Valve Gamescope1122 // commit 17baf4abd1ab3353fb705e4d0d023f84e870f7e8, src/pipewire.cpp1123 // blob 76b3ea8cc8ff01c6635498cbafe38130e33215f2. This fixture proves1124 // interface compatibility; it is not SteamOS hardware evidence.1125 let dump = include_bytes!("../../tests/fixtures/upstream-gamescope-pipewire-contract.json");1126 let mut config = gamescope_config();1127 config.width = None;1128 config.height = None;1129 config1130 .gamescope_pipewire1131 .as_mut()1132 .unwrap()1133 .match_properties1134 .clear();11351136 let resolved = resolve_pipewire_node(dump, &config).unwrap();1137 assert_eq!(resolved.target_object, "8842");1138 assert_eq!(1139 resolved.pointer_surface_dimensions,1140 PointerSurfaceDimensions::new(1_920, 1_080).unwrap()1141 );1142 assert_eq!(1143 resolved.video_mode,1144 CaptureVideoMode {1145 width: 1_920,1146 height: 1_080,1147 framerate: 60,1148 }1149 );11501151 let args: Vec<String> =1152 gamescope_pipeline_args(&config, &resolved.target_object, resolved.video_mode)1153 .unwrap()1154 .into_iter()1155 .map(|arg| arg.into_string().unwrap())1156 .collect();1157 assert!(args.contains(&"target-object=8842".to_owned()));1158 assert!(1159 args.contains(1160 &"video/x-raw,format=NV12,width=1920,height=1080,framerate=60/1".to_owned()1161 )1162 );1163 assert!(args.contains(&"max-rate=60".to_owned()));1164 }11651166 #[test]1167 fn rejects_ambiguous_or_unbounded_gamescope_native_size() {1168 let config = gamescope_config();1169 let dump = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1170 "node.name":"gamescope","media.class":"Video/Source",1171 "device.bus-path":"pci-0000:04:00.0","object.serial":7311172 },"params":{"EnumFormat":[1173 {"mediaType":"video","mediaSubtype":"raw","size":{"width":2560,"height":1600}},1174 {"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}1175 ]}}}]"#;1176 assert!(resolve_pipewire_node(dump, &config).is_err());11771178 let unbounded = std::str::from_utf8(dump)1179 .unwrap()1180 .replace("2560", "7681")1181 .replace(1182 r#",{"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}"#,1183 "",1184 );1185 assert!(resolve_pipewire_node(unbounded.as_bytes(), &config).is_err());11861187 let mut native_config = config.clone();1188 native_config.width = None;1189 native_config.height = None;1190 let odd = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1191 "node.name":"gamescope","media.class":"Video/Source",1192 "device.bus-path":"pci-0000:04:00.0","object.serial":7311193 },"params":{"EnumFormat":[1194 {"mediaType":"video","mediaSubtype":"raw","size":{"width":1365,"height":768}}1195 ]}}}]"#;1196 let error = resolve_pipewire_node(odd, &native_config).unwrap_err();1197 assert!(error.to_string().contains("must be even"), "{error:#}");1198 }1199}