Files
vectorcraft/apps/vectorcraft/src/gpu.rs
T
NetterSilverandClaude Haiku 5.5 02c555cad7 Reject a plus sign in Windows GPU PCI id hex digits
Found while testing apps/vectorcraft/src/gpu.rs:313 (pci_ids)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 02:03:09 +07:00

957 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Which graphics adapter the window renders with (#306, #502), and what happens when it can't
//! show the window.
//!
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU the Wayland
//! compositor draws with ([`crate::compositor_gpu`]), the GPU that drives a display and the one
//! that drives the primary display ([`display_gpus`]), the power preference
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
//! eframe is handed [`selector`], which takes the first of that order.
//!
//! A GPU without a monitor is the wrong one even when it can present: on a desktop whose monitors
//! all hang off the discrete GPU, power saving picked the Ryzen's integrated GPU, Windows had to
//! present every frame across adapters, and the integrated GPU's driver reset under that, which
//! lost the graphics device, or took the desktop's compositor and every monitor down for minutes
//! (pdfcraft#378). So, when Preferences say Automatic, the GPU that drives the display the user
//! looks at comes first, and the power preference only orders the rest. Windows says which adapter
//! drives which display (`EnumDisplayDevices`), Linux through sysfs; elsewhere, or when nothing
//! matches, the order is the power preference's as before. Power Saving and High Performance,
//! chosen by the user, order by kind as they always did.
//!
//! Under Wayland, the compositor's own GPU comes before the displays: the compositor says which
//! GPU it draws with, and frames from another one may never reach the screen, with no error, even
//! from the GPU behind the built-in panel when the compositor runs on the discrete one.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
//! configuring the surface (#502). Nothing in the process can show a window after that, so when
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
//! adapter, so it ends when none is left.
//!
//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen,
//! the discrete GPU never put the window's first frame on the screen, with no error and no panic,
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
//! the app again without that adapter in the same way.
//!
//! A driver can also crash the whole process, and then no code in it runs to start it again:
//! Intel's Direct3D 12 driver (`igd12umd64.dll`) ended 0.9.0 before its window appeared (#2243).
//! So each start writes [`START_FILE`] in the log folder before the graphics start, adds the
//! adapter once one is picked, and clears both once the first frame reaches the screen or the app
//! ends without crashing ([`Startup::track`]). The next start that finds a start still recorded
//! there leaves that adapter out, as a restart does, and keeps leaving it out; when the crash came
//! while the adapters were being listed, before one was picked, it lists OpenGL's adapters alone
//! (except on macOS, where wgpu has none). A start that `WGPU_BACKEND` or `WGPU_ADAPTER_NAME`
//! chooses leaves nothing out, and the crashes are forgotten when the app version or
//! Preferences › Performance › Graphics Processor changes, when a start shows its first frame on
//! the adapter that crashed, or when no adapter is left to show the window.
use std::io::{Read as _, Seek as _, Write as _};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s
/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts
/// again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// The file in the log folder that records each start of the window until its first frame
/// reaches the screen, and the adapters earlier starts crashed on ([`Startup::track`]).
pub const START_FILE: &str = "graphics-start.json";
/// The most bytes of [`START_FILE`] read, and the most crashed adapters it keeps.
const START_FILE_LIMIT: u64 = 64 * 1024;
const CRASHED_LIMIT: usize = 16;
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
/// Where to read how to choose another adapter, for the error that ends the app.
const HELP: &str =
"to choose a graphics adapter, see https://github.com/storytold/vectorcraft/blob/main/docs/development.md#desktop-graphics-processor";
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
/// window is something else (such as the compositor restarting).
pub const STARTUP_FRAMES: u64 = 10;
/// How long the window's first frame may take to reach the screen, from the app's creation,
/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole
/// interface and compiles its shaders, a second at most on a slow machine.
pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20);
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
/// laptops), and a Wayland compositor may not show frames from another GPU (#502).
pub const AUTOMATIC: PowerPreference = if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None };
/// The power preference that orders the adapters: the `WGPU_POWER_PREF` environment variable
/// (`low`, `high`, `none`) when set, otherwise Preferences › Performance › Graphics Processor
/// (`gpuPreference`). Automatic, and any value this version doesn't know (0.5.0's default
/// `powerSaving`, a newer or damaged preference file), is [`AUTOMATIC`].
pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> PowerPreference {
match (env, pref) {
(Some(p), _) => p,
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
(None, Some("lowPower")) => PowerPreference::LowPower,
(None, _) => AUTOMATIC,
}
}
/// Whether the adapter is the app's to choose: neither `WGPU_POWER_PREF` nor Preferences name a
/// kind of GPU (the arguments of [`power_preference`]). Then the GPU that drives the display comes
/// first ([`display_gpus`]); a user's own choice is kept as it is.
pub fn automatic(pref: Option<&str>, env: Option<PowerPreference>) -> bool {
env.is_none() && !matches!(pref, Some("highPerformance" | "lowPower"))
}
/// The GPUs to rank first: those that drive a display, when the choice is [`automatic`].
pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> Vec<DisplayGpu> {
if automatic(pref, env) { display_gpus() } else { Vec::new() }
}
/// The GPU to rank first: the one the Wayland compositor draws with, when the choice is
/// [`automatic`].
pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) -> Option<(u32, u32)> {
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, with OpenGL beside it
/// only when `opengl` ([`windows_opengl`]), unless `WGPU_BACKEND` names others (`env_set`;
/// `default` then holds them). Creating a Vulkan instance loads every installed Vulkan driver into
/// the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window
/// appeared. DX12 is Windows' own backend, as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
match (cfg!(windows) && !env_set, opengl) {
(true, true) => Backends::DX12 | Backends::GL,
(true, false) => Backends::DX12,
(false, _) => default,
}
}
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
/// adapter out (an earlier start crashed on it, or a restart failed on it), when
/// `WGPU_ADAPTER_NAME` names one (it may be an OpenGL adapter), or when DX12 lists no hardware
/// adapter (Windows 7, or a GPU without a DX12 driver, which then draws through OpenGL as before
/// rather than on the CPU with WARP). wgpu's OpenGL backend keeps a hidden window on a thread that
/// pumps no messages, and Windows waits on it when the input language changes, so the whole app
/// hung on a switch to another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
pub fn windows_opengl(left_out: &LeftOut) -> bool {
opengl_needed(left_out, !skipped().is_empty(), std::env::var_os(NAME_ENV).is_some(), dx12_lists_hardware)
}
/// [`windows_opengl`], with a restart leaving adapters out (`skipping`) and an adapter named
/// (`named`): `dx12_hardware` is asked only when nothing else decides.
fn opengl_needed(left_out: &LeftOut, skipping: bool, named: bool, dx12_hardware: impl FnOnce() -> bool) -> bool {
left_out.opengl_only || !left_out.adapters.is_empty() || skipping || named || !dx12_hardware()
}
/// Whether Direct3D 12 lists a hardware adapter; WARP, its software one, doesn't count. A driver
/// that crashes here crashes a start while it lists the adapters, which the next start answers with
/// OpenGL alone.
#[cfg(windows)]
fn dx12_lists_hardware() -> bool {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
descriptor.backends = Backends::DX12;
// The shader compiler the window's own instance takes (#1194).
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::from_env().unwrap_or(wgpu::Dx12Compiler::Fxc);
let instance = wgpu::Instance::new(descriptor);
let kinds: Vec<DeviceType> = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).iter().map(|a| a.get_info().device_type).collect();
log::info!("Direct3D 12 lists these graphics adapters: {kinds:?}");
any_hardware(&kinds)
}
#[cfg(not(windows))]
fn dx12_lists_hardware() -> bool {
false
}
/// Is one of the adapters of these kinds hardware, not software such as WARP?
#[cfg(any(windows, test))]
fn any_hardware(kinds: &[DeviceType]) -> bool {
kinds.iter().any(|k| *k != DeviceType::Cpu)
}
/// The limits to ask the device for: what eframe `asked` for, lowered to what the `adapter` offers
/// wherever it offers less, so a GPU short of one of them still gets a device (#2105). The texture
/// side, which bounds the window's surface, is the adapter's own, above eframe's 8192 px when the
/// adapter allows more, so a window larger than that keeps its surface (#1459).
pub fn device_limits(asked: wgpu::Limits, adapter: &wgpu::Limits) -> wgpu::Limits {
let mut limits = asked.or_worse_values_from(adapter);
limits.max_texture_dimension_2d = adapter.max_texture_dimension_2d;
limits
}
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
/// Identifies the adapter across a restart ([`key`]).
pub key: String,
pub name: String,
pub backend: Backend,
pub device_type: DeviceType,
/// PCI vendor and device ids, to match a [`DisplayGpu`]; zero where the backend reports none
/// (Metal, and OpenGL's device id).
pub pci: (u32, u32),
/// Reports that it can present to the window's surface.
pub presents: bool,
}
impl Candidate {
fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
let info = adapter.get_info();
Self {
key: key(info.backend, info.vendor, info.device, &info.name),
presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
name: info.name,
backend: info.backend,
device_type: info.device_type,
pci: (info.vendor, info.device),
}
}
/// Whether it is the GPU the compositor draws with (`compositor`, by PCI ids).
fn composites(&self, compositor: Option<(u32, u32)>) -> bool {
compositor == Some(self.pci)
}
/// Whether it drives one of `displays`, and whether that is the primary one.
fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) {
let shown = displays.iter().find(|g| g.pci == self.pci);
(shown.is_some(), shown.is_some_and(|g| g.primary))
}
}
/// A GPU with a monitor attached, as the system reports it ([`display_gpus`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DisplayGpu {
/// PCI `(vendor, device)` ids, as wgpu's `AdapterInfo` reports them.
pub pci: (u32, u32),
/// Drives the display the user most likely looks at: on Windows the primary display, on Linux
/// a built-in panel (`eDP`, `LVDS` or `DSI`).
pub primary: bool,
}
impl std::fmt::Display for DisplayGpu {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04x}:{:04x}{}", self.pci.0, self.pci.1, if self.primary { " (primary)" } else { "" })
}
}
/// The GPUs that drive a display, each once. Windows enumerates its display devices
/// (`EnumDisplayDevices`: those attached to the desktop, and the primary one); Linux reads the
/// connected connectors in `/sys/class/drm`. macOS and the rest report nothing, so the power
/// preference decides as before; so does a system whose GPUs report no PCI ids.
pub fn display_gpus() -> Vec<DisplayGpu> {
#[cfg(windows)]
{
windows_display_gpus()
}
#[cfg(target_os = "linux")]
{
linux_display_gpus(std::path::Path::new("/sys/class/drm"))
}
#[cfg(not(any(windows, target_os = "linux")))]
{
Vec::new()
}
}
/// Record that the GPU `pci` drives a display, merging it with its other displays.
#[cfg(any(windows, target_os = "linux", test))]
fn add_display_gpu(gpus: &mut Vec<DisplayGpu>, pci: (u32, u32), primary: bool) {
match gpus.iter_mut().find(|g| g.pci == pci) {
Some(gpu) => gpu.primary |= primary,
None => gpus.push(DisplayGpu { pci, primary }),
}
}
/// Windows' display devices: one per GPU output; those `ATTACHED_TO_DESKTOP` show a monitor, one
/// of them the `PRIMARY_DEVICE`. The registry doesn't tell a user which adapter drives a display,
/// and WMI reports a resolution for a GPU without a monitor, so this is the way.
#[cfg(windows)]
fn windows_display_gpus() -> Vec<DisplayGpu> {
use winsafe::co::DISPLAY_DEVICE as Flags;
let mut gpus = Vec::new();
// A machine has a handful of display devices; the cap only bounds a runaway enumeration.
for device in winsafe::EnumDisplayDevices(None, None).take(256) {
let device = match device {
Ok(d) => d,
// The enumeration ends with "no more items" or, from a driver, with any other error.
Err(e) => {
log::debug!("display devices: {e}");
break;
}
};
if !device.StateFlags.has(Flags::ATTACHED_TO_DESKTOP) {
continue;
}
// Remote Desktop and other non-PCI devices (`ROOT\BasicDisplay\0000`) are left out.
let Some(pci) = pci_ids(&device.DeviceID()) else { continue };
add_display_gpu(&mut gpus, pci, device.StateFlags.has(Flags::PRIMARY_DEVICE));
}
gpus
}
/// The PCI `(vendor, device)` of a Windows device id such as
/// `PCI\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1`, any case; `None` for anything else.
#[cfg(any(windows, test))]
fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
let field = |key: &str| {
device_id.split(['\\', '&']).find_map(|part| {
let (name, hex) = part.split_at_checked(key.len())?;
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
})
};
Some((field("VEN_")?, field("DEV_")?))
}
/// Linux: every connector `cardN-CONNECTOR` under `drm` whose `status` is `connected`, with the
/// PCI ids of its card (`cardN/device/{vendor,device}`). A built-in panel is the primary display.
#[cfg(any(target_os = "linux", test))]
fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let mut gpus = Vec::new();
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
for entry in entries.flatten().take(256) {
let name = entry.file_name();
let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
if !connected {
continue;
}
let device = drm.join(card).join("device");
let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue };
add_display_gpu(&mut gpus, (v, d), is_internal_panel(connector));
}
gpus
}
/// A laptop's built-in panel: an embedded DisplayPort, LVDS or DSI connector.
#[cfg(any(target_os = "linux", test))]
fn is_internal_panel(connector: &str) -> bool {
["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
}
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
/// Metal reports no ids (`0000:0000` for every GPU), so an adapter without them is `backend:name`
/// (`Metal:Intel Iris Pro Graphics`): otherwise leaving out the one that failed would leave out
/// every GPU of a dual-GPU Mac (#651). Commas, which separate [`SKIP_ENV`]'s keys, become spaces.
fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
if vendor == 0 && device == 0 {
format!("{backend:?}:{}", name.replace(',', " ").trim())
} else {
format!("{backend:?}:{vendor:04x}:{device:04x}")
}
}
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; the GPU the compositor draws with (`compositor`); the
/// GPUs that drive one of `displays`, the primary display's first; then by `power`; then DX12
/// before Vulkan, keeping the system's order among equals (all of it for
/// [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's
/// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545);
/// elsewhere there is no DX12 adapter, so the order is unchanged.
///
/// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no
/// ids at all), the order is the power preference's alone, as it was.
pub fn adapter_order(
candidates: &[Candidate],
power: PowerPreference,
displays: &[DisplayGpu],
compositor: Option<(u32, u32)>,
named: Option<&str>,
skip: &[String],
) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
let (drives_display, drives_primary) = c.drives(displays);
(
unnamed,
c.device_type == DeviceType::Cpu,
c.backend == Backend::Gl,
!c.composites(compositor),
!drives_display,
!drives_primary,
power_rank(c.device_type, power),
c.backend == Backend::Vulkan,
)
});
order.into_iter().map(|(i, _)| i).collect()
}
/// wgpu's own ranking for a power preference: the preferred kind of GPU, the other kind, unknown,
/// virtual, software.
fn power_rank(t: DeviceType, power: PowerPreference) -> u8 {
match (power, t) {
(PowerPreference::None, _) => 0,
(PowerPreference::LowPower, DeviceType::IntegratedGpu) | (PowerPreference::HighPerformance, DeviceType::DiscreteGpu) => 0,
(_, DeviceType::IntegratedGpu | DeviceType::DiscreteGpu) => 1,
(_, DeviceType::Other) => 2,
(_, DeviceType::VirtualGpu) => 3,
(_, DeviceType::Cpu) => 4,
}
}
/// The adapters [`SKIP_ENV`] lists.
pub fn skipped() -> Vec<String> {
std::env::var(SKIP_ENV).map(|v| parse_skip(&v)).unwrap_or_default()
}
fn parse_skip(v: &str) -> Vec<String> {
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
}
/// Whether `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` is set: then a start uses the backends and the
/// adapter they name, and leaves out none that earlier starts crashed on.
pub fn explicit_choice() -> bool {
std::env::var_os("WGPU_BACKEND").is_some() || std::env::var_os(NAME_ENV).is_some()
}
/// How the last start ended when it ended before the window's first frame reached the screen,
/// with no error or panic to report: a crash in the graphics driver (#2243), or the process was
/// ended from outside.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Crash {
/// While the adapters were being listed, before one was picked.
Listing,
/// On the adapter with this [`key`] and name.
Adapter { key: String, name: String },
}
/// What a start leaves out because earlier starts crashed ([`Startup::track`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LeftOut {
/// The adapters ([`key`]s) that crashed a start.
pub adapters: Vec<String>,
/// A start crashed while listing the adapters: list OpenGL's alone. Never on macOS, where wgpu
/// has no OpenGL adapters to fall back to.
pub opengl_only: bool,
/// The crash the last start ended in, when this start leaves something out for it.
pub crash: Option<Crash>,
}
impl LeftOut {
/// `backends` to list: OpenGL alone after a crash while listing, when it is among them;
/// otherwise `backends`.
pub fn backends(&self, backends: Backends) -> Backends {
if self.opengl_only && backends.contains(Backends::GL) { Backends::GL } else { backends }
}
}
/// An adapter in [`START_FILE`]: its [`key`] and name.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Recorded {
key: String,
name: String,
}
/// The start in progress, in [`START_FILE`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Starting {
/// The adapter picked; `None` while the adapters are being listed.
adapter: Option<Recorded>,
/// The listing found an adapter of a backend other than OpenGL.
native: bool,
}
/// What [`START_FILE`] holds.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct StartRecord {
/// The app version and the Graphics Processor preference (`gpuPreference`) it was written
/// under: the crashes count only for the same ones.
version: String,
preference: String,
/// The start in progress, from before the graphics start until its first frame reaches the
/// screen or the app ends without crashing.
starting: Option<Starting>,
/// The adapters that crashed a start, oldest first.
crashed: Vec<Recorded>,
/// A start crashed while listing the adapters.
listing_crashed: bool,
}
impl StartRecord {
/// This start's record, made from the one the last start left (`previous`; `None` when there
/// is none or it can't be read), and the crash that start ended in, if it did. A record
/// written by another version or under another `preference` is dropped.
fn begin(previous: Option<Self>, version: &str, preference: &str) -> (Self, Option<Crash>) {
let mut record = previous.filter(|r| r.version == version && r.preference == preference).unwrap_or_default();
let crash = record.starting.take().map(|s| match s.adapter {
Some(Recorded { key, name }) => Crash::Adapter { key, name },
None => Crash::Listing,
});
match &crash {
Some(Crash::Adapter { key, name }) if !record.crashed.iter().any(|c| &c.key == key) => {
record.crashed.push(Recorded { key: key.clone(), name: name.clone() });
if record.crashed.len() > CRASHED_LIMIT {
record.crashed.remove(0);
}
}
Some(Crash::Listing) => record.listing_crashed = true,
_ => {}
}
record.version = version.to_string();
record.preference = preference.to_string();
record.starting = Some(Starting::default());
(record, crash)
}
/// What this start leaves out: nothing when `explicit` ([`explicit_choice`]). `crash` is the
/// one [`begin`](Self::begin) found.
fn left_out(&self, crash: Option<Crash>, explicit: bool) -> LeftOut {
if explicit {
return LeftOut::default();
}
let opengl_only = self.listing_crashed && !cfg!(target_os = "macos");
let crash = crash.filter(|c| matches!(c, Crash::Adapter { .. }) || opengl_only);
LeftOut { adapters: self.crashed.iter().map(|c| c.key.clone()).collect(), opengl_only, crash }
}
/// The adapter this start picked, after a listing that found one of a backend other than
/// OpenGL when `native`.
fn picked(&mut self, key: &str, name: &str, native: bool) {
self.starting = Some(Starting { adapter: Some(Recorded { key: key.to_string(), name: name.trim().to_string() }), native });
}
/// The listing found no adapter that can show the window: nothing is left to leave out, so the
/// crashes are forgotten and the next start tries every adapter again.
fn none_left(&mut self) {
self.crashed.clear();
self.listing_crashed = false;
}
/// The window's first frame reached the screen: the adapter it was drawn on, and the listing
/// when it found an adapter of a backend other than OpenGL, no longer count as crashed.
fn presented(&mut self) {
let Some(starting) = self.starting.take() else { return };
if let Some(adapter) = starting.adapter {
self.crashed.retain(|c| c.key != adapter.key);
}
if starting.native {
self.listing_crashed = false;
}
}
/// The start ended without crashing: a normal exit, an error, or a restart on another adapter.
fn ended(&mut self) {
self.starting = None;
}
fn to_json(&self) -> String {
let recorded = |r: &Recorded| serde_json::json!({ "key": r.key, "name": r.name });
let mut v = serde_json::json!({
"version": self.version,
"gpuPreference": self.preference,
"crashed": self.crashed.iter().map(recorded).collect::<Vec<_>>(),
"listingCrashed": self.listing_crashed,
});
if let (Some(s), Some(map)) = (&self.starting, v.as_object_mut()) {
map.insert("starting".into(), serde_json::json!({ "adapter": s.adapter.as_ref().map(recorded), "native": s.native }));
}
v.to_string()
}
/// The record in `text`; `None` when it isn't one.
fn parse(text: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let recorded = |v: &serde_json::Value| -> Option<Recorded> {
Some(Recorded { key: v.get("key")?.as_str()?.to_string(), name: v.get("name")?.as_str()?.to_string() })
};
let starting = match v.get("starting") {
None => None,
Some(s) => Some(Starting {
adapter: match s.get("adapter") {
None | Some(serde_json::Value::Null) => None,
Some(a) => Some(recorded(a)?),
},
native: s.get("native")?.as_bool()?,
}),
};
let crashed = v.get("crashed")?.as_array()?;
Some(Self {
version: v.get("version")?.as_str()?.to_string(),
preference: v.get("gpuPreference")?.as_str()?.to_string(),
starting,
crashed: crashed.iter().take(CRASHED_LIMIT).map(recorded).collect::<Option<_>>()?,
listing_crashed: v.get("listingCrashed")?.as_bool()?,
})
}
}
/// [`START_FILE`], open and locked while the start runs, so that a second copy of the app starting
/// at the same time doesn't take it for the record of a start that crashed.
struct StartFile {
file: std::fs::File,
record: StartRecord,
}
impl StartFile {
/// Open `dir`'s [`START_FILE`], with the record the last start left in it (`None` when there is
/// none or it can't be read). `Ok(None)` when another copy of the app holds it.
fn open(dir: &Path) -> std::io::Result<Option<(std::fs::File, Option<StartRecord>)>> {
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(START_FILE))?;
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
Err(std::fs::TryLockError::Error(e)) => return Err(e),
}
let mut text = String::new();
// Text that isn't UTF-8, or is longer than any record, is ignored like a damaged record.
let previous = (&file).take(START_FILE_LIMIT).read_to_string(&mut text).ok().and_then(|_| StartRecord::parse(&text));
Ok(Some((file, previous)))
}
fn write(&mut self) {
let text = self.record.to_json();
let written = self.file.set_len(0).and_then(|()| self.file.rewind()).and_then(|()| self.file.write_all(text.as_bytes()));
if let Err(e) = written {
log::warn!("{START_FILE}: {e}");
}
}
}
/// The value of `gpuPreference` the crashes are recorded under: any value this version doesn't
/// know reads as `automatic`, as in [`power_preference`].
fn preference_name(pref: Option<&str>) -> &'static str {
match pref {
Some("highPerformance") => "highPerformance",
Some("lowPower") => "lowPower",
_ => "automatic",
}
}
/// What the window's start-up leaves for [`finish`].
#[derive(Default)]
pub struct Startup {
/// The key of the adapter [`selector`] picked.
adapter: OnceLock<String>,
/// The UI's context, which counts the frames run.
ui: OnceLock<egui::Context>,
/// When the app was created, the files named at the start opened: the first frame is due.
ready: OnceLock<Instant>,
/// [`START_FILE`] while the start runs ([`Self::track`]).
start: Mutex<Option<StartFile>>,
}
impl Startup {
/// The app was created on `ctx`.
pub fn created(&self, ctx: &egui::Context) {
// Set once; the window is created once.
let _ = self.ui.set(ctx.clone());
}
/// The app is ready to draw its first frame.
pub fn ready(&self) {
// Set once, like `created`.
let _ = self.ready.set(Instant::now());
}
/// Record this start in `dir`'s [`START_FILE`] until the window's first frame reaches the
/// screen or the app ends without crashing, and say what it leaves out for the starts that
/// crashed before (see the module docs). `preference` is Preferences › Performance › Graphics
/// Processor, and `explicit` is [`explicit_choice`]. When the file can't be used (it can't be
/// written, or another copy of the app is starting), nothing is recorded or left out.
pub fn track(&self, dir: &Path, preference: Option<&str>, explicit: bool) -> LeftOut {
let (file, previous) = match StartFile::open(dir) {
Ok(Some(opened)) => opened,
Ok(None) => {
log::info!("{START_FILE}: another VectorCraft is starting, so this start isn't recorded");
return LeftOut::default();
}
Err(e) => {
log::warn!("{START_FILE}: {e}");
return LeftOut::default();
}
};
let (record, crash) = StartRecord::begin(previous, env!("CARGO_PKG_VERSION"), preference_name(preference));
let left_out = record.left_out(crash.clone(), explicit);
match &crash {
Some(Crash::Adapter { key, name }) => {
log::warn!("the last start ended on graphics adapter {name} ({key}) before the window's first frame reached the screen")
}
Some(Crash::Listing) => log::warn!("the last start ended while it listed the graphics adapters"),
None => {}
}
if explicit && (!record.crashed.is_empty() || record.listing_crashed) {
log::info!("WGPU_BACKEND or WGPU_ADAPTER_NAME is set: no adapter is left out for the starts that crashed");
}
if !left_out.adapters.is_empty() {
log::warn!("leaving out the graphics adapters earlier starts crashed on: {}", left_out.adapters.join(", "));
}
if left_out.opengl_only {
log::warn!("an earlier start crashed while it listed the graphics adapters: listing OpenGL's alone");
}
let mut file = StartFile { file, record };
file.write();
*self.start.lock().unwrap_or_else(PoisonError::into_inner) = Some(file);
left_out
}
/// [`selector`] picked the adapter with this [`key`] and name; `native` when the listing found
/// an adapter of a backend other than OpenGL.
fn picked(&self, key: &str, name: &str, native: bool) {
// Set once; eframe picks the adapter once.
let _ = self.adapter.set(key.to_string());
self.update(|r| r.picked(key, name, native), false);
}
/// [`selector`] found no adapter that can show the window.
fn none_left(&self) {
self.update(StartRecord::none_left, false);
}
/// The window's first frame reached the screen, or the window started minimized.
fn presented(&self) {
self.update(StartRecord::presented, true);
}
/// The app ends, or starts again, without crashing.
fn ended(&self) {
self.update(StartRecord::ended, true);
}
/// Change the record and write it; `last` releases the file for the next start.
fn update(&self, change: impl FnOnce(&mut StartRecord), last: bool) {
let mut start = self.start.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(file) = start.as_mut() {
change(&mut file.record);
file.write();
}
if last {
*start = None;
}
}
}
/// What [`watch_first_frame`] makes of the window's start.
#[derive(Debug, PartialEq, Eq)]
enum FirstFrame {
/// Not due yet.
Wait,
/// On the screen (the second frame began, so the first was presented), or the window was
/// minimized at the start: nothing to watch.
Shown,
/// Overdue.
Hung,
/// Overdue, but the UI is still building the first frame: the app's own work (on a slow
/// machine, or with antivirus reading every font file), not the adapter, which has been given
/// nothing to show yet.
Busy,
}
/// The window's start after `frames` frames run, `since` its first frame was due and
/// `since_built` the first frame's UI pass ended (`None` while it runs). A frame counts once its
/// UI pass ends, before it is painted, so the adapter's time starts there: with no frame run, the
/// UI thread is in the app's code, and starting again without the adapter wouldn't help (#5160).
fn first_frame(frames: u64, since: Duration, since_built: Option<Duration>, minimized: bool) -> FirstFrame {
if frames >= 2 || minimized {
FirstFrame::Shown
} else if frames == 0 {
if since >= FIRST_FRAME_TIMEOUT { FirstFrame::Busy } else { FirstFrame::Wait }
} else if since_built.unwrap_or(since) >= FIRST_FRAME_TIMEOUT {
FirstFrame::Hung
} else {
FirstFrame::Wait
}
}
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after its UI pass
/// ended, start the app again without the adapter, as [`finish`] does for one that failed, and end
/// this process, whose UI thread is stuck in the graphics driver. A first frame still being built
/// that long is waited for, with a note in the log.
pub fn watch_first_frame(startup: Arc<Startup>) {
let mut built: Option<Instant> = None;
let mut noted = false;
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
let Some(due) = startup.ready.get() else { continue };
let ui = startup.ui.get();
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
if frames >= 1 && built.is_none() {
built = Some(Instant::now());
}
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), built.map(|t| t.elapsed()), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Busy => {
if !noted {
log::warn!(
"the window's first frame is still being built after {}s: waiting for it (the app's work, not the graphics adapter)",
FIRST_FRAME_TIMEOUT.as_secs()
);
noted = true;
}
continue;
}
FirstFrame::Shown => {
startup.presented();
return;
}
FirstFrame::Hung => {}
}
let mut skip = skipped();
let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return };
log::error!(
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
FIRST_FRAME_TIMEOUT.as_secs()
);
// The new app records its own start.
startup.ended();
skip.push(failed.to_string());
match restart(&skip.join(",")) {
Ok(()) => std::process::exit(0),
Err(e) => {
log::error!("starting again failed: {e}");
return;
}
}
};
if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) {
log::warn!("no watch on the window's first frame: {e}");
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME`, the adapters a restart
/// left out and those `crashed` ([`LeftOut::adapters`]). Every adapter, the displays and the
/// choice are logged: which GPU draws is the first question in every black-window report.
pub fn selector(
power: PowerPreference,
displays: Vec<DisplayGpu>,
compositor: Option<(u32, u32)>,
crashed: Vec<String>,
startup: Arc<Startup>,
) -> NativeAdapterSelectorMethod {
let named = std::env::var(NAME_ENV).ok();
let skip = skipped();
let left_out: Vec<String> = skip.iter().chain(&crashed).cloned().collect();
Arc::new(move |adapters, surface| {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &left_out);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
let (drives_display, drives_primary) = c.drives(&displays);
let note = if !c.presents {
", can't show the window"
} else if skip.contains(&c.key) {
", failed before"
} else if crashed.contains(&c.key) {
", crashed an earlier start"
} else if c.composites(compositor) {
", the compositor draws with it"
} else if drives_primary {
", drives the primary display"
} else if drives_display {
", drives a display"
} else {
""
};
format!("{} ({:?}, {:?}, {}{note})", c.name.trim(), c.backend, c.device_type, c.key)
})
.collect();
log::info!("graphics adapters: {}", listed.join("; "));
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
let Some((adapter, chosen)) = first else {
startup.none_left();
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
};
let why = match chosen.drives(&displays) {
_ if chosen.composites(compositor) => "the compositor draws with it".to_string(),
(_, true) => "it drives the primary display".to_string(),
(true, false) => "it drives a display".to_string(),
(false, false) if displays.is_empty() => format!("power preference {power:?}"),
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
};
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
startup.picked(&chosen.key, &chosen.name, candidates.iter().any(|c| c.backend != Backend::Gl));
Ok(adapter.clone())
})
}
/// The last panic came from the graphics stack (see [`watch_panics`]).
static GRAPHICS_PANIC: AtomicBool = AtomicBool::new(false);
/// Note whether each panic comes from the graphics stack, so [`finish`] can tell a failing adapter
/// from a bug. The default hook still prints it.
pub fn watch_panics() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
GRAPHICS_PANIC.store(info.location().is_some_and(|l| in_graphics_stack(l.file())), Ordering::Relaxed);
default(info);
}));
}
/// A source file of wgpu (`wgpu-core-30.0.1/src/…`, `wgpu-hal-…`), egui-wgpu or naga.
fn in_graphics_stack(file: &str) -> bool {
file.split(['/', '\\']).any(|dir| ["wgpu-", "egui-wgpu-", "naga-"].iter().any(|p| dir.starts_with(p)))
}
/// The adapter to start again without: the one picked, when the graphics failed while the window
/// was starting up and it wasn't already left out.
fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a str>, skip: &[String]) -> Option<&'a str> {
picked.filter(|p| graphics_failed && frames < STARTUP_FRAMES && !skip.iter().any(|s| s == p))
}
/// After the window's run: `Ok` when it closed normally or the app started again (see the module
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
/// ended it.
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
// Whatever follows, this start didn't crash; a new app records its own start.
startup.ended();
let (why, graphics_failed) = match outcome {
Ok(Ok(())) => return Ok(()),
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
Ok(Err(e)) => (e.to_string(), false),
Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)),
};
let frames = startup.ui.get().map_or(0, egui::Context::cumulative_frame_nr);
let mut skip = skipped();
let Some(failed) = restart_without(graphics_failed, frames, startup.adapter.get().map(String::as_str), &skip) else {
return Err(if graphics_failed { format!("{why} ({HELP})") } else { why });
};
log::error!("the window failed on graphics adapter {failed} ({why}): starting again without it");
skip.push(failed.to_string());
restart(&skip.join(",")).map_err(|e| format!("{why}; starting again failed: {e}"))
}
/// Start the app again with the same arguments, leaving out the adapters in `skip`. On Unix the
/// new app replaces this process (same process id, so an AppImage keeps its files mounted), and
/// this returns only on failure; on Windows it starts beside this one, which then exits.
fn restart(skip: &str) -> std::io::Result<()> {
log::logger().flush();
let mut c = std::process::Command::new(std::env::current_exe()?);
c.args(std::env::args_os().skip(1)).env(SKIP_ENV, skip);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
Err(c.exec())
}
#[cfg(not(unix))]
c.spawn().map(|_| ())
}
#[cfg(test)]
mod tests;