fix(renderer): rescue failed Linux Auto presentation per launch

Probe Qt's default backend in a disposable graphics-only child before runtime and the main window. After proven failure, allow one software presentation attempt without persisting a renderer preference. Preserve explicit backend policy and saved-GPU validation.

Bound and reap children, keep deep links in the parent, and avoid blocked diagnostic pipes. Cover healthy/failing decisions, overrides, missing executables, paths with spaces and watchdog cleanup in the passing Qt suite.

cx
This commit is contained in:
vicrodh committed 2026-09-06 05:08:17 -06:00
1 parent 279a6fe847
commit fd421044bc
7 files changed
+363 -13

No files matched your search

+1
View File
@@ -4582,6 +4582,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"tempfile",
"tokio",
"url",
"urlencoding",
+1
View File
@@ -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"
+21 -3
View File
@@ -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<QGuiApplication *>(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);
}
+26
View File
@@ -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();
@@ -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<bool, String> {
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"
);
}
}
+49 -10
View File
@@ -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<String> {
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<Vec<u8>, 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 \
+6
View File
@@ -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).