From 44d5a0e7b90a449bd2f51a5f3d8add95010c8500 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:20:56 -0700 Subject: [PATCH] Tests: UI tests that render with wgpu take turns, so the Windows test run no longer crashes Harness::render creates a wgpu device on its first call, and egui_kittest prefers a CPU adapter. When cargo test runs several of these tests on parallel threads, the deckcraft-ui-egui test binary can exit with 0xc0000005 (STATUS_ACCESS_VIOLATION), so cargo xtask ci stops at its test step on Windows without cargo-nextest. The 16 tests that call render now hold one mutex for their whole run (crate::gpu_lock::gpu_lock, as in photocraft's testkit), so only one of them runs at a time. --- crates/ui-egui/src/animpane_tests.rs | 1 + crates/ui-egui/src/compare_tests.rs | 1 + crates/ui-egui/src/gpu_lock.rs | 56 +++++++++++++++++++++++ crates/ui-egui/src/guides_tests.rs | 1 + crates/ui-egui/src/inktoshape_tests.rs | 1 + crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/replacefonts_tests.rs | 1 + crates/ui-egui/src/ruler_tests.rs | 3 ++ crates/ui-egui/src/smartart_tests.rs | 1 + crates/ui-egui/src/spelling_tests.rs | 4 ++ crates/ui-egui/src/texture_limit_tests.rs | 1 + crates/ui-egui/src/thesaurus_tests.rs | 1 + 12 files changed, 74 insertions(+) create mode 100644 crates/ui-egui/src/gpu_lock.rs diff --git a/crates/ui-egui/src/animpane_tests.rs b/crates/ui-egui/src/animpane_tests.rs index c57e1eda..4268ec07 100644 --- a/crates/ui-egui/src/animpane_tests.rs +++ b/crates/ui-egui/src/animpane_tests.rs @@ -192,6 +192,7 @@ fn preview_rejects_a_from_that_is_not_an_effect_index() { #[test] #[ignore = "writes a screenshot for review"] fn screenshot() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut h = harness(); h.run_steps(2); let img = h.render().unwrap(); diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs index 9108b4de..12169200 100644 --- a/crates/ui-egui/src/compare_tests.rs +++ b/crates/ui-egui/src/compare_tests.rs @@ -53,6 +53,7 @@ fn pane_opens_lists_changes_and_accept_all_applies_them() { #[test] #[ignore = "writes a screenshot for review"] fn screenshot() { + let _gpu = crate::gpu_lock::gpu_lock(); let (mut h, _) = harness(); // Review tab. h.state_mut().ui.tab = "Review".into(); diff --git a/crates/ui-egui/src/gpu_lock.rs b/crates/ui-egui/src/gpu_lock.rs new file mode 100644 index 00000000..49e98a77 --- /dev/null +++ b/crates/ui-egui/src/gpu_lock.rs @@ -0,0 +1,56 @@ +//! Tests that render a harness run one at a time in each test process. +//! +//! `Harness::render` creates a wgpu device on its first call, and egui_kittest prefers a CPU +//! adapter. When `cargo test` runs several of these tests on parallel threads, the test binary can +//! exit with `0xc0000005` (`STATUS_ACCESS_VIOLATION`). Under cargo-nextest each test is its own +//! process and the lock has no effect. +//! +//! Every test that calls `render()` on a harness takes [`gpu_lock`] on its first line +//! (`let _gpu = crate::gpu_lock::gpu_lock();`) and holds the guard for its whole run. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +static GPU_LOCK: Mutex<()> = Mutex::new(()); + +/// Returns a guard that the test holds for its whole run, so tests that render run one at a time. +/// A test that failed while holding it does not block the tests after it. +pub(crate) fn gpu_lock() -> MutexGuard<'static, ()> { + GPU_LOCK.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panicked_holder_does_not_block_the_next_test() { + let _ = std::thread::spawn(|| { + let _gpu = gpu_lock(); + panic!("a GPU test failed"); + }) + .join(); + drop(gpu_lock()); + } + + #[test] + fn holders_take_turns() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static INSIDE: AtomicUsize = AtomicUsize::new(0); + static MAX: AtomicUsize = AtomicUsize::new(0); + let threads: Vec<_> = (0..8) + .map(|_| { + std::thread::spawn(|| { + let _gpu = gpu_lock(); + let now = INSIDE.fetch_add(1, Ordering::SeqCst) + 1; + MAX.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(2)); + INSIDE.fetch_sub(1, Ordering::SeqCst); + }) + }) + .collect(); + for t in threads { + assert!(t.join().is_ok()); + } + assert_eq!(MAX.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs index 4180e503..baa480e5 100644 --- a/crates/ui-egui/src/guides_tests.rs +++ b/crates/ui-egui/src/guides_tests.rs @@ -10,6 +10,7 @@ use crate::{Services, SlideApp}; #[test] fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let app = SlideApp::new(s, Services::default()); diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/inktoshape_tests.rs index 7a7ebafc..ce7875ad 100644 --- a/crates/ui-egui/src/inktoshape_tests.rs +++ b/crates/ui-egui/src/inktoshape_tests.rs @@ -11,6 +11,7 @@ use crate::{Services, SlideApp}; #[test] fn pen_a_rectangle_then_ink_to_shape() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let mut app = SlideApp::new(s, Services::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1410c7f8..6e8c2f32 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2086,6 +2086,8 @@ mod eyedropper_tests; #[cfg(test)] mod find_dialog_tests; #[cfg(test)] +mod gpu_lock; +#[cfg(test)] mod huge_deck_tests; #[cfg(test)] mod ime_tests; @@ -2765,6 +2767,7 @@ mod tests { /// they can be selected and moved. #[test] fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut harness = canvas_harness(); let off = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"] diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index 205c65aa..efec9b76 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -63,6 +63,7 @@ fn ribbon_button_opens_the_dialog_and_replaces() { #[test] #[ignore = "writes a screenshot for review"] fn screenshot() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut h = harness(); h.render().unwrap().save("/tmp/replacefonts0.png").unwrap(); h.state_mut().dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs index 7c313563..dd15c2a2 100644 --- a/crates/ui-egui/src/ruler_tests.rs +++ b/crates/ui-egui/src/ruler_tests.rs @@ -34,6 +34,7 @@ fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { #[test] fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); @@ -105,6 +106,7 @@ fn editing_harness() -> Harness<'static, SlideApp> { #[test] fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + let _gpu = crate::gpu_lock::gpu_lock(); use egui_kittest::kittest::Queryable; let mut h = editing_harness(); let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); @@ -127,6 +129,7 @@ fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { #[test] fn tabs_dialog_sets_clears_and_applies_on_ok() { + let _gpu = crate::gpu_lock::gpu_lock(); use egui_kittest::kittest::Queryable; let mut h = editing_harness(); h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs index 0e668439..01510197 100644 --- a/crates/ui-egui/src/smartart_tests.rs +++ b/crates/ui-egui/src/smartart_tests.rs @@ -78,6 +78,7 @@ fn home_convert_to_smartart_gallery_converts_the_text() { #[test] fn layout_gallery_shows_cached_pictures_and_previews_on_hover() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); s.execute("insert.smartArt", &json!({"kind": "process", "items": ["Plan", "Build", "Ship"]})).unwrap(); diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs index 5d6cf700..e67088fb 100644 --- a/crates/ui-egui/src/spelling_tests.rs +++ b/crates/ui-egui/src/spelling_tests.rs @@ -10,6 +10,7 @@ use crate::{Services, SlideApp}; #[test] fn spelling_dialog_changes_and_ignores_words() { + let _gpu = crate::gpu_lock::gpu_lock(); let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; let dic = "6\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\n"; deckcraft_engine::spell::install("en-US", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); @@ -49,6 +50,7 @@ fn spelling_dialog_changes_and_ignores_words() { /// typed; the context menu's correction and Add to Dictionary clear them. #[test] fn squiggles_mark_misspelt_words_in_the_background() { + let _gpu = crate::gpu_lock::gpu_lock(); let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; let dic = "7\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\nreview\n"; deckcraft_engine::spell::install("xx-SQ", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); @@ -115,6 +117,7 @@ fn squiggles_mark_misspelt_words_in_the_background() { /// gets there a few milliseconds per frame. #[test] fn squiggles_in_table_cells_and_notes_and_on_the_ui_thread() { + let _gpu = crate::gpu_lock::gpu_lock(); let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; let dic = "10\nthe/S\nplan/S\nsales\nregion/S\ntotal/S\nnorth\nsouth\nremember\nto\nshow\n"; deckcraft_engine::spell::install("xx-TB", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); @@ -192,6 +195,7 @@ fn right_click(h: &mut Harness<'_, SlideApp>, pos: egui::Pos2) { /// dialog marks text and Do Not Check Spelling clears its squiggles. #[test] fn notes_menu_language_dialog_and_repeated_words() { + let _gpu = crate::gpu_lock::gpu_lock(); use egui::accesskit::Role; let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\n"; let dic = "6\nthe\nplan\nremember\nis\ngood\nwork\n"; diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs index 69dbf523..d73273e6 100644 --- a/crates/ui-egui/src/texture_limit_tests.rs +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -57,6 +57,7 @@ fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { /// notes and past the window. #[test] fn presenter_view_keeps_a_portrait_next_slide_in_its_column() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); h.run_steps(3); h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs index 985e6401..e52e4503 100644 --- a/crates/ui-egui/src/thesaurus_tests.rs +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -9,6 +9,7 @@ use crate::{Services, SlideApp}; #[test] fn thesaurus_pane_swaps_the_word_at_the_caret() { + let _gpu = crate::gpu_lock::gpu_lock(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap();