feat(core,fuse,gui,cli): pause/resume sync and a visible upload queue (phase 2)

Backend (docs/UI_UX_PLAN.md B-1, B-2):
- Request::SetSyncPaused { paused, until } pauses the drain worker and the
  mirror reconcile. FUSE reads, write staging, the watcher and the poll keep
  running. The pause is persisted in the `state` table (`sync_paused_until`),
  so it survives a restart; a timed pause ends by itself through a timer
  thread, re-armed on mount. Resuming wakes the drain and runs a full
  reconcile. Response::Status gains `paused` and `paused_until`.
- Request::ListPendingOps lists the queue with paths, attempts, last error,
  next attempt and parked/failing flags. Request::RetryPendingOp { id } makes
  one backed-off op (or every failed one) due now. Parked ops are never
  un-parked and nothing is dropped from the queue.

Front ends:
- Sync page: a status card with Pause/Resume (split button with 1 h, 8 h and
  24 h pauses) and a "Waiting to Upload" list with Retry now and Retry All.
- The sidebar strip and the tray show the paused state; the tray gains Pause
  Sync / Resume Sync and names failing changes.
- CLI: `pdfs sync pause [--for 30m|1h|2d]`, `pdfs sync resume`,
  `pdfs sync queue`, `pdfs sync retry [id]`; `pdfs status` reports a pause.

Not in this phase: merging Activity into the Sync page, discarding a queued
op (needs a recovery copy first), and per-folder pause.
This commit is contained in:
nvrl committed 2026-09-23 02:06:41 +02:00
1 parent cf9e1665ca
commit 067561564e
17 files changed
+995 -13

No files matched your search

+28
View File
@@ -246,6 +246,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
failing_error: queued.last_error.clone(),
staged_bytes: staging.bytes,
staged_oldest_secs: staging.oldest_secs,
paused: core.sync_pause().is_some(),
paused_until: core.sync_pause().flatten(),
username: username.to_string(),
mountpoint: mountpoint.display().to_string(),
pinned: pins.len(),
@@ -989,6 +991,32 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
}
}
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
match core.set_sync_paused(paused, until) {
Ok(()) => CtlResponse::Ok {
message: match (paused, until) {
(false, _) => "sync resumed".to_string(),
(true, None) => "sync paused until resumed".to_string(),
(true, Some(_)) => "sync paused".to_string(),
},
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
Ok(items) => CtlResponse::PendingOps { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
Ok(0) if id.is_some() => CtlResponse::Ok {
message: "nothing to retry: the operation is already due or waiting for a rename"
.to_string(),
},
Ok(moved) => CtlResponse::Ok {
message: format!("retrying {moved} queued operation(s) now"),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
Ok(items) => CtlResponse::RestorableFolders { items },
Err(e) => CtlResponse::error(e),
+10 -1
View File
@@ -285,8 +285,9 @@ impl Core {
}
};
let online = self.online.load(Ordering::Relaxed);
let paused = self.sync_paused();
let op = match due {
Some(op) if online => op,
Some(op) if online && !paused => op,
idle => {
// Claimed but offline: hand it straight back, or it stays
// invisible to every worker until this one happens to be
@@ -306,6 +307,14 @@ impl Core {
// its writer has to be noticed.
self.sweep_parked_creates();
}
// Paused, nothing due changes that: sleep until a resume
// wakes the worker (or the idle poll notices a timed pause
// ran out), not until the next op falls due — that is
// "now" for everything already queued.
if paused {
self.wait_for_drain_work();
continue;
}
// A debounced or backed-off op may be waiting: sleep only
// until it becomes due rather than the full idle-poll.
self.wait_for_drain_work_or_due();
+7 -1
View File
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use fuser::ReplyXattr;
@@ -88,8 +88,10 @@ mod drain;
mod filesystem;
pub use filesystem::ProtonFs;
mod mount;
mod pause;
mod photos;
mod profile;
mod queue;
mod reads;
mod redate;
mod revisions;
@@ -508,6 +510,10 @@ struct Core {
/// `Response::Status` so the UI can say so rather than leaving the user to
/// infer it from a wall of EIO.
online: Arc<AtomicBool>,
/// When the user paused syncing, the Unix second it resumes by itself;
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
sync_paused_until: Arc<AtomicI64>,
/// Writes accepted from the kernel but not yet uploaded, keyed by node
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
/// which it is rebuilt on mount.
+6
View File
@@ -357,6 +357,7 @@ pub fn mount(
// handlers can trigger reconciles.
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
let paused_until = pause::load_paused_until(&db);
let core = Core {
client: client.clone(),
rt: rt.clone(),
@@ -377,6 +378,7 @@ pub fn mount(
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)),
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
@@ -451,6 +453,10 @@ pub fn mount(
// Start the folder-sync engine. It watches every mirror folder, polls the
// remotes, and reconciles on its own thread — never in front of a FUSE call.
workers.extend(sync::spawn(core.clone(), sync_rx));
// A timed pause persisted by an earlier run still has to end on time.
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
core.arm_pause_timer(paused_until);
}
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
// out identical to the live file, surface the ones that genuinely diverge.
+149
View File
@@ -0,0 +1,149 @@
//! Pausing sync: the user's "stop uploading for a while".
//!
//! A pause holds back the two things that push local state to the remote — the
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
//! is not a sync), writes keep being accepted and staged, and the watcher and
//! poll keep noticing changes. Resuming lets all of it catch up at once.
//!
//! The pause is persisted in the database, so a restart does not quietly undo
//! 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.
use super::*;
/// The `state` key holding the resume time, in Unix seconds.
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
/// A pause with no end time: it lasts until the user resumes.
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
/// The persisted pause, or `0` when there is none (or it cannot be read — a
/// broken state row must not keep sync paused forever).
pub(crate) fn load_paused_until(db: &Db) -> i64 {
match db.state_i64(PAUSED_UNTIL_KEY) {
Ok(Some(until)) => until,
Ok(None) => 0,
Err(error) => {
warn!(%error, "reading the sync pause failed; treating sync as running");
0
}
}
}
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
/// seconds). `0` is "not paused".
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
until != 0 && now < until
}
impl Core {
/// True while the user has sync paused.
pub(crate) fn sync_paused(&self) -> bool {
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
}
/// The pause's end, for status reporting: `None` when not paused,
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
/// Unix second `t`.
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
let until = self.sync_paused_until.load(Ordering::Relaxed);
match paused_at(until, now_secs()) {
false => None,
true if until == PAUSED_INDEFINITELY => Some(None),
true => Some(Some(until)),
}
}
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
/// it when `paused` is false.
pub(crate) fn set_sync_paused(
&self,
paused: bool,
until: Option<i64>,
) -> Result<(), CoreError> {
let value = match (paused, until) {
(false, _) => 0,
(true, None) => PAUSED_INDEFINITELY,
(true, Some(until)) if until <= now_secs() => {
return Err(CoreError::invalid("the pause would already be over"));
}
(true, Some(until)) => until,
};
let persisted = match value {
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
};
if let Err(error) = persisted {
return Err(CoreError::internal(format!(
"saving the sync pause: {error}"
)));
}
self.sync_paused_until.store(value, Ordering::Relaxed);
match value {
0 => {
info!("sync resumed");
self.resume_sync_work();
}
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
until => {
info!(until, "sync paused");
self.arm_pause_timer(until);
}
}
Ok(())
}
/// 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) {
self.wake_drain();
self.sync_now(None);
}
/// End a timed pause when its time comes. A later pause or resume replaces
/// the stored deadline, which the timer checks before acting, so a stale
/// timer does nothing.
pub(crate) fn arm_pause_timer(&self, until: i64) {
let core = self.clone();
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
let spawned = std::thread::Builder::new()
.name("pdfs-pause".into())
.spawn(move || {
if !core.shutdown.sleep(wait) {
return;
}
if core
.sync_paused_until
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
warn!(%error, "clearing an expired sync pause failed");
}
info!("sync pause ended");
core.resume_sync_work();
}
});
if let Err(error) = spawned {
// The drain and the reconcile still read the clock, so the pause
// still ends — just at the next idle poll instead of on time.
warn!(%error, "starting the sync pause timer failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pause_is_in_force_until_its_deadline() {
assert!(!paused_at(0, 100));
assert!(paused_at(200, 100));
assert!(!paused_at(200, 200));
assert!(!paused_at(200, 300));
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
}
}
+60
View File
@@ -0,0 +1,60 @@
//! The user's view of the pending-op queue: what has not reached the remote
//! yet, why, and a way to stop waiting out a backoff.
//!
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
//! Nothing here drops a queued op: its staged blob is the only copy of the
//! user's write (see `docs/RECOVERY.md`).
use super::*;
use pdfs_core::control::PendingOpInfo;
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
impl Core {
/// Every queued op, oldest first, with a path a person can read.
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
let ops = self
.db
.pending_ops()
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
Ok(ops
.into_iter()
.map(|op| {
let parked = op.next_attempt_at >= PARK_UNTIL;
let path = self
.db
.node_path(&op.uid)
.ok()
.flatten()
.filter(|path| !path.is_empty())
.or_else(|| op.name.clone())
.unwrap_or_else(|| op.uid.clone());
PendingOpInfo {
id: op.id,
kind: op.kind,
path,
attempts: op.attempts,
last_error: op.last_error,
queued_at: op.created_at / 1000,
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
parked,
failing: op.attempts >= FAILING_ATTEMPTS,
}
})
.collect())
}
/// Retry one backed-off op now, or every failed one when `id` is `None`.
/// Returns how many ops were moved up.
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
let now = now_millis();
let moved = match id {
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
None => self.db.retry_failed_ops_now(now),
}
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
if moved > 0 {
self.wake_drain();
}
Ok(moved)
}
}
+13 -1
View File
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
reconcile_all(&core);
} else {
for id in ids {
if core.sync_paused() {
break;
}
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
reconcile_all(core);
} else {
for id in ids {
if core.sync_paused() {
break;
}
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
@@ -230,8 +236,14 @@ pub(super) fn classify(
true
}
/// Reconcile every mirror folder in turn.
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
/// watcher reported meanwhile.
fn reconcile_all(core: &Core) {
if core.sync_paused() {
debug!("sync: paused, skipping reconcile");
return;
}
let folders = match core.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {