Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan` stepped over a NotFound or PermissionDenied the way it does for a child that vanished mid-walk -- correct for a child, wrong for the root, where it ended the walk, returned Ok with nothing in it, and reported a successful scan of a library that was no longer there. A lost root is now its own error. The images under it are marked Availability::Offline per FR-CAT-9 and no catalog row is deleted; `library::persist` clears the mark per file as each one is listed again, so a root that comes back needs no repair step. Partly satisfied rather than closed, and the gap is worth stating. The recovery half is real and reachable on Android today, because `map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how a phone actually gets a library in this build. The causes the requirement names -- revocation, reinstall, a removed card -- are properties of a persisted tree permission, and there is none: SAF does not exist here, `SourceRef::Document` is constructed only in test modules, and `LocalStorage` rejects the variant outright. When SAF lands it becomes a third producer of this error and nothing above it changes, which is why the discovery belongs in the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+86
-12
@@ -270,6 +270,22 @@ pub struct LibraryController {
|
||||
/// judgement, and carrying the old one over would report a server down
|
||||
/// that was never contacted.
|
||||
reachability: RefCell<dr_sync::Reachability>,
|
||||
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||
/// Why the library folder itself could not be opened, if it could not.
|
||||
///
|
||||
/// Beside [`Self::reachability`] rather than inside it, because
|
||||
/// `dr_sync::Reachability` models *the server*, and it is deliberately
|
||||
/// unmoved by a refusal — a forbidden file must not report the network as
|
||||
/// down (see its own tests). A revoked tree grant is a refusal, so folding
|
||||
/// it in would either break that rule or need an exception carved through
|
||||
/// it.
|
||||
///
|
||||
/// Set only by a scan that failed at the root, and cleared only by one
|
||||
/// that succeeded. Both states drive the same banner as being offline
|
||||
/// does, because what the user can do is the same — carry on with what is
|
||||
/// stored on the device — but the sentence under it is different, and so
|
||||
/// is what will end it.
|
||||
root_lost: RefCell<Option<String>>,
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||||
/// rather than leaving two draining the same finished channel.
|
||||
@@ -372,6 +388,7 @@ impl LibraryController {
|
||||
sidecar_timer: RefCell::new(None),
|
||||
generation: std::cell::Cell::new(0),
|
||||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||||
root_lost: RefCell::new(None),
|
||||
outbox_timer: RefCell::new(None),
|
||||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||||
geometry_timer: RefCell::new(None),
|
||||
@@ -443,10 +460,15 @@ impl LibraryController {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// Whether the app currently believes the server is unreachable.
|
||||
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||||
/// Whether the library cannot be reached, for either of the two reasons.
|
||||
///
|
||||
/// One answer rather than two because every caller asks it for the same
|
||||
/// purpose: to decide whether starting a transfer is worth attempting.
|
||||
/// A revoked grant fails that question exactly as a dead network does, and
|
||||
/// a sync started against it would spend its retries proving it.
|
||||
pub fn is_offline(&self) -> bool {
|
||||
self.reachability.borrow().is_offline()
|
||||
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
|
||||
}
|
||||
|
||||
/// Whether the grid is narrowed to locally-stored originals.
|
||||
@@ -941,6 +963,17 @@ fn drain_scan(
|
||||
{
|
||||
log::info!("back online");
|
||||
}
|
||||
// TRACES: FR-PLAT-AND-2
|
||||
// And it is the only evidence that clears a lost root,
|
||||
// for the same reason: the walk began by listing the
|
||||
// root, so a scan that finished is a root that opened.
|
||||
// The rows it marked offline are restored one at a
|
||||
// time by `library::persist`, as each file is listed
|
||||
// again — this only stops the banner claiming what is
|
||||
// no longer true.
|
||||
if ctl.root_lost.borrow_mut().take().is_some() {
|
||||
log::info!("library folder is readable again");
|
||||
}
|
||||
refresh_offline(&w, ctl);
|
||||
|
||||
// An incremental rescan lists almost nothing, so
|
||||
@@ -995,7 +1028,11 @@ fn drain_scan(
|
||||
stop(&ctl.scan_timer);
|
||||
return;
|
||||
}
|
||||
ScanMessage::Failed { message, offline } => {
|
||||
ScanMessage::Failed {
|
||||
message,
|
||||
offline,
|
||||
lost_root,
|
||||
} => {
|
||||
log::warn!("scan failed: {message}");
|
||||
w.set_library_scanning(false);
|
||||
// Recorded as a failure even where it is only the
|
||||
@@ -1004,7 +1041,26 @@ fn drain_scan(
|
||||
// stopped because of it.
|
||||
job.fail(message.clone());
|
||||
|
||||
if offline {
|
||||
if lost_root {
|
||||
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||
// The library folder itself could not be opened —
|
||||
// a share withdrawn, an unplugged drive, and in
|
||||
// time a revoked document-tree grant. The worker
|
||||
// has already marked every row under this root
|
||||
// offline and deleted none of them; this is the
|
||||
// half the user sees.
|
||||
//
|
||||
// Tested first because it is also true that the
|
||||
// library is unreachable, and the generic answer
|
||||
// would be reached first and be less useful.
|
||||
*ctl.root_lost.borrow_mut() = Some(message);
|
||||
refresh_offline(&w, ctl);
|
||||
// Same reason as the offline arm below: without
|
||||
// this a launch that began with a revoked grant
|
||||
// shows an empty grid, which is the one impression
|
||||
// this whole path exists to avoid.
|
||||
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||||
} else if offline {
|
||||
// Not an error state. The catalog from the last
|
||||
// successful scan is still on disk and still
|
||||
// accurate for everything already indexed, so the
|
||||
@@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
|
||||
/// which is what keeps it testable without a display server.
|
||||
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let reach = ctl.reachability.borrow();
|
||||
let offline = reach.is_offline();
|
||||
// TRACES: FR-PLAT-AND-2
|
||||
// A lost root wins over a dead network, and does so even when both are
|
||||
// true — which is the ordinary case, since the scan that discovered the
|
||||
// grant was gone was also the last request the app made. Reported the
|
||||
// other way round the user is told to wait for a connection that is
|
||||
// working, and the thing that would actually fix it is never mentioned.
|
||||
let lost = ctl.root_lost.borrow();
|
||||
let offline = reach.is_offline() || lost.is_some();
|
||||
|
||||
window.set_library_offline(offline);
|
||||
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
|
||||
window.set_library_offline_reason(match lost.as_deref() {
|
||||
Some(why) => why.into(),
|
||||
None => reach.reason().unwrap_or_default().into(),
|
||||
});
|
||||
window.set_library_offline_since(
|
||||
reach
|
||||
.offline_for(std::time::Instant::now())
|
||||
.map(describe_duration)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
// A duration is what a network outage has and a revoked permission
|
||||
// does not: "for 4 minutes" invites waiting, and waiting is precisely
|
||||
// what will not help here.
|
||||
if lost.is_some() {
|
||||
slint::SharedString::default()
|
||||
} else {
|
||||
reach
|
||||
.offline_for(std::time::Instant::now())
|
||||
.map(describe_duration)
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
},
|
||||
);
|
||||
drop(lost);
|
||||
|
||||
// A stale scan error under an offline banner reports one problem twice.
|
||||
if offline {
|
||||
|
||||
Reference in New Issue
Block a user