mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(sync,gui,cli): get a synced folder unstuck when its local copy is gone (#46)
A mirror folder whose files were all gone from this computer stayed in error for good. The wipe guard rightly refuses to trash the whole folder on Drive, but nothing got the folder out of that state: a queued switch to on-demand waits for a pass that ends idle, and its push pass hit the same guard. Only removing the folder from sync cleared the baseline that made the empty folder read as "delete everything". - The push pass for a switch to on-demand forgets the baseline rows of a local copy that is gone in full instead of trashing them, so the switch goes ahead and the mount shows what Drive has. - New Request::ResetSyncFolder, `pdfs sync reset ID` and "Sync From Scratch…" in a synced folder's menu clear a mirror folder's baseline (and a queued switch to on-demand, whose push pass would upload a duplicate of every file). The next pass compares both sides as on the first sync: one-sided files are copied across, files that differ keep both versions, nothing is deleted. - The wipe guards' messages name the reset; restore logs why it skipped a folder. Two simulations cover it. The on-demand one failed in exactly the reported state (mirror, pending ondemand, error) before the fix. All 8 catalogs translated.
This commit is contained in:
1 parent
ea10a43216
commit
a5acae41b3
23 files changed
+832
-238
No files matched your search
@@ -388,6 +388,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
| R::SetSyncFolderPaused { .. }
|
||||
| R::ResetSyncFolder { .. }
|
||||
| R::RestoreSyncFolders { .. }
|
||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||
@@ -1432,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
|
||||
Ok(message) => {
|
||||
core.log_activity(ActivityKind::Sync, &message, "", true);
|
||||
CtlResponse::Ok { message }
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SyncNow { id }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
|
||||
@@ -1080,6 +1080,50 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget what a mirror folder synced before, so its next pass compares the
|
||||
/// two sides as on the first sync: what one side has alone is copied to the
|
||||
/// other, and a file that differs is kept twice as a conflict. Nothing is
|
||||
/// deleted on either side.
|
||||
///
|
||||
/// This is the way out for a folder whose passes refuse to run because its
|
||||
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
|
||||
/// is what makes that read as "delete everything", and it is what this
|
||||
/// drops. A queued switch to on-demand is dropped with it: the push pass
|
||||
/// that switch runs reads an unrecorded local file as new and would upload
|
||||
/// a second copy of every file Drive already has.
|
||||
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
|
||||
let lock = self.sync_lock(id);
|
||||
let Some(_guard) = lock.try_lock() else {
|
||||
return Err(CoreError::conflict(
|
||||
"this folder is syncing; try again when the pass ends",
|
||||
));
|
||||
};
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode != "mirror" {
|
||||
return Err(CoreError::invalid(
|
||||
"an online-only folder keeps nothing on this computer to sync from scratch",
|
||||
));
|
||||
}
|
||||
self.db
|
||||
.sync_entries_clear(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
if folder.pending_mode.as_deref() == Some("ondemand") {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
info!(id, path = %folder.local_path, "sync folder reset");
|
||||
Ok(format!(
|
||||
"{} will sync from scratch",
|
||||
base_name(&folder.local_path)
|
||||
))
|
||||
}
|
||||
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// one, which would fail the fresh mount with EBUSY).
|
||||
|
||||
@@ -450,6 +450,10 @@ impl Core {
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
|
||||
// The reply is gone once the app has shown it; the log keeps why.
|
||||
for reason in &skipped {
|
||||
info!(reason, "restore skipped a folder");
|
||||
}
|
||||
let mut message = format!("restored {restored} folder(s)");
|
||||
if !skipped.is_empty() {
|
||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
||||
|
||||
@@ -2873,4 +2873,112 @@ mod tests {
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The synced folder `id` as the daemon lists it.
|
||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||
match daemon.request(&Request::ListSyncFolders) {
|
||||
Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose
|
||||
/// local copy is then deleted in full while the folder is paused, so no
|
||||
/// pass sees half of it. Waits for the pass that refuses to run (#46).
|
||||
fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
|
||||
let id = sync_folder(daemon, local, "mirror");
|
||||
std::fs::write(local.join("a.txt"), b"alpha").unwrap();
|
||||
std::fs::create_dir(local.join("sub")).unwrap();
|
||||
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
|
||||
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||
let on_drive = |name: &str, content: &[u8]| {
|
||||
drive.tree().iter().any(|(path, entry)| {
|
||||
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha")
|
||||
&& on_drive("sub/b.txt", b"beta")
|
||||
&& listed_folder(daemon, id).is_some_and(|f| f.state == "idle")),
|
||||
"the folder synced: {:?} {:?}",
|
||||
listed_folder(daemon, id),
|
||||
drive.tree().keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||
.unwrap();
|
||||
std::fs::remove_file(local.join("a.txt")).unwrap();
|
||||
std::fs::remove_dir_all(local.join("sub")).unwrap();
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||
.unwrap();
|
||||
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed_folder(daemon, id)
|
||||
.is_some_and(|f| f.state == "error")),
|
||||
"the pass refused to trash everything: {:?}",
|
||||
listed_folder(daemon, id)
|
||||
);
|
||||
assert!(
|
||||
on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
|
||||
"nothing was trashed on Drive: {:?}",
|
||||
drive.tree().keys().collect::<Vec<_>>()
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("reset-gone-local-copy");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let local = dir.join("docs");
|
||||
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||
|
||||
daemon.request(&Request::ResetSyncFolder { id }).unwrap();
|
||||
let has = |rel: &str, content: &[u8]| {
|
||||
std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content)
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || has("a.txt", b"alpha")
|
||||
&& has("sub/b.txt", b"beta")
|
||||
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
|
||||
"the reset downloaded the folder again: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_folder_whose_local_copy_is_gone_still_goes_on_demand() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("ondemand-gone-local-copy");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let local = dir.join("docs");
|
||||
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderMode {
|
||||
id,
|
||||
mode: "ondemand".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
|
||||
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
|
||||
&& is_mounted(&local)),
|
||||
"the queued switch landed: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha");
|
||||
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,20 @@ impl Core {
|
||||
// guard on every pass — wedging the folder's sync permanently — the
|
||||
// moment a rule covers the whole tree.
|
||||
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||
guard_local_wipe(&tracked, &local)?;
|
||||
// A local copy that is gone in its entirety is not trashed on Drive,
|
||||
// for the reason `guard_local_wipe` gives. A full pass stops there, but
|
||||
// this one exists for a switch to on-demand, which needs nothing local
|
||||
// and leaves Drive as it is: refusing the pass would only keep the
|
||||
// folder out of `idle`, wedging the switch for good (#46). So the pass
|
||||
// forgets those paths instead of trashing them, and the mount then
|
||||
// shows everything Drive still has.
|
||||
let local_gone = match guard_local_wipe(&tracked, &local) {
|
||||
Ok(()) => false,
|
||||
Err(reason) => {
|
||||
warn!(id = folder_id, %reason, "sync: going on-demand without trashing anything on Drive");
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
// The remote folder uids come from the baseline instead of a walk: every
|
||||
// directory this device has synced recorded its uid there when it was
|
||||
@@ -579,8 +592,27 @@ impl Core {
|
||||
// showing the folder's remote contents — otherwise everything they deleted
|
||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||
// under a folder already trashed (its children went with it).
|
||||
let deletions = local_deletions(&tracked, &local);
|
||||
if local_gone {
|
||||
// Forgotten instead of trashed. A row left behind would read to
|
||||
// the switch as a deletion not on Drive yet and withdraw it, and
|
||||
// to a later full pass as a reason to stop again. Forgotten, a full
|
||||
// pass downloads the paths again.
|
||||
let mut forgotten: HashSet<&str> = HashSet::new();
|
||||
for rel in &deletions {
|
||||
let mut parent = parent_rel(rel);
|
||||
while !parent.is_empty() && !forgotten.contains(parent) {
|
||||
parent = parent_rel(parent);
|
||||
}
|
||||
if parent.is_empty() {
|
||||
let _ = self.db.sync_entries_remove_subtree(folder_id, rel);
|
||||
forgotten.insert(rel);
|
||||
}
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
let mut trashed: Vec<String> = Vec::new();
|
||||
for rel in &local_deletions(&tracked, &local) {
|
||||
for rel in &deletions {
|
||||
if trashed
|
||||
.iter()
|
||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||
|
||||
@@ -192,7 +192,8 @@ pub(super) fn guard_local_wipe<B, L>(
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
||||
them on Drive. Check that the folder is mounted and readable.",
|
||||
them on Drive. Check that the folder is mounted and readable; to download \
|
||||
them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
@@ -213,7 +214,8 @@ pub(super) fn guard_remote_wipe<B, R>(
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
||||
delete them here. If they were deleted on purpose, remove the folder from sync.",
|
||||
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||
to upload them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user