fixed race conditions + extendet test suite

This commit is contained in:
nvrl committed 2026-07-26 15:56:58 +02:00
1 parent a091c2f34f
commit 8470662c54
12 files changed
+2245 -201

No files matched your search

+97 -72
View File
@@ -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, &notifier, 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");
+5
View File
@@ -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
}
+37 -13
View File
@@ -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
+118
View File
@@ -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
View File
@@ -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
+5 -9
View File
@@ -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
+20 -2
View File
@@ -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 {
@@ -477,4 +496,3 @@ impl Core {
parts.reverse();
Some(parts.join("/"))
}
}
+20 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+51 -16
View File
@@ -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[@]}"