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 <noreply@anthropic.com>
This commit is contained in:
Hal Goff
2026-10-09 12:19:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bd27555519
commit 36af4afb19
3 changed files with 181 additions and 72 deletions
+3 -1
View File
@@ -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).
+173 -69
View File
@@ -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<String>,
options: Vec<(String, String)>,
control_file: Option<String>,
create_images: bool,
integrated: bool,
}
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
#[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();
+5 -2
View File
@@ -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)