diff --git a/apps/lightcraft/src/launch.rs b/apps/lightcraft/src/launch.rs index f73ee291f..13edce0fd 100644 --- a/apps/lightcraft/src/launch.rs +++ b/apps/lightcraft/src/launch.rs @@ -62,10 +62,12 @@ pub(crate) struct LaunchArgs { } pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> LaunchArgs { - use lightcraft_engine::import::{LaunchPath, launch_path}; + use lightcraft_engine::import::{LaunchPath, launch_deadline, launch_path_until}; let (mut files, mut notices, mut library) = (Vec::new(), Vec::new(), None); + // one deadline for every argument's library check: they all run before the window opens + let deadline = launch_deadline(); for a in args { - match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { + match launch_path_until(a, cwd, home, lightcraft_engine::devices::dcim_in, deadline) { Some(LaunchPath::Library(dir)) if library.is_none() => library = Some(std::path::PathBuf::from(dir)), Some(LaunchPath::Library(dir)) => { log::warn!("one library at a time: not opening {dir} given at launch"); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 6fe665896..e38d8c261 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -43,7 +43,9 @@ mod types; pub(crate) use commit::workers; pub use commit::{batch_size, civil, commit_prepared, system_clock}; pub use defaults::{CameraDefault, ImportDefaults, apply_import_defaults, has_import_look, import_defaults}; -pub use files::{EXTENSIONS, LaunchPath, accept_list, expand, expand_within, is_supported, launch_path}; +pub use files::{ + EXTENSIONS, LAUNCH_PROBE_LIMIT, LaunchPath, accept_list, expand, expand_within, is_supported, launch_deadline, launch_path, launch_path_until, +}; pub(crate) use files::{expand_noting, probe_all, probe_paths, probe_with}; #[cfg(test)] use job::PreparedItem; diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index b8dde60fe..d7bf5caa8 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -137,10 +137,16 @@ pub fn library_dir_of(path: &Path) -> Option { (path.is_dir() && crate::library::is_library(path)).then(|| path.to_path_buf()) } -/// How long a launch argument's library check may take before LightCraft gives up on it and treats the argument -/// as photos to import (in the background). A path on a dead network mount can block a file-system query for -/// minutes, and this check runs before the window opens. -pub(crate) const LAUNCH_PROBE_LIMIT: std::time::Duration = std::time::Duration::from_secs(2); +/// How long the launch arguments' library checks may take, all of them together, before LightCraft gives up and +/// treats the arguments not yet checked as photos to import (in the background). A path on a dead network mount +/// can block a file-system query for minutes, and these checks run before the window opens. +pub const LAUNCH_PROBE_LIMIT: std::time::Duration = std::time::Duration::from_secs(2); + +/// When the launch arguments' library checks, starting now, must be done: [`LAUNCH_PROBE_LIMIT`] from now. +pub fn launch_deadline() -> std::time::Instant { + let now = std::time::Instant::now(); + now.checked_add(LAUNCH_PROBE_LIMIT).unwrap_or(now) +} /// `probe`'s answer, or `None` when it takes longer than `limit`: it runs on its own thread, which is left to /// finish (or stay blocked) on its own. Also `None` when no thread can be started. @@ -169,6 +175,19 @@ pub(crate) fn within(limit: std::time::Duration, probe: impl /// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or /// refused, and so is the home folder `home`: either would walk most of the disk. pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { + launch_path_until(arg, cwd, home, dcim, launch_deadline()) +} + +/// [`launch_path`], with its library check bounded by `deadline` (from [`launch_deadline`]), which every argument +/// of one launch shares: several paths on a dead mount wait [`LAUNCH_PROBE_LIMIT`] in all, not each. Once it has +/// passed, an argument is imported without the check. +pub fn launch_path_until( + arg: &str, + cwd: Option<&Path>, + home: Option<&Path>, + dcim: impl Fn(&Path) -> Option, + deadline: std::time::Instant, +) -> Option { if arg.trim().is_empty() { return None; } @@ -203,8 +222,11 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); } + let left = deadline.saturating_duration_since(std::time::Instant::now()); let probed = abs.clone(); - if let Some(dir) = within(LAUNCH_PROBE_LIMIT, move || library_dir_of(&probed)) { + if !left.is_zero() + && let Some(dir) = within(left, move || library_dir_of(&probed)) + { return Some(LaunchPath::Library(dir.to_string_lossy().to_string())); } Some(LaunchPath::Import(shown)) @@ -345,6 +367,21 @@ mod prepared_tests { assert_eq!(within(LAUNCH_PROBE_LIMIT, || Some(1)), Some(1), "a quick answer is kept"); } + /// The launch arguments share one deadline (CodeRabbit on #2979): once it has passed, an argument is imported + /// without its library check, so several paths on a dead mount can't each wait the limit. + #[test] + fn launch_arguments_share_one_deadline() { + let dir = temp_dir("deadline"); + std::fs::write(dir.join(lightcraft_catalog::journal::LOG), b"x").unwrap(); + let arg = dir.to_string_lossy().to_string(); + let none = |_: &Path| None; + assert_eq!(launch_path_until(&arg, None, None, none, launch_deadline()), Some(LaunchPath::Library(arg.clone()))); + let start = std::time::Instant::now(); + assert_eq!(launch_path_until(&arg, None, None, none, start), Some(LaunchPath::Import(arg.clone())), "deadline passed"); + assert!(start.elapsed() < std::time::Duration::from_millis(500), "waited {:?}", start.elapsed()); + let _ = std::fs::remove_dir_all(dir); + } + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the /// files this build cannot import (see [`accept_list`]). fn pickable_extensions() -> Vec<&'static str> {