mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fixed race conditions + extendet test suite
This commit is contained in:
1 parent
a091c2f34f
commit
8470662c54
12 files changed
+2245
-201
No files matched your search
@@ -2,73 +2,19 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||
/// any stale cached metadata/data for the affected inodes.
|
||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||
/// which event carried them.
|
||||
///
|
||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||
fn apply_event(
|
||||
state: &Mutex<State>,
|
||||
content: &ContentCache,
|
||||
pending: &Mutex<HashMap<NodeUid, PendingRevision>>,
|
||||
notifier: &Notifier,
|
||||
event: &DriveEvent,
|
||||
) {
|
||||
match event {
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
parent_node_uid,
|
||||
is_trashed,
|
||||
..
|
||||
} => {
|
||||
let mut st = state.lock();
|
||||
if *is_trashed {
|
||||
// Trashing makes a node vanish from its parent listing.
|
||||
let child = st.by_uid.get(node_uid).copied();
|
||||
if let Some((parent, name)) = st.forget(node_uid) {
|
||||
content.evict(node_uid);
|
||||
match child {
|
||||
Some(child) => {
|
||||
let _ =
|
||||
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||
}
|
||||
None => {
|
||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if pending.lock().contains_key(node_uid) {
|
||||
// A node we owe an upload for is *ahead* of the remote, not
|
||||
// behind it: this event is almost always the echo of our own
|
||||
// empty-file create, and re-fetching would replace the size and
|
||||
// mtime of the write we just accepted with the stale revision's
|
||||
// — making a file that was copied in seconds ago read as empty
|
||||
// until its upload lands (offline.md Phase 3).
|
||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||
} else if let Some(&ino) = st.by_uid.get(node_uid) {
|
||||
// Known node changed: drop its cached attrs/data (and listing if
|
||||
// it is a directory) so the next access re-fetches. Its content
|
||||
// blob may now be stale, so evict it too.
|
||||
st.invalidate_listing(ino);
|
||||
content.evict(node_uid);
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
// A create (or move-in) shows up as a change to the parent listing;
|
||||
// drop it so the new child is picked up on the next readdir.
|
||||
if let Some(parent_uid) = parent_node_uid
|
||||
&& let Some(&parent) = st.by_uid.get(parent_uid)
|
||||
{
|
||||
st.invalidate_listing(parent);
|
||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||
}
|
||||
}
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
let mut st = state.lock();
|
||||
/// Returns whether this mount actually held the node, so the caller can evict
|
||||
/// its content blob once if *any* mount did.
|
||||
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
||||
// Capture the inode before `forget` clears the uid mapping.
|
||||
let child = st.by_uid.get(node_uid).copied();
|
||||
content.evict(node_uid);
|
||||
if let Some((parent, name)) = st.forget(node_uid) {
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let Some((parent, name)) = st.forget(uid) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(notifier) = notifier {
|
||||
match child {
|
||||
Some(child) => {
|
||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||
@@ -78,19 +24,97 @@ fn apply_event(
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||
/// any stale cached metadata/data for the affected inodes.
|
||||
///
|
||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||
///
|
||||
/// Applied to **every** mounted inode space, not just the primary one. This task
|
||||
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
||||
/// not a mount — so a file trashed from another device has to be withdrawn from
|
||||
/// whichever of our sessions is showing it, which for anything under a sync
|
||||
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
||||
/// the primary state alone left forks serving a deleted file indefinitely, the
|
||||
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||
/// mount must be notified through its **own** channel; that pairing is what
|
||||
/// [`Core::for_each_mount`] exists to preserve.
|
||||
fn apply_event(core: &Core, event: &DriveEvent) {
|
||||
match event {
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
parent_node_uid,
|
||||
is_trashed,
|
||||
..
|
||||
} => {
|
||||
// A node we owe an upload for is *ahead* of the remote, not behind
|
||||
// it: this event is almost always the echo of our own empty-file
|
||||
// create, and re-fetching would replace the size and mtime of the
|
||||
// write we just accepted with the stale revision's — making a file
|
||||
// that was copied in seconds ago read as empty until its upload
|
||||
// lands (offline.md Phase 3). The parent listing is still refreshed
|
||||
// below; it is only the node itself that is ours to define.
|
||||
let ours = !*is_trashed && core.pending.lock().contains_key(node_uid);
|
||||
if ours {
|
||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||
}
|
||||
let mut had_node = false;
|
||||
core.for_each_mount(|st, notifier| {
|
||||
if *is_trashed {
|
||||
// Trashing makes a node vanish from its parent listing.
|
||||
had_node |= forget_and_notify(st, notifier, node_uid);
|
||||
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||
// Known node changed: drop its cached attrs/data (and
|
||||
// listing if it is a directory) so the next access
|
||||
// re-fetches. Its content blob may now be stale too.
|
||||
had_node = true;
|
||||
st.invalidate_listing(ino);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
}
|
||||
// A create (or move-in) shows up as a change to the parent
|
||||
// listing; drop it so the new child is picked up on the next
|
||||
// readdir.
|
||||
if let Some(parent_uid) = parent_node_uid
|
||||
&& let Some(&parent) = st.by_uid.get(parent_uid)
|
||||
{
|
||||
st.invalidate_listing(parent);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Once, not once per mount: the content cache is per-daemon.
|
||||
if had_node {
|
||||
core.cache.evict(node_uid);
|
||||
}
|
||||
}
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
core.cache.evict(node_uid);
|
||||
core.for_each_mount(|st, notifier| {
|
||||
forget_and_notify(st, notifier, node_uid);
|
||||
});
|
||||
}
|
||||
// Continuity or scope was lost: our cached listings may be arbitrarily
|
||||
// stale, so drop every listing and tell the kernel to forget all
|
||||
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
||||
let mut st = state.lock();
|
||||
core.for_each_mount(|st, notifier| {
|
||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||
for &ino in &dirs {
|
||||
st.invalidate_listing(ino);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// No substantive local change; the cursor advance is handled by the
|
||||
// caller persisting the event id.
|
||||
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||
@@ -102,15 +126,16 @@ fn apply_event(
|
||||
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
||||
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
||||
/// only on fatal error.
|
||||
///
|
||||
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||
/// to be applied to every mounted inode space, and the registry that enumerates
|
||||
/// them lives on the `Core` (see [`apply_event`]).
|
||||
pub(super) async fn run_event_sync(
|
||||
client: ProtonDriveClient,
|
||||
scope: DriveEventScopeId,
|
||||
state: Arc<Mutex<State>>,
|
||||
content: Arc<ContentCache>,
|
||||
db: Arc<Db>,
|
||||
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
|
||||
notifier: Notifier,
|
||||
core: Core,
|
||||
) {
|
||||
let db = core.db.clone();
|
||||
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
||||
// Resume: pick up exactly where the last run left off.
|
||||
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
||||
@@ -175,7 +200,7 @@ pub(super) async fn run_event_sync(
|
||||
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||
break;
|
||||
}
|
||||
apply_event(&state, &content, &pending, ¬ifier, event);
|
||||
apply_event(&core, event);
|
||||
let applied = event.id().clone();
|
||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
||||
|
||||
@@ -505,6 +505,11 @@ impl Core {
|
||||
fork.notifier = Arc::new(OnceLock::new());
|
||||
// Size upgrades are keyed by inode, which is per-fork too.
|
||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||
// The daemon's single drain thread belongs to the primary mount, but it
|
||||
// drains ops for nodes that live *here*. Publishing this inode space is
|
||||
// what lets it find them; without it a queued create's node keeps its
|
||||
// `local~` uid after the upload lands and reads it as empty (B74).
|
||||
fork.register_state();
|
||||
fork
|
||||
}
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ impl Core {
|
||||
/// it away from the one the user asked for. Best effort: the event sync
|
||||
/// would correct the tree anyway, but not before the user has looked at it.
|
||||
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
||||
let mut st = self.state.lock();
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
return;
|
||||
};
|
||||
@@ -275,6 +275,7 @@ impl Core {
|
||||
if let Err(e) = st.db.upsert_node(&node) {
|
||||
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Make a node that so far exists only on this machine real, and adopt the
|
||||
@@ -419,6 +420,14 @@ impl Core {
|
||||
///
|
||||
/// The inode is deliberately kept, so anything already holding the file open
|
||||
/// keeps working across the drain.
|
||||
///
|
||||
/// Applied to **every** live inode space, not just this Core's. A node
|
||||
/// created inside an on-demand sync folder lives in that fork's `state`,
|
||||
/// while the drain that lands its create belongs to the primary mount — so
|
||||
/// reaching for `self.state` here found nothing and left the fork's entry on
|
||||
/// its `local~` uid, which reads as an empty file for as long as the daemon
|
||||
/// runs (`docs/BUGS.md` B74). A uid is unique across mounts, so at most one
|
||||
/// of them matches and the others are no-ops.
|
||||
pub(crate) fn adopt_real_uid(
|
||||
&self,
|
||||
local: &NodeUid,
|
||||
@@ -431,8 +440,10 @@ impl Core {
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
|
||||
let mut st = self.state.lock();
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
self.for_each_state(|st| {
|
||||
let Some(ino) = st.by_uid.remove(local) else {
|
||||
return;
|
||||
};
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
@@ -460,8 +471,7 @@ impl Core {
|
||||
kids.push(ino);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(st);
|
||||
});
|
||||
// Write the real node through, now that nothing points at the old uid.
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||
@@ -604,11 +614,11 @@ impl Core {
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
// The conflict copy is a node the tree has never seen.
|
||||
let mut st = self.state.lock();
|
||||
self.for_each_state(|st| {
|
||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||
st.invalidate_listing(ino);
|
||||
}
|
||||
drop(st);
|
||||
});
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&name,
|
||||
@@ -662,13 +672,25 @@ impl Core {
|
||||
|
||||
/// A node's parent uid and name, as the tree currently has them.
|
||||
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
||||
{
|
||||
let st = self.state.lock();
|
||||
// Across every mount: the drain runs on the primary Core, but a node
|
||||
// written inside a sync folder is interned in that fork's inode space,
|
||||
// so asking `self.state` alone always missed it and fell through to the
|
||||
// DB. That still gave the right answer — the fallback below exists for
|
||||
// exactly this shape of miss — but it went to disk for something already
|
||||
// in memory, and only *because* of the fallback was it not a bug.
|
||||
let mut found = None;
|
||||
self.for_each_state(|st| {
|
||||
if found.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
||||
&& let Some(parent) = entry.node.parent_uid.clone()
|
||||
{
|
||||
return Some((parent, entry.node.name.clone()));
|
||||
found = Some((parent, entry.node.name.clone()));
|
||||
}
|
||||
});
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
// The in-memory tree only holds what has been walked to since the daemon
|
||||
// started, and the drain routinely runs before anything has walked to
|
||||
@@ -843,7 +865,7 @@ impl Core {
|
||||
if let Some(rev) = sealed_rev.clone() {
|
||||
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
||||
}
|
||||
let mut st = self.state.lock();
|
||||
self.for_each_state(|st| {
|
||||
for aw in st.active_writes.values_mut() {
|
||||
if aw.uid == *uid {
|
||||
aw.base_mtime = sealed_mtime;
|
||||
@@ -853,6 +875,7 @@ impl Core {
|
||||
"rebased open write handle onto the revision just uploaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ordered so that a write queued *during* the fetch above is still
|
||||
// caught: it took its baseline from the node's optimistic stamp, and
|
||||
@@ -860,7 +883,7 @@ impl Core {
|
||||
if self.rebaseline_pending(uid, &node) {
|
||||
return;
|
||||
}
|
||||
let mut st = self.state.lock();
|
||||
self.for_each_state(|st| {
|
||||
let Some(parent) = st
|
||||
.by_uid
|
||||
.get(uid)
|
||||
@@ -869,7 +892,8 @@ impl Core {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
st.intern(parent, node);
|
||||
st.intern(parent, node.clone());
|
||||
});
|
||||
}
|
||||
|
||||
/// Point a still-queued write at the revision this upload just sealed, and
|
||||
|
||||
@@ -1264,6 +1264,124 @@ impl Filesystem for ProtonFs {
|
||||
// fuser warning logs when applications probe file descriptors.
|
||||
reply.error(Errno::ENOTTY);
|
||||
}
|
||||
|
||||
// The callbacks below are deliberately unsupported. Each answers with the
|
||||
// same errno `fuser`'s default would, minus the `[Not Implemented]` WARN it
|
||||
// logs on every call — a probe is a normal thing for an application to do,
|
||||
// not an incident. git alone emits one `link` per loose object, which
|
||||
// buried real diagnostics in the journal (`docs/BUGS.md` B73). The errno
|
||||
// contract these keep is asserted by the acceptance suite's clean-refusal
|
||||
// case, so it must not drift.
|
||||
|
||||
fn mknod(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_parent: INodeNo,
|
||||
_name: &OsStr,
|
||||
_mode: u32,
|
||||
_umask: u32,
|
||||
_rdev: u32,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
// Drive stores files and folders; devices, FIFOs and sockets have no
|
||||
// representation. Regular files arrive through `create`, never here.
|
||||
reply.error(Errno::ENOSYS);
|
||||
}
|
||||
|
||||
fn symlink(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_parent: INodeNo,
|
||||
_link_name: &OsStr,
|
||||
_target: &Path,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
// EPERM, not ENOSYS: the operation is meaningful, Drive simply cannot
|
||||
// represent it, and callers treat EPERM as a definite "no" rather than
|
||||
// retrying against an older interface.
|
||||
reply.error(Errno::EPERM);
|
||||
}
|
||||
|
||||
fn link(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino: INodeNo,
|
||||
_newparent: INodeNo,
|
||||
_newname: &OsStr,
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
// A node has exactly one parent on Drive, so there are no hard links.
|
||||
// git falls back to rename() on EPERM, which this filesystem supports.
|
||||
reply.error(Errno::EPERM);
|
||||
}
|
||||
|
||||
fn fsyncdir(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino: INodeNo,
|
||||
_fh: FileHandle,
|
||||
_datasync: bool,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
// ENOSYS, which the kernel absorbs: it clears its fsyncdir probe and
|
||||
// reports success to the caller. There is no dirty directory buffer to
|
||||
// flush — directory changes are durable in SQLite before the callback
|
||||
// that made them returns.
|
||||
reply.error(Errno::ENOSYS);
|
||||
}
|
||||
|
||||
fn setxattr(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino: INodeNo,
|
||||
_name: &OsStr,
|
||||
_value: &[u8],
|
||||
_flags: i32,
|
||||
_position: u32,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
// Drive has no user-extended-attribute store. ENOSYS reaches userspace
|
||||
// as EOPNOTSUPP, the errno callers expect from a filesystem without
|
||||
// xattrs. Reads (`getxattr`/`listxattr`) are implemented, since the
|
||||
// thumbnail interface is exposed that way.
|
||||
reply.error(Errno::ENOSYS);
|
||||
}
|
||||
|
||||
fn lseek(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino: INodeNo,
|
||||
_fh: FileHandle,
|
||||
_offset: i64,
|
||||
_whence: i32,
|
||||
reply: ReplyLseek,
|
||||
) {
|
||||
// Must stay ENOSYS. A file's holes are not knowable without fetching
|
||||
// its blocks, and ENOSYS is what makes the kernel stop asking and
|
||||
// emulate SEEK_DATA/SEEK_HOLE itself. Any other errno would surface to
|
||||
// the caller as a failed seek.
|
||||
reply.error(Errno::ENOSYS);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn copy_file_range(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino_in: INodeNo,
|
||||
_fh_in: FileHandle,
|
||||
_offset_in: u64,
|
||||
_ino_out: INodeNo,
|
||||
_fh_out: FileHandle,
|
||||
_offset_out: u64,
|
||||
_len: u64,
|
||||
_flags: CopyFileRangeFlags,
|
||||
reply: ReplyWrite,
|
||||
) {
|
||||
// Also must stay ENOSYS: there is no server-side copy, and this is the
|
||||
// errno that sends coreutils (and the kernel) down the read/write path
|
||||
// instead of failing the copy outright.
|
||||
reply.error(Errno::ENOSYS);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle
|
||||
|
||||
+189
-17
@@ -47,10 +47,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fuser::ReplyXattr;
|
||||
use fuser::{
|
||||
BackgroundSession, BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem,
|
||||
FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption, Notifier, OpenAccMode,
|
||||
OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
|
||||
ReplyEntry, ReplyIoctl, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||
BackgroundSession, BsdFileFlags, Config, CopyFileRangeFlags, Errno, FileAttr, FileHandle,
|
||||
FileType, Filesystem, FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption,
|
||||
Notifier, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData,
|
||||
ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyLseek, ReplyOpen, ReplyWrite, Request,
|
||||
Session, TimeOrNow, WriteFlags,
|
||||
};
|
||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
||||
use pdfs_core::config::{AppDirs, SweepMode};
|
||||
@@ -319,6 +320,75 @@ struct Core {
|
||||
/// uploading that same tree — the engine would upload files as they vanish
|
||||
/// and then walk the FUSE mount as if it were local.
|
||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||
/// Every live inode space: this mount's own `state` plus one per on-demand
|
||||
/// fork ([`Core::fork_state`]). Shared by every fork, unlike `state` itself.
|
||||
///
|
||||
/// There is exactly **one** drain thread per daemon, owned by the primary
|
||||
/// mount, and it serves the whole `pending_op` table — including ops queued
|
||||
/// by a forked mount, which keeps its nodes in its own `state`. So a drain
|
||||
/// that reaches for `self.state` looks in the wrong inode space and silently
|
||||
/// finds nothing: the fork's entry keeps its `local~` placeholder uid
|
||||
/// forever, and every read of it short-circuits to empty because
|
||||
/// [`Core::read_range`] refuses to ask the API about a `local~` uid. That
|
||||
/// was `docs/BUGS.md` B74. Background work that rewrites node identity must
|
||||
/// walk this instead ([`Core::for_each_state`]).
|
||||
///
|
||||
/// `Weak`, so an unmounted fork's state is dropped rather than pinned here.
|
||||
states: Arc<StateRegistry>,
|
||||
}
|
||||
|
||||
/// One live inode space, as published to the shared registry.
|
||||
///
|
||||
/// The notifier travels with the state because the two are only meaningful
|
||||
/// together: an inode number names a different node in every mount, so telling
|
||||
/// the kernel to drop inode 42 is only correct on the session that minted it.
|
||||
/// Background work that invalidates as well as mutates needs the matching pair,
|
||||
/// which is what [`Core::for_each_mount`] hands it.
|
||||
type LiveMount = (Arc<Mutex<State>>, Arc<OnceLock<Notifier>>);
|
||||
|
||||
struct MountedState {
|
||||
/// `Weak`, so an unmounted fork's state is dropped rather than pinned here;
|
||||
/// a dead entry is reaped on the next registry walk.
|
||||
state: std::sync::Weak<Mutex<State>>,
|
||||
/// This mount's kernel notification channel — the same cell as
|
||||
/// [`Core::notifier`], still empty until its session is spawned.
|
||||
notifier: Arc<OnceLock<Notifier>>,
|
||||
}
|
||||
|
||||
/// Every mounted inode space in the daemon, shared by the primary `Core` and
|
||||
/// every [`Core::fork_state`] clone of it.
|
||||
///
|
||||
/// Split out from `Core` so the reap-and-walk rule can be tested on its own: a
|
||||
/// fork that fails to appear here, or one that lingers after unmount, is exactly
|
||||
/// the failure `docs/BUGS.md` B74 was.
|
||||
#[derive(Default)]
|
||||
struct StateRegistry(Mutex<Vec<MountedState>>);
|
||||
|
||||
impl StateRegistry {
|
||||
/// Publish an inode space and the channel to the session that owns it.
|
||||
///
|
||||
/// Registering the same `state` twice would make every walk visit it twice;
|
||||
/// callers register exactly once, at mount time.
|
||||
fn register(&self, state: &Arc<Mutex<State>>, notifier: Arc<OnceLock<Notifier>>) {
|
||||
let mut states = self.0.lock();
|
||||
states.retain(|m| m.state.strong_count() > 0);
|
||||
states.push(MountedState {
|
||||
state: Arc::downgrade(state),
|
||||
notifier,
|
||||
});
|
||||
}
|
||||
|
||||
/// Every live mount, reaping the entries whose session has gone. Returns
|
||||
/// owned handles so callers take the per-state locks one at a time rather
|
||||
/// than holding the registry lock across the work.
|
||||
fn live(&self) -> Vec<LiveMount> {
|
||||
let mut states = self.0.lock();
|
||||
states.retain(|m| m.state.strong_count() > 0);
|
||||
states
|
||||
.iter()
|
||||
.filter_map(|m| Some((m.state.upgrade()?, m.notifier.clone())))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Core::apply_sync_folder_mode`] did not switch a folder. The two cases are
|
||||
@@ -333,6 +403,39 @@ enum SwitchBlocked {
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Register an inode space so background work can reach it. Called once for
|
||||
/// the primary mount and once per on-demand fork.
|
||||
fn register_state(&self) {
|
||||
self.states.register(&self.state, self.notifier.clone());
|
||||
}
|
||||
|
||||
/// Run `apply` against every live inode space — this daemon's primary mount
|
||||
/// and each on-demand fork — one lock at a time.
|
||||
///
|
||||
/// The drain and the sync engine are per-daemon but node state is per-mount,
|
||||
/// so anything they change about a node has to be offered to whichever mount
|
||||
/// actually holds it. Which one that is cannot be known from the op: a
|
||||
/// `pending_op` row records a uid, not the session that queued it. Applying
|
||||
/// to all of them is correct because a uid is unique across mounts — at most
|
||||
/// one state has an entry for it, and the rest are no-ops.
|
||||
fn for_each_state(&self, mut apply: impl FnMut(&mut State)) {
|
||||
for (state, _) in self.states.live() {
|
||||
apply(&mut state.lock());
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`Core::for_each_state`], but also hands over the mount's kernel
|
||||
/// notification channel, so work that invalidates cached metadata reaches
|
||||
/// the session that actually minted the inodes it is naming.
|
||||
///
|
||||
/// `None` for a mount whose session has not been spawned yet — there is
|
||||
/// nothing to invalidate in a kernel that has never been told about it.
|
||||
fn for_each_mount(&self, mut apply: impl FnMut(&mut State, Option<&Notifier>)) {
|
||||
for (state, notifier) in self.states.live() {
|
||||
apply(&mut state.lock(), notifier.get());
|
||||
}
|
||||
}
|
||||
|
||||
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
|
||||
/// start serves previously-seen metadata (stable inodes, instant listings)
|
||||
/// without re-hitting the API. The root inode is already installed by
|
||||
@@ -1537,7 +1640,14 @@ impl Core {
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.hidden.lock().insert(uid.clone());
|
||||
self.state.lock().forget(uid);
|
||||
// Withdrawn from every mount, not just the one the unlink came through:
|
||||
// a sync folder maps a remote folder that also exists under My Files, so
|
||||
// the same uid can be interned in two inode spaces at once and the other
|
||||
// one would go on serving a file the user just trashed
|
||||
// (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget(uid);
|
||||
});
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.wake_drain();
|
||||
@@ -2154,7 +2264,11 @@ impl Core {
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, new_name, None))
|
||||
.map_err(|e| CoreError::from_api(&e, "rename"))?;
|
||||
self.state.lock().forget(&uid);
|
||||
// Every mount, so a fork showing the same node re-interns it under the
|
||||
// new name instead of keeping the old one (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget(&uid);
|
||||
});
|
||||
self.invalidate_parent_listing(rel);
|
||||
Ok(new_name.to_string())
|
||||
}
|
||||
@@ -2215,7 +2329,9 @@ impl Core {
|
||||
fn remove_replaced(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||
if is_local_uid(uid) {
|
||||
self.discard_queued_ops(uid);
|
||||
self.state.lock().forget(uid);
|
||||
self.for_each_state(|st| {
|
||||
st.forget(uid);
|
||||
});
|
||||
debug!(%uid, name, "replaced a node whose create was still queued");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2228,7 +2344,11 @@ impl Core {
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
self.discard_queued_ops(uid);
|
||||
self.state.lock().forget(uid);
|
||||
// The node was trashed on the server; withdraw it from every inode
|
||||
// space that had it, not only the mount the rename came through.
|
||||
self.for_each_state(|st| {
|
||||
st.forget(uid);
|
||||
});
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.invalidate_trash();
|
||||
@@ -2413,14 +2533,15 @@ impl Core {
|
||||
hidden.remove(uid);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
for parent in parents {
|
||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||
// Keyed by uid, so it applies to every mount: a restored node reappears
|
||||
// in whichever inode spaces show its parent, not only the primary one.
|
||||
self.for_each_state(|st| {
|
||||
for parent in &parents {
|
||||
if let Some(&ino) = st.by_uid.get(parent) {
|
||||
st.invalidate_listing(ino);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.invalidate_trash();
|
||||
Ok(parsed.len())
|
||||
}
|
||||
@@ -2456,11 +2577,15 @@ impl Core {
|
||||
/// Forget every trace of nodes that no longer exist anywhere: their inode and
|
||||
/// DB row, and their cached content.
|
||||
fn drop_local(&self, uids: &[NodeUid]) {
|
||||
let mut st = self.state.lock();
|
||||
// Every mount: these nodes are gone from the server, and a sync-folder
|
||||
// fork showing the same uid would otherwise keep serving them
|
||||
// (`docs/BUGS.md` B74). A uid is unique across inode spaces, so this is
|
||||
// a no-op in every mount but the one that holds it.
|
||||
self.for_each_state(|st| {
|
||||
for uid in uids {
|
||||
st.forget(uid);
|
||||
}
|
||||
drop(st);
|
||||
});
|
||||
for uid in uids {
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
@@ -3423,11 +3548,58 @@ mod pending_size_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Intervals, PendingRevision, conflict_name, copy_pending_for_truncate, fuse_name,
|
||||
is_stale_mount, node_visible, rename_needs_queue,
|
||||
Intervals, PendingRevision, StateRegistry, conflict_name, copy_pending_for_truncate,
|
||||
fuse_name, is_stale_mount, node_visible, rename_needs_queue,
|
||||
};
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
|
||||
/// The registry every mount publishes itself into is what lets the daemon's
|
||||
/// single drain thread reach a node living in an on-demand fork's inode
|
||||
/// space. A fork missing from it reads as an empty file for the life of the
|
||||
/// daemon (`docs/BUGS.md` B74), so the walk has to see *every* registered
|
||||
/// mount — and only the ones still mounted.
|
||||
#[test]
|
||||
fn state_registry_walks_every_live_mount_and_reaps_the_rest() {
|
||||
let registry = StateRegistry::default();
|
||||
assert!(registry.live().is_empty(), "a fresh registry has no mounts");
|
||||
|
||||
// The primary mount plus two on-demand forks.
|
||||
let (primary, _p) = state_test_helper();
|
||||
let (fork_a, _a) = state_test_helper();
|
||||
let (fork_b, _b) = state_test_helper();
|
||||
let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary));
|
||||
let fork_a = std::sync::Arc::new(parking_lot::Mutex::new(fork_a));
|
||||
let fork_b = std::sync::Arc::new(parking_lot::Mutex::new(fork_b));
|
||||
for state in [&primary, &fork_a, &fork_b] {
|
||||
registry.register(state, std::sync::Arc::new(std::sync::OnceLock::new()));
|
||||
}
|
||||
assert_eq!(registry.live().len(), 3, "every registered mount is walked");
|
||||
|
||||
// `live()` must not itself pin a mount alive, or an unmounted fork could
|
||||
// never be reaped.
|
||||
assert_eq!(
|
||||
std::sync::Arc::strong_count(&fork_a),
|
||||
1,
|
||||
"the registry holds forks weakly"
|
||||
);
|
||||
|
||||
// Unmounting a fork drops its state; the dead entry goes on the next walk.
|
||||
drop(fork_a);
|
||||
assert_eq!(registry.live().len(), 2, "an unmounted fork is dropped");
|
||||
assert_eq!(
|
||||
registry.0.lock().len(),
|
||||
2,
|
||||
"and its slot is reaped, not merely skipped"
|
||||
);
|
||||
|
||||
drop(primary);
|
||||
drop(fork_b);
|
||||
assert!(
|
||||
registry.live().is_empty(),
|
||||
"the last mount going away empties the registry"
|
||||
);
|
||||
}
|
||||
|
||||
/// The predicate must answer *only* for a dead FUSE connection. A healthy
|
||||
/// directory and an absent path are both "not stale" — widening it to any
|
||||
/// `metadata` error would make the on-demand restore lazily unmount paths
|
||||
|
||||
@@ -222,7 +222,11 @@ pub fn mount(
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state();
|
||||
|
||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||
// an upload, and reads must be served from their staged blobs until they land.
|
||||
@@ -341,15 +345,7 @@ pub fn mount(
|
||||
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
||||
// can invalidate kernel-cached metadata without threading a handle through.
|
||||
let _ = core.notifier.set(notifier.clone());
|
||||
rt.spawn(run_event_sync(
|
||||
client,
|
||||
scope,
|
||||
core.state,
|
||||
core.cache,
|
||||
core.db,
|
||||
core.pending,
|
||||
notifier,
|
||||
));
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
|
||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||
|
||||
@@ -466,7 +466,26 @@ impl Core {
|
||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||
/// the caller then leaves the path empty.
|
||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||
let st = self.state.lock();
|
||||
// Searched across every mount: a node browsed through a sync folder is
|
||||
// interned in that fork's inode space, so the primary state alone had no
|
||||
// entry for it and the caller silently showed an empty path
|
||||
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||
// it, which is the one the user reached it through.
|
||||
let mut path = None;
|
||||
self.for_each_state(|st| {
|
||||
if path.is_some() {
|
||||
return;
|
||||
}
|
||||
path = walk_to_root(st, uid);
|
||||
});
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
let mut ino = *st.by_uid.get(uid)?;
|
||||
let mut parts = Vec::new();
|
||||
while ino != ROOT_INO {
|
||||
@@ -476,5 +495,4 @@ impl Core {
|
||||
}
|
||||
parts.reverse();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,13 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.state.lock().forget_or_unlink(uid);
|
||||
// Every mount, not just the primary: a conflict copy inside a sync
|
||||
// folder lives in that fork's inode space, and leaving it hooked up
|
||||
// there keeps a node we have just trashed remotely visible and
|
||||
// readable there (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
self.discard_queued_ops(uid);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
@@ -232,12 +238,21 @@ impl Core {
|
||||
/// Whether a node has a write in flight or a file handle open against it.
|
||||
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||
/// writer/holder check.
|
||||
/// Asked across **every** mount. The sweep runs on the primary Core but
|
||||
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
let state = self.state.lock();
|
||||
let Some(&ino) = state.by_uid.get(uid) else {
|
||||
return false;
|
||||
let mut busy = false;
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
return;
|
||||
};
|
||||
state.active_writes.contains_key(&ino) || state.handles.values().any(|&held| held == ino)
|
||||
busy |=
|
||||
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
||||
});
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||
|
||||
+172
-1
@@ -2148,7 +2148,10 @@ default (B71) precisely so that stays true until there is field evidence.
|
||||
|
||||
## B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount
|
||||
|
||||
**Status:** Layers A + B fixed in-tree 2026-07-25 — offline-tested; **live validation pending**.
|
||||
**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**.
|
||||
Validation was blocked until B74 was fixed (its scenario ends in a rename, which
|
||||
B74 made read back as empty); with that out of the way the acceptance case
|
||||
`regression B70: transient download name is not sealed` passes live.
|
||||
**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads`
|
||||
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
|
||||
copies while their truncated partials kept the canonical name — e.g.
|
||||
@@ -2362,3 +2365,171 @@ or the CLI's response read in `crates/pdfs-cli/src/main.rs`.
|
||||
**Impact:** the trash listing is the user's recovery path after any destructive
|
||||
operation, including the B71 sweep. It needs to work before the sweep is allowed
|
||||
to trash anything.
|
||||
|
||||
## B73 — Unimplemented FUSE handlers log a WARN per probe
|
||||
|
||||
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||
expanded acceptance suite.
|
||||
**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse
|
||||
cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal:
|
||||
|
||||
```
|
||||
WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target")
|
||||
WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink")
|
||||
WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0)
|
||||
WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0)
|
||||
WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3)
|
||||
```
|
||||
|
||||
**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default
|
||||
trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the
|
||||
acceptance suite accepts every one of them as a clean refusal — but the logging
|
||||
is not conditional on anything the user did wrong.
|
||||
**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks,
|
||||
`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA`
|
||||
(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`.
|
||||
Warnings that look like faults bury real ones and make both `--journal-check`
|
||||
and manual triage noisier.
|
||||
**Measured** across two full acceptance runs against one daemon lifetime (PID
|
||||
887441, mount never remounted):
|
||||
|
||||
| op | warnings | behaviour |
|
||||
|---|---|---|
|
||||
| `link` | 11 | repeats on every call |
|
||||
| `symlink` | 4 | repeats on every call |
|
||||
| `mknod` | 2 | repeats on every call |
|
||||
| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again |
|
||||
| `lseek` | 1 | cached |
|
||||
| `copy_file_range` | 1 | cached |
|
||||
| `fsyncdir` | 1 | cached |
|
||||
|
||||
So the FUSE kernel module remembers `ENOSYS` for the file-level operations and
|
||||
stops issuing them for the lifetime of the mount, but not for the namespace
|
||||
operations, which are re-issued forever.
|
||||
|
||||
`link` leads for a sharper reason than "tools probe it": git uses `link()` to
|
||||
place **every loose object**, falling back to `rename()` when it fails. In one
|
||||
workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the
|
||||
`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume
|
||||
is therefore proportional to work done, not a fixed startup cost: a `git clone`
|
||||
of a real repository emits one multi-line warning per object. The lone `symlink`
|
||||
that is not the acceptance probe is git's `core.symlinks` detection at
|
||||
`git init` (a random name pointing at `testing`).
|
||||
|
||||
That moves this from cosmetic to a genuine operational problem: ordinary use of
|
||||
git inside a mount can bury every other log line.
|
||||
|
||||
**Fix (applied):** all seven callbacks are now implemented explicitly in
|
||||
`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno
|
||||
`fuser`'s default would** and nothing else — no WARN. Following `ioctl`
|
||||
(which already used this pattern and says why in its comment), the errno
|
||||
contract is unchanged, so the acceptance suite's clean-refusal assertions hold
|
||||
byte for byte:
|
||||
|
||||
| op | errno | why that one |
|
||||
|---|---|---|
|
||||
| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` |
|
||||
| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` |
|
||||
| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface |
|
||||
| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite |
|
||||
| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing |
|
||||
|
||||
Only the three namespace ops were strictly required (the other four silence
|
||||
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
||||
refusal explicit and documented at the call site rather than inherited.
|
||||
|
||||
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
||||
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
||||
in the journal across the whole run (was 17, 10 of them `link`).
|
||||
**Regression cover:** the acceptance suite's clean-refusal probes assert the
|
||||
errno contract; they pass unchanged.
|
||||
|
||||
## B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty
|
||||
|
||||
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||
expanded acceptance suite (`regression B70` case), then isolated to a broader
|
||||
trigger.
|
||||
**Impact:** on a *sync folder* mount (not `~/ProtonDrive`), a file whose create
|
||||
was still queued reads back as **0 bytes for as long as the daemon runs**, even
|
||||
though the bytes are on Drive intact. This is the write-to-temp-then-rename
|
||||
pattern used by every browser download, most editor saves, `rsync`, and `git`.
|
||||
|
||||
**Not data loss.** An earlier revision of this entry called it permanent data
|
||||
loss; that was wrong and is corrected here. The uploaded revision is complete and
|
||||
correct: for every "lost" file the node row carried the right `size` and
|
||||
`active_revision_state: "Active"`, and a daemon restart made all of them read
|
||||
back with the expected sha256. Nothing had to be recovered. The defect was
|
||||
entirely local and entirely in the read path.
|
||||
|
||||
**Reproduction** (on an on-demand mount, per file: write, close, rename, wait for
|
||||
`pdfs status` to report the queue drained, then read back):
|
||||
|
||||
| scenario | result |
|
||||
|---|---|
|
||||
| plain name, never renamed | content intact |
|
||||
| plain name, renamed after close | **0 bytes until restart** |
|
||||
| `.crdownload`, renamed after close | **0 bytes until restart** |
|
||||
| `.crdownload`, never renamed | content intact |
|
||||
| already-settled file, renamed | content intact |
|
||||
| any of the above, after a daemon restart | content intact |
|
||||
|
||||
The gap between `close()` and `rename()` does not matter — 0 s, 2 s and 20 s all
|
||||
behave the same. The boundary is not timing but state: the affected node is one
|
||||
whose **create op was still queued**, on a mount that is not the primary one.
|
||||
|
||||
**Root cause — one drain thread, many inode spaces.** The daemon mounts
|
||||
`~/ProtonDrive` plus one FUSE session per `ondemand` sync folder. Each of those
|
||||
is a `Core::fork_state` clone with its **own** `State` — its own `entries`,
|
||||
`by_uid`, `children`, `next_ino` — while sharing `db`, `cache`, `pending` and
|
||||
`client`. There is exactly **one** `pdfs-drain` thread in the process, owned by
|
||||
the primary mount, and it serves the whole shared `pending_op` table.
|
||||
|
||||
So when a create queued by a sync-folder mount lands, the drain calls
|
||||
`adopt_real_uid` — which looked only at `self.state`, the *primary* mount's inode
|
||||
space. The fork's entry keeps its `local~…` placeholder uid forever. And
|
||||
`Core::read_range` (`crates/pdfs-fuse/src/reads.rs:314`) short-circuits a local
|
||||
uid to an empty vec:
|
||||
|
||||
```rust
|
||||
if is_local_uid(uid) { return Ok(Vec::new()); }
|
||||
```
|
||||
|
||||
which is why the read is silent, instant, and zero-length. A restart rebuilds
|
||||
every state from the DB, where `remap_local_uid` had already written the real
|
||||
uid, so the file reads correctly from then on.
|
||||
|
||||
Confirmed live: `read handler uid=local~1785067300752-0 fsize=140000` three
|
||||
seconds after `pending create landed`, and at the same moment
|
||||
`adopt_real_uid … hit=None strays=[]` — the primary state had no entry for the
|
||||
uid at all. `/proc/<pid>/task/*/comm` showed 1 `pdfs-drain` against 8 fuser
|
||||
sessions.
|
||||
|
||||
**Fix:** `Core` gained a `states: Arc<Mutex<Vec<Weak<Mutex<State>>>>>` registry
|
||||
that every mount publishes itself into (`Core::register_state`, called from
|
||||
`mount.rs` for the primary and from `fork_state` for each fork), plus
|
||||
`Core::for_each_state`, which walks every live inode space one lock at a time.
|
||||
A uid is unique across mounts, so at most one state matches and the rest are
|
||||
no-ops. Five drain sites were converted: `adopt_real_uid`, `adopt_drained_name`,
|
||||
the conflict-copy `invalidate_listing`, the `active_writes` baseline rebase in
|
||||
`refresh_after_upload`, and that function's closing `intern`.
|
||||
|
||||
**It was completely silent.** No `ERROR`, no `WARN`, no failed op, no conflict
|
||||
copy, no retry — `pdfs status` reported the queue drained, because it had. Any
|
||||
diagnosis that relies on the journal or on queue state will not see this.
|
||||
|
||||
**Regression cover:** `scripts/fuse-acceptance.py`, case
|
||||
`regression B74: rename after close keeps its content` — the narrowest form
|
||||
(plain names, no overwrite, no transient suffix). It failed in two consecutive
|
||||
live runs before the fix and passes after it. The pre-existing B70 case failed on
|
||||
the same cause and now passes too.
|
||||
|
||||
**Remaining work.** `drain.rs` still has fork-blind *reads* (`node_place`,
|
||||
`root_uid`, `parent_is_gone`) and `lib.rs` has ~31 direct `state.lock()` sites;
|
||||
any of them reached from a background thread has the same shape of problem.
|
||||
Worth an audit, and worth a unit test over `for_each_state` so a future fork
|
||||
cannot silently drop out of the registry.
|
||||
|
||||
**Note on B70:** B70's own fix was never implicated. The `.crdownload` park/un-park
|
||||
behaves correctly in isolation; the empty read arrived with the rename, on the
|
||||
same path a plain temp file takes. B70's live validation was blocked on this and
|
||||
is now unblocked — its acceptance case passes.
|
||||
+95
-3
@@ -14,9 +14,12 @@ cargo clippy --workspace --all-targets -- -D warnings
|
||||
The account-free suite exercises the filesystem syscall contract against a
|
||||
temporary local directory. This validates the runner and covers creation/open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||
`sendfile`, durability barriers, namespace/error semantics, open-file lifetime,
|
||||
names and enumeration, and concurrent I/O. It never reads application state or
|
||||
contacts Proton:
|
||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
||||
and real application workloads. It never reads application state or contacts
|
||||
Proton:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
@@ -34,6 +37,85 @@ Use a dedicated test account without irreplaceable data:
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||
```
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
records the facts it established, and a live run is compared against those
|
||||
recordings, so a case fails when the mount *differs* from an ordinary
|
||||
filesystem — not only when it raises. The report names the exact key:
|
||||
|
||||
```
|
||||
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
|
||||
! creation and open flags.truncated.size: live=6 reference=0
|
||||
```
|
||||
|
||||
Facts are recorded in one of two ways, chosen in the test body:
|
||||
|
||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||
reported mode bits, and which optional syscalls exist at all. Reported under
|
||||
`[capabilities]`, never a failure.
|
||||
|
||||
Because the choice is made at each call site, the set of accepted divergences
|
||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||
|
||||
### Operations the filesystem does not implement
|
||||
|
||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||
one that is dirty breaks software that never asked for the feature.
|
||||
|
||||
### Regressions from the bug ledger
|
||||
|
||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||
renamed after close must keep its content) need a real mount and a reachable
|
||||
daemon; they are skipped, not failed, without one.
|
||||
|
||||
B74 is currently **failing** — it is a live data-loss defect, not a regression
|
||||
guard, and it was found by exactly this mechanism: the B70 case failed, and
|
||||
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||
red run against a real mount until it is fixed.
|
||||
|
||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||
queue to drain before reading. Reading straight back through the mount proves
|
||||
nothing: the local attribute and content caches will happily return the bytes
|
||||
that were just written even when nothing reached Drive, which is precisely how
|
||||
B74 stayed invisible.
|
||||
|
||||
### Timeouts, reports, and hung mounts
|
||||
|
||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
||||
That backstop is the only way to learn *where* a mount hung. A timeout ends the
|
||||
run: later cases against a wedged mount produce noise, not information.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||
--timeout 300 --report-junit results.xml --budget 30
|
||||
```
|
||||
|
||||
`--report-json` also writes every recorded observation, which is what to attach
|
||||
to a bug report. `--budget SECONDS` flags cases that pass but got slower —
|
||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
||||
stops at the first failure.
|
||||
|
||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||
it; that has happened, and it was caught only by reading the journal by hand.
|
||||
|
||||
Abandoned `pdfs-acceptance-*` roots from a crashed run are removed at startup,
|
||||
but only if they match the exact generated name and are over an hour old
|
||||
(`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never disturbed.
|
||||
|
||||
### Automated mode matrix
|
||||
|
||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
||||
@@ -53,6 +135,16 @@ in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||
example `target/debug/pdfs`.
|
||||
|
||||
### Durability drill
|
||||
|
||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
||||
only copy of a file whose upload has not finished, so this is the case that
|
||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
||||
|
||||
### Selecting cases
|
||||
|
||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
|
||||
+1436
-63
File diff suppressed because it is too large.
Load diff
+51
-16
@@ -4,44 +4,79 @@ set -Eeuo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: scripts/fuse-acceptance.sh [--offline-only]
|
||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...]
|
||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR
|
||||
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
|
||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
|
||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
|
||||
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
||||
|
||||
The local reference suite always runs first and requires no account. --live then
|
||||
runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only
|
||||
when all live mountpoints show the same remote folder.
|
||||
The local reference suite always runs first and requires no account; the mount
|
||||
is then diffed against its recorded behaviour. --live runs the same contract on
|
||||
each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
|
||||
show the same remote folder.
|
||||
|
||||
Options are forwarded to the Python runner. The useful ones:
|
||||
--list print every case and the targets it runs against
|
||||
--timeout SECONDS per-case limit (default 180); a hung case dumps stacks
|
||||
--fail-fast stop at the first failure instead of continuing
|
||||
--report-json PATH machine-readable results and recorded observations
|
||||
--report-junit PATH JUnit XML for CI
|
||||
--budget SECONDS report any case slower than this
|
||||
--journal-check fail if the daemon logs errors during the run
|
||||
--durability restart the daemon mid-suite and re-verify bytes
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
runner=(python3 -u "$script_dir/fuse-acceptance.py")
|
||||
|
||||
mountpoints=()
|
||||
passthrough=()
|
||||
# Paths come first and options after them, so the first dash-prefixed argument
|
||||
# ends the path list and everything from there is forwarded untouched.
|
||||
collect_paths() {
|
||||
while (( $# > 0 )) && [[ "$1" != -* ]]; do
|
||||
mountpoints+=("$1")
|
||||
shift
|
||||
done
|
||||
passthrough=("$@")
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--offline-only|"")
|
||||
(( $# <= 1 )) || usage
|
||||
exec python3 -u "$script_dir/fuse-acceptance.py"
|
||||
shift || true
|
||||
exec "${runner[@]}" "$@"
|
||||
;;
|
||||
--live)
|
||||
shift
|
||||
collect_paths "$@"
|
||||
(( ${#mountpoints[@]} > 0 )) || usage
|
||||
;;
|
||||
--live) shift ; (( $# > 0 )) || usage ;;
|
||||
--managed-live)
|
||||
shift
|
||||
(( $# == 2 )) || usage
|
||||
exec python3 -u "$script_dir/fuse-acceptance.py" --managed-live "$@"
|
||||
(( $# >= 2 )) || usage
|
||||
first="$1"; second="$2"; shift 2
|
||||
exec "${runner[@]}" --managed-live "$first" "$second" "$@"
|
||||
;;
|
||||
--*)
|
||||
# Bare options with no mode: an offline run with those options.
|
||||
exec "${runner[@]}" "$@"
|
||||
;;
|
||||
*)
|
||||
collect_paths "$@"
|
||||
;;
|
||||
--*) usage ;;
|
||||
esac
|
||||
|
||||
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
||||
for mountpoint in "$@"; do
|
||||
for mountpoint in "${mountpoints[@]}"; do
|
||||
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
||||
done
|
||||
fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)"
|
||||
fstype="$(findmnt -T "${mountpoints[0]}" -n -o FSTYPE | head -n 1)"
|
||||
[[ "$fstype" == fuse* ]] || {
|
||||
echo "refusing primary non-FUSE path $1 (type: ${fstype:-unknown})" >&2
|
||||
echo "refusing primary non-FUSE path ${mountpoints[0]} (type: ${fstype:-unknown})" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
exec python3 -u "$script_dir/fuse-acceptance.py" --live "$@"
|
||||
exec "${runner[@]}" --live "${mountpoints[@]}" "${passthrough[@]}"
|
||||
Reference in new issue
Block a user