fix(capture): serialize Gamescope consumers
- commit
- dc5e2187012b2e649adbe3d4cc773d0a84b09819
- parent
- 9f412ea1232eb402ccfaf4b9ddb468dda9eaff3b
- parent
- 3d722d9f3bc86092ac718040bc8d51997b339f52
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | crates/ | 149 | ++++++++++++++++++------------ |
| M | crates/ | 6 | +- |
| M | crates/ | 42 | ++++++-- |
| M | crates/ | 59 | ++++++++++-- |
| M | docs/ | 11 | ++ |
| M | scripts/ | 142 | ++++++++++++++++++++++++++--- |
| M | scripts/ | 82 | ++++++++++++++++- |
@@ -622,15 +622,9 @@ async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {
622622 config.validate()?;623623 624624 let configured_framerate = config.framerate;625+ validate_capture_probe_args(&args, configured_framerate)?;625626 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- .await627+ consume_capture_probe(args, source, None, "test-pattern", configured_framerate).await634628 }635629 636630 async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {@@ -643,6 +637,9 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
643637 config.source == VideoSource::GamescopePipewire,644638 "capture config source must be gamescope-pipewire"645639 );640+ config.ensure_runtime_directory()?;641+ let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, true)642+ .context("acquiring exclusive Gamescope capture lifecycle")?;646643 let preflight = source::preflight_gamescope_pipewire(&config).await?;647644 let observed = (648645 u32::from(preflight.video_mode.width),@@ -663,11 +660,17 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
663660 observed.0, observed.1, preflight.video_mode.framerate664661 );665662 let configured_framerate = preflight.video_mode.framerate;666- let source = source::spawn_gamescope_pipewire(config, clock::SessionClock::start()).await?;663+ validate_capture_probe_args(&args, configured_framerate)?;664+ let source::ProbeEncodedSource { source, shutdown } =665+ source::spawn_gamescope_pipewire_for_probe(666+ config,667+ clock::SessionClock::start(),668+ preflight,669+ )?;667670 consume_capture_probe(668671 args,669- source.frames,670- source.task,672+ source,673+ shutdown,671674 "Gamescope PipeWire",672675 configured_framerate,673676 )@@ -676,20 +679,21 @@ async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {
676679 677680 async fn consume_capture_probe(678681 args: CaptureProbeArgs,679- mut receiver: tokio::sync::watch::Receiver<Option<source::EncodedFrame>>,680- task: tokio::task::JoinHandle<Result<()>>,682+ source: source::EncodedSource,683+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,681684 source_name: &str,682685 configured_framerate: u32,683686 ) -> Result<()> {684- if let Some(minimum_fps) = args.minimum_fps {685- ensure!(686- minimum_fps.is_finite()687- && minimum_fps > 0.0688- && minimum_fps <= f64::from(configured_framerate),689- "--minimum-fps must be finite, positive, and no greater than configured framerate {configured_framerate}"690- );691- }692- let task = CaptureTaskGuard::new(task);687+ debug_assert!(validate_capture_probe_args(&args, configured_framerate).is_ok());688+ let source::EncodedSource {689+ frames: mut receiver,690+ current_gop,691+ task,692+ pointer_surface_dimensions: _,693+ encoder_control,694+ } = source;695+ drop(current_gop);696+ drop(encoder_control);693697 let started = Instant::now();694698 let mut received = 0_u32;695699 let mut keyframes = 0_u32;@@ -699,28 +703,48 @@ async fn consume_capture_probe(
699703 let mut dropped = 0_u64;700704 let mut max_post_encode_queue_age_micros = 0_u128;701705 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);706+ let capture_result: Result<()> = async {707+ while received < args.frames {708+ tokio::time::timeout(Duration::from_secs(5), receiver.changed())709+ .await710+ .with_context(|| format!("timed out waiting for encoded {source_name} frame"))?711+ .with_context(|| format!("{source_name} encoder stopped"))?;712+ let Some(frame) = receiver.borrow_and_update().clone() else {713+ continue;714+ };715+ max_post_encode_queue_age_micros =716+ max_post_encode_queue_age_micros.max(frame.observed_at.elapsed().as_micros());717+ if let Some(previous) = last_sequence {718+ dropped += frame.sequence.saturating_sub(previous + 1);719+ }720+ last_sequence = Some(frame.sequence);721+ keyframes += u32::from(frame.keyframe);722+ decodable_keyframes += u32::from(frame.keyframe && frame.codec_config);723+ encoded_bytes = encoded_bytes.saturating_add(frame.data.len() as u64);724+ received += 1;714725 }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;726+ Ok(())720727 }728+ .await;721729 722730 let elapsed = started.elapsed();723- task.abort_and_wait().await;731+ drop(receiver);732+ if let Some(shutdown) = shutdown {733+ let _ = shutdown.send(());734+ }735+ let source_result = task736+ .await737+ .with_context(|| format!("joining {source_name} capture task"))?;738+ match (capture_result, source_result) {739+ (Ok(()), Ok(())) => {}740+ (Err(error), Ok(())) => return Err(error),741+ (Ok(()), Err(error)) => return Err(error),742+ (Err(error), Err(source_error)) => {743+ return Err(error.context(format!(744+ "{source_name} capture teardown also failed: {source_error:#}"745+ )));746+ }747+ }724748 ensure!(keyframes > 0, "capture probe produced no H.264 keyframe");725749 ensure!(726750 decodable_keyframes > 0,@@ -749,27 +773,16 @@ async fn consume_capture_probe(
749773 Ok(())750774 }751775 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- }776+fn validate_capture_probe_args(args: &CaptureProbeArgs, configured_framerate: u32) -> Result<()> {777+ if let Some(minimum_fps) = args.minimum_fps {778+ ensure!(779+ minimum_fps.is_finite()780+ && minimum_fps > 0.0781+ && minimum_fps <= f64::from(configured_framerate),782+ "--minimum-fps must be finite, positive, and no greater than configured framerate {configured_framerate}"783+ );772784 }785+ Ok(())773786 }774787 775788 async fn serve_command(args: ServeArgs) -> Result<()> {@@ -1123,6 +1136,22 @@ mod tests {
11231136 assert!(parse_size("1280").is_err());11241137 }11251138 1139+ 1140+ fn capture_probe_threshold_is_validated_before_source_start() {1141+ let args = |minimum_fps| CaptureProbeArgs {1142+ source: SourceArg::TestPattern,1143+ frames: 1,1144+ expect_size: None,1145+ minimum_fps,1146+ config: None,1147+ };1148+ assert!(validate_capture_probe_args(&args(None), 60).is_ok());1149+ assert!(validate_capture_probe_args(&args(Some(55.0)), 60).is_ok());1150+ assert!(validate_capture_probe_args(&args(Some(0.0)), 60).is_err());1151+ assert!(validate_capture_probe_args(&args(Some(61.0)), 60).is_err());1152+ assert!(validate_capture_probe_args(&args(Some(f64::NAN)), 60).is_err());1153+ }1154+11261155 11271156 fn direct_serve_requires_identity_and_source() {11281157 assert!(Cli::try_parse_from(["sigil", "serve"]).is_err());@@ -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@@ -270,9 +313,7 @@ async fn run_test_pattern(
270313 u16::try_from(height).context("configured height exceeds encoder metadata")?,271314 )272315 .await;273- if result.is_ok() {274- let _ = child.kill().await;275- }316+ let _ = child.kill().await;276317 let status = child.wait().await.context("waiting for ffmpeg")?;277318 stderr_task.abort();278319 match result {@@ -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@@ -182,7 +182,9 @@ timeout --signal=TERM --kill-after=2s 5s "$probe" --version
182182 host_identity="$private/host.key"183183 probe_identity="$private/probe.key"184184 state="$private/state"185+external_cqp_state="$private/external-cqp-state"185186 fixed_config="$private/host-1280x800.toml"187+external_cqp_config="$private/host-1280x800-external-cqp.toml"186188 native_config="$private/host-native.toml"187189 invitation="$private/probe.goq-invite"188190 @@ -193,6 +195,7 @@ probe_node_id="$(sed -n 's/^node_id=//p' "$private/probe-identity.log")"
193195 [[ -n "$host_node_id" && -n "$probe_node_id" ]]194196 195197 pw_dump="$(command -v pw-dump)"198+pw_link="$(command -v pw-link)"196199 gst_launch="$(command -v gst-launch-1.0)"197200 gst_inspect="$(command -v gst-inspect-1.0)"198201 ffmpeg="$(command -v ffmpeg)"@@ -248,13 +251,43 @@ vaapi_render_node = "$render_node"
248251 rate_control = "cbr"249252 bitrate_kbps = 12000250253 EOF251-chmod 0600 "$fixed_config" "$native_config"254+255+cat >"$external_cqp_config" <<EOF256+identity_path = "$host_identity"257+state_path = "$external_cqp_state"258+source = "gamescope-pipewire"259+width = 1280260+height = 800261+framerate = 60262+codec = "h264"263+input_mode = "log"264+ffmpeg_path = "$ffmpeg"265+266+[gamescope_pipewire]267+node_name = "gamescope"268+media_class = "Video/Source"269+xwayland_display = ":0"270+pw_dump_path = "$pw_dump"271+gst_launch_path = "$gst_launch"272+gst_inspect_path = "$gst_inspect"273+encoder_backend = "external-gst-launch"274+vaapi_encoder = "$va_encoder"275+vaapi_render_node = "$render_node"276+rate_control = "cqp"277+quantizer = 24278+EOF279+chmod 0600 "$fixed_config" "$external_cqp_config" "$native_config"252280 253281 "$sigil" config check --config "$fixed_config" >"$private/fixed-config-check.log"282+"$sigil" config check --config "$external_cqp_config" \283+ >"$private/external-cqp-config-check.log"254284 "$sigil" config check --config "$native_config" >"$private/native-config-check.log"255285 grep -Fxq config=ok "$private/fixed-config-check.log"256286 grep -Fxq capture_preflight=ok "$private/fixed-config-check.log"257287 grep -Fxq encoded_mode=1280x800@60 "$private/fixed-config-check.log"288+grep -Fxq config=ok "$private/external-cqp-config-check.log"289+grep -Fxq capture_preflight=ok "$private/external-cqp-config-check.log"290+grep -Fxq encoded_mode=1280x800@60 "$private/external-cqp-config-check.log"258291 grep -Fxq config=ok "$private/native-config-check.log"259292 grep -Fxq capture_preflight=ok "$private/native-config-check.log"260293 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 +307,7 @@ native_size="$(sed -n 's/^encoded_mode=\([0-9][0-9]*x[0-9][0-9]*\)@60$/\1/p' "$p
274307 [[ "$(stat -c %a "$invitation")" == 600 ]]275308 276309 restore_required=false310+capture_probe_pid=''277311 rollback_units=()278312 rollback_helper="$private/rollback-original.sh"279313 fixed_candidate_unit="goq-uat-fixed-candidate-$invocation_id"@@ -354,6 +388,11 @@ cleanup() {
354388 local status=$?355389 trap - EXIT356390 trap '' INT TERM HUP391+ if [[ -n "$capture_probe_pid" ]] && kill -0 "$capture_probe_pid" 2>/dev/null; then392+ kill "$capture_probe_pid" 2>/dev/null || true393+ wait "$capture_probe_pid" 2>/dev/null || true394+ capture_probe_pid=''395+ fi357396 if ! restore_original; then358397 echo "CRITICAL: original Sigil service did not return ready; rollback timer remains armed" >&2359398 status=1@@ -505,6 +544,52 @@ validate_host_recovery() {
505544 ! 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"506545 }507546 547+capture_inventory() {548+ local phase="$1"549+ ps -u "$(id -u)" -o pid=,comm=,args= >"$evidence/$phase-processes.txt"550+ awk -v own_pid="$$" '551+ $1 == own_pid { next }552+ ($2 == "sigil" || $2 == "sigil-host") &&553+ ($0 ~ / serve( |$)/ || $0 ~ / capture probe( |$)/) { print; next }554+ $2 ~ /^gst-launch/ && $0 ~ /pipewiresrc/ { print }555+ ' "$evidence/$phase-processes.txt" >"$evidence/$phase-conflicts.txt"556+ if [[ -s "$evidence/$phase-conflicts.txt" ]]; then557+ echo "a Sigil or GStreamer PipeWire capture process survived $phase" >&2558+ cat "$evidence/$phase-conflicts.txt" >&2559+ return 1560+ fi561+ "$pw_link" -l >"$evidence/$phase-pipewire-links.txt"562+ if grep -Fiq gamescope "$evidence/$phase-pipewire-links.txt"; then563+ echo "a Gamescope PipeWire consumer link survived $phase" >&2564+ cat "$evidence/$phase-pipewire-links.txt" >&2565+ return 1566+ fi567+ "$pw_dump" >"$evidence/$phase-pipewire.json"568+}569+570+validate_capture_log() {571+ local log="$1"572+ grep -Fxq probe=ok "$log"573+ grep -Fxq dropped_after_encode_before_probe_consumer=0 "$log"574+}575+576+run_capture_probe() {577+ local name="$1"578+ local config="$2"579+ local size="$3"580+ local minimum_fps="${4:-}"581+ local command=(582+ "$sigil" capture probe --source gamescope-pipewire583+ --config "$config" --frames 300 --expect-size "$size"584+ )585+ [[ -z "$minimum_fps" ]] || command+=(--minimum-fps "$minimum_fps")586+ capture_inventory "pre-$name"587+ timeout --signal=TERM --kill-after=5s 90s "${command[@]}" \588+ >"$evidence/$name-capture.log"589+ validate_capture_log "$evidence/$name-capture.log"590+ capture_inventory "post-$name"591+}592+508593 fixed_timer="goq-uat-fixed-$invocation_id"509594 arm_rollback "$fixed_timer"510595 restore_required=true@@ -515,12 +600,42 @@ if systemctl --user is-active --quiet sigil-host.service; then
515600 fi516601 [[ "$(systemctl --user show -p MainPID --value sigil-host.service)" -eq 0 ]]517602 518-timeout --signal=TERM --kill-after=5s 60s \603+run_capture_probe fixed "$fixed_config" 1280x800 55604+605+capture_inventory pre-external-cqp606+timeout --signal=TERM --kill-after=5s 90s \607+ "$sigil" capture probe --source gamescope-pipewire \608+ --config "$external_cqp_config" --frames 300 --expect-size 1280x800 \609+ --minimum-fps 55 >"$evidence/external-cqp-capture.log" &610+capture_probe_pid=$!611+external_link_ready=false612+for _ in $(seq 1 200); do613+ if ! kill -0 "$capture_probe_pid" 2>/dev/null; then614+ break615+ fi616+ if "$pw_link" -l | grep -Fiq gamescope; then617+ external_link_ready=true618+ break619+ fi620+ sleep 0.05621+done622+if [[ "$external_link_ready" != true ]]; then623+ echo "external CQP probe never opened a Gamescope PipeWire consumer link" >&2624+ exit 1625+fi626+if timeout --signal=TERM --kill-after=2s 10s \519627 "$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"628+ --config "$fixed_config" --frames 1 --expect-size 1280x800 \629+ >"$evidence/contended-capture.out" 2>"$evidence/contended-capture.error"; then630+ echo "a second Gamescope capture probe overlapped external CQP capture" >&2631+ exit 1632+fi633+grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \634+ "$evidence/contended-capture.error"635+wait "$capture_probe_pid"636+capture_probe_pid=''637+validate_capture_log "$evidence/external-cqp-capture.log"638+capture_inventory post-external-cqp524639 525640 start_candidate fixed "$fixed_config"526641 run_probe_cycle fixed iroh-moq 1280x800 1001 "$invitation" 1500 relay@@ -551,12 +666,7 @@ stop_candidate
551666 native_timer="goq-uat-native-$invocation_id"552667 arm_rollback "$native_timer"553668 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"669+run_capture_probe native "$native_config" "$native_size"560670 561671 start_candidate native "$native_config"562672 run_probe_cycle native iroh-moq "$native_size" 2001 "" 1500@@ -622,8 +732,11 @@ native_unique_sessions="$(awk -F= '$1 == "session_id" { print $2 }' \
622732 [[ "$fixed_unique_sessions" -eq 20 && "$native_unique_sessions" -eq 20 ]]623733 total_sessions="${#all_raw_probes[@]}"624734 fixed_observed_fps="$(sed -n 's/^observed_fps=//p' "$evidence/fixed-capture.log")"735+external_cqp_observed_fps="$(sed -n 's/^observed_fps=//p' \736+ "$evidence/external-cqp-capture.log")"625737 native_observed_fps="$(sed -n 's/^observed_fps=//p' "$evidence/native-capture.log")"626-[[ -n "$fixed_observed_fps" && -n "$native_observed_fps" ]]738+[[ -n "$fixed_observed_fps" && -n "$external_cqp_observed_fps" \739+ && -n "$native_observed_fps" ]]627740 if awk -v fps="$native_observed_fps" 'BEGIN { exit !(fps >= 55) }'; then628741 native_55fps_status=met629742 else@@ -637,6 +750,9 @@ fi
637750 echo fixed_size=1280x800638751 echo fixed_minimum_fps=55639752 echo "fixed_observed_fps=$fixed_observed_fps"753+ echo external_cqp_minimum_fps=55754+ echo "external_cqp_observed_fps=$external_cqp_observed_fps"755+ echo cross_state_capture_contention=rejected640756 echo "native_size=$native_size"641757 echo native_performance_gate=observational642758 echo "native_observed_fps=$native_observed_fps"@@ -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 \