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>
| M | crates/ | 116 | ++++++++++++++------------ |
| M | crates/ | 6 | +- |
| M | crates/ | 42 | +++++++-- |
| M | crates/ | 55 | +++++++++++- |
| M | docs/ | 11 | ++ |
| M | scripts/ | 133 | +++++++++++++++++++++++++++--- |
| M | scripts/ | 82 | ++++++++++++++++++- |
@@ -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 {@@ -79,9 +79,9 @@ pub enum LockAcquireError {
7979 impl std::fmt::Display for LockAcquireError {8080 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {8181 match self {82- Self::Busy => {83- formatter.write_str("another Sigil daemon already owns this state directory")84- }82+ Self::Busy => formatter.write_str(83+ "another Sigil daemon or capture probe already owns this lifecycle scope",84+ ),8585 Self::Unsafe(error) => write!(formatter, "unsafe lock storage: {error}"),8686 }8787 }@@ -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- ¤t_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+ ¤t_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();@@ -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 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 2831 pub(crate) use in_process::EncoderControlTestHarness;2932 @@ -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+ 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+ 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@@ -1344,6 +1344,17 @@ decodable H.264 keyframe, the resolved output size, and a changing sequence:
13441344 --minimum-fps 5513451345 ```13461346 1347+The Gamescope probe acquires the same per-user global lifecycle lock as the1348+daemon and holds it through encoder teardown. Stop `sigil-host.service` before1349+running it; a second configured Sigil daemon using the same runtime root or1350+another capture probe fails before opening PipeWire. The lock cannot exclude1351+arbitrary third-party consumers, so inventory non-Sigil PipeWire consumers1352+before a benchmark.1353+Gamescope may divide vblank delivery across simultaneous consumers, making two1354+otherwise healthy 60 fps pipelines appear as roughly 30 fps each. A benchmark1355+with an existing Gamescope consumer is invalid, not evidence that CQP or the1356+transport is intrinsically half-rate.1357+13471358 The probe prints `resolved_encoded_mode=<width>x<height>@<cap>` before capture13481359 and the dimensions observed on encoded frames afterward. Repeat with13491360 `--expect-size <width>x<height>` when recording a strict benchmark. The@@ -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@@ -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 \