From 36af4afb198fc1c2b84a5b95e6aa48a186f57be0 Mon Sep 17 00:00:00 2001 From: Hal Goff Date: Fri, 9 Oct 2026 17:19:26 +0100 Subject: [PATCH] Never crash: fall back to OpenGL when wgpu can't start (#461, #435, #392) (#530) When wgpu can't get a GPU device or surface (old Intel Macs, some Windows drivers), run_native returned an error and PdfCraft quit before showing a window. Now, when wgpu fails while starting (eframe::Error::Wgpu, before the app is created), the error is logged and the app starts again on eframe's OpenGL (glow) renderer, which the web build already uses. - PDFCRAFT_RENDERER=gl (opengl, glow) starts on OpenGL; =wgpu never falls back; unset or anything else tries wgpu, then OpenGL. - Window and app set-up move into native_options/app_creator so the second run starts exactly like the first; eframe reuses its event loop. - Page rasters and their tiled backdrop stay within the GPU's max_texture_side (OpenGL can report 2048; an oversized upload aborts). Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp Co-authored-by: Claude Opus 5.5 --- apps/pdfcraft/Cargo.toml | 4 +- apps/pdfcraft/src/main.rs | 242 +++++++++++++++++++++++++---------- crates/ui-egui/src/canvas.rs | 7 +- 3 files changed, 181 insertions(+), 72 deletions(-) diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 78d9fb5c80..2400fe9ea3 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,7 +14,9 @@ workspace = true ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] } +# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or +# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index de88d640c0..3509a7dcf6 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -19,6 +19,9 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::cell::Cell; +use std::rc::Rc; + use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] @@ -150,6 +153,85 @@ fn main() -> eframe::Result { } } let integrated = cfg!(target_os = "macos"); + migrate_legacy_folders(); + // The log file lives in the settings folder; opened after the arguments (so `--version` leaves + // no file behind) and after the PrintCraft migration (which a fresh folder would block). + // Records logged until now are written to it first. + if let (Some(logger), Some(dir)) = (logger, settings_dir()) { + match logger.attach_dir(&dir.join("logs")) { + Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + } + } + let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); + let launch = Launch { files, options, control_file, create_images, integrated }; + // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one + // that launched us as well as later ones. Lives until the event loop returns. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + // Set once the app is created, which is after the renderer has started. + let started = Rc::new(Cell::new(false)); + let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu }; + let result = eframe::run_native( + "PdfCraft", + native_options(integrated, first), + app_creator( + launch.clone(), + Rc::clone(&started), + #[cfg(target_os = "macos")] + &apple_events, + ), + ); + match result { + // Only a renderer that couldn't start: without a window or display at all, OpenGL can't + // help either, and winit's own error says more. + Err(e @ eframe::Error::Wgpu(_)) if retry_with_gl(choice, started.get()) => { + // Old or unusual GPUs and drivers (#461, #435, #392) can't give wgpu a device; OpenGL + // usually still works there, so that's better than quitting. + log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu."); + eframe::run_native( + "PdfCraft", + native_options(integrated, eframe::Renderer::Glow), + app_creator( + launch, + started, + #[cfg(target_os = "macos")] + &apple_events, + ), + ) + } + other => other, + } +} + +/// Which renderer to start with, from `PDFCRAFT_RENDERER`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RendererChoice { + /// wgpu, then OpenGL if wgpu can't start (unset, or anything unrecognised). + Auto, + /// wgpu only (`wgpu`): a failure is reported, not worked around. + Wgpu, + /// OpenGL only (`gl`, `opengl` or `glow`): for drivers where wgpu starts but misbehaves. + Gl, +} + +fn renderer_choice(value: Option<&str>) -> RendererChoice { + match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("wgpu") => RendererChoice::Wgpu, + Some("gl" | "opengl" | "glow") => RendererChoice::Gl, + _ => RendererChoice::Auto, + } +} + +/// Whether a failed run should be retried with OpenGL: only when wgpu was tried first by choice of +/// nobody, and it failed before the app was created (so while starting the renderer, not later). +fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool { + choice == RendererChoice::Auto && !app_started +} + +/// The window and renderer settings for one run. +fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions { let mut viewport = egui::ViewportBuilder::default() .with_title("PdfCraft") .with_inner_size([1440.0, 920.0]) @@ -165,80 +247,82 @@ fn main() -> eframe::Result { if integrated { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } - migrate_legacy_folders(); - // The log file lives in the settings folder; opened after the arguments (so `--version` leaves - // no file behind) and after the PrintCraft migration (which a fresh folder would block). - // Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, settings_dir()) { - match logger.attach_dir(&dir.join("logs")) { - Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), - // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. - Err(e) => log::warn!("no log file: {e}"), - } - } let persistence_path = settings_dir().map(|d| d.join("app.ron")); - let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() }; - configure_gpu(&mut native); - // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one - // that launched us as well as later ones. Lives until the event loop returns. - #[cfg(target_os = "macos")] - let apple_events = apple_events::AppleEvents::install(); - #[cfg(target_os = "macos")] - let apple_events = &apple_events; - eframe::run_native( - "PdfCraft", - native, - Box::new(move |cc| { - let mut app = PdfCraftApp::new(); - if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { - app.restore(&json); + // Set explicitly, so the renderer never depends on which one eframe defaults to. + let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + if renderer == eframe::Renderer::Wgpu { + configure_gpu(&mut native); + } + native +} + +/// What the command line asked for, kept so a second run (with OpenGL) can start the same way. +#[derive(Clone)] +struct Launch { + files: Vec, + options: Vec<(String, String)>, + control_file: Option, + create_images: bool, + integrated: bool, +} + +fn app_creator<'a>( + launch: Launch, + started: Rc>, + #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, +) -> eframe::AppCreator<'a> { + let Launch { files, options, control_file, create_images, integrated } = launch; + Box::new(move |cc| { + started.set(true); + let mut app = PdfCraftApp::new(); + if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { + app.restore(&json); + } + app.integrated_titlebar = integrated; + app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); + #[cfg(target_os = "macos")] + { + app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + } + if let Some(file) = &control_file { + let client = app.attach_control(&cc.egui_ctx); + match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { + // Never the token (AGENTS.md §3): it stays in the owner-only file. + Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"), + Err(e) => log::error!("--control {file}: {e}"), } - app.integrated_titlebar = integrated; - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); - app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); - #[cfg(target_os = "macos")] - { - app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + } + // Autosave unsaved changes; offer to recover documents a crashed session left behind. + if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { + app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); + } + // A portable marker whose data folder can't be written (#157): say where settings went. + if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable { + app.notify_fmt( + "Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.", + &[("folder", &w.folder.display().to_string()), ("error", &w.error)], + ); + } + if create_images { + if let Err(e) = app.begin_image_import_paths(&files) { + app.notify(e); } - if let Some(file) = &control_file { - let client = app.attach_control(&cc.egui_ctx); - match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { - // Never the token (AGENTS.md §3): it stays in the owner-only file. - Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"), - Err(e) => log::error!("--control {file}: {e}"), - } + } else { + // With the preference on, last session's files come back first; files named on + // the command line open after them, in front (#442). + app.reopen_last_files(&files); + for f in files { + app.open_path(&f); } - // Autosave unsaved changes; offer to recover documents a crashed session left behind. - if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { - app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); + } + for (k, v) in options { + if let Err(e) = app.set_option(&k, &v) { + log::warn!("--{k} {v}: {e}"); } - // A portable marker whose data folder can't be written (#157): say where settings went. - if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable { - app.notify_fmt( - "Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.", - &[("folder", &w.folder.display().to_string()), ("error", &w.error)], - ); - } - if create_images { - if let Err(e) = app.begin_image_import_paths(&files) { - app.notify(e); - } - } else { - // With the preference on, last session's files come back first; files named on - // the command line open after them, in front (#442). - app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } - } - for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { - log::warn!("--{k} {v}: {e}"); - } - } - Ok(Box::new(app)) - }), - ) + } + Ok(Box::new(app)) + }) } /// Write the control endpoint so that only the current user can read the token. @@ -434,6 +518,26 @@ mod tests { assert_eq!(super::path_from_arg("file://".to_string()), "file://"); } + #[test] + fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); + } + #[test] fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() { let mut native = eframe::NativeOptions::default(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5bca9d9948..ac94f77504 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1567,10 +1567,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); } else { let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); - let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD; + // A GPU may take smaller textures than these (OpenGL drivers report as little as + // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); // Whole-page raster: sharp when small, a low-res backdrop when tiled. let (want_scale, want_tag) = if tiled { - let bs = BASE_SIDE / pw_pt.max(ph_pt); + let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); (bs, scale_tag(bs)) } else { (scale, tag)