diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs index dd1f5273c..562f0b60c 100644 --- a/crates/engine/src/files/fs.rs +++ b/crates/engine/src/files/fs.rs @@ -76,11 +76,17 @@ pub fn fs_pair_loader() -> PairLoader { pub fn fs_hooks() -> (FileLoader, FileProbe) { let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); let probe: FileProbe = Arc::new(|path: &str| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + use std::io::Read; + // one open, then everything from the open file: its size and modification time are not + // round trips of their own (on a network share each of them is one) + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let meta = file.metadata().ok(); + let len = meta.as_ref().map_or(0, |m| usize::try_from(m.len()).unwrap_or(usize::MAX)); let _permit = crate::memory::work_gate().acquire(len); - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).map_err(|e| format!("{path}: {e}"))?; let mut info = probe_bytes(path, &bytes)?; - info.file_modified = file_modified(path); + info.file_modified = meta.as_ref().and_then(modified_iso); Ok(info) }); (loader, probe) @@ -104,10 +110,10 @@ fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result< r } -/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// The modification time in `meta` as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the /// filesystem doesn't say or the time is before 1970. -fn file_modified(path: &str) -> Option { - let t = std::fs::metadata(path).ok()?.modified().ok()?; +fn modified_iso(meta: &std::fs::Metadata) -> Option { + let t = meta.modified().ok()?; let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; Some(crate::import::civil(secs)) } diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs index e8bcebe4c..613820257 100644 --- a/crates/engine/src/files/tests.rs +++ b/crates/engine/src/files/tests.rs @@ -427,6 +427,21 @@ fn undated_files_take_the_file_date() { let _ = std::fs::remove_dir_all(&dir); } +/// The probe opens the file once and says the same thing as before about one that can't be read: +/// its path, then the system's words (a missing file, a folder). +#[test] +fn a_probe_of_a_file_that_cannot_be_read_names_the_path_and_the_cause() { + let dir = std::env::temp_dir().join(format!("lightcraft-probe-errors-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (_, probe) = fs_hooks(); + for path in [dir.join("no-such-file.jpg"), dir.clone()] { + let path = path.to_string_lossy().to_string(); + let e = probe(&path).unwrap_err(); + assert_eq!(e, format!("{path}: {}", std::fs::read(&path).unwrap_err())); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..7c0c42809 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-27 + +### Reading a photo from a network share asks the server less +- Checking a photo (before an import, and for Reload, Recheck, Relink and Convert) asked for the file's size, then + opened it, then asked for its modification time as a question of its own. It now opens the file once and reads + both from the open file, so each photo costs the server fewer round trips (about a quarter less time to read a + set of photos from an SMB share, in one measurement on one share). What is imported and the messages for a file + that can't be read are the same. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock