collective

Browse / github.com/storytold/deckcraft / patch

Soak tests: fail on resident memory growth after warm-up

Soak tests: fail on resident memory growth after warm-up Closes the memory half of the gap "Soak testing has no memory or timing budgets" in docs/gaps.md. The engine soak and the UI soak now read the process's resident memory after a quarter of the run (so caches and allocator arenas are warm) and fail if it has grown by more than 256 MiB by the end. The failure message carries the seed. The reading comes from the `memory-stats` crate (MIT or Apache-2.0), added as a dev-dependency of the engine and ui-egui crates. I used a crate because the repo forbids `unsafe`, which rules out calling the Windows and macOS APIs directly. The crate reads `/proc`

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1425, open)
Base
aaf6c1c38aa6c59beda8110bd014cc6c96c6ec74
Size
+74 −14 in 6 files
Review
pending
Shared
2026-10-28
Id
c05c0aeb4fbe5db38aa2fa74

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/engine/Cargo.toml crates/engine/src/soak_tests.rs crates/ui-egui/Cargo.toml crates/ui-egui/src/ui_soak_tests.rs docs/gaps.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -222,7 +222,7 @@ dependencies = [  "objc2-foundation 0.3.2",  "parking_lot",  "percent-encoding",- "windows-sys 0.52.0",+ "windows-sys 0.61.2",  "wl-clipboard-rs",  "x11rb", ]@@ -1008,6 +1008,7 @@ dependencies = [  "image",  "js-sys",  "log",+ "memory-stats",  "serde",  "serde_json",  "thiserror 2.0.21",@@ -1218,6 +1219,7 @@ dependencies = [  "egui",  "egui_kittest",  "log",+ "memory-stats",  "serde",  "serde_json",  "skrifa 0.47.0",@@ -1593,7 +1595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [  "libc",- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -2700,6 +2702,16 @@ dependencies = [  "autocfg", ] +[[package]]+name = "memory-stats"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"+dependencies = [+ "libc",+ "windows-sys 0.52.0",+]+ [[package]] name = "minicov" version = "0.3.8"@@ -2847,7 +2859,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -3320,7 +3332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [  "libc",- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -3863,7 +3875,7 @@ dependencies = [  "errno",  "libc",  "linux-raw-sys 0.12.1",- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -3920,7 +3932,7 @@ dependencies = [  "security-framework",  "security-framework-sys",  "webpki-root-certs",- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -4541,10 +4553,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [  "fastrand",- "getrandom 0.3.4",+ "getrandom 0.4.3",  "once_cell",  "rustix 1.1.5",- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -5461,7 +5473,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [- "windows-sys 0.52.0",+ "windows-sys 0.61.2", ]  [[package]]diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml--- a/crates/engine/Cargo.toml+++ b/crates/engine/Cargo.toml@@ -29,6 +29,7 @@ chrono = { workspace = true }  [dev-dependencies] deckcraft-anim = { workspace = true }+memory-stats = "1.2.0" zip = { workspace = true }  # The browser clock for comment timestamps (wasm has no std::time::SystemTime).diff --git a/crates/engine/src/soak_tests.rs b/crates/engine/src/soak_tests.rs--- a/crates/engine/src/soak_tests.rs+++ b/crates/engine/src/soak_tests.rs@@ -64,6 +64,16 @@ fn params(r: &mut Rng, s: &Session) -> Value {     } } +/// Physical memory in use, in bytes: the resident set size on Linux and macOS, the working set on+/// Windows. `None` where the platform cannot say.+fn rss_bytes() -> Option<u64> {+    memory_stats::memory_stats().map(|m| m.physical_mem as u64)+}++/// RSS growth allowed between the warmed-up start and the end of a soak. Generous: `cargo test`+/// threads share the process, so other tests' allocations count too.+const MAX_RSS_GROWTH: u64 = 256 << 20;+ fn doc(s: &Session) -> Option<deckcraft_model::Presentation> {     s.active().map(|d| (*d.doc).clone()) }@@ -91,7 +101,12 @@ fn soak(seed: u64, steps: usize) {     let mut r = Rng(seed | 1);     let mut log: VecDeque<String> = VecDeque::new();     let mut edits = 0usize;+    let mut rss_start = None;     for step in 0..steps {+        // Sample after a quarter of the run so one-off caches and allocator arenas are warm.+        if step == steps / 4 {+            rss_start = rss_bytes();+        }         let id = *r.pick(&ids).unwrap();         let p = params(&mut r, &s);         log.push_back(format!("{id} {p}"));@@ -140,7 +155,13 @@ fn soak(seed: u64, steps: usize) {     }     // The soak must really edit, not just bounce off disabled commands.     assert!(edits * 10 >= steps, "only {edits} of {steps} steps changed the document; seed {seed}");-    eprintln!("soak seed {seed}: {edits} of {steps} steps edited");+    if let (Some(start), Some(end)) = (rss_start, rss_bytes()) {+        let grew = end.saturating_sub(start);+        eprintln!("soak seed {seed}: {edits} of {steps} steps edited, memory {} -> {} MiB", start >> 20, end >> 20);+        assert!(grew <= MAX_RSS_GROWTH, "memory grew {} MiB over {} MiB; seed {seed}", grew >> 20, MAX_RSS_GROWTH >> 20);+    } else {+        eprintln!("soak seed {seed}: {edits} of {steps} steps edited");+    } }  fn steps(default: usize) -> usize {diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -32,6 +32,7 @@ skrifa = { workspace = true } # kittest's own dev-dependencies do not propagate; tests need both font features explicitly. egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }+memory-stats = "1.2.0"  [build-dependencies] serde_json = { workspace = true }diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs--- a/crates/ui-egui/src/ui_soak_tests.rs+++ b/crates/ui-egui/src/ui_soak_tests.rs@@ -23,6 +23,16 @@ 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); +/// RSS growth allowed between the warmed-up start and the end of a soak; generous because+/// `cargo test` threads share the process.+const MAX_RSS_GROWTH: u64 = 256 << 20;++/// Physical memory in use, in bytes: the resident set size on Linux and macOS, the working set on+/// Windows. `None` where the platform cannot say.+fn rss_bytes() -> Option<u64> {+    memory_stats::memory_stats().map(|m| m.physical_mem as u64)+}+ struct Rng(u64);  impl Rng {@@ -166,7 +176,12 @@ fn soak(seed: u64, steps: usize) {     let mut log = VecDeque::new();     let t0 = Instant::now();     let mut frames = 0usize;+    let mut rss_start = None;     for step in 0..steps {+        // Sample after a quarter of the run so texture caches and allocator arenas are warm.+        if step == steps / 4 {+            rss_start = rss_bytes();+        }         log.push_back(act(&mut h, &mut r));         if log.len() > 20 {             log.pop_front();@@ -189,6 +204,11 @@ fn soak(seed: u64, steps: usize) {     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}");+    if let (Some(start), Some(end)) = (rss_start, rss_bytes()) {+        let grew = end.saturating_sub(start);+        eprintln!("ui soak seed {seed}: memory {} -> {} MiB", start >> 20, end >> 20);+        assert!(grew <= MAX_RSS_GROWTH, "memory grew {} MiB over {} MiB; seed {seed}", grew >> 20, MAX_RSS_GROWTH >> 20);+    } }  fn env(name: &str, default: u64) -> u64 {diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -49,7 +49,7 @@ 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.** -### Soak testing has no memory or timing budgets+### Soak memory budgets run in CI on Linux only - **Evidence:** `crates/engine/src/soak_tests.rs` runs seeded random command sequences over a   ~100-slide deck (validity, selection, undo/redo exactness, bounded undo, save/reopen in both   formats every 100 steps); `DECKCRAFT_SOAK_STEPS` runs it long. It found a chart `clamp` panic@@ -57,9 +57,13 @@ revision history.   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.-- **Impact:** long-session crash rate is sampled in CI; RSS is not measured directly.-- **Estimate:** 1–2 h (RSS sampling per platform).+  mean frame under 250 ms; 4 × 3000-action runs are clean. Both soaks also sample resident memory+  (the `memory-stats` dev-dependency: `/proc` on Linux, `GetProcessMemoryInfo` on Windows, Mach+  task info on macOS) after a quarter of the run and fail if it then grows by more than 256 MiB.+- **Impact:** long-session crash rate is sampled in CI; the RSS budget is only exercised on Linux+  (`ci.yml`), and the Windows and macOS code paths have never run in CI. The engine soak has no+  wall-time budget.+- **Estimate:** 1 h (a Windows and macOS test job; a timing budget).  ### A control-server timing test may fail under load - **Evidence:** `silent_refused_peers_do_not_delay_the_next_refusal` in@@ -372,6 +376,7 @@ Detail in [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | The engine and UI soaks fail if resident memory grows by more than 256 MiB after warm-up (through `memory-stats`: Linux, Windows and macOS; only Linux runs in CI) | | 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back | | 2026-10-24 | trivial | AutoFormat starts bulleted and numbered lists as you type (`* `, `-- `, `> `, `1. `, `a) ` and the rest), with an AutoCorrect option to turn it off | | 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) |