diff --git a/Cargo.lock b/Cargo.lock index fffbb49f..c9bbd713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,7 @@ dependencies = [ "serde_json", "winit", "winresource", + "winsafe", ] [[package]] @@ -5614,6 +5615,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/README.md b/README.md index c32d0b6c..e2d5105d 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,9 @@ The desktop app writes its `log` records to standard error and to `logs/designcr settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or `$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`; Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no -terminal, so this file is what to attach to a bug report: the crash guard's panic report and a -failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log` +terminal, so this file is what to attach to a bug report: the graphics adapters found and the +one drawing, why the app stopped (also when its window never opened), the crash guard's panic +report and a failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log` (and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file. @@ -142,23 +143,71 @@ The file stops growing at 16 MiB. `--version` writes no file. |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | | `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | -| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. | +| `WGPU_POWER_PREF` | The kind of GPU that draws the window: `low` (integrated), `high` (discrete) or `none` (the system's order). Default: the GPU that drives the primary display, then `low` on Windows and macOS and `none` elsewhere. | +| `WGPU_ADAPTER_NAME` | The graphics adapter by (part of) its name, any case (`nvidia`, `radv`, `intel`); the log lists the names. | +| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off: the compute shader it needs failed to compile on an Intel Mac and lost the graphics device (#334). | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. -### Graphics backend +### Graphics backend and processor -The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A -graphics driver that faults takes the process down before any Rust code can catch it, so the -backend is chosen before the window exists, and a start that never showed a frame is remembered: -`gpu.json` in the settings directory records the backend being tried until the first frame has -been presented. A record left behind means that backend crashed (or the app was killed) at -start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows; -Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every -backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to -start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an -integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is -the Windows default. The code is `apps/designcraft/src/gpu.rs`. +The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu, so any +adapter that can show the window will do. The code is `apps/designcraft/src/gpu.rs` (the adapter) +and `apps/designcraft/src/gpu/backend.rs` (the backend). + +- **Backend.** A graphics driver that faults takes the process down before any Rust code can + catch it, so each start creates its wgpu instance with one backend, chosen before the window + exists: on Windows DirectX 12, then OpenGL, then Vulkan, each on its own (creating an OpenGL + instance crashed inside AMD's `atio6axx.dll`, #167, and creating a Vulkan instance loads every + installed Vulkan driver, of which a faulty one can crash the app); on Linux Vulkan with OpenGL + beside it, then OpenGL alone; on macOS Metal. Seen in the wild: on an AMD hybrid-graphics laptop + (a Radeon RX 6800M beside an integrated Radeon) the AMD Vulkan driver faulted at the first + present, which is why DirectX 12 is the Windows default. +- **A start that never showed a frame is remembered:** `gpu.json` in the settings directory + records the backend being tried until the first frame has been presented. A record left behind + means that backend crashed (or the app was killed) at start-up, so the next start tries the next + backend and says so in the status bar. The list starts over once every backend has failed, or + with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to start afresh. +- **Adapter.** Among the adapters of those backends, the app picks the one that draws the window + (eframe's `native_adapter_selector`): adapters that report they can't present to the window are + never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); + the native backends before OpenGL; the GPU that drives a display, and among those the one that + drives the primary display; then the power preference; then a GPU's DirectX 12 adapter before its + Vulkan one. A GPU without a monitor is the wrong one even when it can present: Windows has to + copy every frame to the GPU that shows it, and an integrated GPU's driver reset under that, + losing the graphics device or blanking every monitor for minutes. Windows says which GPU drives + which display (`EnumDisplayDevices`), Linux through `/sys/class/drm` (a built-in `eDP`, `LVDS` or + `DSI` panel counts as the primary display); on macOS, or when nothing matches, the power + preference decides: power saving on Windows and macOS, the system's order elsewhere (Mesa puts + the GPU the desktop runs on first, and a Wayland compositor may refuse frames from another GPU). + `WGPU_POWER_PREF` replaces this with the user's choice of kind. +- **If the window fails on its adapter while starting up** (an error from wgpu, such as a lost + device, or a panic inside wgpu, egui-wgpu or naga during the first frames), the app starts + again without that adapter and tries the next one, telling which in the status bar; the run that + failed keeps its log as `designcraft.1.log`. When no adapter of the start's backends is left, it + starts again with the next backend. Software renderers (WARP, llvmpipe) come last: they are + passed over while another backend is left to try, and allowed once no graphics processor could + show the window. On Unix the new app replaces the process (an AppImage stays mounted); on + Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the + backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`). What was left + out travels in the restarted app's `--gpu-skip=` argument (`Vulkan:1002:164e`, `Metal:Intel + Iris Pro Graphics`, or a whole backend such as `Dx12`), one more on each restart, so the + restarts end. When nothing is left, the app logs why and exits with an error. Documents + recovered after a crash stay in the recovery folder until they are saved or closed, so a restart + never loses them. +- **If the window's first frame is late** (20 seconds after the app was created and the files + named at the start opened), the log and the status bar say so. The adapter counts as hanging, + and the app starts again without it, only when the UI thread has been stuck in the graphics + stack, outside DesignCraft's own code, for 60 seconds while the window wasn't known to be + minimized, covered or in the background. A large document composing on a slow machine never + counts. +- **The log** lists the GPUs that drive a display (`display GPUs (PCI vendor:device): 10de:2204 + (primary)`), every adapter with whether it drives a display, the one drawn on and why (`drawing + on NVIDIA GeForce RTX 3090 (Dx12, DiscreteGpu): it drives the primary display`), and the driver + version. That is the first thing to look for in a report of a window that stays black or never + opens. +- On Linux with Mesa, `MESA_VK_DEVICE_SELECT=10de:2f04!` (vendor:device as `vulkaninfo --summary` + or `lspci -nn` show it) leaves only that GPU to Vulkan, and `DRI_PRIME=1` picks the other GPU. ### Web diff --git a/ROADMAP.md b/ROADMAP.md index d9f87d40..984973a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -360,6 +360,7 @@ Newest first. - **2026-10-15** · IDML placed PDFs keep their page and Crop to box (`PDFAttribute` `PageNumber` and `PDFCrop`, written for every placed PDF); two crops of one PDF page stay two assets; a page the PDF lacks is left out of PDF export with a warning instead of failing it; `$ID/` font names resolve; the IDML in a package links the images in its Links folder without embedding them. No readiness percentage change. - **2026-10-15** · The CJK leading model and character alignment take effect only in paragraphs set with a Japanese composer (`HL Composer J`, `HL Single J`), which IDML import and export keep: Western and Arabic paragraphs set baseline to baseline on the Roman baseline, a paragraph switched to a Japanese composer gets the file's Aki Below spacing, and under Aki Below a line's leading is the space below it. No readiness percentage change. - **2026-10-15** · Document pages follow their parent page's margins and columns unless margins were set on the page itself (`Page::local_margins`): applying a different parent gives a page that parent's margins, a page that changes side after an insert takes the other parent page's, and a page given margins equal to its parent's keeps them when the parent changes. IDML import and older files mark pages whose margins differ from their parent as their own. No readiness percentage change. +- **2026-10-15** · Start-up picks the GPU that drives the display (or the kind `WGPU_POWER_PREF` or the adapter `WGPU_ADAPTER_NAME` names), and when a start-up fails or hangs it restarts on the next adapter, then the next backend, then a software renderer, keeping recovered documents; start-up errors are logged to standard error and the log file (#273, #334). No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 176bcf8a..64ec786b 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,6 +31,12 @@ arboard = { workspace = true } # The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs. winit = { version = "0.30", default-features = false, features = ["x11"] } +[target.'cfg(windows)'.dependencies] +# EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU +# that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over +# Win32 (MIT); the `user` feature is User32.dll only. +winsafe = { version = "0.0.29", default-features = false, features = ["user"] } + [target.'cfg(target_os = "macos")'.dependencies] # The same winit as eframe; only the native termination opt-in is used. winit = { version = "0.30", default-features = false } diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 6dfb0236..361ed25b 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -1,398 +1,1329 @@ -//! Which wgpu backend draws the window, and the fallback when one of them crashes at start-up. +//! Which graphics adapter the window renders with, and what happens when it can't show the window. //! -//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites the -//! interface, so every backend shows the same pixels and the one that never crashes is the right -//! one. wgpu's default backend set (`PRIMARY | GL`) enumerates Vulkan first, and on an AMD -//! hybrid-graphics laptop (a Radeon RX 6800M beside an integrated Radeon, two versions of -//! `amdvlk64.dll` loaded into the process) AMD's Vulkan driver faulted with an access violation -//! at the first swapchain present. A fault inside a driver can't be caught (the crash guard never -//! sees it), so the backend is chosen before the window exists: on Windows DirectX 12 first (the -//! backend browsers' WebGPU uses), then Vulkan, then OpenGL; on Linux Vulkan, then OpenGL; on -//! macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, `vulkan`, `gl`, `metal`, or a -//! comma-separated list — overrides the choice and turns the fallback off. +//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites it and draws +//! the interface, so any adapter that can present to the window will do. Which backends the +//! instance is created with is decided first, with a fallback for drivers that take the process +//! down ([`backend`]). Among the adapters of those backends, [`adapter_order`] ranks: 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 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. eframe is +//! handed [`selector`], which takes the first of that order. //! -//! The fallback: before the window is created, `gpu.json` in the settings directory records the -//! backend being tried; once a frame has been presented, that entry is cleared. An entry still -//! there at the next start means the previous start never got that far — the driver crashed, or -//! the app was killed meanwhile — so that backend joins `failed`, the next candidate is tried and -//! the status bar says so. When every candidate has failed the list starts over, and so does a -//! new DesignCraft version (drivers and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing. +//! 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. +//! So, unless `WGPU_POWER_PREF` says otherwise, 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. +//! +//! 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. Creating the device can fail too: on an Intel Mac, wgpu's check of +//! indirect draw arguments needs a compute shader the Metal driver couldn't compile, and the device +//! was lost before the window showed (#334; DesignCraft never draws indirectly, so that check is +//! off, [`instance_flags`]). 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. When no adapter of the start's backends +//! is left, it starts again with the next backend ([`backend::Fallback::next_after`]). Software +//! renderers (WARP, llvmpipe) come last of all: while a backend is left to try, a start passes +//! over them, and only once no hardware adapter of any backend could show the window does the app +//! start again with them allowed. Each restart leaves out one more adapter or backend, or allows +//! software once, so it ends when none is left. +//! +//! An adapter can also hang instead of failing: on a hybrid laptop, 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. [`watch_first_frame`] notices a first frame that is late and, +//! when the UI thread is stuck in the graphics stack for [`HANG_TIMEOUT`], starts the app again +//! without that adapter in the same way. -use std::path::{Path, PathBuf}; +pub mod backend; -use eframe::wgpu; -use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::time::{Duration, Instant}; -/// The record's file name inside the settings directory. -pub const FILE: &str = "gpu.json"; +use eframe::egui_wgpu::NativeAdapterSelectorMethod; +use eframe::wgpu::{self, Backend, DeviceType, PowerPreference}; -/// A graphics backend wgpu can draw the window with. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Backend { - Dx12, - Vulkan, - Metal, - Gl, +/// The argument that tells a restarted app what the window failed on: comma-separated adapter +/// [`key`]s (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`), [`backend::Backend::key`]s +/// (`Dx12`), [`SOFTWARE_LEFT`] and [`SOFTWARE`]. Added by the app itself when it starts again; an +/// argument rather than an environment variable, so the processes the app starts (a browser for a +/// link, `lpr`) don't inherit it. +pub const SKIP_ARG: &str = "--gpu-skip="; + +/// In a skip list: a start passed over a software renderer, so one is left to try. +const SOFTWARE_LEFT: &str = "software-left"; + +/// In a skip list: no hardware adapter could show the window, so software renderers may draw it. +const SOFTWARE: &str = "software"; + +/// 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 or backend, see https://github.com/storytold/designcraft/blob/main/README.md#graphics-backend-and-processor"; + +/// Frames the UI ran before the failure, below which it was still starting up. A failing adapter +/// fails 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 +/// [`watch_first_frame`] logs it and says so in the status bar. 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); + +/// How long the UI thread may be stuck in the graphics stack before the window's first frame, +/// with the window not known to be hidden, before its adapter counts as hanging +/// ([`watch_first_frame`]). +pub const HANG_TIMEOUT: Duration = Duration::from_secs(60); + +/// The preference used unless `WGPU_POWER_PREF` sets one. Windows and macOS show frames from any +/// GPU (the integrated one avoids the flicker of presenting a discrete GPU's frames through it). +/// 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. +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 [`AUTOMATIC`]. +pub fn power_preference(env: Option) -> PowerPreference { + env.unwrap_or(AUTOMATIC) } -impl Backend { - /// The name the status bar and the log use. - pub fn name(self) -> &'static str { - match self { - Backend::Dx12 => "DirectX 12", - Backend::Vulkan => "Vulkan", - Backend::Metal => "Metal", - Backend::Gl => "OpenGL", - } - } - - /// wgpu's bit for this backend. - pub fn bit(self) -> wgpu::Backends { - match self { - Backend::Dx12 => wgpu::Backends::DX12, - Backend::Vulkan => wgpu::Backends::VULKAN, - Backend::Metal => wgpu::Backends::METAL, - Backend::Gl => wgpu::Backends::GL, - } - } - - /// The backend of the adapter wgpu picked; `None` for the web and no-op backends. - pub fn of(backend: wgpu::Backend) -> Option { - match backend { - wgpu::Backend::Dx12 => Some(Backend::Dx12), - wgpu::Backend::Vulkan => Some(Backend::Vulkan), - wgpu::Backend::Metal => Some(Backend::Metal), - wgpu::Backend::Gl => Some(Backend::Gl), - _ => None, - } - } +/// Whether the adapter is the app's to choose: `WGPU_POWER_PREF` names no kind of GPU. Then the +/// GPU that drives the display comes first ([`display_gpus`]); a user's own choice is kept as it is. +pub fn automatic(env: Option) -> bool { + env.is_none() } -/// The backends to try on this platform, best first. -pub fn candidates() -> &'static [Backend] { - if cfg!(windows) { - &[Backend::Dx12, Backend::Vulkan, Backend::Gl] - } else if cfg!(target_os = "macos") { - &[Backend::Metal] - } else { - &[Backend::Vulkan, Backend::Gl] - } +/// The GPUs to rank first: those that drive a display, when the choice is [`automatic`]. +pub fn preferred_displays(env: Option) -> Vec { + if automatic(env) { display_gpus() } else { Vec::new() } } -/// What `gpu.json` holds. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct Record { - /// The DesignCraft version that wrote the record; another version starts afresh. - #[serde(default)] - pub version: String, - /// The backend of a start that has not presented a frame yet. - #[serde(default)] - pub trying: Option, - /// Backends whose start never presented a frame. - #[serde(default)] - pub failed: Vec, +/// wgpu's instance flags without its check of indirect draw and dispatch arguments: nothing in +/// DesignCraft or egui draws indirectly, so the check only costs a compute shader at start-up, one +/// some drivers can't compile (Metal on an Intel Iris Pro lost the device, #334). +/// `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on, like wgpu's other flag variables. +pub fn instance_flags() -> wgpu::InstanceFlags { + (wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env() } -/// The backend for a start, from [`choose`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Choice { +/// 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, - /// The record to write before the window is created. - pub record: Record, - /// The backend the previous start never finished with, when this start moves away from it. - pub switched_from: Option, + 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, } -/// Pick the backend for this start from the candidates (best first) and the previous start's -/// record; `None` without candidates. -pub fn choose(candidates: &[Backend], previous: &Record, version: &str) -> Option { - let first = *candidates.first()?; - let same_version = previous.version == version; - let mut failed: Vec = if same_version { previous.failed.clone() } else { Vec::new() }; - failed.retain(|b| candidates.contains(b)); - let unfinished = previous.trying.filter(|_| same_version); - if let Some(b) = unfinished - && !failed.contains(&b) +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 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; so does a system whose GPUs report no PCI ids. +pub fn display_gpus() -> Vec { + #[cfg(windows)] { - failed.push(b); + windows_display_gpus() } - let backend = match candidates.iter().copied().find(|b| !failed.contains(b)) { - Some(b) => b, - // Every candidate has failed: start over rather than never starting. - None => { - failed.clear(); - first + #[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, 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 { + 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; } - }; - let switched_from = unfinished.filter(|b| *b != backend); - Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from }) -} - -/// The record in `path`; a missing or unreadable file is an empty record. -pub fn read(path: &Path) -> Record { - std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default() -} - -/// Write `record` to `path` (creating the directory). -pub fn write(path: &Path, record: &Record) -> std::io::Result<()> { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + // 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)); } - let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?; - std::fs::write(path, bytes) + gpus } -/// A start in progress: the backend chosen for it and the record that says so until a frame has -/// been presented. -#[derive(Debug)] +/// 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) { 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 { + let read_hex = |p: std::path::PathBuf| -> Option { + 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. Commas, which separate [`SKIP_ARG`]'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 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 when both +/// backends are in the instance (`WGPU_BACKEND=dx12,vulkan`), and Intel's Vulkan driver made the +/// whole window flicker black where DX12 and OpenGL didn't; 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. +pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: &[DisplayGpu], named: Option<&str>, skip: &[String]) -> Vec { + 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, + !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 skip list of a [`SKIP_ARG`] argument; `None` for any other argument. +pub fn skip_arg(arg: &str) -> Option> { + arg.strip_prefix(SKIP_ARG).map(parse_skip) +} + +fn parse_skip(v: &str) -> Vec { + v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect() +} + +/// The window's start, shared by [`selector`], [`watch_first_frame`], the app and [`finish`]. +#[derive(Default)] pub struct Startup { - pub backend: Backend, - pub switched_from: Option, - /// `gpu.json`, when the record is persisted. - path: Option, - record: Record, + /// What the restart that began this start left out ([`SKIP_ARG`]). + skip: Vec, + /// The key of the adapter [`selector`] picked. + adapter: OnceLock, + /// [`selector`] passed over a software renderer. + software_left: AtomicBool, + /// The UI's context, which counts the frames run. + ui: OnceLock, + /// When the app was created, the files named at the start opened: the first frame is due. + ready: OnceLock, + /// The UI thread is in the app's own code ([`Startup::enter`]). + busy: AtomicBool, + /// How often the UI thread left the app's code: it moves while the thread is alive. + leaves: AtomicU64, + /// A note for the status bar from [`watch_first_frame`]. + notice: Mutex>, + /// The backend fallback of this start (`gpu.json`); `None` when `WGPU_BACKEND` chooses. + fallback: Mutex>, } impl Startup { - /// Choose the backend for this start and record it in `path` (nothing is recorded without - /// one). `None` when `WGPU_BACKEND` is set — wgpu follows it and the fallback is off — or the - /// platform has no candidates. - pub fn begin(path: Option, version: &str) -> Option { - if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) { - log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off"); - return None; - } - Self::begin_unforced(path, version) + pub fn new(fallback: Option, skip: Vec) -> Self { + Self { skip, fallback: Mutex::new(fallback), ..Self::default() } } - fn begin_unforced(path: Option, version: &str) -> Option { - let previous = path.as_deref().map(read).unwrap_or_default(); - let choice = choose(candidates(), &previous, version)?; - if let Some(p) = &path - && let Err(e) = write(p, &choice.record) - { - log::warn!("graphics: {}: {e}", p.display()); - } - match choice.switched_from { - Some(from) => log::warn!( - "graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})", - from.name(), - choice.backend.name(), - choice.record.failed - ), - None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()), - } - Some(Startup { backend: choice.backend, switched_from: choice.switched_from, path, record: choice.record }) + /// The UI thread runs the app's code (`logic`, `ui`): a long wait now is the app being busy, + /// never a graphics hang. + pub fn enter(&self) { + self.busy.store(true, Ordering::Relaxed); } - /// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it - /// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers - /// (AMD's) crash while the instance is created (#167), before any fallback could help, so - /// there OpenGL is only ever tried as its own candidate. - pub fn backends(&self) -> wgpu::Backends { - let mut backends = self.backend.bit(); - if !cfg!(windows) && !self.record.failed.contains(&Backend::Gl) { - backends |= wgpu::Backends::GL; - } - backends + /// The UI thread leaves the app's code, to eframe and the graphics stack. + pub fn leave(&self) { + self.busy.store(false, Ordering::Relaxed); + self.leaves.fetch_add(1, Ordering::Relaxed); } - /// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend - /// that is actually drawing. - pub fn started_with(&mut self, backend: Backend) { - if self.record.trying == Some(backend) { - return; - } - self.backend = backend; - self.record.trying = Some(backend); - self.save(); + /// The note [`watch_first_frame`] left for the status bar, once. + pub fn take_notice(&self) -> Option { + self.notice.lock().unwrap_or_else(PoisonError::into_inner).take() } - /// A frame has been presented (or the app exited normally): this start did not crash. - pub fn presented(&mut self) { - if self.record.trying.take().is_some() { - self.save(); + /// While a backend is left to try, software renderers are passed over ([`SOFTWARE`]). + fn software_last(&self) -> bool { + !self.skip.iter().any(|k| k == SOFTWARE) && self.with_fallback(|_| ()).is_some() + } + + /// 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()); + } + + /// The first frame was presented, or the app exits normally: no driver fault took this start + /// down ([`backend::Fallback::finished`]). + pub fn presented(&self) { + self.with_fallback(|f| f.finished()); + } + + /// Clear the record of the backend being tried before starting again (the failure was caught), + /// returning it for [`Startup::retry`]. + fn caught(&self) -> Option { + self.with_fallback(|f| f.finished()).flatten() + } + + /// Starting again failed: the record says this start is still trying `trying`. + fn retry(&self, trying: Option) { + self.with_fallback(|f| f.retry(trying)); + } + + /// wgpu picked an adapter of `backend` ([`backend::Fallback::started_with`]). + pub fn started_with(&self, backend: wgpu::Backend) { + if let Some(b) = backend::Backend::of(backend) { + self.with_fallback(|f| f.started_with(b)); } } /// The status-bar line for a start that moved away from a backend that crashed. pub fn status_line(&self) -> Option { - let from = self.switched_from?; - Some(format!( - "Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).", - from.name(), - self.backend.name(), - from.name() - )) + self.with_fallback(|f| f.status_line()).flatten() } - fn save(&self) { - if let Some(p) = &self.path - && let Err(e) = write(p, &self.record) - { - log::warn!("graphics: {}: {e}", p.display()); + fn with_fallback(&self, f: impl FnOnce(&mut backend::Fallback) -> T) -> Option { + self.fallback.lock().unwrap_or_else(PoisonError::into_inner).as_mut().map(f) + } +} + +/// One look at the window's start, every [`WATCH_TICK`]. +#[derive(Clone, Copy, Debug)] +struct Sample { + /// Passes the UI ran; from 2 on, the first frame was presented. + frames: u64, + /// The UI thread is in the app's own code. + busy: bool, + /// [`Startup::leaves`]. + leaves: u64, + /// The window is known to be minimized, occluded or without focus: a compositor may hold its + /// frames back (a Wayland surface that isn't shown blocks the next frame). + hidden: bool, +} + +/// What [`Watch::step`] makes of the window's start. +#[derive(Debug, PartialEq, Eq)] +enum Verdict { + Wait, + /// The first frame was presented: nothing more to watch. + Shown, + /// The first frame is late: log it and say so, once. + Late, + /// The UI thread has been stuck in the graphics stack for [`HANG_TIMEOUT`]. + Hung, +} + +/// The first-frame watch. VectorCraft gives up on an adapter once the first frame is +/// [`FIRST_FRAME_TIMEOUT`] late. That also fired on healthy starts: a window on a locked screen or +/// another workspace whose compositor holds its frames back, or a large document composing on a +/// slow machine. So here a late frame is only reported; the adapter counts as hanging only when the +/// UI thread is stuck outside the app's code (in eframe, wgpu or the driver), makes no progress at +/// all for [`HANG_TIMEOUT`], and the window isn't known to be hidden. A busy UI thread, however +/// long, never counts. +#[derive(Debug, Default)] +struct Watch { + /// Time waited for the first frame, the window not known to be hidden. + waited: Duration, + /// Time the UI thread has been stuck outside the app's code without progress. + stuck: Duration, + leaves: u64, + late: bool, +} + +impl Watch { + fn step(&mut self, s: Sample, dt: Duration) -> Verdict { + if s.frames >= 2 { + return Verdict::Shown; + } + if s.hidden { + self.stuck = Duration::ZERO; + return Verdict::Wait; + } + self.waited = self.waited.saturating_add(dt); + if s.busy || s.leaves != self.leaves { + self.leaves = s.leaves; + self.stuck = Duration::ZERO; + } else { + self.stuck = self.stuck.saturating_add(dt); + } + if self.stuck >= HANG_TIMEOUT { + Verdict::Hung + } else if self.waited >= FIRST_FRAME_TIMEOUT && !self.late { + self.late = true; + Verdict::Late + } else { + Verdict::Wait } } } +/// How often [`watch_first_frame`] looks. +const WATCH_TICK: Duration = Duration::from_millis(250); + +/// Watch the window's first frame from another thread, from the app's creation +/// ([`Startup::ready`]); see [`Watch`]. Each look also asks for a repaint, so a UI thread that is +/// only idle draws its second frame and ends the watch. When the adapter hangs, start the app again +/// without it, as [`finish`] does for one that failed, and end this process, whose UI thread is +/// stuck in the graphics driver. +pub fn watch_first_frame(startup: Arc) { + let watch = move || { + let mut watch = Watch::default(); + loop { + std::thread::sleep(WATCH_TICK); + if startup.ready.get().is_none() { + continue; + } + let ui = startup.ui.get(); + let hidden = ui.is_some_and(|c| { + c.input(|i| { + let v = i.viewport(); + v.minimized == Some(true) || v.occluded == Some(true) || v.focused == Some(false) + }) + }); + let sample = Sample { + frames: ui.map_or(0, egui::Context::cumulative_frame_nr), + busy: startup.busy.load(Ordering::Relaxed), + leaves: startup.leaves.load(Ordering::Relaxed), + hidden, + }; + if let Some(c) = ui { + c.request_repaint(); + } + let adapter = startup.adapter.get().map_or("(none)", String::as_str); + match watch.step(sample, WATCH_TICK) { + Verdict::Wait => continue, + Verdict::Shown => return, + Verdict::Late => { + log::warn!( + "the window's first frame hasn't reached the screen in {}s on graphics adapter {adapter}", + FIRST_FRAME_TIMEOUT.as_secs() + ); + *startup.notice.lock().unwrap_or_else(PoisonError::into_inner) = Some(format!( + "The window took over {} s to show on {adapter}. If it stays black, see the log; WGPU_ADAPTER_NAME or WGPU_POWER_PREF chooses another graphics processor.", + FIRST_FRAME_TIMEOUT.as_secs() + )); + continue; + } + Verdict::Hung => {} + } + let mut skip = startup.skip.clone(); + let Some(failed) = restart_without(true, sample.frames, startup.adapter.get().map(String::as_str), &skip) else { return }; + log::error!( + "the window's first frame didn't reach the screen and the UI thread was stuck in the graphics stack for {}s on graphics adapter {failed}: starting again without it", + HANG_TIMEOUT.as_secs() + ); + skip.push(failed.to_string()); + match restart(&skip, &startup) { + Ok(()) => end_hung_process(), + 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}"); + } +} + +/// End this process from the watch thread while its UI thread is stuck in the graphics driver. +/// On Windows `exit` runs the DLLs' detach code, which can wait on that thread forever and leave an +/// invisible process holding the log and the control port, so the process is terminated. +fn end_hung_process() { + log::logger().flush(); + #[cfg(windows)] + if let Err(e) = winsafe::HPROCESS::GetCurrentProcess().TerminateProcess(0) { + log::error!("ending the process failed: {e}"); + } + std::process::exit(0); +} + +/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a +/// `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart left out. 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, startup: Arc) -> NativeAdapterSelectorMethod { + let named = std::env::var(NAME_ENV).ok(); + let skip = startup.skip.clone(); + Arc::new(move |adapters, surface| { + let candidates: Vec = adapters.iter().map(|a| Candidate::new(a, surface)).collect(); + let order = adapter_order(&candidates, power, &displays, named.as_deref(), &skip); + let (order, passed_over) = without_software(order, &candidates, startup.software_last()); + if passed_over { + startup.software_left.store(true, Ordering::Relaxed); + log::info!("software renderers passed over while another backend is left to try"); + } + let listed: Vec = 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 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: {}", if listed.is_empty() { "none".to_string() } else { listed.join("; ") }); + let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i))); + let Some((adapter, chosen)) = first else { + return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len())); + }; + let why = match chosen.drives(&displays) { + (_, 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); + // Set once; eframe picks the adapter once. + let _ = startup.adapter.set(chosen.key.clone()); + Ok(adapter.clone()) + }) +} + +/// `order` without software renderers when `software_last`, and whether it dropped any. +fn without_software(order: Vec, candidates: &[Candidate], software_last: bool) -> (Vec, bool) { + if !software_last { + return (order, false); + } + let before = order.len(); + let order: Vec = order.into_iter().filter(|&i| candidates.get(i).is_some_and(|c| c.device_type != DeviceType::Cpu)).collect(); + let dropped = order.len() != before; + (order, dropped) +} + +/// 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 previous hook still reports it. +pub fn watch_panics() { + let previous = 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); + previous(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, startup: &Startup) -> Result<(), String> { + 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 starting = graphics_failed && frames < STARTUP_FRAMES; + let next = if starting { + let fallback = startup.with_fallback(|f| (f.backend, f.next_after(&backend::skipped_backends(&startup.skip)))); + next_start(&startup.skip, startup.adapter.get().map(String::as_str), fallback, startup.software_left.load(Ordering::Relaxed)) + } else { + None + }; + let Some((skip, what)) = next else { + return Err(if graphics_failed { format!("{why} ({HELP})") } else { why }); + }; + log::error!("the window failed ({why}): starting again {what}"); + restart(&skip, startup).map_err(|e| format!("{why}; starting again failed: {e}")) +} + +/// The skip list to start again with after the graphics failed while the window was starting up, +/// and what it changes (for the log); `None` when nothing is left to try. In this order: without the +/// adapter `picked`; on the next backend (`fallback`: this start's backend and the next one, see +/// [`backend::Fallback::next_after`]); with software renderers allowed, once, when a start passed +/// one over (`software_left`, or [`SOFTWARE_LEFT`] in `skip`). +fn next_start( + skip: &[String], + picked: Option<&str>, + fallback: Option<(backend::Backend, Option)>, + software_left: bool, +) -> Option<(Vec, String)> { + let mut next = skip.to_vec(); + if let Some(failed) = restart_without(true, 0, picked, skip) { + next.push(failed.to_string()); + return Some((next, format!("without graphics adapter {failed}"))); + } + let software_left = software_left || skip.iter().any(|k| k == SOFTWARE_LEFT); + if let Some((from, Some(to))) = fallback { + next.push(from.key().to_string()); + if software_left && !next.iter().any(|k| k == SOFTWARE_LEFT) { + next.push(SOFTWARE_LEFT.to_string()); + } + return Some((next, format!("with {} (no {} graphics adapter could show the window)", to.name(), from.name()))); + } + if software_left && !skip.iter().any(|k| k == SOFTWARE) { + // Every backend again, now with their software renderers; the adapters that failed stay out. + next.retain(|k| k != SOFTWARE_LEFT && backend::Backend::from_key(k).is_none()); + next.push(SOFTWARE.to_string()); + return Some((next, "on a software renderer (no graphics processor could show the window)".to_string())); + } + None +} + +/// Start the app again with the same arguments and the skip list `skip` ([`SKIP_ARG`]). 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. The +/// failure was caught, so `gpu.json` stops blaming the backend first, and blames it again when +/// starting again fails. +fn restart(skip: &[String], startup: &Startup) -> 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).filter(|a| a.to_str().is_none_or(|a| skip_arg(a).is_none()))); + c.arg(format!("{SKIP_ARG}{}", skip.join(","))); + let trying = startup.caught(); + #[cfg(unix)] + let result = { + use std::os::unix::process::CommandExt as _; + Err(c.exec()) + }; + #[cfg(not(unix))] + let result = c.spawn().map(|_| ()); + if result.is_err() { + startup.retry(trying); + } + result +} + #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - const V: &str = "0.5.0"; - const WIN: &[Backend] = &[Backend::Dx12, Backend::Vulkan, Backend::Gl]; - - fn temp_path(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d.join(FILE) + fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } } - fn record(trying: Option, failed: &[Backend]) -> Record { - Record { version: V.into(), trying, failed: failed.to_vec() } + fn names(c: &[Candidate], order: &[usize]) -> Vec { + order.iter().map(|&i| c[i].name.clone()).collect() + } + + const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); + const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + const WARP: (u32, u32) = (0x1414, 0x008c); + + fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } + } + + fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } + } + + fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } + } + + /// A Windows desktop as wgpu lists it with every backend: a Ryzen's integrated GPU without a + /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and + /// OpenGL (which reports no device id). + fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] + } + + /// Power saving picked the integrated GPU, whose driver reset when Windows had to present its + /// frames on the monitors of the NVIDIA card. The GPU that drives the displays comes first, + /// through DX12; the integrated GPU stays the fallback a restart reaches. + #[test] + fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); + } + + /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the + /// discrete one. Power saving and the display agree, in either enumeration order. + #[test] + fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); + } + + /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so + /// its GPU draws, whichever kind it is. + #[test] + fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); + } + + /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, + /// Metal's zeros) change nothing: power saving's order. + #[test] + fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); + } + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); + // The Wayland desktop's adapters report no ids in these tests: the primary display never matches. + let c = wayland_desktop(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); + } + + /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. + #[test] + fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); + } + + /// `WGPU_POWER_PREF`, the user's own choice, is kept as it is; only without it do the displays + /// count. + #[test] + fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None)); + assert!(!automatic(Some(PowerPreference::None))); + assert!(!automatic(Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None), display_gpus()); } #[test] - fn every_platform_has_candidates_without_duplicates() { - let c = candidates(); - assert!(!c.is_empty()); - for (i, b) in c.iter().enumerate() { - assert!(!c[..i].contains(b), "{b:?} twice in {c:?}"); - } - if cfg!(windows) { - assert_eq!(c[0], Backend::Dx12, "DirectX 12 is the Windows default (AMD's Vulkan driver faulted at the first present)"); - assert!(!c.contains(&Backend::Metal)); - } - if cfg!(target_os = "macos") { - assert_eq!(c, &[Backend::Metal]); + fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids("PCI\\VEN_10DÉ&DEV_2204"), None); + assert_eq!(pci_ids(""), None); + } + + /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary + /// display, and none is listed twice. (A headless CI runner may list none.) + #[cfg(windows)] + #[test] + fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); } } #[test] - fn a_fresh_start_takes_the_first_candidate() { - let c = choose(WIN, &Record::default(), V).unwrap(); - assert_eq!(c.backend, Backend::Dx12); - assert_eq!(c.record, record(Some(Backend::Dx12), &[])); - assert_eq!(c.switched_from, None); - assert!(choose(&[], &Record::default(), V).is_none()); + fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } + } + + /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port + /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without + /// PCI ids. Each GPU is listed once, the one with the panel as primary. + #[test] + fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("designcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) } #[test] - fn a_start_that_never_presented_moves_to_the_next_candidate() { - let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V).unwrap(); - assert_eq!(c.backend, Backend::Vulkan); - assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Dx12])); - assert_eq!(c.switched_from, Some(Backend::Dx12)); + fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); + } - let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12]), V).unwrap(); - assert_eq!(c.backend, Backend::Gl); - assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Vulkan]); - assert_eq!(c.switched_from, Some(Backend::Vulkan)); + /// A dual-GPU Linux desktop as Vulkan lists it with Mesa's device-select layer: the GPU KDE's + /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software + /// renderer, and OpenGL. + fn wayland_desktop() -> Vec { + vec![ + gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), + gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), + ] } #[test] - fn a_finished_start_keeps_the_failed_list_and_switches_silently() { - // `trying` was cleared by a presented frame; the next start still avoids what failed. - let c = choose(WIN, &record(None, &[Backend::Dx12]), V).unwrap(); - assert_eq!(c.backend, Backend::Vulkan); - assert_eq!(c.record.failed, &[Backend::Dx12]); - assert_eq!(c.switched_from, None); - // The same backend failing again is listed once. - let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V).unwrap(); - assert_eq!(c.record.failed, &[Backend::Dx12]); - } - - #[test] - fn when_every_candidate_failed_the_list_starts_over() { - let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12, Backend::Vulkan]), V).unwrap(); - assert_eq!(c.backend, Backend::Dx12); - assert_eq!(c.record.failed, &[]); - assert_eq!(c.switched_from, Some(Backend::Gl)); - } - - #[test] - fn another_version_or_platform_forgets_what_failed() { - let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] }; - let c = choose(WIN, &old, V).unwrap(); - assert_eq!(c.backend, Backend::Dx12); - assert_eq!(c.record, record(Some(Backend::Dx12), &[])); - assert_eq!(c.switched_from, None); - // A backend that is no candidate here (a record copied from another machine) is dropped. - let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V).unwrap(); - assert_eq!(c.backend, Backend::Gl); - assert_eq!(c.record.failed, &[Backend::Vulkan]); - } - - #[test] - fn the_record_round_trips_and_garbage_reads_as_empty() { - let path = temp_path("roundtrip"); - assert_eq!(read(&path), Record::default()); - let r = record(Some(Backend::Vulkan), &[Backend::Dx12]); - write(&path, &r).expect("write"); - assert_eq!(read(&path), r); - let text = std::fs::read_to_string(&path).expect("read"); - assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}"); - std::fs::write(&path, b"{not json").expect("garbage"); - assert_eq!(read(&path), Record::default()); - // Unknown fields and missing ones are tolerated. - std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial"); - assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] }); - let _ = std::fs::remove_dir_all(path.parent().expect("dir")); - } - - #[test] - fn a_start_records_its_backend_until_a_frame_is_presented() { - let path = temp_path("startup"); - let mut s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); - let first = candidates()[0]; - assert_eq!(s.backend, first); - assert_eq!(read(&path).trying, Some(first)); - assert!(s.backends().contains(first.bit())); - // OpenGL is the in-process fallback, except on Windows where it is only its own candidate. - assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl); - assert_eq!(s.status_line(), None); - - // wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one. - s.started_with(Backend::Gl); - assert_eq!(read(&path).trying, Some(Backend::Gl)); - s.presented(); - assert_eq!(read(&path), record(None, &[])); - s.presented(); - assert_eq!(read(&path), record(None, &[])); - - // The next start after one that never presented: switches, says so, and leaves OpenGL - // out of the set once it failed. - write(&path, &record(Some(Backend::Gl), &[])).expect("write"); - let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); - // This synthetic prior start records OpenGL even on Metal-only macOS. - // Switching depends on the chosen backend, not the candidate count. - if s.backend != Backend::Gl { - assert_eq!(s.switched_from, Some(Backend::Gl)); - let line = s.status_line().expect("status"); - assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); - assert!(!s.backends().contains(wgpu::Backends::GL)); + fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { + let c = wayland_desktop(); + let order = adapter_order(&c, power_preference(None), &[], None, &[]); + let first = &c[order[0]].name; + if cfg!(any(windows, target_os = "macos")) { + assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); } else { - assert_eq!(s.switched_from, None); + // The integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. + assert_eq!(first, "NVIDIA GeForce RTX 5070"); } - let _ = std::fs::remove_dir_all(path.parent().expect("dir")); } #[test] - fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { - let mut s = Startup::begin_unforced(None, V).expect("candidates"); - s.started_with(Backend::Gl); - s.presented(); - // A file where the directory should be: every write fails, nothing panics. - let blocked = temp_path("blocked"); - let dir = blocked.parent().expect("dir"); - std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp"); - std::fs::write(dir, "a file where the directory should be").expect("block"); - let mut s = Startup::begin_unforced(Some(blocked.clone()), V).expect("candidates"); - s.started_with(Backend::Gl); - s.presented(); - let _ = std::fs::remove_file(dir); + fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { + let c = wayland_desktop(); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); + assert_eq!( + order(PowerPreference::None), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::LowPower), + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::HighPerformance), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + } + + /// A restart leaves out the adapter the window failed on, and once every adapter failed there + /// is nothing to try. A dual-GPU Mac reports both GPUs under Metal with no PCI ids: their keys + /// still differ, so when the integrated GPU fails, the restart renders on the discrete one. + #[test] + fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + pci: (0, 0), + presents: true, + }; + let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; + assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); + let skip = parse_skip(&c[first[0]].key); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); + } + + fn sample(frames: u64, busy: bool, leaves: u64, hidden: bool) -> Sample { + Sample { frames, busy, leaves, hidden } + } + + /// Runs `ticks` looks with the same sample; the verdicts other than `Wait`. + fn run(w: &mut Watch, s: Sample, ticks: u32) -> Vec<(u32, Verdict)> { + (1..=ticks).filter_map(|t| Some((t, w.step(s, WATCH_TICK))).filter(|(_, v)| *v != Verdict::Wait)).collect() + } + + const TICKS_PER_S: u32 = 4; + + /// The UI thread stuck in the graphics stack (not in the app's code, no progress) with the + /// window not known to be hidden: late after 20 s, hung after 60 s. + #[test] + fn a_ui_thread_stuck_in_the_graphics_stack_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(1, false, 1, false), 61 * TICKS_PER_S); + assert_eq!(v.first(), Some(&(20 * TICKS_PER_S, Verdict::Late))); + // `leaves` moved from 0 to 1 on the first look, so the stuck time starts one look later. + assert_eq!(v.get(1), Some(&(60 * TICKS_PER_S + 1, Verdict::Hung))); + // The second frame began: the first was presented. + assert_eq!(Watch::default().step(sample(2, false, 9, false), WATCH_TICK), Verdict::Shown); + } + + /// A busy UI thread (a large document composing on a slow machine) is only ever late. + #[test] + fn a_busy_ui_thread_never_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(0, true, 0, false), 600 * TICKS_PER_S); + assert_eq!(v, [(20 * TICKS_PER_S, Verdict::Late)]); + // Nor does one that keeps coming back from the graphics stack. + let mut w = Watch::default(); + for t in 0..(600 * TICKS_PER_S) { + assert_ne!(w.step(sample(1, false, u64::from(t), false), WATCH_TICK), Verdict::Hung); + } + } + + /// A window known to be hidden (locked screen, another workspace, minimized) may have its frames + /// held back by the compositor: that time counts for nothing and starts the stuck time over. + #[test] + fn time_while_the_window_is_hidden_does_not_count() { + let mut w = Watch::default(); + assert!(run(&mut w, sample(1, false, 0, true), 600 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 50 * TICKS_PER_S).iter().all(|(_, v)| *v == Verdict::Late)); + assert!(run(&mut w, sample(1, false, 0, true), 10 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 59 * TICKS_PER_S).is_empty()); + assert_eq!(run(&mut w, sample(1, false, 0, false), TICKS_PER_S), [(TICKS_PER_S, Verdict::Hung)]); + } + + #[test] + fn a_hung_adapter_is_left_out_once() { + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); + } + + /// Windows, a hardware DX12 adapter failing: the software renderer (WARP) is passed over while + /// another backend is left, the restarts go through OpenGL and Vulkan, and only then back to + /// every backend with software allowed. The chain ends. + #[test] + fn software_renderers_come_after_every_backends_hardware() { + use backend::Backend as B; + let c = ryzen_desktop(); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(without_software(order.clone(), &c, false), (order.clone(), false)); + let (hardware, dropped) = without_software(order, &c, true); + assert!(dropped && !hardware.contains(&4), "{hardware:?}"); + + let dx12 = "Dx12:10de:2204".to_string(); + // The adapter that failed first. + let (skip, _) = next_start(&[], Some(&dx12), Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str()]); + // Only WARP left on DX12 (passed over, nothing picked): OpenGL next. + let (skip, what) = next_start(&skip, None, Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT]); + assert!(what.contains("OpenGL"), "{what}"); + // OpenGL has no adapter, Vulkan next; then Vulkan has none and no backend is left. + let (skip, _) = next_start(&skip, None, Some((B::Gl, Some(B::Vulkan))), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT, "Gl"]); + let (skip, what) = next_start(&skip, None, Some((B::Vulkan, None)), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), SOFTWARE], "{what}"); + assert!(backend::skipped_backends(&skip).is_empty()); + // With software allowed and nothing left: the end. + assert_eq!(next_start(&skip, None, Some((B::Dx12, None)), false), None); + // Nothing was passed over (a Mac whose GPUs all failed): no software round. + assert_eq!(next_start(&[], None, Some((B::Metal, None)), false), None); + // Without the backend fallback (`WGPU_BACKEND`), only adapters. + assert_eq!(next_start(&[], None, None, false), None); + } + + /// The skip list travels as an argument, which processes the app starts don't inherit. + #[test] + fn the_skip_list_is_an_argument() { + assert_eq!(skip_arg("--gpu-skip=Dx12:10de:2204,Dx12"), Some(vec!["Dx12:10de:2204".to_string(), "Dx12".to_string()])); + assert_eq!(skip_arg("--gpu-skip="), Some(vec![])); + assert_eq!(skip_arg("--gpu-skip"), None); + assert_eq!(skip_arg("layout.designcraft"), None); + } + + #[test] + fn a_restart_tries_the_next_adapter() { + let c = wayland_desktop(); + let mut skip = vec![]; + let mut tried = vec![]; + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { + tried.push(c[i].name.clone()); + skip.push(c[i].key.clone()); + } + assert_eq!( + tried, + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + } + + #[test] + fn adapters_that_cannot_present_to_the_window_are_never_tried() { + let mut c = wayland_desktop(); + c[0].presents = false; + c[3].presents = false; + for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { + let order = adapter_order(&c, power, &[], None, &[]); + assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); + assert_eq!(order.len(), 2); + } + for p in &mut c { + p.presents = false; + } + assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); + } + + #[test] + fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { + let c = wayland_desktop(); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); + assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + assert_eq!(order.len(), 4); + // Any case; every match comes before the rest, natively first. + let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); + assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); + // An empty name or one nothing matches changes nothing. + for named in ["", "no such gpu"] { + assert_eq!( + adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), + adapter_order(&c, PowerPreference::LowPower, &[], None, &[]) + ); + } + // A named adapter that failed is left out like any other. + let skip = vec![c[1].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); + } + + /// A Windows machine lists every GPU twice when Vulkan and DX12 are both in the instance: + /// power saving still takes the integrated GPU first, each GPU through DX12 first. + #[test] + fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { + let c = vec![ + gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + ]; + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(order, [3, 1, 2, 0, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); + } + + /// One Intel GPU whose Vulkan driver made the window flicker black: DX12 comes first whatever + /// the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` or a restart leaving DX12 + /// out still reach it. + #[test] + fn a_windows_gpu_renders_through_dx12_before_vulkan() { + let c = vec![ + gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), + ]; + for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); + } + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); + } + + #[test] + fn power_preference_follows_the_environment() { + assert_eq!(power_preference(None), AUTOMATIC); + for p in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(power_preference(Some(p)), p); + } + assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); + } + + /// The Intel Mac of #334 lost its Metal device compiling the compute shader of wgpu's indirect + /// call check: off unless `WGPU_VALIDATION_INDIRECT_CALL` turns it on. + #[test] + fn the_indirect_call_check_is_off() { + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!instance_flags().contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + // The other flags of the build stay as wgpu sets them. + let others = wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL; + if std::env::vars_os().all(|(k, _)| !k.to_string_lossy().starts_with("WGPU_")) { + assert_eq!(instance_flags(), others); + } + } + + #[test] + fn skipped_adapters_parse_from_a_comma_separated_list() { + assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); + assert!(parse_skip("").is_empty()); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); + // A whole backend left out is its bare name; it never matches an adapter's key. + let skip = parse_skip("Dx12,Dx12:10de:2204"); + assert_eq!(backend::skipped_backends(&skip), [backend::Backend::Dx12]); + let c = vec![pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090)]; + assert!(adapter_order(&c, PowerPreference::None, &[], None, &skip).is_empty()); + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, &skip[..1]), [0]); + } + + #[test] + fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { + for file in [ + "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", + r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", + "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", + "naga-30.0.0/src/back/spv/writer.rs", + ] { + assert!(in_graphics_stack(file), "{file}"); + } + for file in [ + "apps/designcraft/src/main.rs", + "/home/wgpu/designcraft/crates/ui-egui/src/lib.rs", + "egui-0.36.2/src/context.rs", + "winit-0.30.12/src/x.rs", + ] { + assert!(!in_graphics_stack(file), "{file}"); + } + } + + #[test] + fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { + let amd = Some("Vulkan:1002:164e"); + assert_eq!(restart_without(true, 0, amd, &[]), amd); + assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); + // After start-up the adapter has shown frames; a lost window is something else. + assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); + // A bug outside the graphics, or no adapter picked: nothing another adapter would change. + assert_eq!(restart_without(false, 0, amd, &[]), None); + assert_eq!(restart_without(true, 0, None, &[]), None); + // Never the same adapter twice, so restarts end. + assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); + } + + /// Without a backend fallback (`WGPU_BACKEND` set), a start whose graphics failed with no + /// adapter to leave out ends with the error and the hint; a bug ends with its message. + #[test] + fn a_normal_exit_or_another_failure_does_not_restart() { + let startup = Startup::new(None, Vec::new()); + assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); + // No adapter was picked (none can show the window): the error is returned, nothing restarts. + let none = + eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + assert_eq!( + finish(Ok(Err(none)), &startup), + Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) + ); + let _ = startup.adapter.set("Vulkan:1002:164e".into()); + GRAPHICS_PANIC.store(false, Ordering::Relaxed); + assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); + // Nothing to record without a fallback. + startup.presented(); + startup.started_with(wgpu::Backend::Gl); + assert_eq!(startup.status_line(), None); } } diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs new file mode 100644 index 00000000..7a944b35 --- /dev/null +++ b/apps/designcraft/src/gpu/backend.rs @@ -0,0 +1,520 @@ +//! Which wgpu backend draws the window, and the fallback when one of them takes the process down +//! at start-up. +//! +//! A fault inside a graphics driver can't be caught: on an AMD hybrid-graphics laptop (a Radeon +//! RX 6800M beside an integrated Radeon, two versions of `amdvlk64.dll` loaded into the process) +//! AMD's Vulkan driver faulted with an access violation at the first swapchain present; creating +//! an OpenGL instance crashed inside AMD's `atio6axx.dll` (#167); and creating a Vulkan instance +//! loads every installed Vulkan driver into the process, of which a faulty one (Intel's +//! `igvk64.dll`, VectorCraft #806) crashed before the window appeared. So each start creates the +//! instance with one backend, chosen before the window exists: on Windows DirectX 12 first, then +//! OpenGL, then Vulkan, each on its own; on Linux Vulkan, with OpenGL beside it as the in-process +//! fallback, then OpenGL alone; on macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, +//! `vulkan`, `gl`, `metal`, or a comma-separated list — overrides the choice and turns the +//! fallback off. +//! +//! The fallback: before the window is created, `gpu.json` in the settings directory records the +//! backend being tried; once a frame has been presented, or the graphics failed with an error the +//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start +//! means the previous start never got that far — the driver crashed, or the app was killed +//! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says +//! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but +//! aren't recorded: the restart caught their failure, and a later launch may try them again. When +//! every candidate has failed the list starts over, and so does a new DesignCraft version (drivers +//! and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing. + +use std::path::{Path, PathBuf}; + +use eframe::wgpu; +use serde::{Deserialize, Serialize}; + +/// The record's file name inside the settings directory. +pub const FILE: &str = "gpu.json"; + +/// A graphics backend wgpu can draw the window with. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Dx12, + Vulkan, + Metal, + Gl, +} + +impl Backend { + /// The name the status bar and the log use. + pub fn name(self) -> &'static str { + match self { + Backend::Dx12 => "DirectX 12", + Backend::Vulkan => "Vulkan", + Backend::Metal => "Metal", + Backend::Gl => "OpenGL", + } + } + + /// wgpu's bit for this backend. + pub fn bit(self) -> wgpu::Backends { + match self { + Backend::Dx12 => wgpu::Backends::DX12, + Backend::Vulkan => wgpu::Backends::VULKAN, + Backend::Metal => wgpu::Backends::METAL, + Backend::Gl => wgpu::Backends::GL, + } + } + + /// How a restart's skip list ([`super::skip_arg`]) names the whole backend: wgpu's name for it, which also + /// starts each of its adapters' keys (`Dx12:10de:2204`). + pub fn key(self) -> &'static str { + match self { + Backend::Dx12 => "Dx12", + Backend::Vulkan => "Vulkan", + Backend::Metal => "Metal", + Backend::Gl => "Gl", + } + } + + /// The backend [`Backend::key`] names; `None` for anything else, such as an adapter's key. + pub fn from_key(key: &str) -> Option { + [Backend::Dx12, Backend::Vulkan, Backend::Metal, Backend::Gl].into_iter().find(|b| b.key() == key) + } + + /// The backend of the adapter wgpu picked; `None` for the web and no-op backends. + pub fn of(backend: wgpu::Backend) -> Option { + match backend { + wgpu::Backend::Dx12 => Some(Backend::Dx12), + wgpu::Backend::Vulkan => Some(Backend::Vulkan), + wgpu::Backend::Metal => Some(Backend::Metal), + wgpu::Backend::Gl => Some(Backend::Gl), + _ => None, + } + } +} + +/// The backends to try on this platform, best first. Windows reaches Vulkan last: creating its +/// instance loads every installed Vulkan driver (VectorCraft #806). +pub fn candidates() -> &'static [Backend] { + if cfg!(windows) { + &[Backend::Dx12, Backend::Gl, Backend::Vulkan] + } else if cfg!(target_os = "macos") { + &[Backend::Metal] + } else { + &[Backend::Vulkan, Backend::Gl] + } +} + +/// What `gpu.json` holds. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// The DesignCraft version that wrote the record; another version starts afresh. + #[serde(default)] + pub version: String, + /// The backend of a start that has not presented a frame yet. + #[serde(default)] + pub trying: Option, + /// Backends whose start never presented a frame. + #[serde(default)] + pub failed: Vec, +} + +/// The backend for a start, from [`choose`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + pub backend: Backend, + /// The record to write before the window is created. + pub record: Record, + /// The backend the previous start never finished with, when this start moves away from it. + pub switched_from: Option, +} + +/// The backends a restart's skip list leaves out ([`super::skip_arg`]): a restart found no adapter +/// of them that could show the window. +pub fn skipped_backends(skip: &[String]) -> Vec { + skip.iter().filter_map(|k| Backend::from_key(k)).collect() +} + +/// Pick the backend for this start from the candidates (best first), the previous start's record +/// and the backends a restart left out (`skipped`, not recorded); `None` without candidates. +pub fn choose(candidates: &[Backend], previous: &Record, version: &str, skipped: &[Backend]) -> Option { + let first = *candidates.first()?; + let same_version = previous.version == version; + let mut failed: Vec = if same_version { previous.failed.clone() } else { Vec::new() }; + let unfinished = previous.trying.filter(|_| same_version); + if let Some(b) = unfinished + && !failed.contains(&b) + { + failed.push(b); + } + failed.retain(|b| candidates.contains(b)); + let pick = |failed: &[Backend]| candidates.iter().copied().find(|b| !failed.contains(b) && !skipped.contains(b)); + let backend = match pick(&failed) { + Some(b) => b, + // Every candidate has failed: start over rather than never starting. + None => { + failed.clear(); + pick(&failed).unwrap_or(first) + } + }; + let switched_from = unfinished.filter(|b| *b != backend); + Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from }) +} + +/// The record in `path`; a missing or unreadable file is an empty record. +pub fn read(path: &Path) -> Record { + std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default() +} + +/// Write `record` to `path` (creating the directory). +pub fn write(path: &Path, record: &Record) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?; + std::fs::write(path, bytes) +} + +/// See [`Fallback::backends`]. +fn instance_backends(backend: Backend, failed: &[Backend]) -> wgpu::Backends { + let mut backends = backend.bit(); + if !cfg!(windows) && !failed.contains(&Backend::Gl) { + backends |= wgpu::Backends::GL; + } + backends +} + +/// The first of `candidates` whose bit isn't in `tried` and that hasn't `failed`. +fn next_candidate(candidates: &[Backend], tried: wgpu::Backends, failed: &[Backend]) -> Option { + candidates.iter().copied().find(|b| !tried.contains(b.bit()) && !failed.contains(b)) +} + +/// A start in progress: the backend chosen for it and the record that says so until a frame has +/// been presented. +#[derive(Debug)] +pub struct Fallback { + pub backend: Backend, + pub switched_from: Option, + /// The backends the instance is created with ([`Fallback::backends`]). + instance: wgpu::Backends, + /// `gpu.json`, when the record is persisted. + path: Option, + record: Record, +} + +impl Fallback { + /// Choose the backend for this start and record it in `path` (nothing is recorded without + /// one), leaving out the backends a restart `skipped`. `None` when `WGPU_BACKEND` is set — + /// wgpu follows it and the fallback is off — or the platform has no candidates. + pub fn begin(path: Option, version: &str, skipped: &[Backend]) -> Option { + if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) { + log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off"); + return None; + } + Self::begin_unforced(path, version, skipped) + } + + fn begin_unforced(path: Option, version: &str, skipped: &[Backend]) -> Option { + let previous = path.as_deref().map(read).unwrap_or_default(); + let choice = choose(candidates(), &previous, version, skipped)?; + if let Some(p) = &path + && let Err(e) = write(p, &choice.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + match choice.switched_from { + Some(from) => log::warn!( + "graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})", + from.name(), + choice.backend.name(), + choice.record.failed + ), + None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()), + } + let instance = instance_backends(choice.backend, &choice.record.failed); + Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record }) + } + + /// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it + /// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers + /// (AMD's) crash while the instance is created (#167), before any fallback could help, so + /// there OpenGL is only ever tried as its own candidate. + pub fn backends(&self) -> wgpu::Backends { + self.instance + } + + /// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend + /// that is actually drawing. + pub fn started_with(&mut self, backend: Backend) { + if self.record.trying == Some(backend) { + return; + } + self.backend = backend; + self.record.trying = Some(backend); + self.save(); + } + + /// The backend to start again with when no adapter of this start's backends could show the + /// window: the next candidate not among them, not failed and not left out by a restart + /// (`skipped`). `None` when there is none, so the restarts end. + pub fn next_after(&self, skipped: &[Backend]) -> Option { + let tried = skipped.iter().fold(self.backends(), |set, b| set | b.bit()); + next_candidate(candidates(), tried, &self.record.failed) + } + + /// The start got past the point where a driver fault could take the process down: a frame was + /// presented, the app exited normally, or the graphics failed with an error the app caught. + /// Returns the backend that was being tried, for [`Fallback::retry`]. + pub fn finished(&mut self) -> Option { + let trying = self.record.trying.take(); + if trying.is_some() { + self.save(); + } + trying + } + + /// Undo [`Fallback::finished`]: the start isn't over after all (starting again failed). + pub fn retry(&mut self, trying: Option) { + if self.record.trying != trying { + self.record.trying = trying; + self.save(); + } + } + + /// The status-bar line for a start that moved away from a backend that crashed. + pub fn status_line(&self) -> Option { + let from = self.switched_from?; + Some(format!( + "Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).", + from.name(), + self.backend.name(), + from.name() + )) + } + + fn save(&self) { + if let Some(p) = &self.path + && let Err(e) = write(p, &self.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const V: &str = "0.5.0"; + const WIN: &[Backend] = &[Backend::Dx12, Backend::Gl, Backend::Vulkan]; + + fn temp_path(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d.join(FILE) + } + + fn record(trying: Option, failed: &[Backend]) -> Record { + Record { version: V.into(), trying, failed: failed.to_vec() } + } + + #[test] + fn every_platform_has_candidates_without_duplicates() { + let c = candidates(); + assert!(!c.is_empty()); + for (i, b) in c.iter().enumerate() { + assert!(!c[..i].contains(b), "{b:?} twice in {c:?}"); + } + if cfg!(windows) { + assert_eq!(c, WIN, "DirectX 12 first (AMD's Vulkan driver faulted at the first present), Vulkan last (#806)"); + } + if cfg!(target_os = "macos") { + assert_eq!(c, &[Backend::Metal]); + } + } + + #[test] + fn a_fresh_start_takes_the_first_candidate() { + let c = choose(WIN, &Record::default(), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + assert!(choose(&[], &Record::default(), V, &[]).is_none()); + } + + #[test] + fn a_start_that_never_presented_moves_to_the_next_candidate() { + let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record, record(Some(Backend::Gl), &[Backend::Dx12])); + assert_eq!(c.switched_from, Some(Backend::Dx12)); + + let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Vulkan); + assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Gl]); + assert_eq!(c.switched_from, Some(Backend::Gl)); + } + + #[test] + fn a_finished_start_keeps_the_failed_list_and_switches_silently() { + // `trying` was cleared by a presented frame; the next start still avoids what failed. + let c = choose(WIN, &record(None, &[Backend::Dx12]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Dx12]); + assert_eq!(c.switched_from, None); + // The same backend failing again is listed once. + let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V, &[]).unwrap(); + assert_eq!(c.record.failed, &[Backend::Dx12]); + } + + #[test] + fn when_every_candidate_failed_the_list_starts_over() { + let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12, Backend::Gl]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record.failed, &[]); + assert_eq!(c.switched_from, Some(Backend::Vulkan)); + } + + #[test] + fn another_version_or_platform_forgets_what_failed() { + let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] }; + let c = choose(WIN, &old, V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + // A backend that is no candidate here (a record copied from another machine) is dropped. + let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Vulkan]); + } + + /// A restart that found no adapter of DirectX 12 names the backend in its skip list: that start + /// takes the next candidate, with or without a record, and the record doesn't keep it as failed + /// (a later launch tries it again). + #[test] + fn backends_a_restart_left_out_are_not_chosen_or_recorded() { + let skip: Vec = ["Dx12:10de:2204", "Dx12", "Metal:Intel Iris Pro Graphics", "dx12", ""].map(String::from).to_vec(); + assert_eq!(skipped_backends(&skip), [Backend::Dx12]); + // A backend's key is wgpu's name for it, the prefix of its adapters' keys. + for w in [wgpu::Backend::Dx12, wgpu::Backend::Vulkan, wgpu::Backend::Metal, wgpu::Backend::Gl] { + let b = Backend::of(w).expect("native backend"); + assert_eq!(b.key(), format!("{w:?}")); + assert_eq!(Backend::from_key(b.key()), Some(b)); + } + let c = choose(WIN, &Record::default(), V, &[Backend::Dx12]).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record, record(Some(Backend::Gl), &[])); + let c = choose(WIN, &record(None, &[Backend::Gl]), V, &[Backend::Dx12]).unwrap(); + assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Gl])); + // Every candidate failed or left out: the record starts over, still without the left-out one. + let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Gl]), V, &[Backend::Dx12]).unwrap(); + assert_eq!(c.record, record(Some(Backend::Gl), &[])); + // A backend that is no candidate here is ignored. + let c = choose(WIN, &Record::default(), V, &[Backend::Metal]).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + } + + /// When no adapter of a start's backends could show the window, the restart moves on to a + /// backend not yet tried in this start (an in-process OpenGL fallback counts as tried), and + /// the restarts end once none is left. + #[test] + fn the_next_backend_after_one_without_a_working_adapter() { + let none = wgpu::Backends::empty(); + assert_eq!(next_candidate(WIN, wgpu::Backends::DX12, &[]), Some(Backend::Gl)); + assert_eq!(next_candidate(WIN, wgpu::Backends::DX12 | wgpu::Backends::GL, &[]), Some(Backend::Vulkan)); + assert_eq!(next_candidate(WIN, wgpu::Backends::DX12, &[Backend::Gl]), Some(Backend::Vulkan)); + assert_eq!(next_candidate(WIN, wgpu::Backends::all(), &[]), None); + assert_eq!(next_candidate(WIN, none, &[Backend::Dx12, Backend::Gl, Backend::Vulkan]), None); + // Linux: Vulkan with OpenGL beside it has tried both. + assert_eq!(next_candidate(&[Backend::Vulkan, Backend::Gl], wgpu::Backends::VULKAN | wgpu::Backends::GL, &[]), None); + assert_eq!(next_candidate(&[Backend::Metal], wgpu::Backends::METAL | wgpu::Backends::GL, &[]), None); + + let s = Fallback::begin_unforced(None, V, &[]).expect("candidates"); + let next = s.next_after(&[]); + if cfg!(windows) { + assert_eq!(next, Some(Backend::Gl)); + assert_eq!(s.next_after(&[Backend::Gl]), Some(Backend::Vulkan)); + assert_eq!(s.next_after(&[Backend::Gl, Backend::Vulkan]), None); + } else { + assert_eq!(next, None, "the first start already has OpenGL beside its backend"); + } + } + + #[test] + fn the_record_round_trips_and_garbage_reads_as_empty() { + let path = temp_path("roundtrip"); + assert_eq!(read(&path), Record::default()); + let r = record(Some(Backend::Vulkan), &[Backend::Dx12]); + write(&path, &r).expect("write"); + assert_eq!(read(&path), r); + let text = std::fs::read_to_string(&path).expect("read"); + assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}"); + std::fs::write(&path, b"{not json").expect("garbage"); + assert_eq!(read(&path), Record::default()); + // Unknown fields and missing ones are tolerated. + std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial"); + assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] }); + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn a_start_records_its_backend_until_a_frame_is_presented() { + let path = temp_path("startup"); + let mut s = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates"); + let first = candidates()[0]; + assert_eq!(s.backend, first); + assert_eq!(read(&path).trying, Some(first)); + assert!(s.backends().contains(first.bit())); + // OpenGL is the in-process fallback, except on Windows where it is only its own candidate + // (#167), and Windows never loads Vulkan drivers on its first try (#806). + assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl); + if cfg!(windows) { + assert_eq!(s.backends(), wgpu::Backends::DX12); + } + assert_eq!(s.status_line(), None); + + // wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one. + s.started_with(Backend::Gl); + assert_eq!(read(&path).trying, Some(Backend::Gl)); + // A restart takes the entry and puts it back when starting again failed. + let trying = s.finished(); + assert_eq!((trying, read(&path)), (Some(Backend::Gl), record(None, &[]))); + s.retry(trying); + assert_eq!(read(&path).trying, Some(Backend::Gl)); + s.finished(); + assert_eq!(read(&path), record(None, &[])); + assert_eq!(s.finished(), None); + assert_eq!(read(&path), record(None, &[])); + + // The next start after one that never presented: switches, says so, and leaves OpenGL + // out of the set once it failed. + write(&path, &record(Some(Backend::Gl), &[])).expect("write"); + let s = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates"); + // This synthetic prior start records OpenGL even on Metal-only macOS. + // Switching depends on the chosen backend, not the candidate count. + if s.backend != Backend::Gl { + assert_eq!(s.switched_from, Some(Backend::Gl)); + let line = s.status_line().expect("status"); + assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); + assert!(!s.backends().contains(wgpu::Backends::GL)); + } else { + assert_eq!(s.switched_from, None); + } + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { + let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates"); + s.started_with(Backend::Gl); + s.finished(); + // A file where the directory should be: every write fails, nothing panics. + let blocked = temp_path("blocked"); + let dir = blocked.parent().expect("dir"); + std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp"); + std::fs::write(dir, "a file where the directory should be").expect("block"); + let mut s = Fallback::begin_unforced(Some(blocked.clone()), V, &[]).expect("candidates"); + s.started_with(Backend::Gl); + s.finished(); + let _ = std::fs::remove_file(dir); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index d4830bab..c379d5aa 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -6,8 +6,10 @@ //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `designcraft_ui_egui::control` for the methods. //! -//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window -//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it. +//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) and the adapter that draws the +//! window are chosen in `gpu`, with a fallback for a driver or adapter that fails at start-up; +//! `WGPU_BACKEND`, `WGPU_POWER_PREF` and `WGPU_ADAPTER_NAME` override them. Why the app stopped +//! is logged (`logging`), also when the window never opened. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -25,21 +27,22 @@ struct App { app: DesignApp, #[cfg(target_os = "macos")] menu: Option, - /// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`. - startup: Option, + /// The window's start: the adapter and backend chosen for it; see `gpu`. + startup: std::sync::Arc, /// Frames begun so far. frames: u32, } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.startup.enter(); + if let Some(note) = self.startup.take_notice() { + self.app.status(note); + } // The third call: two frames went through `ui`, so the first was presented and the driver // survived it (a fault at the first present is what the fallback in `gpu` is for). - if self.frames >= 2 - && let Some(s) = self.startup.as_mut() - { - s.presented(); - self.startup = None; + if self.frames == 2 { + self.startup.presented(); } self.frames = self.frames.saturating_add(1); #[cfg(target_os = "macos")] @@ -52,12 +55,15 @@ impl eframe::App for App { } } self.app.logic(ctx); + self.startup.leave(); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.startup.enter(); self.app.ui(ui); + self.startup.leave(); #[cfg(target_os = "macos")] if self.app.take_ime_discard() { discard_marked_text(); @@ -65,9 +71,7 @@ impl eframe::App for App { } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. - if let Some(s) = self.startup.as_mut() { - s.presented(); - } + self.startup.presented(); if let Err(e) = preserve_documents_on_exit(&mut self.app) { log::error!("Couldn't preserve documents on exit: {e}"); eprintln!("designcraft: couldn't preserve documents on exit: {e}"); @@ -254,13 +258,14 @@ fn control_port_from(source: &str, value: Option) -> Option { port } -fn main() -> eframe::Result { +fn main() -> std::process::ExitCode { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok()); let mut files = Vec::new(); let mut sample = false; + let mut skip = Vec::new(); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { @@ -268,9 +273,12 @@ fn main() -> eframe::Result { "--sample" => sample = true, "--version" => { println!("designcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); + return std::process::ExitCode::SUCCESS; } - _ => files.push(a), + _ => match gpu::skip_arg(&a) { + Some(list) => skip = list, + None => files.push(a), + }, } } // The log file lives under the settings directory; opened after the arguments, so `--version` @@ -298,21 +306,37 @@ fn main() -> eframe::Result { options.viewport = options.viewport.with_icon(icon); } // The graphics backend, decided before the window exists: a driver that faults takes the - // process down before any Rust code can catch it (see `gpu`). - let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); - if let Some(s) = &gpu - && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup - { - create.instance_descriptor.backends = s.backends(); + // process down before any Rust code can catch it (see `gpu::backend`). + let fallback = gpu::backend::Fallback::begin( + if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::backend::FILE)) }, + env!("CARGO_PKG_VERSION"), + &gpu::backend::skipped_backends(&skip), + ); + let instance_backends = fallback.as_ref().map(gpu::backend::Fallback::backends); + let startup = std::sync::Arc::new(gpu::Startup::new(fallback, skip.clone())); + // The adapter: the GPU that drives the (primary) display unless `WGPU_POWER_PREF` chooses a + // kind; a GPU without a monitor reset its driver and took every monitor down. Logged first: + // the first question in every black-window report. + let power_env = eframe::wgpu::PowerPreference::from_env(); + let power = gpu::power_preference(power_env); + let displays = gpu::preferred_displays(power_env); + let chosen_gpu = !gpu::automatic(power_env); + if chosen_gpu { + log::info!("graphics processor chosen by WGPU_POWER_PREF ({power:?}): which GPU drives the display isn't considered"); + } else { + let listed: Vec = displays.iter().map(ToString::to_string).collect(); + log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") }); } - // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a - // compute shader when the device is created, one that Metal can't compile on an Intel Iris - // Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns - // the check back on. if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - create.instance_descriptor.flags = - create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + if let Some(backends) = instance_backends { + create.instance_descriptor.backends = backends; + } + create.instance_descriptor.flags = gpu::instance_flags(); + // Only adapters that can show the window, in the order `gpu` gives. + create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone())); } + gpu::watch_panics(); + gpu::watch_first_frame(startup.clone()); // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. @@ -332,54 +356,86 @@ fn main() -> eframe::Result { builder.with_close_requested_on_terminate(true); })); } - eframe::run_native( - "DesignCraft", - options, - Box::new(move |cc| { - let mut gpu = gpu; - if let Some(rs) = &cc.wgpu_render_state { - // What a bug report needs to know about the graphics driver. - let info = rs.adapter.get_info(); - log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info); - if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) { - s.started_with(backend); + let created = startup.clone(); + // A panic that ends the window (egui-wgpu's own) is caught here: never a crash. + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + eframe::run_native( + "DesignCraft", + options, + Box::new(move |cc| { + let mut restarted = None; + if let Some(rs) = &cc.wgpu_render_state { + // What a bug report needs to know about the graphics driver. + let info = rs.adapter.get_info(); + log::info!( + "graphics: {} on {:?} ({:?}; driver {} {}; power preference {power:?})", + info.name, + info.backend, + info.device_type, + info.driver, + info.driver_info + ); + created.started_with(info.backend); + created.created(&cc.egui_ctx); + if !skip.is_empty() { + // A processor chosen by `WGPU_POWER_PREF` can be the one that can't show the window. + let hint = if chosen_gpu { " (without WGPU_POWER_PREF it uses the one that drives your display)" } else { "" }; + restarted = Some(format!( + "The graphics processor tried first couldn't show the window, so DesignCraft started again on {} ({:?}, {:?}){hint}.", + info.name.trim(), + info.backend, + info.device_type + )); + } } - } - let mut session = Session::new(); - // Crash recovery: reopen what a previous run left unsaved, then keep it current. - session.recovery_dir = profile::recovery_dir(); - let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); - let mut app = DesignApp::new(session, services()); - if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { - app.status(line); - } - // Recovered documents matter more than the graphics note; both are in the log. - if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) { - app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" })); - } - load_prefs(&mut app); - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - if sample { - let _ = app.run("file.newSample", serde_json::json!({})); - } - for f in files { - if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) { - eprintln!("designcraft: {f}: {e}"); + let mut session = Session::new(); + // Crash recovery: reopen what a previous run left unsaved, then keep it current. + session.recovery_dir = profile::recovery_dir(); + let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); + let mut app = DesignApp::new(session, services()); + if let Some(line) = restarted.or_else(|| created.status_line()) { + app.status(line); } - } - Ok(Box::new(App { - app, - #[cfg(target_os = "macos")] - menu: None, - startup: gpu, - frames: 0, - })) - }), - ) + // Recovered documents matter more than the graphics note; both are in the log. + if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) { + app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" })); + } + load_prefs(&mut app); + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + if sample { + let _ = app.run("file.newSample", serde_json::json!({})); + } + for f in files { + if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) { + log::warn!("{f}: {e}"); + } + } + // The first frame is due from now (`gpu::watch_first_frame`). + created.ready(); + Ok(Box::new(App { + app, + #[cfg(target_os = "macos")] + menu: None, + startup: created, + frames: 0, + })) + }), + ) + })) + .map_err(|payload| designcraft_engine::guard::panic_message(payload.as_ref())); + // The window's end, or a graphics failure that starts the app again. A failure is logged: + // standard error and the log file, since a Windows start has no console to show it. + match gpu::finish(run, &startup) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(e) => { + log::error!("DesignCraft stopped: {e}"); + std::process::ExitCode::FAILURE + } + } } #[cfg(test)] diff --git a/docs/architecture.md b/docs/architecture.md index 5073a4b8..8a464e33 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,9 @@ lines with positioned glyph runs, decorations, anchors and overset. The canvas r background worker with vello_cpu into tiles; after an edit only frames whose composition changed are re-rendered and patched (damage regions, pixel-identical to a full render). The GPU (wgpu: Metal, DirectX 12, Vulkan, OpenGL; WebGPU/WebGL2 on the web) only presents; the backend is chosen -before the window exists, with a start-up fallback list in `gpu.json`. +before the window exists, with a start-up fallback list in `gpu.json`, and the adapter (the GPU +that drives the display first) by the app, which starts again on the next one when an adapter +fails or hangs at start-up (`apps/designcraft/src/gpu.rs`). ## File I/O diff --git a/docs/gaps.md b/docs/gaps.md index 4b822635..81503ab9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,7 +79,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · AMD OpenGL driver crash on Windows (#167), launch failures (#273, #220, #192, #166), blank or frozen canvas after graphics loss (#90). - **Evidence:** a backend fallback landed 2026-10-10 (#297: DirectX 12 on Windows, `gpu.json` - skip list); not yet confirmed by the reporters. + skip list); then VectorCraft's adapter choice (the GPU that drives the display), restart on the + next adapter or backend when one fails or hangs at start-up, the indirect-call check off (the + first Metal error of #334) and a logged reason when the app stops (#273). Not yet confirmed by + the reporters. Still open for #334: wgpu asks an old macOS's Metal compiler for a language + version it doesn't have (`-std=metal3.2`). - **Estimate:** 15–30 h. - **Doc:** [hardware-parity.md](hardware-parity.md) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index fb92c587..d48e45ee 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -15,6 +15,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md] | GPU display (canvas) | GPU Performance: GPU preview, animated zoom (Metal on macOS; Windows with supported GPUs) | canvas rasterized on the CPU (vello_cpu, SIMD, multithreaded), presented by wgpu (Metal) | wgpu DirectX 12 (since #297), Vulkan/OpenGL fallback | Vulkan, OpenGL fallback | WebGPU, WebGL2 fallback | | Start-up on any GPU | robust | Metal device lost on Intel Macs (#334) | AMD OpenGL crash (#167), launch failures (#273) | some reports (#192, #220) | | | Backend fallback | n/a | `gpu.json` skips a backend whose start never showed a frame (#297) | same | same | | +| Adapter choice and restart | n/a | power saving; a GPU that fails or hangs at start-up is left out and the app starts again on the other one; indirect-call check off (#334) | the GPU that drives the primary display (`EnumDisplayDevices`); DirectX 12 adapters, then OpenGL, then Vulkan | the GPU that drives the panel or a monitor (sysfs), then the system's order | | | Animated / scrubby zoom | ✓ (GPU) | ✓ (CPU tiles, damage regions) | ✓ | ✓ | ✓ | | HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ | | UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3b58c0ce..d0b65eae 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -294,6 +294,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. A folder given to `file.recovery.save` keeps other runs' entries: a run replaces or removes only the entries it wrote or recovered there, and refuses a folder whose run lock another app holds. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. +- Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.