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::*;
|
use super::*;
|
||||||
|
|
||||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||||
/// any stale cached metadata/data for the affected inodes.
|
/// 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`
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
/// its content blob once if *any* mount did.
|
||||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
||||||
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();
|
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
let child = st.by_uid.get(node_uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
content.evict(node_uid);
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
if let Some((parent, name)) = st.forget(node_uid) {
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
match child {
|
match child {
|
||||||
Some(child) => {
|
Some(child) => {
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
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
|
// Continuity or scope was lost: our cached listings may be arbitrarily
|
||||||
// stale, so drop every listing and tell the kernel to forget all
|
// stale, so drop every listing and tell the kernel to forget all
|
||||||
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
||||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
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();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
for &ino in &dirs {
|
for &ino in &dirs {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// No substantive local change; the cursor advance is handled by the
|
// No substantive local change; the cursor advance is handled by the
|
||||||
// caller persisting the event id.
|
// caller persisting the event id.
|
||||||
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||||
@@ -102,15 +126,16 @@ fn apply_event(
|
|||||||
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
/// 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
|
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
||||||
/// only on fatal error.
|
/// 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(
|
pub(super) async fn run_event_sync(
|
||||||
client: ProtonDriveClient,
|
client: ProtonDriveClient,
|
||||||
scope: DriveEventScopeId,
|
scope: DriveEventScopeId,
|
||||||
state: Arc<Mutex<State>>,
|
core: Core,
|
||||||
content: Arc<ContentCache>,
|
|
||||||
db: Arc<Db>,
|
|
||||||
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
|
|
||||||
notifier: Notifier,
|
|
||||||
) {
|
) {
|
||||||
|
let db = core.db.clone();
|
||||||
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
||||||
// Resume: pick up exactly where the last run left off.
|
// Resume: pick up exactly where the last run left off.
|
||||||
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
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");
|
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
apply_event(&state, &content, &pending, ¬ifier, event);
|
apply_event(&core, event);
|
||||||
let applied = event.id().clone();
|
let applied = event.id().clone();
|
||||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
||||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
||||||
|
|||||||
@@ -505,6 +505,11 @@ impl Core {
|
|||||||
fork.notifier = Arc::new(OnceLock::new());
|
fork.notifier = Arc::new(OnceLock::new());
|
||||||
// Size upgrades are keyed by inode, which is per-fork too.
|
// Size upgrades are keyed by inode, which is per-fork too.
|
||||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
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
|
fork
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ impl Core {
|
|||||||
/// it away from the one the user asked for. Best effort: the event sync
|
/// 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.
|
/// 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) {
|
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 {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -275,6 +275,7 @@ impl Core {
|
|||||||
if let Err(e) = st.db.upsert_node(&node) {
|
if let Err(e) = st.db.upsert_node(&node) {
|
||||||
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
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
|
/// 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
|
/// The inode is deliberately kept, so anything already holding the file open
|
||||||
/// keeps working across the drain.
|
/// 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(
|
pub(crate) fn adopt_real_uid(
|
||||||
&self,
|
&self,
|
||||||
local: &NodeUid,
|
local: &NodeUid,
|
||||||
@@ -431,8 +440,10 @@ impl Core {
|
|||||||
self.db
|
self.db
|
||||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||||
|
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
if let Some(ino) = st.by_uid.remove(local) {
|
let Some(ino) = st.by_uid.remove(local) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
st.by_uid.insert(real.clone(), ino);
|
st.by_uid.insert(real.clone(), ino);
|
||||||
if let Some(e) = st.entries.get_mut(&ino) {
|
if let Some(e) = st.entries.get_mut(&ino) {
|
||||||
e.uid = real.clone();
|
e.uid = real.clone();
|
||||||
@@ -460,8 +471,7 @@ impl Core {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
drop(st);
|
|
||||||
// Write the real node through, now that nothing points at the old uid.
|
// Write the real node through, now that nothing points at the old uid.
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
if let Err(e) = self.db.upsert_node(&node) {
|
||||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||||
@@ -604,11 +614,11 @@ impl Core {
|
|||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
// The conflict copy is a node the tree has never seen.
|
// 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) {
|
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
}
|
}
|
||||||
drop(st);
|
});
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Upload,
|
ActivityKind::Upload,
|
||||||
&name,
|
&name,
|
||||||
@@ -662,13 +672,25 @@ impl Core {
|
|||||||
|
|
||||||
/// A node's parent uid and name, as the tree currently has them.
|
/// A node's parent uid and name, as the tree currently has them.
|
||||||
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
||||||
{
|
// Across every mount: the drain runs on the primary Core, but a node
|
||||||
let st = self.state.lock();
|
// 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))
|
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
||||||
&& let Some(parent) = entry.node.parent_uid.clone()
|
&& 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
|
// 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
|
// started, and the drain routinely runs before anything has walked to
|
||||||
@@ -843,7 +865,7 @@ impl Core {
|
|||||||
if let Some(rev) = sealed_rev.clone() {
|
if let Some(rev) = sealed_rev.clone() {
|
||||||
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
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() {
|
for aw in st.active_writes.values_mut() {
|
||||||
if aw.uid == *uid {
|
if aw.uid == *uid {
|
||||||
aw.base_mtime = sealed_mtime;
|
aw.base_mtime = sealed_mtime;
|
||||||
@@ -853,6 +875,7 @@ impl Core {
|
|||||||
"rebased open write handle onto the revision just uploaded");
|
"rebased open write handle onto the revision just uploaded");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Ordered so that a write queued *during* the fetch above is still
|
// Ordered so that a write queued *during* the fetch above is still
|
||||||
// caught: it took its baseline from the node's optimistic stamp, and
|
// caught: it took its baseline from the node's optimistic stamp, and
|
||||||
@@ -860,7 +883,7 @@ impl Core {
|
|||||||
if self.rebaseline_pending(uid, &node) {
|
if self.rebaseline_pending(uid, &node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
let Some(parent) = st
|
let Some(parent) = st
|
||||||
.by_uid
|
.by_uid
|
||||||
.get(uid)
|
.get(uid)
|
||||||
@@ -869,7 +892,8 @@ impl Core {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
st.intern(parent, node);
|
st.intern(parent, node.clone());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point a still-queued write at the revision this upload just sealed, and
|
/// 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.
|
// fuser warning logs when applications probe file descriptors.
|
||||||
reply.error(Errno::ENOTTY);
|
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
|
/// 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::ReplyXattr;
|
||||||
use fuser::{
|
use fuser::{
|
||||||
BackgroundSession, BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem,
|
BackgroundSession, BsdFileFlags, Config, CopyFileRangeFlags, Errno, FileAttr, FileHandle,
|
||||||
FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption, Notifier, OpenAccMode,
|
FileType, Filesystem, FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption,
|
||||||
OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
|
Notifier, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData,
|
||||||
ReplyEntry, ReplyIoctl, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyLseek, ReplyOpen, ReplyWrite, Request,
|
||||||
|
Session, TimeOrNow, WriteFlags,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
||||||
use pdfs_core::config::{AppDirs, SweepMode};
|
use pdfs_core::config::{AppDirs, SweepMode};
|
||||||
@@ -319,6 +320,75 @@ struct Core {
|
|||||||
/// uploading that same tree — the engine would upload files as they vanish
|
/// uploading that same tree — the engine would upload files as they vanish
|
||||||
/// and then walk the FUSE mount as if it were local.
|
/// and then walk the FUSE mount as if it were local.
|
||||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
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
|
/// Why [`Core::apply_sync_folder_mode`] did not switch a folder. The two cases are
|
||||||
@@ -333,6 +403,39 @@ enum SwitchBlocked {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
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
|
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
|
||||||
/// start serves previously-seen metadata (stable inodes, instant listings)
|
/// start serves previously-seen metadata (stable inodes, instant listings)
|
||||||
/// without re-hitting the API. The root inode is already installed by
|
/// without re-hitting the API. The root inode is already installed by
|
||||||
@@ -1537,7 +1640,14 @@ impl Core {
|
|||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
self.hidden.lock().insert(uid.clone());
|
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.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
@@ -2154,7 +2264,11 @@ impl Core {
|
|||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.rename_node(&uid, new_name, None))
|
.block_on(self.client.rename_node(&uid, new_name, None))
|
||||||
.map_err(|e| CoreError::from_api(&e, "rename"))?;
|
.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);
|
self.invalidate_parent_listing(rel);
|
||||||
Ok(new_name.to_string())
|
Ok(new_name.to_string())
|
||||||
}
|
}
|
||||||
@@ -2215,7 +2329,9 @@ impl Core {
|
|||||||
fn remove_replaced(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
fn remove_replaced(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||||
if is_local_uid(uid) {
|
if is_local_uid(uid) {
|
||||||
self.discard_queued_ops(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");
|
debug!(%uid, name, "replaced a node whose create was still queued");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -2228,7 +2344,11 @@ impl Core {
|
|||||||
return Err(Errno::EIO);
|
return Err(Errno::EIO);
|
||||||
}
|
}
|
||||||
self.discard_queued_ops(uid);
|
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.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
@@ -2413,14 +2533,15 @@ impl Core {
|
|||||||
hidden.remove(uid);
|
hidden.remove(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
// Keyed by uid, so it applies to every mount: a restored node reappears
|
||||||
let mut st = self.state.lock();
|
// in whichever inode spaces show its parent, not only the primary one.
|
||||||
for parent in parents {
|
self.for_each_state(|st| {
|
||||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
for parent in &parents {
|
||||||
|
if let Some(&ino) = st.by_uid.get(parent) {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
Ok(parsed.len())
|
Ok(parsed.len())
|
||||||
}
|
}
|
||||||
@@ -2456,11 +2577,15 @@ impl Core {
|
|||||||
/// Forget every trace of nodes that no longer exist anywhere: their inode and
|
/// Forget every trace of nodes that no longer exist anywhere: their inode and
|
||||||
/// DB row, and their cached content.
|
/// DB row, and their cached content.
|
||||||
fn drop_local(&self, uids: &[NodeUid]) {
|
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 {
|
for uid in uids {
|
||||||
st.forget(uid);
|
st.forget(uid);
|
||||||
}
|
}
|
||||||
drop(st);
|
});
|
||||||
for uid in uids {
|
for uid in uids {
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
@@ -3423,11 +3548,58 @@ mod pending_size_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
Intervals, PendingRevision, conflict_name, copy_pending_for_truncate, fuse_name,
|
Intervals, PendingRevision, StateRegistry, conflict_name, copy_pending_for_truncate,
|
||||||
is_stale_mount, node_visible, rename_needs_queue,
|
fuse_name, is_stale_mount, node_visible, rename_needs_queue,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
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
|
/// 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
|
/// directory and an absent path are both "not stale" — widening it to any
|
||||||
/// `metadata` error would make the on-demand restore lazily unmount paths
|
/// `metadata` error would make the on-demand restore lazily unmount paths
|
||||||
|
|||||||
@@ -222,7 +222,11 @@ pub fn mount(
|
|||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
mounts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_locks: 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
|
// 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.
|
// 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)
|
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
||||||
// can invalidate kernel-cached metadata without threading a handle through.
|
// can invalidate kernel-cached metadata without threading a handle through.
|
||||||
let _ = core.notifier.set(notifier.clone());
|
let _ = core.notifier.set(notifier.clone());
|
||||||
rt.spawn(run_event_sync(
|
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||||
client,
|
|
||||||
scope,
|
|
||||||
core.state,
|
|
||||||
core.cache,
|
|
||||||
core.db,
|
|
||||||
core.pending,
|
|
||||||
notifier,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
// 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) —
|
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||||
/// the caller then leaves the path empty.
|
/// the caller then leaves the path empty.
|
||||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
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 ino = *st.by_uid.get(uid)?;
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
while ino != ROOT_INO {
|
while ino != ROOT_INO {
|
||||||
@@ -477,4 +496,3 @@ impl Core {
|
|||||||
parts.reverse();
|
parts.reverse();
|
||||||
Some(parts.join("/"))
|
Some(parts.join("/"))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -153,7 +153,13 @@ impl Core {
|
|||||||
return;
|
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.discard_queued_ops(uid);
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(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.
|
/// 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
|
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||||
/// writer/holder check.
|
/// 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 {
|
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
let state = self.state.lock();
|
let mut busy = false;
|
||||||
let Some(&ino) = state.by_uid.get(uid) else {
|
self.for_each_state(|st| {
|
||||||
return false;
|
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
|
/// 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
|
## 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`
|
**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads`
|
||||||
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
|
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
|
||||||
copies while their truncated partials kept the canonical name — e.g.
|
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
|
**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
|
operation, including the B71 sweep. It needs to work before the sweep is allowed
|
||||||
to trash anything.
|
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
|
The account-free suite exercises the filesystem syscall contract against a
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
temporary local directory. This validates the runner and covers creation/open
|
||||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||||
`sendfile`, durability barriers, namespace/error semantics, open-file lifetime,
|
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||||
names and enumeration, and concurrent I/O. It never reads application state or
|
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||||
contacts Proton:
|
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
|
```console
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
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
|
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
|
### Automated mode matrix
|
||||||
|
|
||||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
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
|
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||||
example `target/debug/pdfs`.
|
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
|
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
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() {
|
usage() {
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
usage: scripts/fuse-acceptance.sh [--offline-only]
|
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...]
|
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR
|
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
||||||
|
|
||||||
The local reference suite always runs first and requires no account. --live then
|
The local reference suite always runs first and requires no account; the mount
|
||||||
runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only
|
is then diffed against its recorded behaviour. --live runs the same contract on
|
||||||
when all live mountpoints show the same remote folder.
|
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
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
||||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
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
|
case "${1:-}" in
|
||||||
-h|--help) usage ;;
|
-h|--help) usage ;;
|
||||||
--offline-only|"")
|
--offline-only|"")
|
||||||
(( $# <= 1 )) || usage
|
shift || true
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py"
|
exec "${runner[@]}" "$@"
|
||||||
|
;;
|
||||||
|
--live)
|
||||||
|
shift
|
||||||
|
collect_paths "$@"
|
||||||
|
(( ${#mountpoints[@]} > 0 )) || usage
|
||||||
;;
|
;;
|
||||||
--live) shift ; (( $# > 0 )) || usage ;;
|
|
||||||
--managed-live)
|
--managed-live)
|
||||||
shift
|
shift
|
||||||
(( $# == 2 )) || usage
|
(( $# >= 2 )) || usage
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --managed-live "$@"
|
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
|
esac
|
||||||
|
|
||||||
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
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; }
|
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
||||||
done
|
done
|
||||||
fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)"
|
fstype="$(findmnt -T "${mountpoints[0]}" -n -o FSTYPE | head -n 1)"
|
||||||
[[ "$fstype" == fuse* ]] || {
|
[[ "$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
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --live "$@"
|
exec "${runner[@]}" --live "${mountpoints[@]}" "${passthrough[@]}"
|
||||||
Reference in new issue
Block a user