From d86c4aff520feaec3ae5cce12f99229e941cc0e8 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 1/3] Startup: a launch that stops as its window is shown switches the next to OpenGL On an ASUS G513QY (Radeon RX 6800M, Adrenalin 26.9.2, Windows 11) every launch died with STATUS_ACCESS_VIOLATION inside amdvlk64.dll, on a driver worker thread, a moment after the first frame: eframe shows the window it created hidden, the resulting resize re-creates the swapchain, and the driver crashes there (same offset on two driver versions, with the OBS/Steam/Epic Vulkan layers disabled, and with FIFO, immediate and latency-1 presentation). DirectX 12 and OpenGL open the window fine. A crash in a driver can't be caught, and the launch guard (#243) removed its marker at the first frame, so this looked like a successful launch every time and Window Graphics never switched. The marker now stays until the window has kept drawing for SETTLE (3 s) or the app quits normally before that; frame_drawn returns the time left and the app asks for a repaint then, so an idle window settles too. The notice, the Settings note and docs/preferences.md describe the new rule. Verified on that machine with a scratch settings folder: launch 1 (Vulkan) crashed and left the marker, launch 2 switched to OpenGL with the notice and closed with exit code 0, launch 3 stayed on OpenGL without the notice. Tests: effectcraft launch_guard a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl (a first frame then drop: the next launch gets GL and the notice; a normal quit in that time leaves no marker), and the existing test now asserts that the first frames don't settle a launch (marker present at SETTLE/2, gone at SETTLE). Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft/src/launch_guard.rs | 111 ++++++++++++++++++++++----- apps/effectcraft/src/main.rs | 11 ++- crates/engine/src/prefs.rs | 6 +- docs/preferences.md | 2 +- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index 14c4c440..fac91c05 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -1,39 +1,52 @@ //! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it. //! //! Some graphics drivers crash EffectCraft as its window's device is created (an access -//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver -//! can't be caught, so each launch leaves a marker in the settings folder until its window has -//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to -//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's -//! own override) still wins over both. The marker is locked while its launch runs, so a second -//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts -//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch. +//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after +//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for +//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch). +//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder +//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A +//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those +//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still +//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile +//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to +//! Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use effectcraft_engine::prefs::{PREFS_FILE, Prefs}; use eframe::wgpu::Backends; -/// Left in the settings folder from a launch until its window draws. +/// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +/// How long the window keeps drawing after its first frame before the launch counts as made. +/// A driver that fails as the window is shown and its surface re-created does so within the +/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can +/// switch back in Settings. +pub const SETTLE: Duration = Duration::from_secs(3); + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, - /// Switched to OpenGL because the previous launch never drew: the message to show. + /// Switched to OpenGL because the previous launch stopped as its window opened: the message + /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the window draws (or the process ends). + /// Holds the marker's lock until the launch settles (or the process ends). lock: Option, + /// When the window drew its first frame, until the launch settles. + first_frame: Option, } impl Launch { /// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous - /// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker. - /// `env_override`: `WGPU_BACKEND` is set. + /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this + /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); @@ -45,11 +58,11 @@ impl Launch { if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { - Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"), - Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"), + Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), + Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"), } notice = Some( - "EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \ + "EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \ (Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \ Automatic again." .to_string(), @@ -64,11 +77,33 @@ impl Launch { (None, None) } }; - Launch { backends, notice, marker, lock } + Launch { backends, notice, marker, lock, first_frame: None } } - /// The window drew its first frame: the launch made it. - pub fn drawn(&mut self) { + /// The window drew a frame. Until the launch has settled, returns how long that still takes + /// (ask for a repaint then, so that an idle window settles too). + pub fn frame_drawn(&mut self) -> Option { + self.frame_drawn_at(Instant::now()) + } + + fn frame_drawn_at(&mut self, now: Instant) -> Option { + self.marker.as_ref()?; + let first = *self.first_frame.get_or_insert(now); + let left = SETTLE.saturating_sub(now.saturating_duration_since(first)); + if left.is_zero() { + self.settled(); + return None; + } + Some(left) + } + + /// The app quits normally: however soon after opening, that's no crash. + pub fn exited(&mut self) { + self.settled(); + } + + /// The launch made it: release and remove its marker. + fn settled(&mut self) { self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) @@ -103,12 +138,18 @@ mod tests { fn a_launch_that_never_drew_switches_the_next_to_opengl() { let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - // A first launch that draws: nothing changes. + // A first launch that draws: nothing changes. Its first frames don't settle it (a driver + // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); assert!(dir.join(MARKER).exists()); - l.drawn(); + let t0 = Instant::now(); + assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); + assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2)); + assert!(dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None); assert!(!dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled"); // A launch that crashes before drawing (it never calls `drawn`)… drop(Launch::begin(Some(&dir), false)); // …makes the next one use OpenGL and say so, once. @@ -119,7 +160,7 @@ mod tests { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); - l.drawn(); + l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); } @@ -130,6 +171,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the + /// window is shown and its surface re-created) switches the next one to OpenGL too; a + /// normal quit in that time doesn't. + #[test] + fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + drop(l); + let l = Launch::begin(Some(&dir), false); + if cfg!(target_os = "macos") { + assert_eq!(l.backends, None, "no OpenGL on macOS"); + } else { + assert_eq!(l.backends, Some(Backends::GL)); + assert!(l.notice.is_some()); + } + drop(l); + let _ = std::fs::remove_dir_all(&dir); + // Quitting right after the window opened leaves no marker. + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + l.exited(); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Launch::begin(Some(&dir), false).backends, None); + let _ = std::fs::remove_dir_all(&dir); + } + /// A second copy opened while the first is still opening isn't a crash; OpenGL that can't /// open a window either goes back to Automatic instead of failing every launch. #[test] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..b4e39836 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -78,8 +78,8 @@ fn main() -> eframe::Result { // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); - // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's - // window never drew (a crashing graphics driver, #243). + // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch + // stopped as its window opened (a crashing graphics driver, #243). let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); @@ -290,12 +290,17 @@ impl eframe::App for Desktop { self.app.ui(ui, frame); if !self.drawn { self.drawn = true; - self.launch.drawn(); log::info!("first frame drawn"); } + // The launch guard's marker goes once the window has kept drawing for a few seconds: a + // driver may still crash as the window is shown and its surface re-created. + if let Some(left) = self.launch.frame_drawn() { + ui.ctx().request_repaint_after(left); + } } fn on_exit(&mut self) { + self.launch.exited(); self.app.on_exit(); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc97..852c44c5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -96,8 +96,8 @@ page!(Startup { /// After a crash, offer to open the most recent auto-save. offer_crash_recovery: bool = true, /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose - /// window never drew switches it to `gl` (#243). + /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that + /// stops as its window opens switches it to `gl` (#243). window_graphics: String = "auto".into(), }); @@ -948,7 +948,7 @@ pub fn pages() -> Vec { s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true), Note( - "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.", + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", ), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, diff --git a/docs/preferences.md b/docs/preferences.md index 9e3d4147..426f5d25 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -38,7 +38,7 @@ Commands (CLI, MCP, control channel): - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash -- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. +- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project - `autoSave.enabled`: Automatically Save Projects From a91952dadc54a0a572a16b98a7f1bf7b3513c986 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 2/3] Effects: the .spimtx parser reads its rows with as_chunks clippy 1.99 (current stable, which CI tracks unpinned) fails the workspace gate on chunks_exact_to_as_chunks in parse_spimtx. The length is already checked to be 12, so as_chunks::<4>() has no remainder and the behaviour is unchanged; the workspace's rust-version (1.95) has it. Co-Authored-By: Claude Fable 5.1 --- crates/effects/src/ocio.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9e..c8fcf848 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } From 2a404f6226791c132cb1c0fef13e4f1147f4e271 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:37:00 -0400 Subject: [PATCH 3/3] MCP: the auto-save recovery tests look for the JSON-quoted path automation autosave_is_durable_before_reply_and_queries_do_not_rotate and the CLI's mcp_autosave_survives_kill_and_restart_without_touching_ desktop_settings asserted that the initialize instructions contain the previous session's auto-save path as raw text. The instructions embed the auto-save info as JSON, so on Windows the path's backslashes are escaped and the raw match can never succeed (both failed on every Windows run; the server's reply is right). They now look for the path as serde_json prints it, which also matches on Linux and macOS. Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft-cli/tests/cli.rs | 4 +++- crates/automation/src/tests.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 723016a8..51bdcc4c 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -373,7 +373,9 @@ fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() { assert_eq!(manifest["ended"], false); let mut restarted = McpChild::start(&config, true); let init = restarted.rpc("initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); restarted.tool("open_project", json!({"path":path})); let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"})); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 44a61cc1..b4090ca7 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -269,7 +269,9 @@ fn autosave_is_durable_before_reply_and_queries_do_not_rotate() { drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists. let mut restarted = server().with_autosave(&dir).unwrap(); let init = rpc(&mut restarted, 1, "initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); call_json(&mut restarted, "open_project", json!({"path": path})); assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable");