Say a photograph is downloading, and how far, instead of failing

The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
This commit is contained in:
2026-09-26 11:02:11 -04:00
parent 3b97195b37
commit 4bec01eaf1
9 changed files with 380 additions and 63 deletions
+67 -5
View File
@@ -588,9 +588,31 @@ pub fn spawn_full_fetch(
/// because that is the one name every caller has.
static IN_FLIGHT: std::sync::LazyLock<InFlight> = std::sync::LazyLock::new(InFlight::default);
/// TRACES: FR-NC-6a
/// How far one original's transfer has got: bytes received, and the length
/// the server declared (zero until it has, or if it never does).
///
/// Held in the registry beside the claim rather than handed to the caller,
/// because the caller watching is often not the one downloading: a step along
/// the roll usually lands on a frame the [`Prefetcher`] is already fetching,
/// and the click waits on that transfer instead of starting its own.
#[derive(Default)]
pub(super) struct Transfer {
received: std::sync::atomic::AtomicU64,
declared: std::sync::atomic::AtomicU64,
}
/// TRACES: FR-NC-6a
/// Bytes received so far and bytes expected, for an original being fetched
/// right now by anyone. `None` when nothing is fetching `path` — it is in the
/// cache, or the transfer has not reached the network yet, or it has ended.
pub fn transfer_progress(path: &str) -> Option<(u64, Option<u64>)> {
IN_FLIGHT.progress(path)
}
#[derive(Default)]
pub(super) struct InFlight {
busy: std::sync::Mutex<std::collections::HashSet<String>>,
busy: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<Transfer>>>,
freed: std::sync::Condvar,
/// Threads parked in [`InFlight::claim`], counted under the lock so a
/// test can release the holder only once a waiter is really waiting.
@@ -607,21 +629,32 @@ impl InFlight {
/// stored what the caller was about to download.
fn claim(&self, path: &str) -> Option<InFlightGuard<'_>> {
let mut busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
if busy.insert(path.to_string()) {
if !busy.contains_key(path) {
let transfer = std::sync::Arc::new(Transfer::default());
busy.insert(path.to_string(), transfer.clone());
return Some(InFlightGuard {
of: self,
path: path.to_string(),
transfer,
});
}
#[cfg(test)]
self.waiting
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
while busy.contains(path) {
while busy.contains_key(path) {
busy = self.freed.wait(busy).unwrap_or_else(|e| e.into_inner());
}
None
}
fn progress(&self, path: &str) -> Option<(u64, Option<u64>)> {
use std::sync::atomic::Ordering::Relaxed;
let busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
let t = busy.get(path)?;
let declared = t.declared.load(Relaxed);
Some((t.received.load(Relaxed), (declared > 0).then_some(declared)))
}
fn release(&self, path: &str) {
self.busy
.lock()
@@ -636,6 +669,7 @@ impl InFlight {
pub(super) struct InFlightGuard<'a> {
of: &'a InFlight,
path: String,
transfer: std::sync::Arc<Transfer>,
}
impl Drop for InFlightGuard<'_> {
@@ -678,7 +712,7 @@ pub(super) fn fetch_original(
// Miss, claim, and if the claim had to wait, look again: the thread that
// held the path has finished with it, and what it fetched is on disk.
let _claim = loop {
let claim = loop {
if let Some(bytes) = from_cache() {
return Ok(bytes);
}
@@ -693,7 +727,14 @@ pub(super) fn fetch_original(
let backend = crate::remote::connect(&conn).map_err(FetchFailure::local)?;
let id = RemoteId::Path(RemotePath::new(path));
let bytes = backend.get(&id, None).await?;
let transfer = &claim.transfer;
let bytes = backend
.get_reporting(&id, &|received, declared| {
use std::sync::atomic::Ordering::Relaxed;
transfer.received.store(received, Relaxed);
transfer.declared.store(declared.unwrap_or(0), Relaxed);
})
.await?;
// Store before returning, so the bytes are on disk by the time the
// image is on screen. Doing it after would leave a window where
@@ -936,6 +977,27 @@ mod tests {
);
}
/// Whoever is watching a path reads the holder's progress through the
/// registry, and loses it when the transfer ends — a step onto a frame
/// the prefetcher is fetching shows that transfer's bar.
#[test]
fn progress_is_readable_by_path_while_claimed() {
use std::sync::atomic::Ordering::Relaxed;
let registry = InFlight::default();
assert_eq!(registry.progress("a.CR2"), None);
let claim = registry.claim("a.CR2").unwrap();
assert_eq!(registry.progress("a.CR2"), Some((0, None)));
claim.transfer.received.store(1024, Relaxed);
claim.transfer.declared.store(4096, Relaxed);
assert_eq!(registry.progress("a.CR2"), Some((1024, Some(4096))));
assert_eq!(registry.progress("b.CR2"), None);
drop(claim);
assert_eq!(registry.progress("a.CR2"), None);
}
/// Different photographs never wait on each other.
#[test]
fn distinct_paths_are_claimed_independently() {