mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Compare commits
3
Commits
a4b53b28cc
...
3b4279fc24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b4279fc24 | ||
|
|
16dbb90499 | ||
|
|
73c61aae44 |
No files matched your search
Generated
+31
-31
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:?}")),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user