Compare commits

..
Author SHA1 Message Date
nvrl 14a663cee9 fix(fuse): keep pinned files on disk through remote changes
A remote change to a pinned file called `ContentCache::evict`, which does
not check pins, and only the pin request itself ever downloaded anything.
An edit from another device, or even a rename, left the file listed as kept
offline with no copy, so the next read downloaded it or failed without a
network. A file added to a pinned folder elsewhere was never downloaded,
and a folder pin whose downloads failed while offline never retried them.

A remote change no longer evicts a pinned file's copy. The copy is checked
against the revision's (mtime, size), so a newer revision does not read
it, and a rename keeps the mtime (B123), so the copy stays valid. A new
pdfs-pin-keeper thread checks pins again and downloads files that are
missing or stale:

- the pins a batch of remote events changed, after the batch's listings
  are dropped, including a pinned folder that gets a new file;
- every pin at startup, when the link returns, and after the event feed
  loses continuity;
- a file whose version is restored.

Failed downloads retry with a backoff of 30 s to 30 min. Pinning and the
keeper settle a provisional size first: a copy stored under the
ciphertext size never matched the plaintext it holds.

Fixes B282.
2026-11-14 00:20:06 +01:00
nvrl 3f45e89391 fix(fuse): keep files offline in on-demand synced folders
`pdfs pin` and `pdfs unpin` resolved every path against the primary mount
with `rel_to_mount`. An absolute path in an on-demand synced folder such as
~/Pictures was refused as "not under the mountpoint", and a relative path
named a file in My files instead. A synced folder's file is a different
Drive node from a same-named file in My files, so keeping one offline did
nothing for the other, and every program reading the synced copy
downloaded it.

Pin and unpin now route through `route_to_mount`, like ls, refresh, rm and
mv since B86, and run on the mount that owns the path. A path in a mirror
folder gets an error saying its files are already on this computer. A pin
made through an on-demand folder records the absolute local path, so
`pdfs pins`, the prompt and the HUD open the file where it was kept; pins
under the primary mount still record the mount-relative path.

Fixes B281.
2026-11-14 00:20:06 +01:00
12 changed files with 603 additions and 28 deletions

No files matched your search

+35 -1
View File
@@ -157,6 +157,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
if ours {
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
}
// A file kept offline keeps its copy through a change, and the
// keeper replaces it once this batch's listings are read again.
// The copy is checked against the revision's `(mtime, size)`, so
// no read takes it for a newer revision. Evicted, it was gone until
// something read the file, which then downloaded it (B282). A
// node new in, or moved into, a kept folder is fetched by checking
// that folder.
let kept = match !*is_trashed && !ours {
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
false => None,
};
let mut had_node = false;
core.for_each_mount(|st, notify| {
if *is_trashed {
@@ -172,9 +183,12 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
}
});
// Once, not once per mount: the content cache is per-daemon.
if had_node {
if had_node && kept.is_none() {
core.cache.evict(node_uid);
}
if let Some(kept) = kept {
dirty.kept.insert(kept);
}
// A create, rename or move-in shows up as a change to the parent
// listing too, which is recorded for the end of the batch rather
// than acted on here — see [`DirtyParents`].
@@ -205,6 +219,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
// Changes went by unseen, kept files among them (B282).
core.recheck_all_pins();
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notify| {
let dirs: Vec<u64> = st.children.keys().copied().collect();
@@ -296,6 +312,21 @@ struct DirtyParents {
/// Checked per mount — a mount that already lists every one of those
/// children under that parent keeps its listing.
ours: HashMap<NodeUid, HashSet<NodeUid>>,
/// Pinned files and folders the batch changed, for the pin keeper to
/// check once the listings above are dropped (`keeper`).
kept: HashSet<NodeUid>,
}
/// What the pin keeper has to check after a remote change to `node_uid`,
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
/// else the parent, when a pinned folder holds it and the node is new there.
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
if core.cache.is_pinned(node_uid) {
return Some(node_uid.clone());
}
parent_uid
.filter(|parent| core.cache.is_pinned(parent))
.cloned()
}
/// Whether this mount's cached listing of `parent_uid` already accounts for
@@ -476,6 +507,9 @@ pub(super) async fn run_event_sync(
cursor = Some(applied);
}
flush_dirty_parents(&core, &dirty);
for uid in &dirty.kept {
core.recheck_pinned(uid);
}
// Another device changed the tree: whatever a front-end lists may have
// moved, including what is shared and what is in the trash.
core.events
+45 -9
View File
@@ -204,18 +204,42 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// B99), so resolving one needs this too. A mirror folder has no mount to run
/// against: a file there is an ordinary local file.
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!("{path} is in a mirrored folder; change the local file there instead")
})
}
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
/// on-demand synced folder is a different Drive node from any file of the
/// same name under the primary mount, and keeping one offline did nothing for
/// the other: a program reading the synced folder's copy downloaded it
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!(
"{path} is in a mirrored folder, which keeps all its files on this computer already"
)
})
}
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
fn route_within(
core: &Core,
mountpoint: &Path,
path: &str,
mirrored: impl FnOnce(&str) -> String,
) -> CoreResult<(Core, PathBuf)> {
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
let p = Path::new(path);
let mirrored = core
let in_mirror = core
.db
.sync_folder_list()
.unwrap_or_default()
.iter()
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
if mirrored {
CoreError::invalid(format!(
"{path} is in a mirrored folder; change the local file there instead"
))
if in_mirror {
CoreError::invalid(mirrored(path))
} else {
e
}
@@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core,
}
}
/// The path the pin registry keeps for a pin made through `routed`: the
/// mount-relative path on the primary mount, as it always was, and the
/// absolute path the request named on an on-demand folder's mount, since
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
if routed.primary {
rel.to_path_buf()
} else {
Path::new(path).components().collect()
}
}
/// [`route_change`] for a request that trashes or renames the node it names.
/// A mount's root is My files or a whole synced folder. An on-demand folder's
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
@@ -521,8 +557,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::Subscribe) => {
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) {
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
@@ -533,8 +569,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.unpin(&rel) {
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
Ok((routed, rel)) => match routed.unpin(&rel) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
+271
View File
@@ -0,0 +1,271 @@
//! Keeping pinned files on this computer after they were pinned.
//!
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
//! change from another device deleted the cached copy, a file added to a
//! pinned folder elsewhere was never fetched, and a download that failed while
//! the link was down stayed failed. Each of those left a file listed as kept
//! offline that the next read downloaded, and that could not be read at all
//! without a network (`docs/BUGS.md` B282).
//!
//! The keeper is one thread that checks pins again and downloads what is
//! missing or stale: every pin when the daemon starts and when the link comes
//! back, and the pins a batch of remote events touched once the batch is
//! applied. A remote change no longer deletes a pinned file's copy. The copy
//! is stored under the revision's `(mtime, size)`, so a read of a newer
//! revision does not take it, and the keeper replaces it once the new one is
//! downloaded.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, is_local_uid, node_size, parse_uid};
/// How often the keeper looks for work.
const KEEPER_TICK: Duration = Duration::from_secs(1);
/// The first wait before a check that could not download everything is tried
/// again. It doubles on every failure, up to [`RETRY_MAX`].
const RETRY_FIRST: Duration = Duration::from_secs(30);
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
/// What the keeper owes a check.
#[derive(Debug, Default)]
pub(crate) struct PinWork {
/// Every pin.
all: bool,
/// The pinned files and folders named here. A folder is checked whole.
nodes: HashSet<NodeUid>,
}
impl PinWork {
fn is_empty(&self) -> bool {
!self.all && self.nodes.is_empty()
}
fn merge(&mut self, other: PinWork) {
self.all |= other.all;
self.nodes.extend(other.nodes);
}
}
/// A file a pin keeps, as the tree knows it now.
pub(crate) struct KeptFile {
pub(crate) uid: NodeUid,
pub(crate) name: String,
pub(crate) mtime: i64,
pub(crate) size: u64,
}
impl Core {
/// Have the keeper check every pin.
pub(crate) fn recheck_all_pins(&self) {
self.pin_work.lock().all = true;
}
/// Have the keeper check the pinned file or folder `uid`.
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
self.pin_work.lock().nodes.insert(uid.clone());
}
/// The keeper's loop, until the daemon stops. Starts with a check of every
/// pin, which catches up on whatever changed while the daemon was down.
///
/// Work that failed waits out a backoff of its own. New work does not wait
/// for it, so a file that keeps failing does not hold up the next change.
pub(crate) fn run_pin_keeper(&self) {
self.recheck_all_pins();
let mut backlog = PinWork::default();
let mut backlog_due = Instant::now();
let mut retry = RETRY_FIRST;
while self.shutdown.sleep(KEEPER_TICK) {
if !self.is_online() {
continue;
}
let mut work = std::mem::take(&mut *self.pin_work.lock());
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
if retrying {
work.merge(std::mem::take(&mut backlog));
}
if work.is_empty() {
continue;
}
let failed = self.keep_pins(&work);
if failed.is_empty() {
if retrying {
retry = RETRY_FIRST;
}
continue;
}
backlog.merge(failed);
backlog_due = Instant::now() + retry;
retry = (retry * 2).min(RETRY_MAX);
}
}
/// Download every file `work` covers whose cached copy is missing or
/// stale. Returns what could not be checked or downloaded.
fn keep_pins(&self, work: &PinWork) -> PinWork {
let mut failed = PinWork::default();
let roots: Vec<NodeUid> = if work.all {
self.cache
.list_pins()
.iter()
.filter_map(|pin| parse_uid(&pin.uid))
.chain(work.nodes.iter().cloned())
.collect()
} else {
work.nodes.iter().cloned().collect()
};
let mut seen: HashSet<u64> = HashSet::new();
let mut files: Vec<u64> = Vec::new();
for uid in roots {
if self.shutdown.is_stopping() {
return failed;
}
if !self.cache.is_pinned(&uid) {
continue;
}
let found = {
let st = self.state();
st.by_uid.get(&uid).and_then(|&ino| {
let e = st.entries.get(&ino)?;
Some((ino, e.parent, e.node.is_folder()))
})
};
let Some((ino, parent, is_folder)) = found else {
debug!(%uid, "pin keeper: pinned node is not in the tree");
continue;
};
if is_folder {
match self.pin_subtree_files(ino) {
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
Err(e) => {
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
failed.nodes.insert(uid);
}
}
continue;
}
// An event about this file dropped its parent's listing. Listing
// it again brings the file's entry up to the revision Drive has
// now, which is what the cached copy is checked against.
if !work.all
&& parent != ino
&& let Err(e) = self.ensure_children(parent)
{
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
failed.nodes.insert(uid);
continue;
}
if seen.insert(ino) {
files.push(ino);
}
}
let stale: Vec<KeptFile> = files
.into_iter()
.filter_map(|ino| self.kept_file(ino))
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
.collect();
if stale.is_empty() {
return failed;
}
let job = self.transfers.begin_job("Updating files kept offline");
job.set_total(stale.len() as u64);
let (mut fetched, mut failures) = (0usize, 0usize);
for file in stale {
if self.shutdown.is_stopping() {
break;
}
// Unpinned while the check ran.
if !self.cache.is_pinned(&file.uid) {
job.step();
continue;
}
job.detail(file.name.clone());
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
Ok(()) => fetched += 1,
Err(e) => {
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
failed.nodes.insert(file.uid);
failures += 1;
}
}
job.step();
}
info!(
fetched,
failed = failures,
"pin keeper: brought files kept offline up to date"
);
failed
}
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
/// daemon still owes an upload for, or a file whose real size cannot be
/// read.
///
/// A queued write is ahead of Drive. Downloading Drive's revision would
/// cache what the write replaces, and the drain keeps the uploaded bytes
/// of a pinned file once they land.
///
/// A listing carries the ciphertext size until its size upgrade lands
/// (B12). Stored under that size, the copy never matched the plaintext it
/// holds, so every read downloaded the file again and every check
/// downloaded it once more.
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
let (uid, name, mtime, size, provisional) = {
let st = self.state();
let e = st.entries.get(&ino)?;
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
return None;
};
(
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
claimed_size.is_none(),
)
};
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
return None;
}
let size = match provisional {
true => self.resolve_provisional_size(ino, &uid)?,
false => size,
};
Some(KeptFile {
uid,
name,
mtime,
size,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_that_failed_keeps_every_node_and_the_full_check() {
let mut backlog = PinWork::default();
assert!(backlog.is_empty());
let uid = parse_uid("vol~a").unwrap();
backlog.merge(PinWork {
all: false,
nodes: HashSet::from([uid.clone()]),
});
backlog.merge(PinWork {
all: true,
nodes: HashSet::from([uid.clone()]),
});
assert!(backlog.all);
assert_eq!(backlog.nodes.len(), 1);
assert!(!backlog.is_empty());
}
}
+32 -17
View File
@@ -91,6 +91,7 @@ mod drive;
use drive::{DriveApi, RevisionRead};
mod events;
mod filesystem;
mod keeper;
mod link;
pub use filesystem::ProtonFs;
mod mount;
@@ -589,6 +590,8 @@ struct Core {
/// Nudges the drain workers: set true and notify to have them re-examine
/// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>,
/// The pins the keeper owes a check (`keeper`).
pin_work: Arc<Mutex<keeper::PinWork>>,
/// The daemon's stop signal, shared with every background loop this `Core`
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
shutdown: Arc<shutdown::Shutdown>,
@@ -4270,7 +4273,12 @@ impl Core {
/// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message.
fn pin(&self, rel: &Path) -> CoreResult<String> {
///
/// `recorded` is the path the pin registry keeps for the front ends to
/// open the pin by: `rel` itself on the primary mount, which they join to
/// the mountpoint, and the absolute local path on an on-demand folder's
/// mount, whose node is not reachable under the primary mountpoint at all.
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = {
let st = self.state();
@@ -4294,7 +4302,7 @@ impl Core {
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.add_pin(&uid, recorded, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
let core = self.clone();
let folder = name.clone();
@@ -4304,9 +4312,15 @@ impl Core {
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
}
// Stored under a provisional size, the copy would never match the
// plaintext it holds (`docs/BUGS.md` B282).
let size = match self.kept_file(ino) {
Some(file) if file.uid == uid => file.size,
_ => size,
};
self.fetch_content(&uid, &name, mtime, size)?;
self.cache
.add_pin(&uid, rel, false)
.add_pin(&uid, recorded, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name)
}
@@ -4324,14 +4338,20 @@ impl Core {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) in files {
for ino in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return;
}
job.detail(file.clone());
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
warn!(%uid, error = %e, "pin folder: download failed");
failed += 1;
// A file with a queued write, or no readable size, is
// left to the drain and the keeper (`keeper::kept_file`).
if let Some(file) = self.kept_file(ino) {
job.detail(file.name.clone());
if let Err(e) =
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
{
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
failed += 1;
}
}
job.step();
}
@@ -4365,9 +4385,9 @@ impl Core {
Ok(())
}
/// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
/// The inode of every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
let mut files: Vec<u64> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
@@ -4382,12 +4402,7 @@ impl Core {
if e.node.is_folder() {
stack.push(k);
} else {
files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
files.push(k);
}
}
}
+3
View File
@@ -286,6 +286,9 @@ impl Core {
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
}
self.wake_drain();
// Downloads for pins failed while the link was down, and the events
// that would have named the files are behind us (`docs/BUGS.md` B282).
self.recheck_all_pins();
}
/// Wait up to `budget` for the link to come back. Returns whether it is up.
+12
View File
@@ -511,6 +511,7 @@ pub(crate) fn mount_with(
landed: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
pin_work: Arc::new(Mutex::new(Default::default())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
@@ -621,6 +622,17 @@ pub(crate) fn mount_with(
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep pinned files on this computer through changes made elsewhere, and
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
// B282). Its own thread: a check lists folders and downloads files.
{
let keeper_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-pin-keeper".into())
.spawn(move || keeper_core.run_pin_keeper());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
if matches!(host, Host::Daemon) {
+5
View File
@@ -111,6 +111,11 @@ impl Core {
// stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid);
self.evict_reader(uid);
// A file kept offline gets the restored content back on disk, and
// again when the event of the swap arrives (`docs/BUGS.md` B282).
if self.cache.is_pinned(uid) {
self.recheck_pinned(uid);
}
self.log_activity(
ActivityKind::Restore,
name,
+112
View File
@@ -3447,6 +3447,118 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
// Keeping a file offline took only paths under the primary mount, so
// the copy in an on-demand synced folder could not be kept, and a
// program reading it there downloaded it every time (B281).
let drive = FakeDrive::new();
let dir = scratch("pin-in-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
sync_folder(&daemon, &ondemand, "ondemand");
let content = b"not a real png, but kept all the same".repeat(64);
let file = ondemand.join("photo.png");
std::fs::write(&file, &content).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.request(&Request::PurgeCache).unwrap();
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
let pinned = daemon
.request(&Request::Pin { path: path.clone() })
.unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
let pins = || match daemon.request(&Request::ListPins) {
Ok(Response::Pins { pins }) => pins,
other => panic!("pins: {other:?}"),
};
let listed = pins();
assert!(
listed.iter().any(|pin| pin.path == path && pin.cached),
"the pin opens by the path it was made through: {listed:?}"
);
// With the link gone, only a kept copy can answer the read.
daemon.client.set_online(false);
assert_eq!(std::fs::read(&file).unwrap(), content);
daemon.client.set_online(true);
let unpinned = daemon
.request(&Request::Unpin { path: path.clone() })
.unwrap();
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
assert!(pins().is_empty());
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_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
// A change from another device deleted a pinned file's copy and
// nothing downloaded it again, and a file added to a pinned folder
// elsewhere was never downloaded: both were listed as kept offline
// and read only with a network (B282).
let drive = FakeDrive::new();
let dir = scratch("pin-keeper");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/a.txt").is_some()
&& drive.lookup("solo.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
for path in ["Docs", "solo.txt"] {
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
}
assert!(wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("", "solo.txt").is_some_and(|e| e.cached)
}));
// Another device edits both files and adds one to the kept folder.
drive.device().write("Docs/a.txt", b"second, and longer");
drive.device().write("solo.txt", b"alone, edited");
drive.device().write("Docs/new.txt", b"made elsewhere");
assert!(
wait_until(Duration::from_secs(60), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
}),
"the keeper downloaded the new content: {:?} {:?} {:?}",
entry("Docs", "a.txt"),
entry("", "solo.txt"),
entry("Docs", "new.txt"),
);
// With the link gone, only the kept copies can answer.
daemon.client.set_online(false);
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
assert_eq!(read("Docs/a.txt"), b"second, and longer");
assert_eq!(read("solo.txt"), b"alone, edited");
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
daemon.client.set_online(true);
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) {
+1
View File
@@ -467,6 +467,7 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
| `pdfs-online-probe` | Probes Drive while offline |
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
+74
View File
@@ -109,6 +109,8 @@ a live account since.
| Entry | Title |
|---|---|
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
@@ -383,6 +385,78 @@ a live account since.
Newest first.
### B282 — A file kept offline is downloaded again after a change made elsewhere
**Status:** Fixed, not verified live
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
pin had downloaded it once.
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
`crates/pdfs-fuse/src/keeper.rs`
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
fails without a network. Rename or move it elsewhere and the same happens, though its content
did not change. Add a file to a pinned folder from another device and it is never downloaded.
A folder pin whose downloads failed while the link was down never retries them.
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
pins, and only the pin request itself downloaded anything. The copy was also stored under the
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
(B12), and a copy stored under it never matches the plaintext it holds.
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
downloads files that are missing or stale:
- the pins a batch of remote events changed, after the batch's listings are dropped;
- a pinned folder that gets a new file;
- every pin at startup, when the link comes back, and after the event feed loses continuity;
- a file whose version is restored.
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
provisional size before it stores the copy.
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
folder, and adds a file to that folder. The test waits until all three are cached, then reads
them with the link down. With the keeper disabled, it fails with all three uncached.
---
### B281 — A file in a synced folder cannot be kept offline
**Status:** Fixed, not verified live
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
different mtime. The daemon treats every program the same way: it does not look at the process
that reads.
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
folder then downloads it.
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
the mount that serves an absolute path. Pin and unpin were left out.
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
path in a mirror folder gets an error that says its files are already on this computer. The pin
registry records the absolute local path for a pin made through an on-demand folder, so
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
under the primary mount still record the path relative to it.
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
takes the link down, and reads the file through that folder.
---
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
**Status:** Fixed, not verified live
+12
View File
@@ -54,6 +54,18 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
and a very large photo waits and is made on its own.
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
read it.
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
it again, so the next read downloaded it or failed without a network. Files added to a folder
kept offline from another device were never downloaded. The copy now stays until the new
version is downloaded, which happens on its own. Files kept offline are also checked at
startup and when the connection returns, and failed downloads are tried again.
## [3.7.0] — 2026-11-02
Schema: **44**, unchanged.
+1 -1
View File
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. |
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files and folders kept available offline. |