Compare commits

...
2 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 16dbb90499 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:55 -04:00
Brandon ThomasandClaude Opus 5.5 73c61aae44 FreeBSD CI: two flaky ui-egui tests made deterministic
settings_dialog_by_keyboard_and_clicks: the Performance tab's GPU status
hint reads process-wide GPU state, which other tests flip through
app.gpu. Without a GPU driver (the FreeBSD VM) the reason is a long wgpu
message that wraps to two lines, while "disabled by the GPU rendering
preference" is one, so the controls below moved by 20 px between the
frame that located "button:settingsMemory-2" and the frame that clicked
it. The status is now one truncated line with the full text on hover;
gpu_status_never_moves_the_performance_controls checks the buttons stay
put (it fails without the fix when no Vulkan driver loads).

offline_originals_never_block_a_frame: the slow NAS probe was a 400 ms
sleep judged by a 300 ms wall-clock frame budget, which a loaded QEMU VM
missed (1.06 s). The probe now waits on a gate the test opens; frames
must finish while it is closed (a watchdog records any frame that had to
wait), so no timing budget is needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:04 -04:00
5 changed files with 139 additions and 70 deletions

No files matched your search

Generated
+31 -31
View File
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -3097,7 +3097,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3159,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3168,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3185,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3200,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3212,7 +3212,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -3252,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3266,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"log",
"rustls",
@@ -3277,7 +3277,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3285,7 +3285,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3293,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3311,7 +3311,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3319,14 +3319,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3335,7 +3335,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3374,7 +3374,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3392,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3404,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3416,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3438,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3449,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3462,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.7.1"
version = "0.8.0"
[[package]]
name = "lightcraft-tiff"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3475,7 +3475,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -3502,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"brotli",
"flate2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+4 -1
View File
@@ -412,7 +412,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
} else {
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
};
hint(ui, t, &status);
// One line, the whole text on hover: the reason can be a long driver message (no adapter
// among the backends …), and the GPU state is process-wide, so it may change between frames;
// a hint that wrapped would move every control below it under the pointer.
ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status);
row(ui, t, crate::i18n::tr("Preview size"), |ui| {
let opts: Vec<(u32, String)> =
PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect();
+23
View File
@@ -134,3 +134,26 @@ fn a_dialog_list_shrinks_to_fit_a_short_window() {
let dialog = rect(&h, "dialog:window");
assert!(dialog.height() <= 320.0, "{dialog:?}");
}
/// Settings ▸ Performance: the GPU status line above the controls stays one line whatever it says
/// (without a driver it is a long wgpu message, e.g. on the FreeBSD CI), so the controls below it
/// don't move when the process-wide GPU state changes between two frames (a click aimed at
/// "Memory for caches" then landed on the gap above it).
#[test]
fn gpu_status_never_moves_the_performance_controls() {
let mut h = demo([1300.0, 820.0]);
let r = h.request("ui.key", json!({"key": ",", "cmd": true}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), T);
assert_eq!(r["ok"], true, "{r}");
let mut seen = vec![];
for gpu in [true, false, true] {
// the setting is applied process-wide at the next frame (`app.gpu`), as other tests do
h.app.ui.settings.gpu = gpu;
h.step();
h.step();
seen.push((gpu, rect(&h, "button:settingsMemory-2"), lightcraft_engine::gpu::unavailable_reason()));
}
let first = seen[0].1;
assert!(seen.iter().all(|(_, r, _)| *r == first), "the memory buttons moved: {seen:?}");
}
+80 -37
View File
@@ -1,10 +1,14 @@
//! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe
//! that takes far longer than a frame (a sleeping NAS), the grid, the Info panel, the Missing
//! Photos view, the Rename dialog and an import keep drawing frames quickly, the probe never runs
//! on the UI thread, and the background answers still arrive.
//! that does not answer at all until the test lets it (a sleeping NAS), the grid, the Info panel,
//! the Rename dialog and the Missing Photos view keep drawing frames, the probe never runs on the
//! UI thread, and the background answers arrive once the drive wakes up.
//!
//! The NAS is a gate, not a sleep: a frame that waited for the drive would wait for good (until a
//! watchdog gives up and records it), so the test needs no frame-time budget and holds on a slow
//! or loaded machine (the FreeBSD CI VM) as it does on a fast one.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::ThreadId;
use std::time::{Duration, Instant};
@@ -16,11 +20,11 @@ use crate::headless::Headless;
use crate::state::{RightPanel, ViewMode};
use crate::{LightcraftApp, Services};
/// What one file-system call takes on the "NAS".
const SLOW: Duration = Duration::from_millis(400);
/// A frame that waited for one such call would take at least this long.
/// The import test's frame budget (its files take 150 ms each to read).
const FRAME_LIMIT: Duration = Duration::from_millis(300);
const N: u64 = 12;
/// How long the drive stays asleep at most: only a frame stuck waiting for it gets this far.
const WATCHDOG: Duration = Duration::from_secs(120);
/// Threads the probe ran on, and how often.
#[derive(Clone, Default)]
@@ -39,9 +43,49 @@ impl Calls {
}
}
/// The sleeping drive: every file-system call waits until [`Nas::wake`]. A watchdog wakes it
/// after [`WATCHDOG`] and records that it had to (some frame was waiting for the drive).
#[derive(Default)]
struct Nas {
awake: Mutex<bool>,
changed: Condvar,
forced: AtomicBool,
}
impl Nas {
fn asleep() -> Arc<Self> {
let nas = Arc::new(Nas::default());
let dog = nas.clone();
std::thread::spawn(move || {
let awake = dog.awake.lock().unwrap();
let (mut awake, _) = dog.changed.wait_timeout_while(awake, WATCHDOG, |a| !*a).unwrap();
if !*awake {
dog.forced.store(true, Ordering::SeqCst);
*awake = true;
dog.changed.notify_all();
}
});
nas
}
fn wait(&self) {
let awake = self.awake.lock().unwrap();
drop(self.changed.wait_while(awake, |a| !*a).unwrap());
}
fn wake(&self) {
*self.awake.lock().unwrap() = true;
self.changed.notify_all();
}
/// The watchdog had to wake the drive: a frame waited for it.
fn forced(&self) -> bool {
self.forced.load(Ordering::SeqCst)
}
}
/// `N` photos whose originals are on an unreachable share, a smart previews folder, and a probe
/// that sleeps like a NAS waking up before saying "not there".
fn offline_library(calls: &Calls) -> Session {
/// that waits for the NAS to wake up before saying "not there". Called on the UI thread: a call
/// from that thread is recorded and answered at once, so a regression fails the thread check
/// instead of hanging the test.
fn offline_library(calls: &Calls, nas: &Arc<Nas>) -> Session {
let mut s = Session::new();
for i in 0..N {
let path = format!("/lightcraft-offline-nas/IMG_{i:03}.jpg");
@@ -49,72 +93,71 @@ fn offline_library(calls: &Calls) -> Session {
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
s.media.smart_dir = Some(std::env::temp_dir().join("lightcraft-offline-smart-previews-none"));
let c = calls.clone();
let (c, nas, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id());
s.media.availability.set_probe(Arc::new(move |_| {
c.record();
std::thread::sleep(SLOW);
if std::thread::current().id() != ui_thread {
nas.wait();
}
false
}));
s
}
fn max_frame(h: &mut Headless, frames: usize) -> Duration {
let mut worst = Duration::ZERO;
/// Run `frames` frames while the drive sleeps; each one has to finish without it.
fn frames_while_asleep(h: &mut Headless, nas: &Nas, frames: usize, what: &str) {
for _ in 0..frames {
let t0 = Instant::now();
h.step();
worst = worst.max(t0.elapsed());
assert!(!nas.forced(), "a {what} frame waited for the sleeping drive");
}
worst
}
#[test]
fn offline_originals_never_block_a_frame() {
let calls = Calls::default();
let app = LightcraftApp::new(offline_library(&calls), Services { png: None, ..Default::default() });
let nas = Nas::asleep();
let app = LightcraftApp::new(offline_library(&calls, &nas), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
let ui_thread = std::thread::current().id();
h.app.ui.view = ViewMode::PhotoGrid;
h.step();
// The first frame installs fonts; finish the first layout before timing the views.
// The first frame installs fonts; finish the first layout before the drive matters.
h.step();
assert!(h.app.fonts_ready, "the UI is initialized before frame timing starts");
assert!(h.app.fonts_ready, "the UI is initialized");
assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread");
// the grid: every cell asks for its thumbnail (which asks whether the original is there)
h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap();
let worst = max_frame(&mut h, 20);
assert!(worst < FRAME_LIMIT, "grid frame took {worst:?}");
frames_while_asleep(&mut h, &nas, 20, "grid");
// the Info panel of the active photo, in the loupe
h.app.ui.view = ViewMode::Detail;
h.app.ui.right = RightPanel::Info;
let worst = max_frame(&mut h, 20);
assert!(worst < FRAME_LIMIT, "Info panel frame took {worst:?}");
frames_while_asleep(&mut h, &nas, 20, "Info panel");
// the Missing Photos view fills in as the background checks find the files gone
// the Rename dialog's preview (it checks which names are taken on disk)
h.app.ui.view = ViewMode::PhotoGrid;
h.app.ui.right = RightPanel::None;
h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap();
h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 });
frames_while_asleep(&mut h, &nas, 20, "Rename dialog");
h.app.ui.dialog = None;
// the Missing Photos view: nothing is known yet, so nothing is listed, and frames go on
h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap();
assert_eq!(h.app.session.source, LibrarySource::Missing);
frames_while_asleep(&mut h, &nas, 20, "Missing Photos");
assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked");
assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there");
// the drive answers: the view fills in as the background checks find the files gone
nas.wake();
let t0 = Instant::now();
let mut worst = Duration::ZERO;
while h.app.session.visible().len() < N as usize && t0.elapsed() < Duration::from_secs(60) {
let f = Instant::now();
h.step();
worst = worst.max(f.elapsed());
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(h.app.session.visible().len(), N as usize, "every photo found missing in the end");
assert!(worst < FRAME_LIMIT, "Missing Photos frame took {worst:?}");
// the Rename dialog's preview (it checks which names are taken on disk)
h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap();
h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 });
let worst = max_frame(&mut h, 20);
assert!(worst < FRAME_LIMIT, "Rename dialog frame took {worst:?}");
assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked");
assert_eq!(calls.on(ui_thread), 0, "the slow probe never ran on the UI thread");
}