Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 3b4279fc24 FreeBSD CI: three more flaky tests made deterministic
import_reads_files_off_the_ui_thread_and_can_be_cancelled: the probe was
a 150 ms sleep judged by a 300 ms wall-clock frame budget, which the
loaded FreeBSD QEMU VM can miss. It now waits on the sleeping-NAS gate of
tests_offline, which can also let a counted number of reads through:
frames must go on while every read is held (a watchdog records a frame
that waited), one batch is let through and joins the catalog, then
Cancel and wake: the files being read finish and nothing new starts.

compare_at_one_to_one_gets_a_sharp_window_per_pane ("both originals stay
decoded"): the media cache takes its budget from the process-wide memory
budget, which settings_dialog_by_keyboard_and_clicks sets to 1 GB for a
moment (and left there when it failed on FreeBSD). That leaves a 512 MiB
share, too small for two 24 MP originals (288 MB each), so one pane's
was evicted. compare_at now gives its session the default 768 MiB share;
LIGHTCRAFT_MEMORY_MB=1024 reproduces the failure without this.

dropping_the_lock_frees_the_library_while_another_thread_spawns_processes:
"the spawning thread never got a child off the ground" when a spawn took
longer than the 2 s of lock cycles. The cycles now go on until at least
5 children were spawned during them (at most a minute).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:36:53 -04:00
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
Brandon Thomas a4b53b28cc Merge pull request #1682 from storytold/claude/integrate-batch-35
Integration batch 35: 61 reviewed community PRs (never-crash, raw decode, imports, UI fixes)
2026-10-14 02:04:11 -04:00
7 changed files with 201 additions and 102 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"
+15 -8
View File
@@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a
/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few
/// children have been spawned during them, at most a minute.
#[cfg(unix)]
#[test]
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// Children spawned while the lock cycles, at least, before the test may stop.
const CHILDREN: usize = 5;
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
let count = Arc::new(AtomicUsize::new(0));
let spawner = {
let stop = Arc::clone(&stop);
let (stop, count) = (Arc::clone(&stop), Arc::clone(&count));
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
count.fetch_add(1, Ordering::Relaxed);
}
}
spawned
count.load(Ordering::Relaxed)
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let t0 = Instant::now();
let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN;
let mut cycles = 0usize;
let mut refused = Vec::new();
while cycles < 200_000 && std::time::Instant::now() < deadline {
while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) {
match LibraryLock::acquire(&dir, "LightCraft") {
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
+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:?}");
}
+118 -61
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, the Missing Photos view and an import 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 tests need no frame-time budget and hold on a slow
//! or loaded machine (the FreeBSD CI VM) as they do 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,9 @@ 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.
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 +41,59 @@ impl Calls {
}
}
/// The sleeping drive: every file-system call waits until [`Nas::wake`] (or takes one of the
/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] and records that it
/// had to (some frame was waiting for the drive).
#[derive(Default)]
struct Nas {
/// (awake, calls let through while asleep)
state: Mutex<(bool, usize)>,
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 state = dog.state.lock().unwrap();
let (mut state, _) = dog.changed.wait_timeout_while(state, WATCHDOG, |s| !s.0).unwrap();
if !state.0 {
dog.forced.store(true, Ordering::SeqCst);
state.0 = true;
dog.changed.notify_all();
}
});
nas
}
fn wait(&self) {
let state = self.state.lock().unwrap();
let mut state = self.changed.wait_while(state, |s| !s.0 && s.1 == 0).unwrap();
if !state.0 {
state.1 -= 1;
}
}
/// Answer `n` more calls, then sleep again.
fn let_through(&self, n: usize) {
self.state.lock().unwrap().1 += n;
self.changed.notify_all();
}
fn wake(&self) {
self.state.lock().unwrap().0 = 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,94 +101,96 @@ 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");
}
/// An import whose files take long to read (probe: decoding headers and hashing on a slow drive)
/// runs on a worker thread: frames stay quick, the probe never runs on the UI thread, and Cancel
/// stops it with what was read so far added.
/// runs on a worker thread: frames go on while the drive holds every read, the probe never runs
/// on the UI thread, and Cancel stops it with what was read so far added.
#[test]
fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() {
let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// enough files that the import is still running when its activity row shows (after 0.5 s)
let n = 120;
for i in 0..n {
std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap();
}
let calls = Calls::default();
let nas = Nas::asleep();
let mut s = Session::new();
let c = calls.clone();
let (c, drive, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id());
s.media.file_probe = Some(Arc::new(move |p: &str| {
c.record();
std::thread::sleep(Duration::from_millis(150));
// on the UI thread: answered at once, so a regression fails the thread check, not by hanging
if std::thread::current().id() != ui_thread {
drive.wait();
}
Ok(lightcraft_engine::media::ProbeInfo {
width: 60,
height: 40,
@@ -147,32 +201,35 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() {
}));
let app = LightcraftApp::new(s, 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.step();
// The first frame installs fonts; finish the first layout before timing the import.
// 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 import timing starts");
assert!(h.app.fonts_ready, "the UI is initialized");
let undo0 = h.app.session.undo.len();
crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
// some batches arrive; meanwhile every frame is quick
let t0 = Instant::now();
let mut worst = Duration::ZERO;
while h.app.import.as_ref().is_some_and(|t| t.imported < 8) && 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!(worst < FRAME_LIMIT, "import frame took {worst:?}");
assert!(h.app.import.is_some(), "still importing");
let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default();
assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}");
// Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts
// every read is held: frames go on, and the import's row shows (after 0.5 s) with its ✕
frames_while_asleep(&mut h, &nas, 20, "import");
assert!(h.app.import.is_some(), "importing");
let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row");
let cross = format!("activity:cancel:{id}");
assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows");
assert!(h.step_until(Duration::from_secs(60), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows");
assert!(!nas.forced(), "an import frame waited for the sleeping drive");
// the drive answers one batch of reads: it joins the catalog while the next batch waits
let batch = lightcraft_engine::import::batch_size();
nas.let_through(batch);
let t0 = Instant::now();
while h.app.import.as_ref().is_some_and(|t| t.imported < batch) && t0.elapsed() < Duration::from_secs(60) {
h.step();
assert!(!nas.forced(), "an import frame waited for the sleeping drive");
std::thread::sleep(Duration::from_millis(5));
}
assert!(h.app.import.is_some(), "still importing");
let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default();
assert!(status["imported"].as_u64().unwrap_or(0) >= 8 && status["total"].as_u64().unwrap_or(0) >= 8, "{status}");
// Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts
let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
nas.wake();
let t0 = Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) {
h.step();
@@ -881,9 +881,18 @@ mod in_the_loupe {
assert!(window[0].0 > whole[0].0, "over the whole-frame render");
}
/// The engine caches' share of the default memory budget (1.5 GiB) on a machine with 6 GiB of
/// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float).
const COMPARE_CACHE: usize = 768 << 20;
/// Compare view of the demo library at `zoom`, settled.
///
/// The session's cache budget is set here rather than taken from the process-wide memory
/// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a
/// 512 MiB share, too small for two originals, so one pane's was evicted).
fn compare_at(zoom: crate::state::Zoom) -> Headless {
let (mut h, _) = detail();
h.app.session.media.set_budget(COMPARE_CACHE);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare);