feat(core,fuse,gui,cli): pause a single mirrored folder (phase 6)

This commit is contained in:
nvrl committed 2026-09-23 02:36:45 +02:00
1 parent a86c3e586d
commit b58b60010e
12 files changed
+242 -11

No files matched your search

+35 -7
View File
@@ -569,11 +569,18 @@ enum SyncCmd {
Pause {
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
/// Omit to pause until `pdfs sync resume`.
#[arg(long = "for", value_parser = parse_pause_duration)]
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
duration: Option<u64>,
/// Pause only this mirror folder (id from `sync list`) until resumed.
#[arg(long)]
folder: Option<i64>,
},
/// Resume syncing after `pdfs sync pause`.
Resume,
Resume {
/// Resume only this folder, paused with `sync pause --folder`.
#[arg(long)]
folder: Option<i64>,
},
/// List uploads and changes not yet on Proton Drive.
Queue,
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
@@ -837,6 +844,10 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
Some(pending) => format!("{} → {pending}", f.mode),
None => f.mode.clone(),
};
let mode = match f.paused {
true => format!("{mode}, paused"),
false => mode,
};
println!(
"[{}] {} ({mode}, {}, synced: {sync})",
f.id, f.local_path, f.state
@@ -873,7 +884,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
})?)?
}
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
SyncCmd::Pause { duration } => {
SyncCmd::Pause {
folder: Some(id), ..
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
id,
paused: true,
})?)?,
SyncCmd::Resume { folder: Some(id) } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
id,
paused: false,
})?)?
}
SyncCmd::Pause {
duration,
folder: None,
} => {
let until = duration.map(|secs| unix_now().saturating_add(secs as i64));
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
paused: true,
@@ -882,10 +908,12 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
}
SyncCmd::Queue => return cmd_sync_queue(),
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
SyncCmd::Resume => ok_or_bail(control_request(CtlRequest::SetSyncPaused {
paused: false,
until: None,
})?)?,
SyncCmd::Resume { folder: None } => {
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
paused: false,
until: None,
})?)?
}
SyncCmd::Mode { id, mode } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
}
+13
View File
@@ -381,6 +381,9 @@ pub enum Request {
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
/// with [`Response::Ok`].
SetSyncFolderMode { id: i64, mode: String },
/// Pause or resume one mirror folder's reconcile, independent of the
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
SetSyncFolderPaused { id: i64, paused: bool },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> },
@@ -724,6 +727,9 @@ pub struct SyncFolderInfo {
/// running. Live daemon state, not a stored column.
#[serde(default)]
pub progress: Option<SyncProgress>,
/// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]).
#[serde(default)]
pub paused: bool,
}
/// A folder under this machine's device offered for restore (in
@@ -2409,6 +2415,10 @@ mod tests {
id: 3,
mode: "ondemand".into(),
},
Request::SetSyncFolderPaused {
id: 3,
paused: true,
},
Request::SyncNow { id: Some(3) },
Request::AdoptDevice {
uid: Some("dev-1".into()),
@@ -2530,6 +2540,7 @@ mod tests {
pending_mode: None,
mounted: true,
progress: None,
paused: false,
},
MountSpec {
id: 2,
@@ -2544,6 +2555,7 @@ mod tests {
pending_mode: Some(MountMode::OnDemand),
mounted: false,
progress: None,
paused: false,
},
MountSpec {
id: 3,
@@ -2565,6 +2577,7 @@ mod tests {
total: 5,
current: "report.pdf".into(),
}),
paused: true,
},
],
};
+1
View File
@@ -171,6 +171,7 @@ impl Db {
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
paused: false,
});
}
Ok(mounts)
+3
View File
@@ -110,6 +110,9 @@ pub struct MountSpec {
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
/// A synced folder paused on its own. Live daemon state, not a stored column.
#[serde(default)]
pub paused: bool,
}
#[cfg(test)]
+11
View File
@@ -982,6 +982,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
match core.set_sync_folder_paused(id, paused) {
Ok(()) => CtlResponse::Ok {
message: match paused {
true => format!("folder {id} paused"),
false => format!("folder {id} resumed"),
},
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SyncNow { id }) => {
core.sync_now(id);
CtlResponse::Ok {
+11 -1
View File
@@ -128,6 +128,7 @@ impl Core {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
location.progress = progress.get(sync_folder_id).cloned();
location.paused = self.sync_folder_paused(*sync_folder_id);
}
}
Ok(locations)
@@ -448,7 +449,8 @@ impl Core {
.into_iter()
.map(|f| {
let live = progress.get(&f.id).cloned();
sync_folder_info(f, live)
let paused = self.sync_folder_paused(f.id);
sync_folder_info(f, live, paused)
})
.collect())
}
@@ -561,6 +563,7 @@ impl Core {
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
}
drop(_guard);
self.clear_sync_folder_paused(id);
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
if delete_remote
@@ -625,6 +628,13 @@ impl Core {
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// A switch waits on the folder's reconcile, which a pause skips; refuse
// it up front rather than queue it indefinitely.
if current_mode != target_mode && self.sync_folder_paused(id) {
return Err(CoreError::invalid(
"this folder is paused; resume it before changing its mode",
));
}
if current_mode == target_mode {
let mode_str = target_mode.as_str();
let lock = self.sync_lock(id);
+6 -1
View File
@@ -5153,7 +5153,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
/// Convert a stored synced folder into its wire form for the front-ends, with
/// the live progress of its pass when one is running.
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
fn sync_folder_info(
f: StoredSyncFolder,
progress: Option<SyncProgress>,
paused: bool,
) -> SyncFolderInfo {
SyncFolderInfo {
id: f.id,
local_path: f.local_path,
@@ -5163,6 +5167,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
state: f.state,
last_sync: f.last_sync,
progress,
paused,
}
}
+86
View File
@@ -10,12 +10,44 @@
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
//! deadline that passed while the daemon was down is already over on mount.
//!
//! A single mirror folder can be paused on its own as well: its reconcile is
//! skipped, and with it any mode switch queued behind that reconcile, until the
//! folder is resumed. On-demand folders cannot be paused alone — their writes
//! go through the shared queue, which only the global pause holds back.
use super::*;
/// The `state` key holding the resume time, in Unix seconds.
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
/// The `state` key prefix marking one synced folder paused; its id follows.
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
fn folder_paused_key(id: i64) -> String {
format!("{FOLDER_PAUSED_PREFIX}{id}")
}
/// Whether synced folder `id` is paused on its own. A row that cannot be read
/// counts as running, like the global pause.
fn load_folder_paused(db: &Db, id: i64) -> bool {
match db.state_i64(&folder_paused_key(id)) {
Ok(value) => value.is_some_and(|v| v != 0),
Err(error) => {
warn!(id, %error, "reading a folder pause failed; treating it as running");
false
}
}
}
/// Persist synced folder `id`'s own pause, or clear it.
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
match paused {
true => db.set_state_i64(&folder_paused_key(id), 1),
false => db.clear_state(&folder_paused_key(id)),
}
}
/// A pause with no end time: it lasts until the user resumes.
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
@@ -95,6 +127,48 @@ impl Core {
Ok(())
}
/// True while the user has this one synced folder paused. A row that cannot
/// be read counts as running, like the global pause.
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
load_folder_paused(&self.db, id)
}
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
/// straight away to catch up on what the watcher saw meanwhile.
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
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 paused && folder.mode != "mirror" {
return Err(CoreError::invalid(
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
through the shared queue, so pause all sync instead",
));
}
if let Err(error) = store_folder_paused(&self.db, id, paused) {
return Err(CoreError::internal(format!(
"saving the folder pause: {error}"
)));
}
if paused {
info!(id, path = %folder.local_path, "folder sync paused");
} else {
info!(id, path = %folder.local_path, "folder sync resumed");
self.sync_now(Some(id));
}
Ok(())
}
/// Forget a removed folder's pause, so a later folder reusing its id does
/// not start out paused.
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
if let Err(error) = store_folder_paused(&self.db, id, false) {
warn!(id, %error, "clearing a removed folder's pause failed");
}
}
/// Wake everything a pause held back: the drain worker, and a full
/// reconcile to pick up whatever the watcher saw meanwhile.
fn resume_sync_work(&self) {
@@ -146,4 +220,16 @@ mod tests {
assert!(!paused_at(200, 300));
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
}
#[test]
fn a_folder_pause_is_kept_per_folder_until_cleared() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
assert!(!load_folder_paused(&db, 3));
store_folder_paused(&db, 3, true).unwrap();
assert!(load_folder_paused(&db, 3));
assert!(!load_folder_paused(&db, 4));
assert!(!load_folder_paused(&db, 33));
store_folder_paused(&db, 3, false).unwrap();
assert!(!load_folder_paused(&db, 3));
}
}
+6
View File
@@ -82,6 +82,12 @@ impl Core {
/// is guaranteed — a switch requested over the control socket cannot wait for a
/// pass that may run for minutes.
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
// A paused folder skips the settle too: a queued switch to on-demand
// evicts the local tree, which is only safe after a pass uploaded it.
if self.sync_folder_paused(folder.id) {
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
return;
}
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
// to reconcile — but one may still carry a queued switch back to `mirror`,
// so the settle below is not skipped with it.
+1
View File
@@ -839,6 +839,7 @@ mod tests {
last_sync: 0,
pending_mode: None,
progress: None,
paused: false,
}
}
+64 -2
View File
@@ -915,10 +915,30 @@ fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
row.add_suffix(&change);
}
/// Sync now (mirror only), the on-demand switch, and Stop syncing for one
/// synced folder.
/// Pause or resume and Sync now (mirror only), the on-demand switch, and Stop
/// syncing for one synced folder.
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
let (icon, tooltip) = match spec.paused {
true => (
"media-playback-start-symbolic",
"Resume syncing this folder",
),
false => ("media-playback-pause-symbolic", "Pause syncing this folder"),
};
let pause = gtk4::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.valign(gtk4::Align::Center)
.build();
pause.add_css_class("flat");
let ui_pause = ui.clone();
let paused = spec.paused;
pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused));
row.add_suffix(&pause);
}
// A paused folder skips its passes, so Sync now would do nothing.
if spec.mode != MountMode::OnDemand && !spec.paused {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
@@ -943,6 +963,8 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
)
.valign(gtk4::Align::Center)
.active(target == MountMode::OnDemand)
// The daemon refuses a mode switch on a paused folder.
.sensitive(!spec.paused)
.build();
let ui_mode = ui.clone();
let mode_path = spec.local_path.clone();
@@ -1002,6 +1024,33 @@ fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
dialog.present(win.as_ref());
}
/// Pause or resume one synced folder, then repaint so its row shows the result.
fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderPaused { id, paused },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let failed = match paused {
true => "Couldn't pause the folder",
false => "Couldn't resume the folder",
};
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(
&ui,
match paused {
true => "Folder paused",
false => "Folder resumed",
},
),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
refresh_locations(&ui);
});
}
/// Ask the daemon for an immediate pass over one folder.
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
@@ -1049,6 +1098,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
if matches!(spec.kind, MountKind::Device { .. }) {
parts.push(match &spec.progress {
Some(p) => sync_progress_label(p),
None if spec.paused => "paused".to_string(),
None => sync_state_label(&spec.state).to_string(),
});
}
@@ -1081,6 +1131,7 @@ mod tests {
pending_mode: None,
mounted,
progress: None,
paused: false,
}
}
@@ -1096,6 +1147,17 @@ mod tests {
assert_eq!(location_subtitle(&spec), "Synced · up to date");
}
#[test]
fn a_paused_folder_says_paused_instead_of_its_state() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
spec.paused = true;
assert_eq!(location_subtitle(&spec), "Synced · paused");
}
#[test]
fn an_on_demand_folder_without_a_session_says_so() {
let spec = spec(