cat / goq
clone

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

fix(sigil): probe GStreamer properties programmatically (#105)

FelineStateMachine committed 125ce65
* refactor(sigil): probe GStreamer properties programmatically

Add a runtime-loaded GObject property probe and route GstVA preflight through a bounded Sigil command instead of parsing gst-inspect formatting.

* fix(sigil): own loaded GStreamer symbols safely

Copy C function pointers while retaining their runtime libraries, avoiding a self-referential libloading structure on Linux.

* test(sigil): use the property probe in hardware admission

Route UAT node/factory selection through Sigil’s bounded GObject preflight, retain registry-only factory discovery, and document multi-GPU selection without property-text parsing.

* docs(agents): require real hardware connection tests

Make the bounded dev-auth Portal-to-UMPC Gamescope session and authenticated-service restoration explicit completion gates for behavior-affecting changes.

* fix(sigil): bound enum metadata before ABI traversal

Reject empty, null, or unreasonably large GObject enum metadata before constructing the rate-control value slice.
commit
125ce65e879e8d7bc0a785d650d30b04d8f7dc9e
parent
f6cc570a803c3d7469b1369609df1cc07f85b901
author
FelineStateMachine <dami.etoile@gmail.com>
12 files changed, +552 -237
MAGENTS.md12+
MCargo.lock13+-
Mcrates/sigil-host/Cargo.toml1+
Mcrates/sigil-host/src/main.rs65++++++
Acrates/sigil-host/src/source/gstreamer_properties.rs340++++++++++++++++++++++++++++++
Mcrates/sigil-host/src/source/mod.rs2+
Mcrates/sigil-host/src/source/preflight.rs152++------------
Mdocs/fresh-bazzite-host.md12+-
Mdocs/hardware-uat/README.md9+-
Mdocs/sigil-host-activation.md27++-
Mscripts/run-bazzite-hardware-uat.sh97+++------
Mscripts/tests/bazzite-hardware-uat-selection.sh59+++--
M AGENTS.md +12 -0
M Cargo.lock +12 -1
M crates/sigil-host/Cargo.toml +1 -0
M crates/sigil-host/src/main.rs +65 -0
@@ -201,6 +201,24 @@ enum ConfigCommand {
201201 enum CaptureCommand {202202 /// Consume a finite number of frames without starting iroh.203203 Probe(CaptureProbeArgs),204+ /// Validate one GstVA encoder against an exact render node.205+ EncoderPreflight(EncoderPreflightArgs),206+}207+208+#[derive(Debug, Args)]209+struct EncoderPreflightArgs {210+ #[arg(long)]211+ vaapi_encoder: String,212+ #[arg(long)]213+ vaapi_render_node: PathBuf,214+ #[arg(long, value_enum, required = true)]215+ rate_control: Vec<RateControlArg>,216+}217+218+#[derive(Clone, Copy, Debug, ValueEnum)]219+enum RateControlArg {220+ Cbr,221+ Cqp,204222 }205223 206224 #[derive(Debug, Subcommand)]
@@ -588,6 +606,23 @@ async fn config_command(command: ConfigCommand) -> Result<()> {
588606 589607 async fn capture_command(command: CaptureCommand) -> Result<()> {590608 match command {609+ CaptureCommand::EncoderPreflight(args) => {610+ let rate_controls = args611+ .rate_control612+ .into_iter()613+ .map(|rate_control| match rate_control {614+ RateControlArg::Cbr => crate::config::VaapiRateControl::Cbr,615+ RateControlArg::Cqp => crate::config::VaapiRateControl::Cqp,616+ })617+ .collect::<Vec<_>>();618+ source::probe_encoder_properties(619+ &args.vaapi_encoder,620+ &args.vaapi_render_node,621+ &rate_controls,622+ )?;623+ println!("encoder_preflight=ok");624+ Ok(())625+ }591626 CaptureCommand::Probe(args) => {592627 ensure!(593628 (1..=36_000).contains(&args.frames),
@@ -1207,6 +1242,36 @@ mod tests {
12071242 assert!(validate_capture_probe_args(&args(Some(f64::NAN)), 60).is_err());12081243 }12091244 1245+ #[test]1246+ fn encoder_preflight_requires_an_exact_pair_and_rate_control() {1247+ let valid = [1248+ "sigil",1249+ "capture",1250+ "encoder-preflight",1251+ "--vaapi-encoder",1252+ "varenderD129h264enc",1253+ "--vaapi-render-node",1254+ "/dev/dri/renderD129",1255+ "--rate-control",1256+ "cbr",1257+ "--rate-control",1258+ "cqp",1259+ ];1260+ assert!(Cli::try_parse_from(valid).is_ok());1261+ assert!(1262+ Cli::try_parse_from([1263+ "sigil",1264+ "capture",1265+ "encoder-preflight",1266+ "--vaapi-encoder",1267+ "vah264enc",1268+ "--vaapi-render-node",1269+ "/dev/dri/renderD128",1270+ ])1271+ .is_err()1272+ );1273+ }1274+12101275 #[test]12111276 fn direct_serve_requires_identity_and_source() {12121277 assert!(Cli::try_parse_from(["sigil", "serve"]).is_err());
A crates/sigil-host/src/source/gstreamer_properties.rs +340 -0
@@ -0,0 +1,340 @@
1+use std::path::Path;2+3+use anyhow::Result;4+5+use crate::config::VaapiRateControl;6+7+#[cfg(target_os = "linux")]8+mod linux {9+ use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};10+ use std::mem::MaybeUninit;11+ use std::ptr;12+13+ use anyhow::{Context, Result, ensure};14+ use libloading::Library;15+16+ use super::{Path, VaapiRateControl};17+18+ const G_PARAM_READABLE: u32 = 1 << 0;19+ const G_PARAM_WRITABLE: u32 = 1 << 1;20+ const G_TYPE_ENUM: usize = 12 << 2;21+22+ #[repr(C)]23+ struct GTypeInstance {24+ g_class: *mut c_void,25+ }26+27+ #[repr(C)]28+ struct GParamSpec {29+ g_type_instance: GTypeInstance,30+ name: *const c_char,31+ flags: u32,32+ value_type: usize,33+ owner_type: usize,34+ }35+36+ #[repr(C)]37+ union GValueData {38+ v_int: c_int,39+ v_uint: c_uint,40+ v_long: isize,41+ v_ulong: usize,42+ v_int64: i64,43+ v_uint64: u64,44+ v_float: f32,45+ v_double: f64,46+ v_pointer: *mut c_void,47+ }48+49+ #[repr(C)]50+ struct GValue {51+ g_type: usize,52+ data: [GValueData; 2],53+ }54+55+ #[repr(C)]56+ struct GTypeClass {57+ g_type: usize,58+ }59+60+ #[repr(C)]61+ struct GEnumValue {62+ value: c_int,63+ value_name: *const c_char,64+ value_nick: *const c_char,65+ }66+67+ #[repr(C)]68+ struct GEnumClass {69+ g_type_class: GTypeClass,70+ minimum: c_int,71+ maximum: c_int,72+ n_values: c_uint,73+ values: *mut GEnumValue,74+ }75+76+ #[repr(C)]77+ struct GError {78+ domain: c_uint,79+ code: c_int,80+ message: *mut c_char,81+ }82+83+ type GstInitCheck =84+ unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char, *mut *mut GError) -> c_int;85+ type GstElementFactoryMake = unsafe extern "C" fn(*const c_char, *const c_char) -> *mut c_void;86+ type GstObjectUnref = unsafe extern "C" fn(*mut c_void);87+ type GObjectClassFindProperty =88+ unsafe extern "C" fn(*mut c_void, *const c_char) -> *mut GParamSpec;89+ type GObjectGetProperty = unsafe extern "C" fn(*mut c_void, *const c_char, *mut GValue);90+ type GValueInit = unsafe extern "C" fn(*mut GValue, usize) -> *mut GValue;91+ type GValueGetString = unsafe extern "C" fn(*const GValue) -> *const c_char;92+ type GValueUnset = unsafe extern "C" fn(*mut GValue);93+ type GTypeFundamental = unsafe extern "C" fn(usize) -> usize;94+ type GTypeClassRef = unsafe extern "C" fn(usize) -> *mut c_void;95+ type GTypeClassUnref = unsafe extern "C" fn(*mut c_void);96+ type GErrorFree = unsafe extern "C" fn(*mut GError);97+98+ struct GstreamerApi {99+ _gstreamer: Library,100+ _gobject: Library,101+ _glib: Library,102+ gst_init_check: GstInitCheck,103+ gst_element_factory_make: GstElementFactoryMake,104+ gst_object_unref: GstObjectUnref,105+ g_object_class_find_property: GObjectClassFindProperty,106+ g_object_get_property: GObjectGetProperty,107+ g_value_init: GValueInit,108+ g_value_get_string: GValueGetString,109+ g_value_unset: GValueUnset,110+ g_type_fundamental: GTypeFundamental,111+ g_type_class_ref: GTypeClassRef,112+ g_type_class_unref: GTypeClassUnref,113+ g_error_free: GErrorFree,114+ }115+116+ impl GstreamerApi {117+ unsafe fn load() -> Result<Self> {118+ let gstreamer = unsafe { Library::new("libgstreamer-1.0.so.0") }119+ .context("loading libgstreamer-1.0.so.0")?;120+ let gobject = unsafe { Library::new("libgobject-2.0.so.0") }121+ .context("loading libgobject-2.0.so.0")?;122+ let glib =123+ unsafe { Library::new("libglib-2.0.so.0") }.context("loading libglib-2.0.so.0")?;124+125+ // SAFETY: every symbol uses the public C ABI declared by the matching126+ // versioned runtime SONAME. The libraries remain owned by this value127+ // for at least as long as all function symbols below.128+ unsafe {129+ Ok(Self {130+ gst_init_check: *gstreamer.get(b"gst_init_check\0")?,131+ gst_element_factory_make: *gstreamer.get(b"gst_element_factory_make\0")?,132+ gst_object_unref: *gstreamer.get(b"gst_object_unref\0")?,133+ g_object_class_find_property: *gobject134+ .get(b"g_object_class_find_property\0")?,135+ g_object_get_property: *gobject.get(b"g_object_get_property\0")?,136+ g_value_init: *gobject.get(b"g_value_init\0")?,137+ g_value_get_string: *gobject.get(b"g_value_get_string\0")?,138+ g_value_unset: *gobject.get(b"g_value_unset\0")?,139+ g_type_fundamental: *gobject.get(b"g_type_fundamental\0")?,140+ g_type_class_ref: *gobject.get(b"g_type_class_ref\0")?,141+ g_type_class_unref: *gobject.get(b"g_type_class_unref\0")?,142+ g_error_free: *glib.get(b"g_error_free\0")?,143+ _gstreamer: gstreamer,144+ _gobject: gobject,145+ _glib: glib,146+ })147+ }148+ }149+150+ unsafe fn property(&self, object: *mut c_void, name: &CStr) -> Result<&GParamSpec> {151+ // SAFETY: GstElement begins with a GTypeInstance, whose class pointer152+ // is valid while the element is alive. GObject returns a class-owned153+ // GParamSpec that remains valid for the lifetime of that class.154+ let class = unsafe { (*(object.cast::<GTypeInstance>())).g_class };155+ let property = unsafe { (self.g_object_class_find_property)(class, name.as_ptr()) };156+ ensure!(157+ !property.is_null(),158+ "encoder has no {} property",159+ name.to_string_lossy()160+ );161+ Ok(unsafe { &*property })162+ }163+164+ unsafe fn string_property(165+ &self,166+ object: *mut c_void,167+ name: &CStr,168+ property: &GParamSpec,169+ ) -> Result<String> {170+ let mut value = MaybeUninit::<GValue>::zeroed();171+ let value = value.as_mut_ptr();172+ unsafe {173+ (self.g_value_init)(value, property.value_type);174+ (self.g_object_get_property)(object, name.as_ptr(), value);175+ }176+ let observed = unsafe { (self.g_value_get_string)(value) };177+ let result = ensure_non_null_string(observed, name);178+ unsafe { (self.g_value_unset)(value) };179+ result180+ }181+182+ unsafe fn enum_nicks(&self, property: &GParamSpec) -> Result<Vec<String>> {183+ ensure!(184+ unsafe { (self.g_type_fundamental)(property.value_type) } == G_TYPE_ENUM,185+ "encoder rate-control property is not an enum"186+ );187+ let class = unsafe { (self.g_type_class_ref)(property.value_type) };188+ ensure!(189+ !class.is_null(),190+ "loading encoder rate-control enum metadata"191+ );192+ let class = class.cast::<GEnumClass>();193+ let count = unsafe { (*class).n_values as usize };194+ let values = unsafe { (*class).values };195+ ensure!(196+ (1..=1024).contains(&count) && !values.is_null(),197+ "encoder rate-control enum metadata is empty or unreasonably large"198+ );199+ let values = unsafe { std::slice::from_raw_parts(values, count) };200+ let mut nicks = Vec::with_capacity(values.len());201+ for value in values {202+ if !value.value_nick.is_null() {203+ nicks.push(204+ unsafe { CStr::from_ptr(value.value_nick) }205+ .to_str()206+ .context("encoder rate-control nickname is not UTF-8")?207+ .to_owned(),208+ );209+ }210+ }211+ unsafe { (self.g_type_class_unref)(class.cast()) };212+ Ok(nicks)213+ }214+ }215+216+ fn ensure_non_null_string(value: *const c_char, name: &CStr) -> Result<String> {217+ ensure!(218+ !value.is_null(),219+ "encoder {} property is null",220+ name.to_string_lossy()221+ );222+ Ok(unsafe { CStr::from_ptr(value) }223+ .to_str()224+ .with_context(|| format!("encoder {} property is not UTF-8", name.to_string_lossy()))?225+ .to_owned())226+ }227+228+ pub(super) fn probe(229+ factory: &str,230+ expected_device_path: &Path,231+ rate_controls: &[VaapiRateControl],232+ ) -> Result<()> {233+ let factory = CString::new(factory).context("VA encoder factory contains NUL")?;234+ let expected_device_path = expected_device_path235+ .to_str()236+ .context("VAAPI render node is not UTF-8")?;237+ let device_path = c"device-path";238+ let rate_control = c"rate-control";239+240+ // SAFETY: all interaction is through the versioned public GStreamer and241+ // GObject C ABIs. Host preflight invokes this probe in a bounded child,242+ // so plugin faults cannot unwind through the daemon.243+ unsafe {244+ let api = GstreamerApi::load()?;245+ let mut error = ptr::null_mut();246+ if (api.gst_init_check)(ptr::null_mut(), ptr::null_mut(), &mut error) == 0 {247+ let message = if error.is_null() || (*error).message.is_null() {248+ "unknown GStreamer initialization error".to_owned()249+ } else {250+ CStr::from_ptr((*error).message)251+ .to_string_lossy()252+ .into_owned()253+ };254+ if !error.is_null() {255+ (api.g_error_free)(error);256+ }257+ anyhow::bail!("initializing GStreamer: {message}");258+ }259+260+ let element = (api.gst_element_factory_make)(factory.as_ptr(), ptr::null());261+ ensure!(262+ !element.is_null(),263+ "creating configured VA encoder factory {:?}",264+ factory.to_string_lossy()265+ );266+267+ let result = (|| {268+ let device_property = api.property(element, device_path)?;269+ ensure!(270+ device_property.flags & G_PARAM_READABLE != 0,271+ "encoder device-path property is not readable"272+ );273+ let observed_device_path =274+ api.string_property(element, device_path, device_property)?;275+ ensure!(276+ observed_device_path == expected_device_path,277+ "configured VA encoder factory {:?} uses device-path {observed_device_path:?}, expected {expected_device_path:?}; on multi-GPU hosts select the per-device GstVA factory reported for that render node",278+ factory.to_string_lossy()279+ );280+281+ for property_name in [282+ c"aud",283+ c"b-frames",284+ c"key-int-max",285+ rate_control,286+ c"ref-frames",287+ c"target-usage",288+ ] {289+ let property = api.property(element, property_name)?;290+ ensure!(291+ property.flags & G_PARAM_WRITABLE != 0,292+ "encoder {} property is not writable",293+ property_name.to_string_lossy()294+ );295+ }296+297+ let rate_control_property = api.property(element, rate_control)?;298+ let rate_control_nicks = api.enum_nicks(rate_control_property)?;299+ for required in rate_controls {300+ let (nickname, mode_properties): (&str, &[&CStr]) = match required {301+ VaapiRateControl::Cbr => ("cbr", &[c"bitrate"]),302+ VaapiRateControl::Cqp => ("cqp", &[c"qpi", c"qpp"]),303+ };304+ ensure!(305+ rate_control_nicks.iter().any(|value| value == nickname),306+ "configured VA encoder factory {:?} does not support {nickname} rate control",307+ factory.to_string_lossy()308+ );309+ for property_name in mode_properties {310+ let property = api.property(element, property_name)?;311+ ensure!(312+ property.flags & G_PARAM_WRITABLE != 0,313+ "encoder {} property is not writable",314+ property_name.to_string_lossy()315+ );316+ }317+ }318+ Ok(())319+ })();320+ (api.gst_object_unref)(element);321+ result322+ }323+ }324+}325+326+pub(crate) fn probe_encoder_properties(327+ factory: &str,328+ expected_device_path: &Path,329+ rate_controls: &[VaapiRateControl],330+) -> Result<()> {331+ #[cfg(target_os = "linux")]332+ {333+ linux::probe(factory, expected_device_path, rate_controls)334+ }335+ #[cfg(not(target_os = "linux"))]336+ {337+ let _ = (factory, expected_device_path, rate_controls);338+ anyhow::bail!("GstVA encoder property probing requires Linux")339+ }340+}
M crates/sigil-host/src/source/mod.rs +2 -0
M crates/sigil-host/src/source/preflight.rs +19 -133
@@ -51,94 +51,38 @@ pub(super) async fn preflight_gamescope_static(config: &HostConfig) -> Result<()
5151 "GStreamer element {element:?} is unavailable: {}",5252 diagnostic(&output.stderr)5353 );54- if element == pipewire.vaapi_encoder {55- validate_encoder_inspection(&output.stdout, pipewire)?;56- }5754 }55+ preflight_encoder_properties(pipewire).await?;5856 5957 Ok(())6058 }6159 62-fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {63- let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;64- let lines: Vec<&str> = output.lines().collect();65- let device_block = gst_property_block(&lines, "device-path")66- .context("configured VA encoder does not report a device-path property")?;67- let expected = config.vaapi_render_node.to_string_lossy();68- let expected_default = format!("Default: \"{expected}\"");69- ensure!(70- device_block.contains(&expected_default),71- "configured VA encoder {:?} does not report expected device-path {}",72- config.vaapi_encoder,73- config.vaapi_render_node.display()74- );75- for property in [76- "aud",77- "b-frames",78- "key-int-max",79- "rate-control",80- "ref-frames",81- "target-usage",82- ] {83- let block = gst_property_block(&lines, property).with_context(|| {84- format!(85- "configured VA encoder {:?} has no {property} property",86- config.vaapi_encoder87- )88- })?;89- ensure!(90- block.contains("writable") || block.contains("Writable"),91- "configured VA encoder {:?} property {property} is not writable",92- config.vaapi_encoder93- );94- }95- let rate_control =96- gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");97- let expected_rate_control = match config.rate_control {60+async fn preflight_encoder_properties(config: &GamescopePipewireConfig) -> Result<()> {61+ let executable = std::env::current_exe().context("resolving the running Sigil executable")?;62+ let rate_control = match config.rate_control {9863 VaapiRateControl::Cbr => "cbr",9964 VaapiRateControl::Cqp => "cqp",10065 };66+ let mut command = Command::new(executable);67+ command.args([68+ "capture",69+ "encoder-preflight",70+ "--vaapi-encoder",71+ config.vaapi_encoder.as_str(),72+ "--vaapi-render-node",73+ ]);74+ command.arg(&config.vaapi_render_node);75+ command.args(["--rate-control", rate_control]);76+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;10177 ensure!(102- rate_control.contains(expected_rate_control),103- "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",104- config.vaapi_encoder78+ output.status.success(),79+ "configured VA encoder {:?} failed programmatic property preflight: {}",80+ config.vaapi_encoder,81+ diagnostic(&output.stderr)10582 );106- let mode_properties: &[&str] = match config.rate_control {107- VaapiRateControl::Cbr => &["bitrate"],108- VaapiRateControl::Cqp => &["qpi", "qpp"],109- };110- for property in mode_properties {111- let block = gst_property_block(&lines, property).with_context(|| {112- format!(113- "configured VA encoder {:?} has no {property} property",114- config.vaapi_encoder115- )116- })?;117- ensure!(118- block.contains("writable") || block.contains("Writable"),119- "configured VA encoder {:?} property {property} is not writable",120- config.vaapi_encoder121- );122- }12383 Ok(())12484 }12585 126-fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {127- let start = lines.iter().position(|line| {128- line.strip_prefix(" ").is_some_and(|line| {129- line.starts_with(property) && line[property.len()..].starts_with(' ')130- })131- })?;132- let end = lines[start + 1..]133- .iter()134- .position(|line| {135- line.strip_prefix(" ")136- .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())137- })138- .map_or(lines.len(), |offset| start + 1 + offset);139- Some(lines[start..end].join("\n"))140-}141-14286 pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {14387 let metadata = fs::symlink_metadata(path)14488 .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;
@@ -208,61 +152,3 @@ fn validate_render_node(path: &std::path::Path) -> Result<()> {
208152 })?;209153 Ok(())210154 }211-212-#[cfg(test)]213-mod tests {214- use super::super::tests::gamescope_config;215- use super::*;216-217- #[test]218- fn requires_encoder_to_report_configured_render_node() {219- let config = gamescope_config();220- let pipewire = config.gamescope_pipewire.as_ref().unwrap();221- let inspect = br#"222-Element Properties:223- device-path : The DRM device path used for VA operation224- flags: readable225- String. Default: "/dev/dri/renderD128"226- aud : Insert an AU delimiter for each frame227- flags: readable, writable228- b-frames : Number of B-frames229- flags: readable, writable230- bitrate : Target bitrate231- flags: readable, writable232- key-int-max : Maximum keyframe distance233- flags: readable, writable234- rate-control : Rate control mode235- flags: readable, writable236- Enum. Default: cbr237- (2): cbr - Constant Bitrate238- ref-frames : Number of reference frames239- flags: readable, writable240- target-usage : Speed and quality tradeoff241- flags: readable, writable242-"#;243- validate_encoder_inspection(inspect, pipewire).unwrap();244-245- let index = inspect246- .windows(b"renderD128".len())247- .position(|window| window == b"renderD128")248- .unwrap();249- let mut wrong = inspect.to_vec();250- wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");251- assert!(validate_encoder_inspection(&wrong, pipewire).is_err());252-253- let mut cqp = gamescope_config();254- let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();255- pipewire.vaapi_encoder = "vah264lpenc".into();256- pipewire.rate_control = VaapiRateControl::Cqp;257- pipewire.bitrate_kbps = None;258- pipewire.quantizer = Some(24);259- let cqp_inspect = String::from_utf8_lossy(inspect)260- .replace("Default: cbr", "Default: cqp")261- .replace("(2): cbr", "(16): cqp")262- .replace(263- " ref-frames",264- " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",265- );266- validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();267- }268-}
M docs/fresh-bazzite-host.md +7 -5
@@ -1132,10 +1132,11 @@ this dedicated group single-purpose. Do not add
11321132 `PrivateDevices=true` to the user unit because that would deliberately hide the11331133 validated device.11341134 1135-Set `vaapi_encoder` to one factory whose inspected read-only `device-path`1136-exactly equals `render_node`. This is deliberate: GstVA selects a DRM device by1137-factory and exposes `device-path` as read-only, so Sigil verifies the factory's1138-reported device before starting capture. Set `rate_control = "cbr"` only when1135+Set `vaapi_encoder` to one factory whose programmatically queried read-only1136+`device-path` exactly equals `render_node`. This is deliberate: GstVA selects a1137+DRM device by factory and exposes `device-path` as read-only, so Sigil verifies1138+the live element's reported device before starting capture. Set1139+`rate_control = "cbr"` only when11391140 that factory advertises CBR; otherwise select a low-power factory that11401141 advertises CQP and use `rate_control = "cqp"`. Do not silently substitute a11411142 different factory, render node, or software encoder.
@@ -1592,7 +1593,8 @@ unchanged, and the host log must contain one `Gamescope Xwayland pointer
15921593 feedback reconnected` event per restart. The packaged hardware UAT performs15931594 this sequence after its fixed-mode session checks. That runner independently15941595 enumerates accessible DRM render nodes and matches the exact GstVA H.2641595-factory by its inspected `device-path` and required CBR/CQP properties; it1596+factory by its programmatically queried `device-path` and required CBR/CQP1597+properties; it15961598 never assumes a kernel driver, `renderD128`, or the generic `vah264enc` factory.15971599 If the panel's resolved native15981600 mode is the same 1280x800 mode as the fixed performance leg, the native-config
M docs/hardware-uat/README.md +5 -4
M docs/sigil-host-activation.md +23 -4
@@ -95,7 +95,8 @@ to the host identity represented by this file.
9595 9696 ## 3. Select the exact VA-API render node and GstVA H.264 factory9797 98-Sigil binds the encoder factory to its inspected read-only `device-path`; a98+Sigil binds the encoder factory to its programmatically queried read-only99+`device-path`; a99100 factory name alone is not enough on a multi-GPU machine. The following chooses100101 automatically only when there is one usable capability-backed node/factory101102 pair, independent of its kernel driver. Set
@@ -132,6 +133,26 @@ bounded_gst_inspect() {
132133 rm -f -- "$output_path"133134 }134135 136+bounded_encoder_preflight() {137+ local node="$1"138+ local factory="$2"139+ local output_path140+ local output_size141+ output_path="$(mktemp "$inspection_dir/encoder-preflight.XXXXXX")"142+ if ! "$timeout_path" --signal=TERM --kill-after=2s 5s \143+ "$sigil" capture encoder-preflight \144+ --vaapi-encoder "$factory" --vaapi-render-node "$node" \145+ --rate-control cbr --rate-control cqp 2>&1 \146+ | head -c "$((inspect_max_bytes + 1))" >"$output_path"147+ then148+ rm -f -- "$output_path"149+ return 1150+ fi151+ output_size="$(wc -c <"$output_path" | tr -d '[:space:]')"152+ rm -f -- "$output_path"153+ test "$output_size" -le "$inspect_max_bytes"154+}155+135156 mapfile -t render_nodes < <(136157 for node in /dev/dri/renderD*; do137158 test -c "$node" && test -r "$node" && test -w "$node" && printf '%s\n' "$node"
@@ -148,9 +169,7 @@ mapfile -t va_factories < <(
148169 eligible_pairs=()149170 for node in "${render_nodes[@]}"; do150171 for factory in "${va_factories[@]}"; do151- inspection="$(bounded_gst_inspect "$factory")" || continue152- grep -Fq "Default: \"$node\"" <<<"$inspection" || continue153- grep -Eq '\([0-9]+\): cbr([[:space:]]|$)' <<<"$inspection" || continue172+ bounded_encoder_preflight "$node" "$factory" || continue154173 eligible_pairs+=("$node $factory")155174 done156175 done
M scripts/run-bazzite-hardware-uat.sh +33 -64
@@ -3,6 +3,7 @@
33 set -euo pipefail44 55 GST_INSPECT_MAX_BYTES=10485766+ENCODER_PREFLIGHT_MAX_BYTES=104857667 78 run_bounded_gst_inspect() {89 local timeout_path="$1"
@@ -21,54 +22,26 @@ run_bounded_gst_inspect() {
2122 [[ "$output_size" =~ ^[0-9]+$ && "$output_size" -le "$GST_INSPECT_MAX_BYTES" ]]2223 }2324 24-gst_property_block_contains() {25- local property="$1"26- local needle="$2"27-28- awk -v property="$property" -v needle="$needle" '29- $0 ~ ("^ " property "[[:space:]]+:") {30- inside = 131- }32- inside && index($0, needle) {33- found = 134- }35- inside && $0 !~ ("^ " property "[[:space:]]+:") \36- && $0 ~ /^ [^[:space:]].*[[:space:]]+:/ {37- exit38- }39- END { exit !found }40- '41-}42-43-gst_property_block_is_writable() {44- local property="$1"45-46- awk -v property="$property" '47- $0 ~ ("^ " property "[[:space:]]+:") {48- inside = 149- }50- inside && index(tolower($0), "writable") {51- found = 152- }53- inside && $0 !~ ("^ " property "[[:space:]]+:") \54- && $0 ~ /^ [^[:space:]].*[[:space:]]+:/ {55- exit56- }57- END { exit !found }58- '59-}60-61-gstva_factory_supports_uat() {62- local inspection="$1"63- local property25+run_bounded_encoder_preflight() {26+ local timeout_path="$1"27+ local sigil_path="$2"28+ local output_path="$3"29+ local factory="$4"30+ local render_node="$5"31+ local output_size6432 65- gst_property_block_contains rate-control cbr <<<"$inspection" || return 166- gst_property_block_contains rate-control cqp <<<"$inspection" || return 167- for property in \68- aud b-frames key-int-max rate-control ref-frames target-usage bitrate qpi qpp69- do70- gst_property_block_is_writable "$property" <<<"$inspection" || return 171- done33+ if ! "$timeout_path" --signal=TERM --kill-after=2s 5s \34+ "$sigil_path" capture encoder-preflight \35+ --vaapi-encoder "$factory" \36+ --vaapi-render-node "$render_node" \37+ --rate-control cbr --rate-control cqp 2>&1 \38+ | head -c "$((ENCODER_PREFLIGHT_MAX_BYTES + 1))" >"$output_path"39+ then40+ return 141+ fi42+ output_size="$(wc -c <"$output_path" | tr -d '[:space:]')"43+ [[ "$output_size" =~ ^[0-9]+$ \44+ && "$output_size" -le "$ENCODER_PREFLIGHT_MAX_BYTES" ]]7245 }7346 7447 classify_native_uat_mode() {
@@ -86,17 +59,17 @@ classify_native_uat_mode() {
8659 }8760 8861 select_gstva_h264_encoder() {89- local gst_inspect_path="$1"90- local device_root="${2:-/dev/dri}"91- local require_character="${3:-true}"92- local override_node="${4:-}"93- local override_factory="${5:-}"94- local timeout_path="${6:?timeout command is required}"95- local output_root="${7:?bounded inspection output directory is required}"62+ local sigil_path="$1"63+ local gst_inspect_path="$2"64+ local device_root="${3:-/dev/dri}"65+ local require_character="${4:-true}"66+ local override_node="${5:-}"67+ local override_factory="${6:-}"68+ local timeout_path="${7:?timeout command is required}"69+ local output_root="${8:?bounded inspection output directory is required}"9670 local factory9771 local factories98- local factory_output99- local inspection72+ local probe_output10073 local candidate10174 local pair10275 local candidate_count=0
@@ -127,16 +100,12 @@ select_gstva_h264_encoder() {
127100 128101 while IFS= read -r factory; do129102 [[ -n "$factory" ]] || continue130- factory_output="$output_root/gst-inspect.$factory"131- if ! LC_ALL=C run_bounded_gst_inspect \132- "$timeout_path" "$gst_inspect_path" "$factory_output" "$factory";103+ probe_output="$output_root/encoder-preflight.$(basename "$candidate").$factory"104+ if ! LC_ALL=C run_bounded_encoder_preflight \105+ "$timeout_path" "$sigil_path" "$probe_output" "$factory" "$candidate";133106 then134107 continue135108 fi136- inspection="$(<"$factory_output")"137- gst_property_block_contains device-path "Default: \"$candidate\"" \138- <<<"$inspection" || continue139- gstva_factory_supports_uat "$inspection" || continue140109 pair="$candidate $factory"141110 if [[ -n "$va_candidates" ]]; then142111 va_candidates="$va_candidates
@@ -422,7 +391,7 @@ ffmpeg="$(command -v ffmpeg)"
422391 [[ -S "$XDG_RUNTIME_DIR/pipewire-0" ]]423392 selection_status=0424393 select_gstva_h264_encoder \425- "$gst_inspect" /dev/dri true \394+ "$sigil" "$gst_inspect" /dev/dri true \426395 "$render_node_override" "$va_encoder_override" \427396 "$timeout_command" "$private" || selection_status=$?428397 if [[ "$selection_status" -ne 0 ]]; then
M scripts/tests/bazzite-hardware-uat-selection.sh +33 -26
@@ -60,37 +60,44 @@ cat >"$bin/gst-inspect-1.0" <<'EOF'
6060 set -euo pipefail6161 6262 fixture="${SIGIL_TEST_FACTORY_FIXTURE:?}"63-if [[ "${SIGIL_TEST_GST_MODE:-}" == oversize-factory && $# -gt 0 ]]; then64- awk 'BEGIN { for (i = 0; i < 1048578; i++) printf "x" }'65- exit 066-fi6763 if [[ $# -eq 0 ]]; then6864 awk -F '\t' '{ printf "va: %s: fixture encoder\n", $1 }' "$fixture"6965 exit 07066 fi67+exit 168+EOF69+chmod 0755 "$bin/gst-inspect-1.0"70+cat >"$bin/sigil" <<'EOF'71+#!/usr/bin/env bash72+set -euo pipefail7173 72-record="$(awk -F '\t' -v factory="$1" '$1 == factory { print; exit }' "$fixture")"73-[[ -n "$record" ]] || exit 174-IFS=$'\t' read -r factory device modes completeness <<<"$record"75-printf '%s\n' \76- ' device-path : DRM render device' \77- ' flags: readable' \78- " String. Default: \"$device\"" \79- ' rate-control : Rate control mode' \80- ' flags: readable, writable'81-case ",$modes," in82- *,cqp,*) printf '%s\n' ' (1): cqp' ;;83-esac84-case ",$modes," in85- *,cbr,*) printf '%s\n' ' (2): cbr' ;;86-esac87-for property in aud b-frames key-int-max ref-frames target-usage bitrate qpi qpp; do88- [[ "$completeness" == "missing-$property" ]] && continue89- printf ' %-20s: fixture property\n' "$property"90- printf '%s\n' ' flags: readable, writable'74+fixture="${SIGIL_TEST_FACTORY_FIXTURE:?}"75+[[ "$1" == capture && "$2" == encoder-preflight ]]76+shift 277+factory=''78+device=''79+required_modes=''80+while [[ $# -gt 0 ]]; do81+ case "$1" in82+ --vaapi-encoder) factory="$2"; shift 2 ;;83+ --vaapi-render-node) device="$2"; shift 2 ;;84+ --rate-control) required_modes="${required_modes:+$required_modes,}$2"; shift 2 ;;85+ *) exit 64 ;;86+ esac9187 done88+if [[ "${SIGIL_TEST_GST_MODE:-}" == oversize-factory ]]; then89+ awk 'BEGIN { for (i = 0; i < 1048578; i++) printf "x" }'90+ exit 091+fi92+[[ "$required_modes" == cbr,cqp ]]93+awk -F '\t' -v factory="$factory" -v device="$device" '94+ $1 == factory && $2 == device && $4 == "complete" \95+ && ("," $3 ",") ~ /,cbr,/ && ("," $3 ",") ~ /,cqp,/ { found = 1 }96+ END { exit !found }97+' "$fixture"98+printf '%s\n' encoder_preflight=ok9299 EOF93-chmod 0755 "$bin/gst-inspect-1.0"100+chmod 0755 "$bin/sigil"94101 cat >"$bin/timeout" <<'EOF'95102 #!/usr/bin/env bash96103 set -euo pipefail
@@ -109,7 +116,7 @@ export SIGIL_TEST_FACTORY_FIXTURE="$factory_fixture"
109116 110117 select_fixture() {111118 select_gstva_h264_encoder \112- "$bin/gst-inspect-1.0" "$device_root" false \119+ "$bin/sigil" "$bin/gst-inspect-1.0" "$device_root" false \113120 "${1:-}" "${2:-}" "$bin/timeout" "$fixture"114121 }115122
@@ -221,7 +228,7 @@ fi
221228 rm -f -- "$device_root/renderD130"222229 install -m 0600 /dev/null "$device_root/renderD130"223230 if select_gstva_h264_encoder \224- "$bin/gst-inspect-1.0" "$device_root" true '' '' \231+ "$bin/sigil" "$bin/gst-inspect-1.0" "$device_root" true '' '' \225232 "$bin/timeout" "$fixture"226233 then227234 fail 'a regular file was accepted as a production DRM character device'