diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2a633ca6..8d2b7143 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -52,6 +52,7 @@ impl Session { st.revision = revision.max(st.revision).saturating_add(1); } log::error!("command `{id}` panicked: {msg}"); + self.panics_caught = self.panics_caught.saturating_add(1); if let Some(dir) = self.recovery_dir.clone() && let Err(e) = crate::recovery::save(self, &dir) { @@ -85,6 +86,7 @@ mod tests { assert!(err.to_string().contains("test.panic") && err.to_string().contains("boom"), "{err}"); let st = s.active().unwrap(); assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "the document is the one from before the command"); + assert_eq!(s.panics_caught, 1, "the guard counts what it caught"); // The session keeps working. s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap(); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..08aca66b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -351,6 +351,8 @@ pub struct Session { /// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the /// platform DeckCraft was built for. pub mac_keys: bool, + /// Command panics caught by the guard (the document was rolled back); soak tests require 0. + pub panics_caught: u64, } impl Default for Session { @@ -379,6 +381,7 @@ impl Session { media_status: Default::default(), gif_paused: Default::default(), mac_keys: cfg!(target_os = "macos"), + panics_caught: 0, } } /// A session with one new presentation open. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..8f04ae3b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -257,6 +257,17 @@ impl Default for Renderer { } } +static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn note_panic() { + PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. +pub fn panics_caught() -> u64 { + PANICS.load(std::sync::atomic::Ordering::Relaxed) +} + thread_local! { static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); } @@ -330,6 +341,7 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) } Err(_) => { log::error!("rendering slide {index} panicked; showing a blank slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) @@ -354,6 +366,7 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt Ok(img) => img, Err(_) => { log::error!("rendering part of slide {index} panicked; showing nothing"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } @@ -385,6 +398,7 @@ pub fn render_blend( Ok(img) => img, Err(_) => { log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); render_slide(pres, new, opts) } @@ -397,6 +411,7 @@ pub fn render_layout(pres: &Presentation, master: usize, layout: Option, Ok(img) => img, Err(_) => { log::error!("rendering a layout panicked; showing a blank image"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); Image::default() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..bf83512e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1129,6 +1129,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod ui_soak_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..02c56925 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -162,6 +162,11 @@ impl Textures { Some((tex, region)) } + /// Cached thumbnails (bounded; soak tests check it). + pub fn thumb_count(&self) -> usize { + self.thumbs.len() + } + /// Force the canvas to re-render next time (fonts loaded, theme changed in place…). pub fn invalidate(&mut self) { self.canvas = None; diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs new file mode 100644 index 00000000..6e25c696 --- /dev/null +++ b/crates/ui-egui/src/ui_soak_tests.rs @@ -0,0 +1,206 @@ +//! UI soak: the whole window driven by seeded random input (clicks, drags, keys with and without +//! Ctrl/Shift, typing, scrolling) over the sample deck, with invariants every few actions: no +//! panic, every document valid, undo/journal/texture caches bounded, and frame time bounded. +//! +//! `DECKCRAFT_UI_SOAK_STEPS=5000 cargo test -p deckcraft-ui-egui ui_soak_long -- --ignored` +//! runs a long one; `DECKCRAFT_UI_SOAK_SEED` picks the seed. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use deckcraft_engine::Session; +use egui::{Event, Key, Modifiers, MouseWheelUnit, Pos2, vec2}; +use egui_kittest::Harness; + +use crate::{Services, SlideApp}; + +const W: f32 = 1400.0; +const H: f32 = 900.0; +/// Bounds that a long session must stay under. +const MAX_TEXTURES: usize = 1_500; +const MAX_THUMBS: usize = 600; +const MAX_JOURNAL: usize = 10_000; +/// Mean wall time per frame, generous for slow CI machines (the test profile is optimized). +const MAX_MEAN_FRAME: Duration = Duration::from_millis(250); + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn pos(&mut self) -> Pos2 { + Pos2::new(self.below(W as usize) as f32, self.below(H as usize) as f32) + } +} + +const KEYS: &[Key] = &[ + Key::ArrowLeft, + Key::ArrowRight, + Key::ArrowUp, + Key::ArrowDown, + Key::Enter, + Key::Tab, + Key::Backspace, + Key::Delete, + Key::Escape, + Key::Home, + Key::End, + Key::PageUp, + Key::PageDown, + Key::A, + Key::C, + Key::V, + Key::X, + Key::D, + Key::G, + Key::B, + Key::I, + Key::Z, + Key::Y, + Key::M, + Key::F5, +]; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(vec2(W, H)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { + match r.below(9) { + 0..=2 => { + let p = r.pos(); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + format!("click {p:?}") + } + 3 => { + let (a, b) = (r.pos(), r.pos()); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + for i in 1..=4 { + h.hover_at(a + (b - a) * (i as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + format!("drag {a:?} -> {b:?}") + } + 4 | 5 => { + let k = KEYS[r.below(KEYS.len())]; + let m = match r.below(4) { + 0 => Modifiers::COMMAND, + 1 => Modifiers::SHIFT, + _ => Modifiers::NONE, + }; + h.key_press_modifiers(m, k); + format!("key {m:?} {k:?}") + } + 6 => { + h.event(Event::Text("Soak ✓ שלום 漢字 ".into())); + "type".into() + } + 7 => { + let p = r.pos(); + h.hover_at(p); + h.event(Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, if r.below(2) == 0 { 3.0 } else { -3.0 }), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + format!("scroll {p:?}") + } + _ => { + // Undo or redo through the keyboard, the way people hammer it. + let k = if r.below(2) == 0 { Key::Z } else { Key::Y }; + h.key_press_modifiers(Modifiers::COMMAND, k); + format!("ctrl {k:?}") + } + } +} + +fn check(h: &Harness<'_, SlideApp>, seed: u64, step: usize, log: &VecDeque) { + let ctx = || format!("seed {seed} step {step}; last actions:\n{}", log.iter().cloned().collect::>().join("\n")); + let app = h.state(); + for d in app.session.documents() { + let v = d.doc.validate(); + assert!(v.is_empty(), "invalid document {v:?}; {}", ctx()); + assert!(d.history.undo.len() <= d.history.limit.max(1), "undo over limit; {}", ctx()); + assert!(d.selection.slide < d.doc.slides.len().max(1), "selection slide out of range; {}", ctx()); + } + assert!(app.session.journal.len() <= MAX_JOURNAL, "journal {}; {}", app.session.journal.len(), ctx()); + assert!(app.textures.thumb_count() <= MAX_THUMBS, "thumbnails {}; {}", app.textures.thumb_count(), ctx()); + assert_eq!(app.session.panics_caught, 0, "a command panicked (rolled back by the guard); {}", ctx()); + assert_eq!(deckcraft_render::panics_caught(), 0, "a render panic was caught and drawn blank; {}", ctx()); + let tex = h.ctx.tex_manager().read().num_allocated(); + assert!(tex <= MAX_TEXTURES, "{tex} textures allocated; {}", ctx()); +} + +fn soak(seed: u64, steps: usize) { + let mut h = harness(); + let mut r = Rng(seed | 1); + let mut log = VecDeque::new(); + let t0 = Instant::now(); + let mut frames = 0usize; + for step in 0..steps { + log.push_back(act(&mut h, &mut r)); + if log.len() > 20 { + log.pop_front(); + } + h.run_steps(2); + frames += 8; + if step % 10 == 9 { + check(&h, seed, step, &log); + } + // A modal or the slide show can swallow input forever; leave it now and then. + if step % 50 == 49 { + h.key_press(Key::Escape); + h.run_steps(2); + } + } + check(&h, seed, steps, &log); + let edits = h.state().session.journal.len(); + // Random input must reach the editor, not bounce off one modal. + assert!(edits * 20 >= steps, "only {edits} commands ran in {steps} actions; seed {seed}"); + let mean = t0.elapsed() / frames.max(1) as u32; + eprintln!("ui soak seed {seed}: {steps} actions, {edits} commands, ~{frames} frames, mean frame {mean:?}"); + assert!(mean <= MAX_MEAN_FRAME, "mean frame {mean:?} over {MAX_MEAN_FRAME:?}; seed {seed}"); +} + +fn env(name: &str, default: u64) -> u64 { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +#[test] +fn ui_soak_random_input_keeps_the_app_sound() { + for seed in [3, 0xC0FFEE] { + soak(seed, env("DECKCRAFT_UI_SOAK_STEPS", 150) as usize); + } +} + +#[test] +#[ignore = "long UI soak; set DECKCRAFT_UI_SOAK_STEPS"] +fn ui_soak_long() { + soak(env("DECKCRAFT_UI_SOAK_SEED", 0x5EED), env("DECKCRAFT_UI_SOAK_STEPS", 3000) as usize); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..d6dbd3b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -48,11 +48,14 @@ revision history. - **Estimate:** 4–8 h (backend probing outside the process or a safe-mode fallback, a software renderer path). Needs a tester with the hardware. **Beta blocker.** -### No soak or long-session testing -- **Evidence:** unit tests (504), PPTX malformed/fuzz tests and the command panic guard exist; no - random command sequences over big decks, no memory or undo-depth soak. -- **Impact:** unknown crash and slowdown rate in long sessions. -- **Estimate:** 4–8 h. +### Soak testing is new +- **Evidence:** `crates/ui-egui/src/ui_soak_tests.rs` drives the whole window with seeded random + clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the sample deck, checking + document validity, bounded undo/journal/thumbnail/texture caches, zero caught command and + render panics (`Session::panics_caught`, `deckcraft_render::panics_caught`) and a mean frame + under 250 ms; 4 × 3000-action runs are clean. Engine-level command soak: PR #502. +- **Impact:** long-session crash rate is sampled in CI; RSS is not measured directly. +- **Estimate:** 1–2 h (RSS sampling per platform). ## File formats