diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 8368a9166..14a203420 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -4582,6 +4582,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "tokio", "url", "urlencoding", diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index 0678c4aa9..85bd0aa0c 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -125,6 +125,7 @@ qbz-player = { path = "../qbz-player" } # (StreamExt over the response byte stream). uuid = { version = "1", features = ["v4"] } futures-util = { workspace = true } +tempfile = "3" async-trait = "0.1" zeroize = "1" diff --git a/crates/qbz-qt/cxx/qt_vulkan_probe.cpp b/crates/qbz-qt/cxx/qt_vulkan_probe.cpp index 164af2f51..d22806c6f 100644 --- a/crates/qbz-qt/cxx/qt_vulkan_probe.cpp +++ b/crates/qbz-qt/cxx/qt_vulkan_probe.cpp @@ -110,13 +110,13 @@ extern "C" const char *qbz_qt_vulkan_devices_json() // unsupported Intel-render -> NVIDIA/KWin path; the fatal failure only arrives // when Wayland imports the first DMA-BUF. If that protocol error kills this // process, the parent remains alive and falls back to Auto. -extern "C" int qbz_qt_vulkan_preflight_window() +static int presentationPreflight(bool autoBackend) { if (qobject_cast(QCoreApplication::instance()) == nullptr) return 71; QQuickWindow window; - window.setTitle(QStringLiteral("QBZ GPU preflight")); + window.setTitle(QStringLiteral("QBZ graphics preflight")); window.setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus); window.resize(2, 2); window.setColor(Qt::transparent); @@ -139,8 +139,14 @@ extern "C" int qbz_qt_vulkan_preflight_window() QObject::connect(&pulse, &QTimer::timeout, &window, &QQuickWindow::update); QObject::connect(&window, &QQuickWindow::frameSwapped, &loop, [&] { ++frames; - if (frames == 1) + if (autoBackend && frames >= 3) { + // Pump successive native presentations, not just context creation. + // Auto has no fixed grace sleep on the healthy startup path. + result = 0; + loop.quit(); + } else if (!autoBackend && frames == 1) { settle.start(700); + } window.update(); }); QObject::connect(&window, &QQuickWindow::sceneGraphError, &loop, @@ -173,3 +179,15 @@ extern "C" int qbz_qt_vulkan_preflight_window() QCoreApplication::processEvents(QEventLoop::AllEvents, 50); return result; } + +extern "C" int qbz_qt_vulkan_preflight_window() +{ + return presentationPreflight(false); +} + +// Uses Qt's actual default (OpenGL on Linux), or software selected BEFORE +// QGuiApplication. No Vulkan inventory or multiple-adapter requirement. +extern "C" int qbz_qt_auto_preflight_window() +{ + return presentationPreflight(true); +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 906f8b675..45da4e98b 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -3548,6 +3548,15 @@ fn apply_renderer_preference() { // backend must be undone even when this one is being overridden by env. let reverted = renderer_qt::revert_if_previous_launch_died(); + // Whitespace is not a backend choice. Qt itself does not trim every env + // entry; normalize these before either the child or parent constructs Qt. + #[cfg(target_os = "linux")] + for name in ["QSG_RHI_BACKEND", "QT_QUICK_BACKEND"] { + if std::env::var_os(name).is_some() && !qt_backend_env_overrides(name) { + std::env::remove_var(name); + } + } + if qt_backend_env_overrides("QSG_RHI_BACKEND") || qt_backend_env_overrides("QT_QUICK_BACKEND") { log::info!("[renderer] explicit Qt backend env present; leaving the choice to it"); return; @@ -3766,6 +3775,14 @@ fn main() { // Before even the disposable GPU-preflight QGuiApplication. A child // spawned later inherits the resolved factor and takes the same path. apply_interface_scale_preference(); + #[cfg(target_os = "linux")] + if renderer_qt::auto_preflight::child_requested() { + let app = QGuiApplication::new(); + let result = renderer_qt::auto_preflight::run_child(); + drop(app); + log::logger().flush(); + std::process::exit(result); + } // Internal, disposable graphics child. It must branch before the normal // instance lock, navigation crash-chain, runtime and audio thread: a GPU // whose DMA-BUFs the active Wayland compositor rejects is expected to kill @@ -3788,6 +3805,14 @@ fn main() { // user-visible. The child uses its own Wayland connection; fatal protocol // errors cannot take this parent down. renderer_qt::preflight_saved_gpu_at_boot(); + #[cfg(target_os = "linux")] + apply_renderer_preference(); + #[cfg(target_os = "linux")] + if let Err(reason) = renderer_qt::auto_preflight::at_boot() { + log::error!("[renderer] startup stopped before runtime/Qt: {reason}"); + log::logger().flush(); + std::process::exit(78); + } // Same decision, different primitive: a per-session named mutex arbitrates // and a named pipe carries the handoff. `acquire_or_raise` has already // forwarded this launch's deep link (or a bare PRESENT) by the time it @@ -3905,6 +3930,7 @@ fn main() { // slot: QGuiApplication must first install the platform integration so we // can enumerate Qt's real QVulkanInstance, but the selection env must land // before the first QQuickWindow/QRhi is constructed. + #[cfg(not(target_os = "linux"))] apply_renderer_preference(); apply_scroll_physics(); diff --git a/crates/qbz-qt/src/renderer_preflight_linux.rs b/crates/qbz-qt/src/renderer_preflight_linux.rs new file mode 100644 index 000000000..be4770173 --- /dev/null +++ b/crates/qbz-qt/src/renderer_preflight_linux.rs @@ -0,0 +1,259 @@ +//! Linux managed Auto: presentation proof before the parent constructs Qt. +//! Children never run auth, audio, IPC ownership or navigation recovery. + +use super::*; +use std::process::{Command, Output}; + +const CHILD_ENV: &str = "QBZ_INTERNAL_AUTO_PREFLIGHT_CHILD"; +const PROOF: &str = "QBZ_AUTO_PREFLIGHT_OK"; +static SOFTWARE_NOTICE: AtomicBool = AtomicBool::new(false); + +pub fn child_requested() -> bool { + std::env::var(CHILD_ENV).is_ok_and(|value| value == "1") +} + +pub fn run_child() -> i32 { + std::env::remove_var(CHILD_ENV); + // SAFETY: main calls only on the child's GUI thread, after constructing + // its sole QGuiApplication. C++ owns and destroys the temporary window. + let result = unsafe { qbz_qt_auto_preflight_window() }; + if result == 0 { + use std::io::Write; + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "{PROOF}"); + let _ = stdout.flush(); + } + result +} + +pub fn take_software_notice() -> bool { + SOFTWARE_NOTICE.swap(false, Ordering::SeqCst) +} + +fn external_backend(qsg: &str, quick: &str, qbz: &str) -> bool { + !qsg.trim().is_empty() + || !quick.trim().is_empty() + || (!qbz.trim().is_empty() && !qbz.trim().eq_ignore_ascii_case("auto")) +} + +fn command_for(executable: &std::path::Path, software: bool) -> Command { + // Deliberately no parent arguments: a deep link belongs to the parent. + // PRIME/ICD and QPA selection are inherited exactly as the parent uses them. + let mut command = Command::new(executable); + command.env(CHILD_ENV, "1"); + if software { + command.env("QT_QUICK_BACKEND", "software"); + } + command +} + +fn proof(result: &(Output, bool)) -> Result<(), String> { + let (output, timed_out) = result; + if *timed_out { + return Err("child exceeded the 8-second watchdog (killed and reaped)".into()); + } + if output.status.success() + && String::from_utf8_lossy(&output.stdout) + .lines() + .any(|line| line == PROOF) + { + return Ok(()); + } + Err(format!( + "child status {}; presentation not proven; {}", + output.status, + preflight_output_tail(output) + )) +} + +/// At most Auto then software. A spawn/monitoring failure is an infrastructure +/// error, not proof of bad graphics; report it and stop without rewriting prefs. +fn decide(mut probe: impl FnMut(bool) -> Result<(Output, bool), String>) -> Result { + match proof(&probe(false)?) { + Ok(()) => Ok(false), + Err(reason) => { + log::warn!("[renderer] Auto presentation failed: {reason}; trying software once"); + proof(&probe(true)?) + .map_err(|reason| format!("software presentation also failed: {reason}"))?; + Ok(true) + } + } +} + +/// Run after saved-GPU validation and apply_renderer_preference, but before +/// runtime/audio/QGuiApplication. Backend envs now include a forced saved +/// renderer, so that policy takes precedence too. No Vulkan enumeration here. +pub fn at_boot() -> Result<(), String> { + let env = |name| std::env::var(name).unwrap_or_default(); + if external_backend( + &env("QSG_RHI_BACKEND"), + &env("QT_QUICK_BACKEND"), + &env("QBZ_RENDERER"), + ) { + log::info!("[renderer] explicit backend policy preserved; managed Auto probe skipped"); + return Ok(()); + } + if PREFLIGHT_APPROVED_GPU + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + { + // The parent will use this exact Vulkan identity, already presented. + return Ok(()); + } + if matches!( + env("QT_QPA_PLATFORM").trim().split(':').next(), + Some("offscreen" | "minimal" | "vnc") + ) { + log::info!("[renderer] non-desktop QPA; managed presentation probe skipped"); + return Ok(()); + } + let executable = + std::env::current_exe().map_err(|e| format!("cannot resolve executable: {e}"))?; + let start = std::time::Instant::now(); + let software = decide(|software| { + let started = std::time::Instant::now(); + let result = run_preflight_child( + &mut command_for(&executable, software), + GPU_PREFLIGHT_PARENT_TIMEOUT, + ); + log::info!( + "[renderer] {} presentation probe completed in {} ms", + if software { "software" } else { "Auto" }, + started.elapsed().as_millis() + ); + result + })?; + if software { + // This launch only. No sentinel or preference changes, so a recovered + // driver gets another Auto attempt on the next ordinary launch. + std::env::set_var("QT_QUICK_BACKEND", "software"); + SOFTWARE_NOTICE.store(true, Ordering::SeqCst); + log::warn!( + "[renderer] software presentation confirmed; rescue for this launch only ({} ms total)", + start.elapsed().as_millis() + ); + } else { + log::info!( + "[renderer] Auto presentation confirmed; Qt default backend retained ({} ms total)", + start.elapsed().as_millis() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + + fn outcome(code: i32, marker: bool, timeout: bool) -> (Output, bool) { + ( + Output { + status: std::process::ExitStatus::from_raw(code), + stdout: if marker { + format!("{PROOF}\n").into_bytes() + } else { + Vec::new() + }, + stderr: Vec::new(), + }, + timeout, + ) + } + + #[test] + fn auto_health_failure_and_retry_are_launch_local_and_finite() { + let mut attempts = Vec::new(); + assert!(!decide(|soft| { + attempts.push(soft); + Ok(outcome(0, true, false)) + }) + .unwrap()); + assert_eq!(attempts, [false]); + for (code, marker, timeout) in [(6, false, false), (0, false, false), (0, true, true)] { + attempts.clear(); + assert!(decide(|soft| { + attempts.push(soft); + Ok(if soft { + outcome(0, true, false) + } else { + outcome(code, marker, timeout) + }) + }) + .unwrap()); + assert_eq!(attempts, [false, true]); + } + attempts.clear(); + assert!(decide(|soft| { + attempts.push(soft); + Ok(outcome(6, false, false)) + }) + .is_err()); + assert_eq!(attempts, [false, true]); + assert!(!decide(|_| Ok(outcome(0, true, false))).unwrap()); + } + + #[test] + fn explicit_policy_ignores_empty_and_whitespace_values() { + for value in ["", " ", "\t"] { + assert!(!external_backend(value, value, value)); + assert!(!external_backend(value, value, " Auto ")); + } + for (qsg, quick, qbz) in [("opengl", "", ""), ("", " software ", ""), ("", "", "gpu")] { + assert!(external_backend(qsg, quick, qbz)); + } + } + + #[test] + fn a_spawn_failure_is_diagnostic_without_a_second_child() { + let mut attempts = 0; + let result = decide(|_| { + attempts += 1; + Err("fixture spawn failure".into()) + }); + assert_eq!(attempts, 1); + assert!(result.unwrap_err().contains("spawn failure")); + let mut missing = command_for(std::path::Path::new("/no such qbz executable"), false); + assert!(run_preflight_child(&mut missing, std::time::Duration::from_millis(80)).is_err()); + } + + #[test] + fn child_paths_args_logs_and_watchdog_are_isolated() { + use std::os::unix::fs::symlink; + let dir = tempfile::Builder::new() + .prefix("qbz preflight ") + .tempdir() + .unwrap(); + let executable = dir.path().join("fixture with spaces"); + symlink("/bin/sh", &executable).unwrap(); + let mut command = command_for(&executable, false); + assert_eq!( + command.get_args().count(), + 0, + "parent links never reach the graphics child" + ); + command.args(["-c", "test -z \"$XDG_ACTIVATION_TOKEN\" && test -z \"$DESKTOP_STARTUP_ID\" || exit 9; head -c 131072 /dev/zero >&2; echo QBZ_AUTO_PREFLIGHT_OK"]); + let result = run_preflight_child(&mut command, std::time::Duration::from_secs(2)).unwrap(); + assert!(proof(&result).is_ok()); + assert_eq!(result.0.stderr.len(), 65536); + + // exec replaces the fixture shell: there is exactly one owned child, + // just as the production command directly starts the QBZ executable. + let pid_path = dir.path().join("pid"); + let mut command = command_for(&executable, false); + command + .env("FIXTURE_PID", &pid_path) + .args(["-c", "echo $$ > \"$FIXTURE_PID\"; exec sleep 30"]); + let start = std::time::Instant::now(); + let result = + run_preflight_child(&mut command, std::time::Duration::from_millis(80)).unwrap(); + assert!(result.1); + assert!(start.elapsed() < std::time::Duration::from_secs(1)); + let pid = std::fs::read_to_string(pid_path).unwrap(); + assert!( + !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists(), + "timed-out child must be reaped" + ); + } +} diff --git a/crates/qbz-qt/src/renderer_qt.rs b/crates/qbz-qt/src/renderer_qt.rs index 31ebf6f78..c25797c8c 100644 --- a/crates/qbz-qt/src/renderer_qt.rs +++ b/crates/qbz-qt/src/renderer_qt.rs @@ -336,8 +336,14 @@ struct QtGpuJson { unsafe extern "C" { fn qbz_qt_vulkan_devices_json() -> *const std::ffi::c_char; fn qbz_qt_vulkan_preflight_window() -> i32; + #[cfg(target_os = "linux")] + fn qbz_qt_auto_preflight_window() -> i32; } +#[cfg(target_os = "linux")] +#[path = "renderer_preflight_linux.rs"] +pub mod auto_preflight; + const GPU_PREFLIGHT_CHILD_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_CHILD"; const GPU_PREFLIGHT_IDENTITY_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_IDENTITY"; const GPU_PREFLIGHT_NAME_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_NAME"; @@ -700,26 +706,43 @@ fn approved_identity_from(output: &std::process::Output) -> Option { fn spawn_gpu_preflight(name: &str, identity: &str) -> Result<(std::process::Output, bool), String> { let executable = std::env::current_exe() .map_err(|error| format!("cannot resolve current executable: {error}"))?; - let mut child = std::process::Command::new(executable) + let mut command = std::process::Command::new(executable); + command .env(GPU_PREFLIGHT_CHILD_ENV, "1") .env(GPU_PREFLIGHT_IDENTITY_ENV, identity) - .env(GPU_PREFLIGHT_NAME_ENV, name) + .env(GPU_PREFLIGHT_NAME_ENV, name); + run_preflight_child(&mut command, GPU_PREFLIGHT_PARENT_TIMEOUT) +} + +/// Drain into anonymous temporary files rather than undrained pipes: verbose +/// driver diagnostics must not block the child before it can report success. +/// No worker survives cancellation; kill + wait reaps the one child we own. +fn run_preflight_child( + command: &mut std::process::Command, + budget: std::time::Duration, +) -> Result<(std::process::Output, bool), String> { + use std::io::{Read, Seek, SeekFrom}; + let mut stdout = tempfile::tempfile().map_err(|e| format!("child stdout: {e}"))?; + let mut stderr = tempfile::tempfile().map_err(|e| format!("child stderr: {e}"))?; + let out = stdout.try_clone().map_err(|e| e.to_string())?; + let err = stderr.try_clone().map_err(|e| e.to_string())?; + let started = std::time::Instant::now(); + let mut child = command // An activation token is single-use. The invisible probe must never // consume the token intended to focus the real main window. .env_remove("XDG_ACTIVATION_TOKEN") .env_remove("DESKTOP_STARTUP_ID") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .stdout(out) + .stderr(err) .spawn() .map_err(|error| format!("cannot spawn child: {error}"))?; - let started = std::time::Instant::now(); let mut timed_out = false; loop { match child.try_wait() { Ok(Some(_)) => break, - Ok(None) if started.elapsed() < GPU_PREFLIGHT_PARENT_TIMEOUT => { + Ok(None) if started.elapsed() < budget => { std::thread::sleep(std::time::Duration::from_millis(25)); } Ok(None) => { @@ -734,9 +757,24 @@ fn spawn_gpu_preflight(name: &str, identity: &str) -> Result<(std::process::Outp } } } - let output = child - .wait_with_output() + let status = child + .wait() .map_err(|error| format!("cannot collect child result: {error}"))?; + fn tail(file: &mut std::fs::File) -> Result, String> { + let length = file.metadata().map_err(|e| e.to_string())?.len(); + file.seek(SeekFrom::Start(length.saturating_sub(65536))) + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + file.take(65536) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + Ok(bytes) + } + let output = std::process::Output { + status, + stdout: tail(&mut stdout)?, + stderr: tail(&mut stderr)?, + }; Ok((output, timed_out)) } @@ -863,8 +901,9 @@ pub fn apply_gpu_preference() { // A GPU pick needs Vulkan (see below), so it cannot also honour an explicit // `software`/`opengl` choice — the more specific, explicitly-chosen // RENDERER wins and this says so rather than quietly overriding it. - if let Some(forced) = - std::env::var_os("QSG_RHI_BACKEND").or_else(|| std::env::var_os("QT_QUICK_BACKEND")) + if let Some(forced) = ["QSG_RHI_BACKEND", "QT_QUICK_BACKEND"] + .into_iter() + .find(|name| nonempty_env(name)) { log::info!( "[qbz-qt] gpu: the renderer row already forced {forced:?}; a GPU pick needs \ diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 0155aa972..192dee169 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -1167,6 +1167,12 @@ impl qbz_shell::QbzShell { "Selected GPU is unavailable in this display session — using Auto", )); } + #[cfg(target_os = "linux")] + if crate::renderer_qt::auto_preflight::take_software_notice() { + crate::toast_qt::warning(qbz_i18n::t( + "Renderer switched automatically — the previous renderer failed to start", + )); + } } /// The renderer probe's one-shot report (see the declaration).