cat / goq
clone

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

fix(capture): serialize Gamescope consumers

Dami committed 8b596c4
Hold the global lifecycle lock across capture probe preflight, frame consumption, and encoder teardown. Add external CQP contention and cleanup coverage to the Bazzite hardware gate.
commit
8b596c4de1d71ba3f1ccf36a719fb9767ca9bc14
parent
9f412ea1232eb402ccfaf4b9ddb468dda9eaff3b
author
Dami <dami.etoile@gmail.com>
7 files changed, +356 -89
Mcrates/sigil-host/src/main.rs116++++++++++++++------------
Mcrates/sigil-host/src/secure_state.rs6+-
Mcrates/sigil-host/src/source/external.rs42+++++++--
Mcrates/sigil-host/src/source/mod.rs55+++++++++++-
Mdocs/fresh-bazzite-host.md11++
Mscripts/run-bazzite-hardware-uat.sh133+++++++++++++++++++++++++++---
Mscripts/tests/sigil-appliance-status.sh82++++++++++++++++++-
M crates/sigil-host/src/main.rs +61 -55
@@ -623,14 +623,7 @@ async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {
623623 624624 let configured_framerate = config.framerate;625625 let source = source::spawn_test_pattern(config, clock::SessionClock::start());626- consume_capture_probe(627- args,628- source.frames,629- source.task,630- "test-pattern",631- configured_framerate,632- )633- .await626+ consume_capture_probe(args, source, None, "test-pattern", configured_framerate).await634627 }635628 636629 async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
@@ -643,6 +636,9 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
643636 config.source == VideoSource::GamescopePipewire,644637 "capture config source must be gamescope-pipewire"645638 );639+ config.ensure_runtime_directory()?;640+ let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, true)641+ .context("acquiring exclusive Gamescope capture lifecycle")?;646642 let preflight = source::preflight_gamescope_pipewire(&config).await?;647643 let observed = (648644 u32::from(preflight.video_mode.width),
@@ -663,11 +659,16 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
663659 observed.0, observed.1, preflight.video_mode.framerate664660 );665661 let configured_framerate = preflight.video_mode.framerate;666- let source = source::spawn_gamescope_pipewire(config, clock::SessionClock::start()).await?;662+ let source::ProbeEncodedSource { source, shutdown } =663+ source::spawn_gamescope_pipewire_for_probe(664+ config,665+ clock::SessionClock::start(),666+ preflight,667+ )?;667668 consume_capture_probe(668669 args,669- source.frames,670- source.task,670+ source,671+ shutdown,671672 "Gamescope PipeWire",672673 configured_framerate,673674 )
@@ -676,8 +677,8 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
676677 677678 async fn consume_capture_probe(678679 args: CaptureProbeArgs,679- mut receiver: tokio::sync::watch::Receiver<Option<source::EncodedFrame>>,680- task: tokio::task::JoinHandle<Result<()>>,680+ source: source::EncodedSource,681+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,681682 source_name: &str,682683 configured_framerate: u32,683684 ) -> Result<()> {
@@ -689,7 +690,15 @@ async fn consume_capture_probe(
689690 "--minimum-fps must be finite, positive, and no greater than configured framerate {configured_framerate}"690691 );691692 }692- let task = CaptureTaskGuard::new(task);693+ let source::EncodedSource {694+ frames: mut receiver,695+ current_gop,696+ task,697+ pointer_surface_dimensions: _,698+ encoder_control,699+ } = source;700+ drop(current_gop);701+ drop(encoder_control);693702 let started = Instant::now();694703 let mut received = 0_u32;695704 let mut keyframes = 0_u32;
@@ -699,28 +708,48 @@ async fn consume_capture_probe(
699708 let mut dropped = 0_u64;700709 let mut max_post_encode_queue_age_micros = 0_u128;701710 702- while received < args.frames {703- tokio::time::timeout(Duration::from_secs(5), receiver.changed())704- .await705- .with_context(|| format!("timed out waiting for encoded {source_name} frame"))?706- .with_context(|| format!("{source_name} encoder stopped"))?;707- let Some(frame) = receiver.borrow_and_update().clone() else {708- continue;709- };710- max_post_encode_queue_age_micros =711- max_post_encode_queue_age_micros.max(frame.observed_at.elapsed().as_micros());712- if let Some(previous) = last_sequence {713- dropped += frame.sequence.saturating_sub(previous + 1);711+ let capture_result: Result<()> = async {712+ while received < args.frames {713+ tokio::time::timeout(Duration::from_secs(5), receiver.changed())714+ .await715+ .with_context(|| format!("timed out waiting for encoded {source_name} frame"))?716+ .with_context(|| format!("{source_name} encoder stopped"))?;717+ let Some(frame) = receiver.borrow_and_update().clone() else {718+ continue;719+ };720+ max_post_encode_queue_age_micros =721+ max_post_encode_queue_age_micros.max(frame.observed_at.elapsed().as_micros());722+ if let Some(previous) = last_sequence {723+ dropped += frame.sequence.saturating_sub(previous + 1);724+ }725+ last_sequence = Some(frame.sequence);726+ keyframes += u32::from(frame.keyframe);727+ decodable_keyframes += u32::from(frame.keyframe && frame.codec_config);728+ encoded_bytes = encoded_bytes.saturating_add(frame.data.len() as u64);729+ received += 1;714730 }715- last_sequence = Some(frame.sequence);716- keyframes += u32::from(frame.keyframe);717- decodable_keyframes += u32::from(frame.keyframe && frame.codec_config);718- encoded_bytes = encoded_bytes.saturating_add(frame.data.len() as u64);719- received += 1;731+ Ok(())720732 }733+ .await;721734 722735 let elapsed = started.elapsed();723- task.abort_and_wait().await;736+ drop(receiver);737+ if let Some(shutdown) = shutdown {738+ let _ = shutdown.send(());739+ }740+ let source_result = task741+ .await742+ .with_context(|| format!("joining {source_name} capture task"))?;743+ match (capture_result, source_result) {744+ (Ok(()), Ok(())) => {}745+ (Err(error), Ok(())) => return Err(error),746+ (Ok(()), Err(error)) => return Err(error),747+ (Err(error), Err(source_error)) => {748+ return Err(error.context(format!(749+ "{source_name} capture teardown also failed: {source_error:#}"750+ )));751+ }752+ }724753 ensure!(keyframes > 0, "capture probe produced no H.264 keyframe");725754 ensure!(726755 decodable_keyframes > 0,
@@ -749,29 +778,6 @@ async fn consume_capture_probe(
749778 Ok(())750779 }751780 752-struct CaptureTaskGuard(Option<tokio::task::JoinHandle<Result<()>>>);753-754-impl CaptureTaskGuard {755- fn new(task: tokio::task::JoinHandle<Result<()>>) -> Self {756- Self(Some(task))757- }758-759- async fn abort_and_wait(mut self) {760- if let Some(task) = self.0.take() {761- task.abort();762- let _ = task.await;763- }764- }765-}766-767-impl Drop for CaptureTaskGuard {768- fn drop(&mut self) {769- if let Some(task) = self.0.take() {770- task.abort();771- }772- }773-}774-775781 async fn serve_command(args: ServeArgs) -> Result<()> {776782 let configured_service = args.config.is_some();777783 let (config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {
M crates/sigil-host/src/secure_state.rs +3 -3
M crates/sigil-host/src/source/external.rs +31 -11
@@ -4,7 +4,7 @@ use std::process::Stdio;
44 use anyhow::{Context, Result, bail, ensure};55 use tokio::io::{AsyncRead, AsyncReadExt};66 use tokio::process::Command;7-use tokio::sync::watch;7+use tokio::sync::{oneshot, watch};88 use tracing::debug;99 1010 use crate::clock::SessionClock;
@@ -20,6 +20,15 @@ pub(super) fn spawn_gamescope_pipewire_with_target(
2020 config: HostConfig,2121 session_clock: SessionClock,2222 preflight: GamescopePreflight,23+) -> Result<EncodedSource> {24+ spawn_gamescope_pipewire_with_target_and_shutdown(config, session_clock, preflight, None)25+}26+27+pub(super) fn spawn_gamescope_pipewire_with_target_and_shutdown(28+ config: HostConfig,29+ session_clock: SessionClock,30+ preflight: GamescopePreflight,31+ shutdown: Option<oneshot::Receiver<()>>,2332 ) -> Result<EncodedSource> {2433 let (sender, receiver) = watch::channel(None);2534 let (current_gop_sender, current_gop) = watch::channel(None);
@@ -33,6 +42,7 @@ pub(super) fn spawn_gamescope_pipewire_with_target(
3342 video_mode,3443 sender,3544 current_gop_sender,45+ shutdown,3646 )3747 .await3848 });
@@ -52,6 +62,7 @@ async fn run_gamescope_pipewire(
5262 video_mode: CaptureVideoMode,5363 sender: watch::Sender<Option<EncodedFrame>>,5464 current_gop_sender: watch::Sender<Option<EncodedGop>>,65+ shutdown: Option<oneshot::Receiver<()>>,5566 ) -> Result<()> {5667 let pipewire = gamescope_config(&config)?;5768 let args = gamescope_pipeline_args(&config, &target_object, video_mode)?;
@@ -83,16 +94,25 @@ async fn run_gamescope_pipewire(
8394 .context("GStreamer stderr was not piped")?;8495 let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "gstreamer"));8596 86- let result = forward_annex_b_stream(87- session_clock,88- &mut stdout,89- &sender,90- &current_gop_sender,91- interactive_gop_frames(video_mode.framerate) as usize,92- video_mode.width,93- video_mode.height,94- )95- .await;97+ let result = {98+ let forward = forward_annex_b_stream(99+ session_clock,100+ &mut stdout,101+ &sender,102+ &current_gop_sender,103+ interactive_gop_frames(video_mode.framerate) as usize,104+ video_mode.width,105+ video_mode.height,106+ );107+ tokio::pin!(forward);108+ match shutdown {109+ Some(mut shutdown) => tokio::select! {110+ _ = &mut shutdown => Ok(()),111+ result = &mut forward => result,112+ },113+ None => forward.await,114+ }115+ };96116 let _ = child.kill().await;97117 let exit_status = child.wait().await.ok();98118 stderr_task.abort();
M crates/sigil-host/src/source/mod.rs +49 -6
@@ -8,7 +8,7 @@ use serde_json::Value;
88 use sigil_protocol::PointerSurfaceDimensions;99 use tokio::io::{AsyncRead, AsyncReadExt};1010 use tokio::process::Command;11-use tokio::sync::watch;11+use tokio::sync::{oneshot, watch};1212 use tracing::warn;1313 1414 use crate::clock::SessionClock;
@@ -23,7 +23,10 @@ mod preflight;
2323 2424 #[cfg(test)]2525 use external::gamescope_pipeline_args;26-use external::{forward_annex_b_stream, spawn_gamescope_pipewire_with_target};26+use external::{27+ forward_annex_b_stream, spawn_gamescope_pipewire_with_target,28+ spawn_gamescope_pipewire_with_target_and_shutdown,29+};2730 #[cfg(test)]2831 pub(crate) use in_process::EncoderControlTestHarness;2932 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]
@@ -159,12 +162,52 @@ async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<Gamesc
159162 resolve_pipewire_node(&output.stdout, config)160163 }161164 162-pub async fn spawn_gamescope_pipewire(165+pub struct ProbeEncodedSource {166+ pub source: EncodedSource,167+ pub shutdown: Option<oneshot::Sender<()>>,168+}169+170+pub fn spawn_gamescope_pipewire_for_probe(163171 config: HostConfig,164172 session_clock: SessionClock,165-) -> Result<EncodedSource> {166- let preflight = preflight_gamescope_pipewire(&config).await?;167- spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)173+ preflight: GamescopePreflight,174+) -> Result<ProbeEncodedSource> {175+ match gamescope_config(&config)?.encoder_backend {176+ GamescopeEncoderBackend::ExternalGstLaunch => {177+ let (shutdown, shutdown_receiver) = oneshot::channel();178+ let source = spawn_gamescope_pipewire_with_target_and_shutdown(179+ config,180+ session_clock,181+ preflight,182+ Some(shutdown_receiver),183+ )?;184+ Ok(ProbeEncodedSource {185+ source,186+ shutdown: Some(shutdown),187+ })188+ }189+ GamescopeEncoderBackend::InProcessGstreamer => {190+ #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]191+ {192+ let source = spawn_gamescope_pipewire_in_process_with_target(193+ config,194+ session_clock,195+ preflight,196+ )?;197+ Ok(ProbeEncodedSource {198+ source,199+ shutdown: None,200+ })201+ }202+ #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]203+ {204+ let _ = (session_clock, preflight);205+ bail!(206+ "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"207+ )208+ }209+ }210+ }168211 }169212 170213 /// Start capture after `serve` has completed the static executable/GstVA
M docs/fresh-bazzite-host.md +11 -0
M scripts/run-bazzite-hardware-uat.sh +121 -12
@@ -183,6 +183,7 @@ host_identity="$private/host.key"
183183 probe_identity="$private/probe.key"184184 state="$private/state"185185 fixed_config="$private/host-1280x800.toml"186+external_cqp_config="$private/host-1280x800-external-cqp.toml"186187 native_config="$private/host-native.toml"187188 invitation="$private/probe.goq-invite"188189
@@ -193,6 +194,7 @@ probe_node_id="$(sed -n 's/^node_id=//p' "$private/probe-identity.log")"
193194 [[ -n "$host_node_id" && -n "$probe_node_id" ]]194195 195196 pw_dump="$(command -v pw-dump)"197+pw_link="$(command -v pw-link)"196198 gst_launch="$(command -v gst-launch-1.0)"197199 gst_inspect="$(command -v gst-inspect-1.0)"198200 ffmpeg="$(command -v ffmpeg)"
@@ -248,13 +250,43 @@ vaapi_render_node = "$render_node"
248250 rate_control = "cbr"249251 bitrate_kbps = 12000250252 EOF251-chmod 0600 "$fixed_config" "$native_config"253+254+cat >"$external_cqp_config" <<EOF255+identity_path = "$host_identity"256+state_path = "$state"257+source = "gamescope-pipewire"258+width = 1280259+height = 800260+framerate = 60261+codec = "h264"262+input_mode = "log"263+ffmpeg_path = "$ffmpeg"264+265+[gamescope_pipewire]266+node_name = "gamescope"267+media_class = "Video/Source"268+xwayland_display = ":0"269+pw_dump_path = "$pw_dump"270+gst_launch_path = "$gst_launch"271+gst_inspect_path = "$gst_inspect"272+encoder_backend = "external-gst-launch"273+vaapi_encoder = "$va_encoder"274+vaapi_render_node = "$render_node"275+rate_control = "cqp"276+quantizer = 24277+EOF278+chmod 0600 "$fixed_config" "$external_cqp_config" "$native_config"252279 253280 "$sigil" config check --config "$fixed_config" >"$private/fixed-config-check.log"281+"$sigil" config check --config "$external_cqp_config" \282+ >"$private/external-cqp-config-check.log"254283 "$sigil" config check --config "$native_config" >"$private/native-config-check.log"255284 grep -Fxq config=ok "$private/fixed-config-check.log"256285 grep -Fxq capture_preflight=ok "$private/fixed-config-check.log"257286 grep -Fxq encoded_mode=1280x800@60 "$private/fixed-config-check.log"287+grep -Fxq config=ok "$private/external-cqp-config-check.log"288+grep -Fxq capture_preflight=ok "$private/external-cqp-config-check.log"289+grep -Fxq encoded_mode=1280x800@60 "$private/external-cqp-config-check.log"258290 grep -Fxq config=ok "$private/native-config-check.log"259291 grep -Fxq capture_preflight=ok "$private/native-config-check.log"260292 native_size="$(sed -n 's/^encoded_mode=\([0-9][0-9]*x[0-9][0-9]*\)@60$/\1/p' "$private/native-config-check.log")"
@@ -274,6 +306,7 @@ native_size="$(sed -n 's/^encoded_mode=\([0-9][0-9]*x[0-9][0-9]*\)@60$/\1/p' "$p
274306 [[ "$(stat -c %a "$invitation")" == 600 ]]275307 276308 restore_required=false309+capture_probe_pid=''277310 rollback_units=()278311 rollback_helper="$private/rollback-original.sh"279312 fixed_candidate_unit="goq-uat-fixed-candidate-$invocation_id"
@@ -354,6 +387,11 @@ cleanup() {
354387 local status=$?355388 trap - EXIT356389 trap '' INT TERM HUP390+ if [[ -n "$capture_probe_pid" ]] && kill -0 "$capture_probe_pid" 2>/dev/null; then391+ kill "$capture_probe_pid" 2>/dev/null || true392+ wait "$capture_probe_pid" 2>/dev/null || true393+ capture_probe_pid=''394+ fi357395 if ! restore_original; then358396 echo "CRITICAL: original Sigil service did not return ready; rollback timer remains armed" >&2359397 status=1
@@ -505,6 +543,52 @@ validate_host_recovery() {
505543 ! grep -Eq 'forced-IDR request failed|forced-IDR recovery was not acknowledged|forced-IDR acknowledgement task failed|forced-IDR acknowledgement task ended without a result' "$plain"506544 }507545 546+capture_inventory() {547+ local phase="$1"548+ ps -u "$(id -u)" -o pid=,comm=,args= >"$evidence/$phase-processes.txt"549+ awk -v own_pid="$$" '550+ $1 == own_pid { next }551+ ($2 == "sigil" || $2 == "sigil-host") &&552+ ($0 ~ / serve( |$)/ || $0 ~ / capture probe( |$)/) { print; next }553+ $2 ~ /^gst-launch/ && $0 ~ /pipewiresrc/ { print }554+ ' "$evidence/$phase-processes.txt" >"$evidence/$phase-conflicts.txt"555+ if [[ -s "$evidence/$phase-conflicts.txt" ]]; then556+ echo "a Sigil or GStreamer PipeWire capture process survived $phase" >&2557+ cat "$evidence/$phase-conflicts.txt" >&2558+ return 1559+ fi560+ "$pw_link" -l >"$evidence/$phase-pipewire-links.txt"561+ if grep -Fiq gamescope "$evidence/$phase-pipewire-links.txt"; then562+ echo "a Gamescope PipeWire consumer link survived $phase" >&2563+ cat "$evidence/$phase-pipewire-links.txt" >&2564+ return 1565+ fi566+ "$pw_dump" >"$evidence/$phase-pipewire.json"567+}568+569+validate_capture_log() {570+ local log="$1"571+ grep -Fxq probe=ok "$log"572+ grep -Fxq dropped_after_encode_before_probe_consumer=0 "$log"573+}574+575+run_capture_probe() {576+ local name="$1"577+ local config="$2"578+ local size="$3"579+ local minimum_fps="${4:-}"580+ local command=(581+ "$sigil" capture probe --source gamescope-pipewire582+ --config "$config" --frames 300 --expect-size "$size"583+ )584+ [[ -z "$minimum_fps" ]] || command+=(--minimum-fps "$minimum_fps")585+ capture_inventory "pre-$name"586+ timeout --signal=TERM --kill-after=5s 90s "${command[@]}" \587+ >"$evidence/$name-capture.log"588+ validate_capture_log "$evidence/$name-capture.log"589+ capture_inventory "post-$name"590+}591+508592 fixed_timer="goq-uat-fixed-$invocation_id"509593 arm_rollback "$fixed_timer"510594 restore_required=true
@@ -515,12 +599,42 @@ if systemctl --user is-active --quiet sigil-host.service; then
515599 fi516600 [[ "$(systemctl --user show -p MainPID --value sigil-host.service)" -eq 0 ]]517601 518-timeout --signal=TERM --kill-after=5s 60s \602+run_capture_probe fixed "$fixed_config" 1280x800 55603+604+capture_inventory pre-external-cqp605+timeout --signal=TERM --kill-after=5s 90s \606+ "$sigil" capture probe --source gamescope-pipewire \607+ --config "$external_cqp_config" --frames 300 --expect-size 1280x800 \608+ --minimum-fps 55 >"$evidence/external-cqp-capture.log" &609+capture_probe_pid=$!610+external_link_ready=false611+for _ in $(seq 1 200); do612+ if ! kill -0 "$capture_probe_pid" 2>/dev/null; then613+ break614+ fi615+ if "$pw_link" -l | grep -Fiq gamescope; then616+ external_link_ready=true617+ break618+ fi619+ sleep 0.05620+done621+if [[ "$external_link_ready" != true ]]; then622+ echo "external CQP probe never opened a Gamescope PipeWire consumer link" >&2623+ exit 1624+fi625+if timeout --signal=TERM --kill-after=2s 10s \519626 "$sigil" capture probe --source gamescope-pipewire \520- --config "$fixed_config" --frames 300 --expect-size 1280x800 --minimum-fps 55 \521- >"$evidence/fixed-capture.log"522-grep -Fxq probe=ok "$evidence/fixed-capture.log"523-grep -Fxq dropped_after_encode_before_probe_consumer=0 "$evidence/fixed-capture.log"627+ --config "$fixed_config" --frames 1 --expect-size 1280x800 \628+ >"$evidence/contended-capture.out" 2>"$evidence/contended-capture.error"; then629+ echo "a second Gamescope capture probe overlapped external CQP capture" >&2630+ exit 1631+fi632+grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \633+ "$evidence/contended-capture.error"634+wait "$capture_probe_pid"635+capture_probe_pid=''636+validate_capture_log "$evidence/external-cqp-capture.log"637+capture_inventory post-external-cqp524638 525639 start_candidate fixed "$fixed_config"526640 run_probe_cycle fixed iroh-moq 1280x800 1001 "$invitation" 1500 relay
@@ -551,12 +665,7 @@ stop_candidate
551665 native_timer="goq-uat-native-$invocation_id"552666 arm_rollback "$native_timer"553667 systemctl --user stop "$fixed_timer.timer" "$fixed_timer.service" 2>/dev/null || true554-timeout --signal=TERM --kill-after=5s 90s \555- "$sigil" capture probe --source gamescope-pipewire \556- --config "$native_config" --frames 300 --expect-size "$native_size" \557- >"$evidence/native-capture.log"558-grep -Fxq probe=ok "$evidence/native-capture.log"559-grep -Fxq dropped_after_encode_before_probe_consumer=0 "$evidence/native-capture.log"668+run_capture_probe native "$native_config" "$native_size"560669 561670 start_candidate native "$native_config"562671 run_probe_cycle native iroh-moq "$native_size" 2001 "" 1500
M scripts/tests/sigil-appliance-status.sh +80 -2
@@ -42,9 +42,14 @@ alternate_runtime_directory="$temp_root/runtime-alternate"
4242 invalid_runtime_directory="$temp_root/invalid-runtime"4343 config_directory="$temp_root/config"4444 unsafe_config_directory="$temp_root/config-unsafe"45+gamescope_probe_state="$temp_root/gamescope-probe-state"4546 identity_path="$identity_directory/host.key"4647 config_path="$config_directory/host.toml"4748 unsafe_config_path="$unsafe_config_directory/host.toml"49+gamescope_probe_config="$config_directory/gamescope-probe.toml"50+fake_pw_dump="$temp_root/fake-pw-dump"51+fake_gst_inspect="$temp_root/fake-gst-inspect"52+gamescope_preflight_marker="$temp_root/gamescope-preflight-ran"4853 host_log="$temp_root/host.log"4954 second_log="$temp_root/second.log"5055 live_status="$temp_root/live-status.json"
@@ -59,6 +64,7 @@ candidate_status="$temp_root/candidate-status.json"
5964 mkdir -m 0700 \6065 "$identity_directory" \6166 "$state_directory" \67+ "$gamescope_probe_state" \6268 "$runtime_directory" \6369 "$alternate_runtime_directory"6470 mkdir -m 0755 "$config_directory"
@@ -83,6 +89,44 @@ printf '%s\n' \
8389 >"$config_path"8490 chmod 0600 "$config_path"8591 92+printf '%s\n' \93+ '#!/usr/bin/env bash' \94+ "printf '[]\\n'" \95+ >"$fake_pw_dump"96+chmod 0700 "$fake_pw_dump"97+printf '%s\n' \98+ '#!/usr/bin/env bash' \99+ "touch '$gamescope_preflight_marker'" \100+ 'exit 1' \101+ >"$fake_gst_inspect"102+chmod 0700 "$fake_gst_inspect"103+false_path="/usr/bin/false"104+[[ -x "$false_path" ]]105+printf '%s\n' \106+ "identity_path = \"$identity_path\"" \107+ "state_path = \"$gamescope_probe_state\"" \108+ 'source = "gamescope-pipewire"' \109+ 'width = 1280' \110+ 'height = 800' \111+ 'framerate = 60' \112+ 'codec = "h264"' \113+ 'input_mode = "disabled"' \114+ "ffmpeg_path = \"$ffmpeg\"" \115+ '' \116+ '[gamescope_pipewire]' \117+ 'node_name = "gamescope"' \118+ 'media_class = "Video/Source"' \119+ 'xwayland_display = ":0"' \120+ "pw_dump_path = \"$fake_pw_dump\"" \121+ "gst_launch_path = \"$false_path\"" \122+ "gst_inspect_path = \"$fake_gst_inspect\"" \123+ 'vaapi_encoder = "vah264enc"' \124+ 'vaapi_render_node = "/dev/dri/renderD128"' \125+ 'rate_control = "cqp"' \126+ 'quantizer = 24' \127+ >"$gamescope_probe_config"128+chmod 0600 "$gamescope_probe_config"129+86130 cp "$config_path" "$unsafe_config_path"87131 chmod 0600 "$unsafe_config_path"88132 unsafe_config_hash="$(python3 - "$unsafe_config_path" <<'PY'
@@ -181,7 +225,21 @@ if XDG_RUNTIME_DIR="$runtime_directory" \
181225 printf 'a second per-user Sigil daemon unexpectedly started\n' >&2182226 exit 1183227 fi184-grep -Fq 'another Sigil daemon already owns this state directory' "$second_log"228+grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' "$second_log"229+230+if XDG_RUNTIME_DIR="$runtime_directory" \231+ "$sigil" capture probe \232+ --source gamescope-pipewire \233+ --config "$gamescope_probe_config" \234+ --frames 1 \235+ >"$temp_root/contended-capture.out" \236+ 2>"$temp_root/contended-capture.error"; then237+ printf 'Gamescope capture probe overlapped a live Sigil daemon\n' >&2238+ exit 1239+fi240+grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \241+ "$temp_root/contended-capture.error"242+[[ ! -e "$gamescope_preflight_marker" ]]185243 186244 python3 - "$live_status" "$host_node_id" <<'PY'187245 import json
@@ -247,11 +305,31 @@ if env -u XDG_RUNTIME_DIR \
247305 printf 'enrollment reset unexpectedly mutated a live Sigil daemon\n' >&2248306 exit 1249307 fi250-grep -Fq 'another Sigil daemon already owns this state directory' \308+grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \251309 "$temp_root/live-reset.log"252310 253311 wait "$host_pid"254312 host_pid=''313+if XDG_RUNTIME_DIR="$runtime_directory" \314+ "$sigil" capture probe \315+ --source gamescope-pipewire \316+ --config "$gamescope_probe_config" \317+ --frames 1 \318+ >"$temp_root/released-capture.out" \319+ 2>"$temp_root/released-capture.error"; then320+ printf 'fake Gamescope capture unexpectedly completed\n' >&2321+ exit 1322+fi323+[[ -e "$gamescope_preflight_marker" ]]324+XDG_RUNTIME_DIR="$runtime_directory" \325+ "$sigil" serve \326+ --identity "$identity_path" \327+ --source test-pattern \328+ --state-path "$temp_root/post-probe-state" \329+ --ffmpeg "$ffmpeg" \330+ --max-runtime-seconds 1 \331+ >"$temp_root/post-probe-serve.out" 2>&1332+grep -Fq 'status=ready' "$temp_root/post-probe-serve.out"255333 [[ ! -e "$state_directory/authorization-v1.json" ]]256334 if XDG_RUNTIME_DIR="$runtime_directory" \257335 "$sigil" appliance enrollment-reset \