From ad8b225cfd13a73afb2f6b3b61bb88f9216b2ab9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 16:53:31 +0200 Subject: [PATCH] refactor(fuse): one tree for every mount On-demand sync folders kept their own State, which the drain, the event applier and the sweeps had to visit one by one (B74). Every mount now serves from the primary's tree. A session keeps only its root, which the kernel knows as inode 1, and its notification channel; work that changes the tree tells every mounted session. Size upgrades become daemon-wide, and moves compare mounts by root instead of by state. --- crates/pdfs-fuse/src/background.rs | 20 +- crates/pdfs-fuse/src/devices.rs | 23 +- crates/pdfs-fuse/src/drain.rs | 35 +- crates/pdfs-fuse/src/filesystem.rs | 110 ++-- crates/pdfs-fuse/src/lib.rs | 837 +++++++++++++---------------- crates/pdfs-fuse/src/link.rs | 5 +- crates/pdfs-fuse/src/mount.rs | 12 +- crates/pdfs-fuse/src/relocate.rs | 6 +- crates/pdfs-fuse/src/sharing.rs | 29 +- crates/pdfs-fuse/src/state.rs | 108 ++-- crates/pdfs-fuse/src/sweep.rs | 7 +- docs/CHANGELOG.md | 10 + 12 files changed, 580 insertions(+), 622 deletions(-) diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index d1cca87..dedee9f 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -97,15 +97,13 @@ fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId /// 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. +/// Applied to the tree every mount shares, and told to **every** mounted +/// session. 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. +/// Telling the primary session alone left forks serving a deleted file, the +/// read-side twin of `docs/BUGS.md` B74. [`Core::for_each_mount`] sends what +/// the kernel needs to hear to every session. fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> { let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id); let serializes_shared_publication = @@ -362,8 +360,8 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) { /// 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`]). +/// to reach every mounted session, and the registry that enumerates them lives +/// on the `Core` (see [`apply_event`]). pub(super) async fn run_event_sync( client: Arc, scope: DriveEventScopeId, diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index cd22140..06cefaa 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -27,7 +27,7 @@ use tracing::{info, warn}; use super::sync::{self, base_name}; use super::{ - Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount, + Core, SecondaryInsertRejection, SecondaryMount, SwitchBlocked, clear_stale_mount, device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname, }; @@ -616,23 +616,16 @@ impl Core { }; } - /// A sibling Core that shares this one's client/rt/cache/db (and transfer, - /// activity, mount registries) but gets a **fresh, empty `State`** — its own - /// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session - /// at an `ondemand` sync folder without colliding with the main mount's inodes - /// (devices.md Phase 3). + /// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync + /// folder (devices.md Phase 3). It shares everything with this one, + /// the tree included, except what belongs to a session: its root and its + /// kernel channel, both filled in when the session is built. pub(crate) fn fork_state(&self) -> Core { let mut fork = self.clone(); fork.primary = false; - let share_access = self.state().share_access.clone(); - fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access))); - // A fresh inode space needs a fresh notification channel: this fork's - // session is the only one that knows these inodes, so it must be the one - // notified about them. Filled in by `spawn_ondemand_mount`. + fork.root = Arc::new(OnceLock::new()); fork.notifier = Arc::new(OnceLock::new()); fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false)); - // Size upgrades are keyed by inode, which is per-fork too. - fork.size_upgrades = Arc::new(Mutex::new(HashMap::new())); fork } @@ -934,8 +927,8 @@ impl Core { } } - /// Spawn a secondary FUSE session rooted at `root` over `local` on a forked - /// inode space. Clears any stale kernel mount first (a crashed run can leave + /// Spawn a secondary FUSE session rooted at `root` over `local` from a + /// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave /// one, which would fail the fresh mount with EBUSY). pub(crate) fn spawn_ondemand_mount( &self, diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 835f9fd..b2cf5ee 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -44,7 +44,7 @@ use super::state::{Intervals, PendingRevision}; use super::transfers::CountingReader; use super::{ Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, DRAIN_REVISION_DEBOUNCE, - DRAIN_REVISION_DEBOUNCE_MAX, ROOT_INO, UPLOAD_TIME_MEMORY, WriteAuthority, conflict_name, + DRAIN_REVISION_DEBOUNCE_MAX, UPLOAD_TIME_MEMORY, WriteAuthority, conflict_name, is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size, now_millis, now_secs, parse_node_uid, }; @@ -1155,13 +1155,10 @@ 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. + /// Applied to the tree every mount shares. A node created inside an + /// on-demand sync folder once lived in that fork's own state, where the + /// primary mount's drain never looked, and stayed on its `local~` uid — + /// an empty file for as long as the daemon ran (`docs/BUGS.md` B74). /// /// `adopt` sees the node as Drive made it, before anything is rebased onto /// it. @@ -1559,25 +1556,13 @@ 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)> { - // 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; - } + { + let st = self.state(); if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino)) && let Some(parent) = entry.node.parent_uid.clone() { - found = Some((parent, entry.node.name.clone())); + return 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 @@ -1612,8 +1597,8 @@ impl Core { /// root yet, which is not where the drain runs; the drain must not panic /// over it regardless, since that would stop the queue for good. pub(crate) fn root_uid(&self) -> Option { - let st = self.state(); - st.entries.get(&ROOT_INO).map(|e| e.uid.clone()) + let root = self.root_ino(); + self.state().entries.get(&root).map(|e| e.uid.clone()) } /// Upload a staged revision of a file the server already knows about. diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index 6fae51b..a53ad08 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -72,7 +72,7 @@ impl Filesystem for ProtonFs { } fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { - let parent = parent.0; + let parent = self.tree(parent).0; let name = match fuse_name(name) { Ok(name) => name, Err(error) => { @@ -95,11 +95,13 @@ impl Filesystem for ProtonFs { } fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) { + let ino = self.tree(ino); let mut st = self.core.state(); st.forget_lookup(ino.0, nlookup); } fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let ino = self.tree(ino); let (attr, provisional_in) = { let st = self.core.state(); match st.entries.get(&ino.0) { @@ -175,7 +177,7 @@ impl Filesystem for ProtonFs { /// Enumerating a cold folder is a remote call, so it goes to a worker for /// the same reason `lookup` and `readdir` do. fn opendir(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) { - let ino = ino.0; + let ino = self.tree(ino).0; if self.core.children_cached(ino) { self.serve_opendir(ino, reply); return; @@ -194,13 +196,13 @@ impl Filesystem for ProtonFs { offset: u64, reply: ReplyDirectory, ) { - let ino = ino.0; + let ino = self.tree(ino).0; // The snapshot is the whole point: served from it, this is a pure // in-memory walk with no chance of a remote call, so it never needs a // worker. Only a handle whose `opendir` failed to snapshot (or a kernel // that issued `readdir` without one) falls back to the live path. if let Some(listing) = self.core.state().dir_snapshots.get(&fh.0).cloned() { - reply_listing(&listing, offset, reply); + reply_listing(&listing, offset, self.root, reply); return; } // Same split as `lookup`: only the cold, remote-enumerating path pays @@ -232,6 +234,7 @@ impl Filesystem for ProtonFs { /// a blob that is the whole file — so it is served from a worker rather than /// stalling every other request on the mount behind it. fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let ino = self.tree(ino); if flags.acc_mode() == OpenAccMode::O_RDONLY { self.serve_open(ino, flags, reply); return; @@ -253,6 +256,7 @@ impl Filesystem for ProtonFs { _lock_owner: Option, reply: ReplyData, ) { + let ino = self.tree(ino); // A file open for writing is served from its handle so reads see the // in-flight (possibly unsaved) content: authored bytes come from the // scratch file, untouched bytes from the base. @@ -364,7 +368,7 @@ impl Filesystem for ProtonFs { _flags: i32, reply: ReplyCreate, ) { - let parent = parent.0; + let parent = self.tree(parent).0; if let Err(error) = self.core.require_writable(parent) { reply.error(error); return; @@ -400,6 +404,7 @@ impl Filesystem for ProtonFs { _lock_owner: Option, reply: ReplyWrite, ) { + let ino = self.tree(ino); if let Err(error) = self.core.require_writable(ino.0) { reply.error(error); return; @@ -477,6 +482,7 @@ impl Filesystem for ProtonFs { _flags: Option, reply: ReplyAttr, ) { + let ino = self.tree(ino); if let Err(error) = self.core.require_writable(ino.0) { reply.error(error); return; @@ -544,6 +550,7 @@ impl Filesystem for ProtonFs { mode: i32, reply: ReplyEmpty, ) { + let ino = self.tree(ino); if let Err(error) = self.core.require_writable(ino.0) { reply.error(error); return; @@ -640,16 +647,17 @@ impl Filesystem for ProtonFs { fn release( &self, _req: &Request, - _ino: INodeNo, + ino: INodeNo, fh: FileHandle, _flags: OpenFlags, _lock_owner: Option, _flush: bool, reply: ReplyEmpty, ) { + let ino = self.tree(ino); let (handle, unlinked_uid) = { let mut st = self.core.state(); - let unlinked_uid = release_unlinked_entry(&mut st, _ino.0); + let unlinked_uid = release_unlinked_entry(&mut st, ino.0); let ino = st.handles.remove(&fh.0); let h = ino.and_then(|i| { let aw = st.active_writes.get_mut(&i)?; @@ -680,7 +688,7 @@ impl Filesystem for ProtonFs { } fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { - let parent = parent.0; + let parent = self.tree(parent).0; if let Err(error) = self.core.require_writable(parent) { reply.error(error); return; @@ -700,7 +708,7 @@ impl Filesystem for ProtonFs { } fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { - let parent = parent.0; + let parent = self.tree(parent).0; if let Err(error) = self.core.require_writable(parent) { reply.error(error); return; @@ -729,7 +737,7 @@ impl Filesystem for ProtonFs { _umask: u32, reply: ReplyEntry, ) { - let parent = parent.0; + let parent = self.tree(parent).0; if let Err(error) = self.core.require_writable(parent) { reply.error(error); return; @@ -760,8 +768,8 @@ impl Filesystem for ProtonFs { flags: RenameFlags, reply: ReplyEmpty, ) { - let parent = parent.0; - let newparent = newparent.0; + let parent = self.tree(parent).0; + let newparent = self.tree(newparent).0; if let Err(error) = self .core .require_writable(parent) @@ -794,6 +802,7 @@ impl Filesystem for ProtonFs { } fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) { + let ino = self.tree(ino); let state = self.core.state(); let Some(entry) = state.entries.get(&ino.0) else { reply.error(Errno::ENOENT); @@ -807,6 +816,7 @@ impl Filesystem for ProtonFs { } fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) { + let ino = self.tree(ino); // Always off the dispatch loop: a miss goes to the wire, and a lister // that stats a directory issues one of these per file per advertised // name — inline, that serialized the whole mount behind ~186 ms of @@ -819,6 +829,7 @@ impl Filesystem for ProtonFs { } fn listxattr(&self, _req: &Request, ino: INodeNo, size: u32, reply: ReplyXattr) { + let ino = self.tree(ino); self.serve_listxattr(ino, size, reply); } @@ -968,6 +979,9 @@ pub struct ProtonFs { core: Core, uid: u32, gid: u32, + /// The tree inode of this session's root, which the kernel knows as + /// [`ROOT_INO`]. + root: u64, } /// How many times a write `open` will re-read its base after a `release` @@ -1008,59 +1022,55 @@ fn build_listing(st: &State, ino: u64) -> DirListing { /// The cookie handed back is the index of the *next* entry, so a resumed call /// continues exactly where the last one stopped — which is only true because /// the listing behind those indexes is a snapshot (bugs.md B43). -fn reply_listing(listing: &DirListing, offset: u64, mut reply: ReplyDirectory) { +fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) { for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) { let ft = if *is_dir { FileType::Directory } else { FileType::RegularFile }; - if reply.add(INodeNo(*ino), (i + 1) as u64, ft, name) { + if reply.add(kernel_ino(root, *ino), (i + 1) as u64, ft, name) { break; } } reply.ok(); } +/// The inode the kernel knows tree inode `ino` by, in a session rooted at +/// `root`. The inverse of [`ProtonFs::tree`]. +pub(crate) fn kernel_ino(root: u64, ino: u64) -> INodeNo { + INodeNo(if ino == root { ROOT_INO } else { ino }) +} + pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool { (!mask.contains(AccessFlags::W_OK) || access.writable()) && (!mask.contains(AccessFlags::X_OK) || is_dir) } impl ProtonFs { - /// Build the filesystem rooted at `root` (the user's My Files folder). + /// Build the filesystem rooted at `root` (the user's My Files folder, or + /// the folder an on-demand location shows). pub(super) fn new(core: Core, root: Node) -> Self { - { - let mut st = core.state(); - if let Err(e) = st.db.upsert_node(&root) { - warn!(uid = %root.uid, error = %e, "db upsert root failed"); - } - st.by_uid.insert(root.uid.clone(), ROOT_INO); - // The filesystem root is always owned — it must never be - // classified as a shared root, even when the API returns - // membership data (which happens when the owner shares the - // folder with someone else). Clean up any stale share_access - // row that a previous downgrade_all_share_access may have - // written for this uid, so hydrate_access cannot pick it up. - let access = Access::Owner; - st.share_access.remove(&root.uid); - let _ = st.db.delete_share_access(&root.uid); - st.entries.insert( - ROOT_INO, - Entry { - uid: root.uid.clone(), - parent: ROOT_INO, - node: root, - access, - lookup_count: 1, - open_count: 0, - unlinked: false, - }, - ); - } + let root = core.state().install_root(root); + let _ = core.root.set(root); // SAFETY: geteuid/getegid are infallible and have no preconditions. let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; - Self { core, uid, gid } + Self { + core, + uid, + gid, + root, + } + } + + /// The tree inode the kernel means by `ino`: this session's root for + /// [`ROOT_INO`], and every other inode as it is. + fn tree(&self, ino: INodeNo) -> INodeNo { + if ino.0 == ROOT_INO { + INodeNo(self.root) + } else { + ino + } } /// The body of [`Filesystem::lookup`], on whichever thread ends up serving it. @@ -1169,7 +1179,7 @@ impl ProtonFs { let st = self.core.state(); let listing = build_listing(&st, ino); drop(st); - reply_listing(&listing, offset, reply); + reply_listing(&listing, offset, self.root, reply); } fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr { @@ -1182,7 +1192,7 @@ impl ProtonFs { let mtime = unix_secs(node.modification_time); let crtime = unix_secs(node.creation_time); FileAttr { - ino: INodeNo(ino), + ino: kernel_ino(self.root, ino), size, blocks: size.div_ceil(512), atime: mtime, @@ -1994,11 +2004,11 @@ impl ProtonFs { // and retain an empty destination readdir page. reply.ok(); if let Some(notifier) = self.core.notifier.get() { - let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name)); - let _ = notifier.inval_entry(INodeNo(newparent), OsStr::new(&newname)); - let _ = notifier.inval_inode(INodeNo(parent), 0, 0); + let _ = notifier.inval_entry(parent, OsStr::new(&name)); + let _ = notifier.inval_entry(newparent, OsStr::new(&newname)); + let _ = notifier.inval_inode(parent); if newparent != parent { - let _ = notifier.inval_inode(INodeNo(newparent), 0, 0); + let _ = notifier.inval_inode(newparent); } } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 587bf5f..dbb0f85 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -122,7 +122,7 @@ use mount::{ SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session, }; use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN}; -use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle, lock_state}; +use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle}; use tracing::{debug, error, info, warn}; use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry}; use r#virtual::{ @@ -500,9 +500,13 @@ struct Core { /// so ownership checks never mistake their own mount root for account scope. primary_root_uid: NodeUid, /// False only for a forked on-demand device mount. The synthetic shared - /// directory belongs exclusively to the primary My Files inode space. + /// directory belongs exclusively to the primary My Files mount. primary: bool, + /// The one tree every mount of the daemon serves from. state: Arc>, + /// The tree inode this mount's session is rooted at, set when the session + /// is built ([`ProtonFs::new`]). Each fork has its own cell. + root: Arc>, cache: Arc, /// Open [`RevisionReader`]s keyed by node, so the block fetches of a file /// resolve its keys and block table once instead of once per block. @@ -671,7 +675,7 @@ struct Core { /// Folders whose listing was enumerated cheaply and is having its file sizes /// filled in right now, so a burst of `stat`s over a fresh listing starts one /// upgrade rather than one per entry. See [`Core::spawn_size_upgrade`]. - /// Size upgrades currently running, per folder inode. A `getattr` that + /// Size upgrades currently running, per folder inode, across every mount. A `getattr` that /// needs a real size waits on the entry rather than issuing its own fetch — /// `ls -l` of a folder is one `getattr` per file, and they must collapse /// onto a single batch (bugs.md B14). @@ -683,13 +687,13 @@ struct Core { /// metadata it has cached. Set once the session exists — which is *after* /// the `Core` it is built from, hence the cell. /// - /// Per mount, not per daemon: each on-demand fork runs its own session over - /// its own inode space, so notifying through the primary mount's channel - /// would name inodes that session has never heard of. [`Core::fork_state`] - /// gives each fork an empty cell of its own. - notifier: Arc>, - /// True only after this inode space's FUSE session spawned successfully. - /// Registration happens earlier for the primary drain, so state residency + /// Per mount, not per daemon: each session calls its own root inode 1. + /// [`Core::fork_state`] gives each fork an empty cell of its own. Work that + /// changes the tree for every mount tells every session + /// ([`Core::for_each_mount`]). + notifier: Arc>, + /// True only after this mount's FUSE session spawned successfully. + /// Registration happens earlier for the primary drain, so registration /// alone cannot answer whether the location is mounted. session_live: Arc, /// In-flight upload/download progress, served to `GetQueueStatus`. Shared @@ -710,11 +714,11 @@ struct Core { sync_tx: std::sync::mpsc::Sender, /// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder /// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote - /// node, mounted over its local path, sharing this Core's client/cache/db but - /// with its own inode space (`fork_state`). Held so we can unmount on toggle - /// back to `mirror` and on daemon shutdown. Each entry retains the FUSE - /// connection id and the fork's exact liveness flag so teardown first makes - /// that inode space unroutable. + /// node, mounted over its local path, sharing this Core's client/cache/db and + /// tree but with its own root (`fork_state`). Held so we can unmount on + /// toggle back to `mirror` and on daemon shutdown. Each entry retains the + /// FUSE connection id and the fork's exact liveness flag so teardown first + /// makes that mount unroutable. mounts: Arc>>, /// Per-sync-folder locks, held for a whole reconcile pass and for a whole /// mode switch. A `mirror→ondemand` flip evicts the local tree and mounts @@ -742,20 +746,18 @@ struct Core { /// Shared across forks like `pending`: the drain that records the change and /// the event task that consumes the echo are both per-daemon. self_changes: Arc>>, - /// 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. + /// Every live mount: the primary and one per on-demand fork + /// ([`Core::fork_state`]), with its mountpoint, root and kernel channel. + /// Shared by every fork. /// - /// 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`]). + /// The mounts share one tree, so a change the drain makes is seen by every + /// one of them. Each forked mount used to keep its nodes in a state of its + /// own, where the drain never looked: the fork's entry kept its `local~` + /// placeholder uid forever (`docs/BUGS.md` B74). What is still per mount is + /// the kernel's cache, which [`Core::for_each_mount`] tells every session + /// about. /// - /// `Weak`, so an unmounted fork's state is dropped rather than pinned here. + /// `Weak`, so an unmounted fork is dropped rather than pinned here. states: Arc, } @@ -826,54 +828,36 @@ fn take_self_change( true } -/// 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 = ( - PathBuf, - Arc>, - Arc>, - Arc, -); +/// One live mount, as published to the shared registry. +#[derive(Clone)] +struct LiveMount { + mountpoint: PathBuf, + /// The mount's kernel channel, empty until its session is spawned. + notifier: Arc>, + session_live: Arc, + /// The tree inode the mount is rooted at, empty until its session is built. + root: Arc>, +} + +impl LiveMount { + fn is_live(&self) -> bool { + self.session_live.load(Ordering::Acquire) + } +} struct MountedState { - /// Absolute local root of this inode space. + /// Absolute local root of this mount. mountpoint: PathBuf, - /// `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>, - /// This mount's kernel notification channel — the same cell as - /// [`Core::notifier`], still empty until its session is spawned. - notifier: Arc>, + /// The same cell as [`Core::notifier`]. `Weak`, so an unmounted fork is + /// dropped rather than pinned here; a dead entry is reaped on the next + /// registry walk. + notifier: std::sync::Weak>, session_live: Arc, - /// This mount's in-flight size upgrades, keyed by *its* folder inodes — so - /// a `Core` rebuilt onto this inode space by [`Core::rooted_at`] shares the - /// batch the mount's own session is already running rather than starting a - /// second one against inode numbers that mean nothing to it. - size_upgrades: Arc>>>, + root: Arc>, } -/// The per-mount half of a [`Core`] — every field [`Core::fork_state`] gives a -/// fork a fresh copy of — recovered from the registry. -/// -/// This is what lets a control request naming a path under a secondary mount be -/// answered *in that mount's inode space* instead of being rejected for not -/// being under the primary mountpoint (`docs/BUGS.md` B86). See -/// [`Core::rooted_at`]. -struct MountParts { - mountpoint: PathBuf, - state: Arc>, - notifier: Arc>, - session_live: Arc, - size_upgrades: Arc>>>, -} - -/// Every mounted inode space in the daemon, shared by the primary `Core` and -/// every [`Core::fork_state`] clone of it. +/// Every mount 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 @@ -882,50 +866,48 @@ struct MountParts { struct StateRegistry(Mutex>); impl StateRegistry { - /// Publish an inode space and the channel to the session that owns it. + /// Publish a mount and the channel to the session that serves it. /// - /// A state/path pair is registered exactly once by its owner. Session - /// construction only flips `session_live`; it never republishes the state. + /// A mount is registered exactly once by its owner. Session construction + /// only flips `session_live`; it never republishes the mount. fn register( &self, mountpoint: &Path, - state: &Arc>, - notifier: Arc>, + notifier: &Arc>, session_live: Arc, - size_upgrades: Arc>>>, + root: Arc>, ) { let mut states = self.0.lock(); - states.retain(|m| m.state.strong_count() > 0); + states.retain(|m| m.notifier.strong_count() > 0); debug_assert!( !states .iter() - .any(|mounted| mounted.state.ptr_eq(&Arc::downgrade(state))), - "an inode state must be registered exactly once" + .any(|mounted| mounted.notifier.ptr_eq(&Arc::downgrade(notifier))), + "a mount must be registered exactly once" ); states.push(MountedState { mountpoint: mountpoint.to_path_buf(), - state: Arc::downgrade(state), - notifier, + notifier: Arc::downgrade(notifier), session_live, - size_upgrades, + root, }); } - /// 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. + /// Every registered mount, reaping the entries whose session has gone. + /// Returns owned handles so callers do not hold the registry lock across + /// the work. fn live(&self) -> Vec { let mut states = self.0.lock(); - states.retain(|m| m.state.strong_count() > 0); + states.retain(|m| m.notifier.strong_count() > 0); states .iter() .filter_map(|m| { - Some(( - m.mountpoint.clone(), - m.state.upgrade()?, - m.notifier.clone(), - m.session_live.clone(), - )) + Some(LiveMount { + mountpoint: m.mountpoint.clone(), + notifier: m.notifier.upgrade()?, + session_live: m.session_live.clone(), + root: m.root.clone(), + }) }) .collect() } @@ -935,76 +917,26 @@ impl StateRegistry { /// On-demand roots can be nested below another location. Selecting by the /// longest component prefix ensures the nested session wins rather than the /// broader primary mount. Callers that need the relative suffix derive it - /// with `path.strip_prefix(mountpoint)`; [`StateRegistry::covering_parts`] - /// is the variant that also hands back enough to *serve* a request there. + /// with `path.strip_prefix(mountpoint)`. fn covering(&self, path: &Path) -> Option { self.live() .into_iter() - .filter(|(mountpoint, _, _, live)| { - live.load(Ordering::Acquire) && path.starts_with(mountpoint) - }) - .max_by_key(|(mountpoint, _, _, _)| mountpoint.components().count()) - } - - /// [`StateRegistry::register`] with an empty size-upgrade map, for the - /// tests that exercise the reap-and-route rules and nothing else. - #[cfg(test)] - fn register_bare( - &self, - mountpoint: &Path, - state: &Arc>, - notifier: Arc>, - session_live: Arc, - ) { - self.register( - mountpoint, - state, - notifier, - session_live, - Arc::new(Mutex::new(HashMap::new())), - ); - } - - /// [`StateRegistry::covering`], but returning every per-mount field a - /// [`Core`] needs to be re-rooted onto that inode space. - /// - /// Kept separate from `covering` so the common callers — which only ask - /// *whether* a path is mounted — keep the cheaper tuple. - fn covering_parts(&self, path: &Path) -> Option { - let mut states = self.0.lock(); - states.retain(|m| m.state.strong_count() > 0); - states - .iter() - .filter(|m| m.session_live.load(Ordering::Acquire) && path.starts_with(&m.mountpoint)) - .max_by_key(|m| m.mountpoint.components().count()) - .and_then(|m| { - Some(MountParts { - mountpoint: m.mountpoint.clone(), - state: m.state.upgrade()?, - notifier: m.notifier.clone(), - session_live: m.session_live.clone(), - size_upgrades: m.size_upgrades.clone(), - }) - }) + .filter(|mount| mount.is_live() && path.starts_with(&mount.mountpoint)) + .max_by_key(|mount| mount.mountpoint.components().count()) } fn is_mounted_at(&self, path: &Path) -> bool { - self.covering(path).is_some_and(|(mountpoint, _, _, live)| { - mountpoint == path && live.load(Ordering::Acquire) - }) + self.covering(path) + .is_some_and(|mount| mount.mountpoint == path) } - /// Whether any live inode space owns and currently exposes `uid`. - /// - /// Retained open inodes and revoked incoming shares remain interned after - /// their dentries disappear, so residency alone is not authority. The - /// state's mounted root also scopes the lookup to its own volume, excluding - /// foreign shared-with-me residents from uid-addressed sharing. - fn owns_visible_uid(&self, uid: &NodeUid) -> bool { + /// The tree inodes of the sessions that are mounted. + fn live_roots(&self) -> Vec { self.live() .into_iter() - .filter(|(_, _, _, live)| live.load(Ordering::Acquire)) - .any(|(_, state, _, _)| state.lock().owns_visible_uid(uid)) + .filter(LiveMount::is_live) + .filter_map(|mount| mount.root.get().copied()) + .collect() } } @@ -1099,59 +1031,61 @@ impl Core { (Vec::new(), false, true) } - /// Register an inode space so background work can reach it. Called once for - /// the primary mount and once per on-demand fork. + /// Register a mount so background work can reach its session. Called once + /// for the primary mount and once per on-demand fork. fn register_state(&self, mountpoint: &Path) { self.states.register( mountpoint, - &self.state, - self.notifier.clone(), + &self.notifier, self.session_live.clone(), - self.size_upgrades.clone(), + self.root.clone(), ); } + /// The tree inode this mount is rooted at. [`ROOT_INO`] until the session + /// is built, which is where a test that builds a bare tree puts its root. + pub(crate) fn root_ino(&self) -> u64 { + self.root.get().copied().unwrap_or(ROOT_INO) + } + + /// Whether `uid` names an owned node reachable from the root of a mounted + /// session. See [`State::owns_visible_uid`]. + fn owns_visible_uid(&self, uid: &NodeUid) -> bool { + let roots = self.states.live_roots(); + self.state().owns_visible_uid(uid, &roots) + } + /// This `Core`, re-rooted onto whichever live mount most specifically covers /// the absolute path `abs`, together with `abs` relative to that mount's /// root. /// - /// Everything a `Core` holds is per-daemon except the five fields + /// Everything a `Core` holds is per-daemon except the fields /// [`Core::fork_state`] replaces, so swapping exactly those turns the - /// primary `Core` into the fork that owns the path — sharing the fork's - /// inode space, notification channel and size-upgrade batches rather than - /// shadowing them. Without this, every path-addressed control request is - /// answered against the primary inode space, so anything under a secondary - /// on-demand mount is rejected as "not under the mountpoint" and the user - /// has no way to re-enumerate a folder they can see (`docs/BUGS.md` B86). + /// primary `Core` into the fork that owns the path — resolving from the + /// fork's root and notifying its session. Without this, every + /// path-addressed control request is resolved from the primary root, so + /// anything under a secondary on-demand mount is rejected as "not under the + /// mountpoint" and the user has no way to re-enumerate a folder they can see + /// (`docs/BUGS.md` B86). /// /// `None` when no live mount covers `abs`. fn rooted_at(&self, abs: &Path) -> Option<(Core, PathBuf)> { - let parts = self.states.covering_parts(abs)?; - let rel = abs.strip_prefix(&parts.mountpoint).ok()?.to_path_buf(); + let mount = self.states.covering(abs)?; + let rel = abs.strip_prefix(&mount.mountpoint).ok()?.to_path_buf(); let mut core = self.clone(); - if !Arc::ptr_eq(&parts.state, &self.state) { - // A different inode space, so adopt its per-mount half wholesale. - // `primary` is a property of the mount, and the only mount this - // `Core` can be the primary of is the one it was built for. + if !Arc::ptr_eq(&mount.notifier, &self.notifier) { + // Another mount, so adopt its per-mount half wholesale. `primary` + // is a property of the mount, and the only mount this `Core` can + // be the primary of is the one it was built for. core.primary = false; - core.state = parts.state; - core.notifier = parts.notifier; - core.session_live = parts.session_live; - core.size_upgrades = parts.size_upgrades; + core.notifier = mount.notifier; + core.session_live = mount.session_live; + core.root = mount.root; } Some((core, rel)) } - /// 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. - /// Take this mount's inode lock. + /// Take the tree's lock. /// /// The guard is what applies the mutation's write-throughs, after releasing /// the lock — see [`StateGuard`]. Every caller takes it this way; nothing @@ -1161,33 +1095,33 @@ impl Core { StateGuard::new(self.state.lock(), &self.db) } - fn for_each_state(&self, mut apply: impl FnMut(&mut State)) { - for (_, state, notifier, _) in self.states.live() { - let changed = { - let mut state = lock_state(&state, &self.db); - apply(&mut state); - state.take_access_changes() - }; - notify_access_changes(notifier.get(), &changed); - } + /// Run `apply` against the tree, then tell every mounted session about the + /// access changes it made. + /// + /// For the drain and the sync engine, which are per-daemon: a `pending_op` + /// row records a uid, not the session that queued it, and any mount may be + /// showing the node. + fn for_each_state(&self, apply: impl FnOnce(&mut State)) { + let changed = { + let mut state = self.state(); + apply(&mut state); + state.take_access_changes() + }; + self.notify_access_changes(&changed); } - /// 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. + /// Like [`Core::for_each_state`], but the closure also records what the + /// kernel needs told into a [`NotifyBatch`], which goes to every mounted + /// session. /// - /// The closure records what the kernel needs told into a [`NotifyBatch`] - /// rather than sending it: notifications are flushed after this mount's - /// State lock is released, the same rule [`Core::for_each_state`] and + /// Recorded rather than sent: notifications are flushed after the State + /// lock is released, the same rule [`Core::for_each_state`] and /// [`Core::flush_access_changes`] follow. - fn for_each_mount(&self, mut apply: impl FnMut(&mut State, &mut NotifyBatch)) { - for (_, state, notifier, _) in self.states.live() { - let mut batch = NotifyBatch::default(); - { - let mut state = lock_state(&state, &self.db); - apply(&mut state, &mut batch); - } - batch.flush(notifier.get()); + fn for_each_mount(&self, apply: impl FnOnce(&mut State, &mut NotifyBatch)) { + let mut batch = NotifyBatch::default(); + apply(&mut self.state(), &mut batch); + for mount in self.states.live() { + batch.flush(mount.notifier.get()); } } @@ -1196,7 +1130,17 @@ impl Core { /// may synchronously provoke more filesystem work. fn flush_access_changes(&self) { let changed = self.state().take_access_changes(); - notify_access_changes(self.notifier.get(), &changed); + self.notify_access_changes(&changed); + } + + /// Tell every mounted session that `changed` have new permissions. + fn notify_access_changes(&self, changed: &[u64]) { + if changed.is_empty() { + return; + } + for mount in self.states.live() { + notify_access_changes(mount.notifier.get(), changed); + } } fn require_writable(&self, ino: u64) -> Result<(), Errno> { @@ -1233,21 +1177,7 @@ impl Core { /// drain to wait for a permission change that was never coming, because /// nothing about the node's permissions was ever the problem (B83). pub(crate) fn uid_write_authority(&self, uid: &NodeUid) -> WriteAuthority { - let mut live_access = Vec::new(); - { - let state = self.state(); - if let Some(access) = state.access_by_uid(uid) { - live_access.push(access); - } - } - for (_, state, _, _) in self.states.live() { - if Arc::ptr_eq(&state, &self.state) { - continue; - } - if let Some(access) = state.lock().access_by_uid(uid) { - live_access.push(access); - } - } + let live_access: Vec = self.state().access_by_uid(uid).into_iter().collect(); uid_write_authority(&self.db, uid, &live_access) } } @@ -1338,9 +1268,8 @@ enum KernelNotice { }, } -/// Notifications accumulated under one mount's State lock. Flushed by -/// [`Core::for_each_mount`] against that mount's own channel — inode numbers are -/// per-mount, so the pairing has to survive the deferral. +/// Notifications accumulated under the State lock. Flushed by +/// [`Core::for_each_mount`] to every session once the lock is released. #[derive(Default)] struct NotifyBatch(Vec); @@ -1366,30 +1295,68 @@ impl NotifyBatch { .extend(inodes.iter().map(|&ino| KernelNotice::InvalInode(ino))); } - /// Send everything recorded. A mount whose session has not been spawned has - /// nothing to tell the kernel, so the batch is simply dropped. - fn flush(self, notifier: Option<&Notifier>) { + /// Send everything recorded to one session. A mount whose session has not + /// been spawned has nothing to tell the kernel. A session that never looked + /// a node up answers `ENOENT`, which is ignored like any other refusal. + fn flush(&self, notifier: Option<&KernelChannel>) { let Some(notifier) = notifier else { return }; - for notice in self.0 { + for notice in &self.0 { let _ = match notice { - KernelNotice::InvalInode(ino) => notifier.inval_inode(INodeNo(ino), 0, 0), + KernelNotice::InvalInode(ino) => notifier.inval_inode(*ino), KernelNotice::InvalEntry { parent, name } => { - notifier.inval_entry(INodeNo(parent), OsStr::new(&name)) + notifier.inval_entry(*parent, OsStr::new(name)) } KernelNotice::Delete { parent, child, name, - } => notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name)), + } => notifier.delete(*parent, *child, OsStr::new(name)), }; } } } -fn notify_access_changes(notifier: Option<&Notifier>, changed: &[u64]) { +/// One FUSE session's notification channel. The tree has one inode space for +/// every mount, and each session knows its own root as [`ROOT_INO`], so this +/// names the root that way and every other inode as it is. +pub(crate) struct KernelChannel { + notifier: Notifier, + /// The tree inode of the session's root. + root: u64, +} + +impl KernelChannel { + fn new(notifier: Notifier, root: u64) -> Self { + Self { notifier, root } + } + + pub(crate) fn inval_inode(&self, ino: u64) -> std::io::Result<()> { + let ino = filesystem::kernel_ino(self.root, ino); + self.notifier.inval_inode(ino, 0, 0) + } + + /// Drop the cached attributes of `ino` but not its page cache. + fn inval_attrs(&self, ino: u64) -> std::io::Result<()> { + let ino = filesystem::kernel_ino(self.root, ino); + self.notifier.inval_inode(ino, -1, 0) + } + + pub(crate) fn inval_entry(&self, parent: u64, name: &OsStr) -> std::io::Result<()> { + let parent = filesystem::kernel_ino(self.root, parent); + self.notifier.inval_entry(parent, name) + } + + fn delete(&self, parent: u64, child: u64, name: &OsStr) -> std::io::Result<()> { + let parent = filesystem::kernel_ino(self.root, parent); + let child = filesystem::kernel_ino(self.root, child); + self.notifier.delete(parent, child, name) + } +} + +fn notify_access_changes(notifier: Option<&KernelChannel>, changed: &[u64]) { let Some(notifier) = notifier else { return }; for &ino in changed { - let _ = notifier.inval_inode(INodeNo(ino), 0, 0); + let _ = notifier.inval_inode(ino); } } @@ -1787,8 +1754,8 @@ impl Core { if listed && node.is_folder() { listed_dirs.push(ino); } - // The root entry is owned by `ProtonFs::new`; don't overwrite it. - if ino == ROOT_INO { + // A root entry is owned by `ProtonFs::new`; don't overwrite it. + if st.roots.contains(&ino) { continue; } // A node whose parent row never made it to disk must not be adopted @@ -2337,7 +2304,7 @@ impl Core { self.flush_access_changes(); self.mark_shared_refresh_success(SHARED_WITH_ME_SYNCED_MS); if let Some(notifier) = self.notifier.get() { - let _ = notifier.inval_inode(INodeNo(ino), 0, 0); + let _ = notifier.inval_inode(ino); } Ok(()) } @@ -2377,9 +2344,11 @@ impl Core { st.invalidate_listing(ino); } drop(st); - if let Some(notifier) = self.notifier.get() { - for ino in stale { - let _ = notifier.inval_inode(INodeNo(ino), 0, 0); + for mount in self.states.live() { + if let Some(notifier) = mount.notifier.get() { + for &ino in &stale { + let _ = notifier.inval_inode(ino); + } } } } @@ -2943,9 +2912,11 @@ impl Core { // (B100 resolves sizes there). The kernel holds the page lock of // that read until we reply, and the invalidation waits for the // same lock, so the read never answered (docs/BUGS.md B110). - if let Some(notifier) = core.notifier.get() { - for ino in changed { - let _ = notifier.inval_inode(INodeNo(ino), -1, 0); + for mount in core.states.live() { + if let Some(notifier) = mount.notifier.get() { + for &ino in &changed { + let _ = notifier.inval_attrs(ino); + } } } debug!(folder_ino, files = updated.len(), "filled in listing sizes"); @@ -2974,11 +2945,11 @@ impl Core { /// directory on the way as needed. Leading `/` and `.` components are /// ignored; `..` is rejected. fn resolve_path(&self, rel: &Path) -> Result<(u64, NodeUid), Errno> { - let mut ino = ROOT_INO; + let mut ino = self.root_ino(); let mut uid = { let st = self.state(); st.entries - .get(&ROOT_INO) + .get(&ino) .map(|e| e.uid.clone()) .ok_or(Errno::ENOENT)? }; @@ -3011,14 +2982,14 @@ impl Core { /// Resolve a wire-format uid only when one of this daemon's locations /// proves that it owns the node. /// - /// The primary and on-demand mounts prove residency through - /// [`StateRegistry`]. Mirror locations have no inode space, so their root + /// The primary and on-demand mounts prove residency through the tree and + /// the roots in [`StateRegistry`]. Mirror locations have no tree, so their root /// and last-synced descendants are resolved through `sync_folder` and /// `sync_entry` instead. pub(crate) fn resolve_anywhere(&self, uid: &str) -> CoreResult { resolve_anywhere_with( uid, - |uid| self.states.owns_visible_uid(uid), + |uid| self.owns_visible_uid(uid), |uid| self.db.mirror_contains_uid(&uid.to_string()), ) } @@ -3769,11 +3740,9 @@ impl Core { })?; self.release_dropped_ops(uid, blobs); self.hidden.lock().insert(uid.clone()); - // 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). + // Withdrawn from the tree, which every mount serves from, so no other + // mount goes on serving a file the user just trashed (`docs/BUGS.md` + // B74). self.for_each_state(|st| { st.unlink_mem(uid); }); @@ -4583,15 +4552,15 @@ impl Core { /// held — naming the folder by uid rather than by inode. /// /// [`Core::invalidate_parent_listing`] is the path-based sibling, and it - /// only reaches *this* mount's inode space. A remote mutation made by the + /// only reaches *this* mount's paths. A remote mutation made by the /// sync engine needs more than that, for two reasons. The folder it changed - /// is usually not resident in any inode space at the time — a mirror folder + /// is usually not resident in the tree at the time — a mirror folder /// has no FUSE session at all — so there is no inode to name. And the stale /// listing that outlives the pass is not a hot-cache map but the `listed` /// flag on the folder's DB row, which survives a daemon restart and is what /// a later `ensure_children` trusts. /// - /// So the flag is cleared unconditionally, and the resident inode spaces are + /// So the flag is cleared unconditionally, and the resident listing is /// invalidated on top of it. That is what makes a mirror folder switched to /// on-demand re-enumerate instead of serving a snapshot that predates the /// engine's own uploads — `docs/BUGS.md` B86. @@ -5191,7 +5160,7 @@ impl Core { return Err(CoreError::from_api(&error, "restore")); } // 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. + // in whichever mounts 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) { @@ -5260,10 +5229,8 @@ 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]) { - // 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. + // 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). self.for_each_state(|st| { for uid in uids { st.forget(uid); @@ -7173,46 +7140,65 @@ mod tests { std::sync::Arc::new(std::sync::atomic::AtomicBool::new(live)) } - /// 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. + /// A mount as the registry sees it, with the cells its `Core` would own. + struct TestMount { + notifier: std::sync::Arc>, + live: std::sync::Arc, + root: std::sync::Arc>, + } + + impl TestMount { + fn register(registry: &StateRegistry, mountpoint: &str, root: Option) -> Self { + let mount = Self { + notifier: std::sync::Arc::new(std::sync::OnceLock::new()), + live: session_flag(false), + root: std::sync::Arc::new(std::sync::OnceLock::new()), + }; + if let Some(root) = root { + mount.root.set(root).unwrap(); + } + registry.register( + std::path::Path::new(mountpoint), + &mount.notifier, + mount.live.clone(), + mount.root.clone(), + ); + mount + } + + fn spawn(&self) { + self.live.store(true, std::sync::atomic::Ordering::Release); + } + } + + /// Whether `uid` is visible from the root a single-mount test installs. + fn visible(state: &crate::state::State, uid: &NodeUid) -> bool { + state.owns_visible_uid(uid, &[super::ROOT_INO]) + } + + /// The registry is how background work reaches every mounted session's + /// kernel cache, so the walk has to see *every* registered mount — and + /// only the ones still mounted (`docs/BUGS.md` B74). #[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 (mountpoint, state) in [ - ("/mnt/primary", &primary), - ("/mnt/fork-a", &fork_a), - ("/mnt/fork-b", &fork_b), - ] { - registry.register_bare( - std::path::Path::new(mountpoint), - state, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(false), - ); - } + let primary = TestMount::register(®istry, "/mnt/primary", None); + let fork_a = TestMount::register(®istry, "/mnt/fork-a", None); + let fork_b = TestMount::register(®istry, "/mnt/fork-b", None); 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), + std::sync::Arc::strong_count(&fork_a.notifier), 1, "the registry holds forks weakly" ); - // Unmounting a fork drops its state; the dead entry goes on the next walk. + // Unmounting a fork drops its `Core`; the dead entry goes on the next walk. drop(fork_a); assert_eq!(registry.live().len(), 2, "an unmounted fork is dropped"); assert_eq!( @@ -7232,44 +7218,21 @@ mod tests { #[test] fn state_registry_covering_uses_longest_prefix_and_ignores_dead_forks() { let registry = StateRegistry::default(); - let (primary, _primary_dir) = rooted_state("primary-volume", "primary"); - let (nested, _nested_dir) = rooted_state("nested-volume", "nested"); - let (sibling, _sibling_dir) = rooted_state("sibling-volume", "sibling"); - let primary_uid = primary.entries[&super::ROOT_INO].uid.clone(); - let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary)); - let nested = std::sync::Arc::new(parking_lot::Mutex::new(nested)); - let sibling = std::sync::Arc::new(parking_lot::Mutex::new(sibling)); let primary_path = std::path::Path::new("/home/me/ProtonDrive"); let nested_path = std::path::Path::new("/home/me/ProtonDrive/Device"); let sibling_path = std::path::Path::new("/home/me/Archive"); - let primary_live = session_flag(false); - let nested_live = session_flag(false); - let sibling_live = session_flag(true); + let primary = TestMount::register(®istry, "/home/me/ProtonDrive", Some(2)); + let nested = TestMount::register(®istry, "/home/me/ProtonDrive/Device", Some(3)); + let sibling = TestMount::register(®istry, "/home/me/Archive", Some(4)); + sibling.spawn(); - registry.register_bare( - primary_path, - &primary, - std::sync::Arc::new(std::sync::OnceLock::new()), - primary_live.clone(), - ); - registry.register_bare( - nested_path, - &nested, - std::sync::Arc::new(std::sync::OnceLock::new()), - nested_live.clone(), - ); - registry.register_bare( - sibling_path, - &sibling, - std::sync::Arc::new(std::sync::OnceLock::new()), - sibling_live, - ); assert_eq!(registry.live().len(), 3); assert!(!registry.is_mounted_at(primary_path)); assert!(!registry.is_mounted_at(nested_path)); - assert!( - !registry.owns_visible_uid(&primary_uid), - "an unspawned session must not authorize resident uids" + assert_eq!( + registry.live_roots(), + vec![4], + "an unspawned session's root must not authorize resident uids" ); assert!( registry @@ -7277,38 +7240,45 @@ mod tests { .is_none(), "a registered but unspawned session must not win path covering" ); - primary_live.store(true, std::sync::atomic::Ordering::Release); - nested_live.store(true, std::sync::atomic::Ordering::Release); - assert!(registry.owns_visible_uid(&primary_uid)); + primary.spawn(); + nested.spawn(); + let mut roots = registry.live_roots(); + roots.sort(); + assert_eq!(roots, vec![2, 3, 4]); - let (covering, covering_state, _, _) = registry + let covering = registry .covering(&nested_path.join("folder/file.txt")) .unwrap(); - assert_eq!(covering, nested_path); - assert!(std::sync::Arc::ptr_eq(&covering_state, &nested)); - drop(covering_state); - let (covering, _, _, _) = registry.covering(nested_path).unwrap(); + assert_eq!(covering.mountpoint, nested_path); + assert!(std::sync::Arc::ptr_eq(&covering.notifier, &nested.notifier)); + drop(covering); + let covering = registry.covering(nested_path).unwrap().mountpoint; assert_eq!(covering, nested_path, "an exact mountpoint covers itself"); - let (covering, _, _, _) = registry + let covering = registry .covering(std::path::Path::new("/home/me/ProtonDrive/Device/folder/")) - .unwrap(); + .unwrap() + .mountpoint; assert_eq!( covering, nested_path, "a trailing separator does not change component-prefix selection" ); - let (covering, _, _, _) = registry + let covering = registry .covering(std::path::Path::new( "/home/me/ProtonDrive/DeviceBackup/file", )) - .unwrap(); + .unwrap() + .mountpoint; assert_eq!( covering, primary_path, "/Device must not string-prefix-match /DeviceBackup" ); - let (covering, covering_state, _, _) = - registry.covering(&sibling_path.join("file")).unwrap(); - assert_eq!(covering, sibling_path); - assert!(std::sync::Arc::ptr_eq(&covering_state, &sibling)); + let covering = registry.covering(&sibling_path.join("file")).unwrap(); + assert_eq!(covering.mountpoint, sibling_path); + assert!(std::sync::Arc::ptr_eq( + &covering.notifier, + &sibling.notifier + )); + drop(covering); assert!( registry .covering(std::path::Path::new("/home/me/ProtonDriveBackup/file")) @@ -7329,150 +7299,128 @@ mod tests { ); // This is also the failed-fork case: the caller registered the fork, - // session construction failed, and its last strong `Core`/state handle - // was dropped. It must not make ListLocations report mounted. + // session construction failed, and its last `Core` was dropped. It + // must not make ListLocations report mounted. drop(nested); assert!(!registry.is_mounted_at(nested_path)); assert_eq!(registry.live().len(), 2); } /// The routing half of `docs/BUGS.md` B86: a path under a secondary mount - /// has to resolve to *that* mount's inode space and a suffix relative to - /// *its* root, or `pdfs ls` and `pdfs refresh` can only ever name the - /// primary mount and a stale on-demand folder has no escape hatch. + /// has to resolve to *that* mount's root and a suffix relative to it, or + /// `pdfs ls` and `pdfs refresh` can only ever name the primary mount and a + /// stale on-demand folder has no escape hatch. #[test] - fn covering_parts_route_a_path_to_the_mount_that_owns_it() { + fn covering_routes_a_path_to_the_mount_that_owns_it() { let registry = StateRegistry::default(); - let (primary, _primary_dir) = rooted_state("primary-volume", "primary"); - let (nested, _nested_dir) = rooted_state("nested-volume", "nested"); - let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary)); - let nested = std::sync::Arc::new(parking_lot::Mutex::new(nested)); let primary_path = std::path::Path::new("/home/me/ProtonDrive"); let nested_path = std::path::Path::new("/home/me/ProtonDrive/Device"); - let primary_upgrades = std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new())); - let nested_upgrades = std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new())); - - registry.register( - primary_path, - &primary, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - primary_upgrades.clone(), - ); - registry.register( - nested_path, - &nested, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - nested_upgrades.clone(), - ); + let primary = TestMount::register(®istry, "/home/me/ProtonDrive", Some(2)); + let nested = TestMount::register(®istry, "/home/me/ProtonDrive/Device", Some(3)); + primary.spawn(); + nested.spawn(); let deep = nested_path.join("sub/file.txt"); - let parts = registry.covering_parts(&deep).expect("nested mount covers"); - assert_eq!(parts.mountpoint, nested_path, "the nested mount wins"); - assert!( - std::sync::Arc::ptr_eq(&parts.state, &nested), - "the request must be answered in the nested inode space" + let mount = registry.covering(&deep).expect("nested mount covers"); + assert_eq!(mount.mountpoint, nested_path, "the nested mount wins"); + assert_eq!( + mount.root.get(), + Some(&3), + "the request must be resolved from the nested root" ); assert!( - std::sync::Arc::ptr_eq(&parts.size_upgrades, &nested_upgrades), - "and share that mount's in-flight size upgrades, not the primary's" + std::sync::Arc::ptr_eq(&mount.notifier, &nested.notifier), + "and tell that mount's session, not the primary's" ); assert_eq!( - deep.strip_prefix(&parts.mountpoint).unwrap(), + deep.strip_prefix(&mount.mountpoint).unwrap(), std::path::Path::new("sub/file.txt"), "the suffix is relative to the mount that owns it, not the primary" ); let shallow = primary_path.join("Documents"); - let parts = registry - .covering_parts(&shallow) - .expect("primary mount covers"); - assert!( - std::sync::Arc::ptr_eq(&parts.state, &primary), + let mount = registry.covering(&shallow).expect("primary mount covers"); + assert_eq!( + mount.root.get(), + Some(&2), "a path outside the nested root still routes to the primary" ); assert!( registry - .covering_parts(std::path::Path::new("/tmp/outside")) + .covering(std::path::Path::new("/tmp/outside")) .is_none(), "a path under no mount routes nowhere, and the caller reports that" ); } + /// Every mount shares one tree, so a node under a fork's root and one under + /// the primary root are both found there — but only while the mount whose + /// root they sit under is mounted. #[test] - fn state_registry_resolves_uids_from_any_resident_mount() { - let registry = StateRegistry::default(); - let (primary, _primary_dir) = rooted_state("primary-volume", "primary"); - let (mut fork, _fork_dir) = rooted_state("device-volume", "device"); - let primary_uid = primary.entries[&super::ROOT_INO].uid.clone(); + fn a_uid_is_visible_under_the_root_of_any_mounted_session() { + let (mut state, _dir) = rooted_state("primary-volume", "primary"); + let primary_uid = state.entries[&super::ROOT_INO].uid.clone(); + let device_root = node_helper_in_volume("device-volume", "device", None, "device", true); + let fork_root = state.install_root(device_root); let fork_node = node_helper_in_volume("device-volume", "fork", Some("device"), "fork", true); let fork_uid = fork_node.uid.clone(); - let fork_ino = fork.intern(super::ROOT_INO, fork_node); - fork.children.insert(super::ROOT_INO, vec![fork_ino]); - let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary)); - let fork = std::sync::Arc::new(parking_lot::Mutex::new(fork)); - registry.register_bare( - std::path::Path::new("/mnt/primary"), - &primary, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); - registry.register_bare( - std::path::Path::new("/mnt/device"), - &fork, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); + let fork_ino = state.intern(fork_root, fork_node); + state.children.insert(fork_root, vec![fork_ino]); - assert!(registry.owns_visible_uid(&primary_uid)); - assert!(registry.owns_visible_uid(&fork_uid)); - assert!(!registry.owns_visible_uid(&super::parse_uid("device-volume~missing").unwrap())); - - drop(fork); + let both = [super::ROOT_INO, fork_root]; + assert!(state.owns_visible_uid(&primary_uid, &both)); + assert!(state.owns_visible_uid(&fork_uid, &both)); assert!( - !registry.owns_visible_uid(&fork_uid), - "an unmounted on-demand state must stop authorizing its uids" + !state.owns_visible_uid(&super::parse_uid("device-volume~missing").unwrap(), &both) + ); + + assert!( + !state.owns_visible_uid(&fork_uid, &[super::ROOT_INO]), + "an unmounted on-demand root must stop authorizing its uids" ); } + /// Each session calls its own root inode 1, and nothing else may come out + /// as 1 or come in from it. #[test] - fn state_registry_treats_missing_listing_as_unknown_not_absent() { - let registry = StateRegistry::default(); + fn each_session_names_its_own_root_as_the_kernel_root() { + let fork_root = 41; + assert_eq!( + super::filesystem::kernel_ino(fork_root, fork_root).0, + super::ROOT_INO + ); + assert_eq!(super::filesystem::kernel_ino(fork_root, 7).0, 7); + assert_eq!(super::filesystem::kernel_ino(super::ROOT_INO, 7).0, 7); + } + + #[test] + fn a_missing_listing_is_unknown_not_absent() { let (mut state, _dir) = rooted_state("device-volume", "device"); let child_node = node_helper_in_volume("device-volume", "child", Some("device"), "child.txt", false); let child_uid = child_node.uid.clone(); let child_ino = state.intern(super::ROOT_INO, child_node); state.children.insert(super::ROOT_INO, vec![child_ino]); - let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); - registry.register_bare( - std::path::Path::new("/mnt/device"), - &state, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); - assert!(registry.owns_visible_uid(&child_uid)); + assert!(visible(&state, &child_uid)); - state.lock().invalidate_listing(super::ROOT_INO); + state.invalidate_listing(super::ROOT_INO); assert!( - registry.owns_visible_uid(&child_uid), + visible(&state, &child_uid), "an invalidated listing is unknown and must not hide a valid resident child" ); - state.lock().children.insert(super::ROOT_INO, Vec::new()); + state.children.insert(super::ROOT_INO, Vec::new()); assert!( - !registry.owns_visible_uid(&child_uid), + !visible(&state, &child_uid), "a present listing that omits the child proves it is no longer visible" ); } #[test] fn virtual_root_reconcile_does_not_recreate_an_invalidated_listing() { - let registry = StateRegistry::default(); let (mut state, _dir) = rooted_state("primary-volume", "root"); let root_uid = state.entries[&super::ROOT_INO].uid.clone(); let child_node = @@ -7510,15 +7458,8 @@ mod tests { "reconciliation must leave an invalidated listing absent" ); - let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); - registry.register_bare( - std::path::Path::new("/mnt/device"), - &state, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); assert!( - registry.owns_visible_uid(&child_uid), + visible(&state, &child_uid), "the absent listing remains unknown until refresh, so a valid resident uid is allowed" ); } @@ -7747,8 +7688,7 @@ mod tests { } #[test] - fn state_registry_rejects_open_unlinked_and_revoked_residents() { - let registry = StateRegistry::default(); + fn open_unlinked_and_revoked_residents_are_not_visible() { let (mut state, _dir) = rooted_state("device-volume", "device"); let open_node = @@ -7778,33 +7718,25 @@ mod tests { .push(revoked_ino); state.children.insert(revoked_ino, vec![child_ino]); - let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); - registry.register_bare( - std::path::Path::new("/mnt/device"), - &state, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); - assert!(registry.owns_visible_uid(&open_uid)); - assert!(registry.owns_visible_uid(&child_uid)); + assert!(visible(&state, &open_uid)); + assert!(visible(&state, &child_uid)); - state.lock().unlink_mem(&open_uid); + state.unlink_mem(&open_uid); assert!( - !registry.owns_visible_uid(&open_uid), + !visible(&state, &open_uid), "an open inode retained after unlink is not addressable" ); - state.lock().hide_shared_root(&revoked_uid); - assert!(!registry.owns_visible_uid(&revoked_uid)); + state.hide_shared_root(&revoked_uid); + assert!(!visible(&state, &revoked_uid)); assert!( - !registry.owns_visible_uid(&child_uid), + !visible(&state, &child_uid), "a retained descendant beneath a revoked root is not reachable" ); } #[test] - fn state_registry_rejects_foreign_broken_and_cyclic_residents() { - let registry = StateRegistry::default(); + fn foreign_broken_and_cyclic_residents_are_not_visible() { let (mut state, _dir) = rooted_state("own-volume", "root"); let foreign_node = @@ -7832,24 +7764,16 @@ mod tests { state.children.insert(cycle_a, vec![cycle_b]); state.children.insert(cycle_b, vec![cycle_a]); - let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); - registry.register_bare( - std::path::Path::new("/mnt/device"), - &state, - std::sync::Arc::new(std::sync::OnceLock::new()), - session_flag(true), - ); - assert!( - !registry.owns_visible_uid(&foreign_uid), + !visible(&state, &foreign_uid), "incoming-share residents are outside the mounted root's volume" ); assert!( - !registry.owns_visible_uid(&broken_uid), + !visible(&state, &broken_uid), "an interned node without a visible parent dentry is not reachable" ); assert!( - !registry.owns_visible_uid(&cycle_a_uid), + !visible(&state, &cycle_a_uid), "a cyclic resident parent chain is not reachable" ); } @@ -8544,8 +8468,8 @@ mod tests { // Reads resolve provisional sizes inline, while the kernel holds the // page lock of that read; dropping pages here waited on it forever. let upgrade = function_source(include_str!("lib.rs"), "fn apply_size_upgrade("); - assert!(upgrade.contains("notifier.inval_inode(INodeNo(ino), -1, 0)")); - assert!(!upgrade.contains("notifier.inval_inode(INodeNo(ino), 0, 0)")); + assert!(upgrade.contains("notifier.inval_attrs(ino)")); + assert!(!upgrade.contains("notifier.inval_inode(")); let read = function_source(include_str!("filesystem.rs"), " fn read("); assert!(read.contains("core.resolve_provisional_size(ino.0, &uid)")); } @@ -8891,6 +8815,7 @@ mod tests { unlinked: false, }, ); + state.roots.insert(super::ROOT_INO); (state, dir) } diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index 66e94b9..a908365 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -17,7 +17,7 @@ use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError}; use tracing::{debug, info, warn}; -use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN, ROOT_INO}; +use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN}; /// How long a remote call a FUSE handler is waiting on may take before the link /// is treated as down. @@ -258,8 +258,9 @@ impl Core { match probe { Ok(root) => { { + let root_ino = self.root_ino(); let mut st = self.state(); - if let Some(e) = st.entries.get_mut(&ROOT_INO) { + if let Some(e) = st.entries.get_mut(&root_ino) { e.node = root.clone(); } } diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index 3b4cc36..cc4d330 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -276,13 +276,14 @@ pub(super) fn spawn_session( info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location"); let fs = ProtonFs::new(core.clone(), root); if core.primary { - // Only My Files rehydrates the daemon-wide node cache. A secondary - // inode space is rooted at one device subtree and must not import every - // unrelated persisted node. + // Only My Files rehydrates the daemon-wide node cache, once. A + // secondary mount shares the tree it fills. core.hydrate(); } let session = Session::new(fs, mountpoint, &config)?.spawn()?; - let _ = core.notifier.set(session.notifier()); + let _ = core + .notifier + .set(KernelChannel::new(session.notifier(), core.root_ino())); core.session_live.store(true, Ordering::Release); Ok(session) } @@ -410,6 +411,7 @@ pub(crate) fn mount_with( primary_root_uid: root.uid.clone(), primary: true, state: Arc::new(Mutex::new(State::new(db.clone(), share_access))), + root: Arc::new(OnceLock::new()), cache: Arc::new(cache), readers: Arc::new(Mutex::new(HashMap::new())), block_ring: Arc::new(Mutex::new(BlockRing::default())), @@ -467,7 +469,7 @@ pub(crate) fn mount_with( 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`. + // every mount's session through this registry. core.register_state(mountpoint); core.transfers.set_limits(upload_limit, download_limit); diff --git a/crates/pdfs-fuse/src/relocate.rs b/crates/pdfs-fuse/src/relocate.rs index bc24c27..ada7916 100644 --- a/crates/pdfs-fuse/src/relocate.rs +++ b/crates/pdfs-fuse/src/relocate.rs @@ -35,7 +35,7 @@ const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5); /// Where one side of a move lives. enum Place { - /// My files or an on-demand folder: a live inode space. `core` is rooted at + /// My files or an on-demand folder: a live FUSE mount. `core` is rooted at /// that mount and `rel` is relative to its mountpoint. Mounted { core: Box, rel: PathBuf }, /// A mirror folder: a local directory kept in step by the sync engine. @@ -141,7 +141,7 @@ impl Core { /// are absolute local paths and may be in different locations: My files, /// an on-demand folder, or a mirror folder. Returns the moved name. /// - /// Within one inode space this is [`Core::move_to`]. Across locations it + /// Within one mount this is [`Core::move_to`]. Across locations it /// is a server-side move followed by local bookkeeping; see the module /// documentation. Anything the move could lose is refused up front. pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult { @@ -154,7 +154,7 @@ impl Core { rel: dest_rel, }, ) = (&source, &dest) - && Arc::ptr_eq(&core.state, &dest_core.state) + && Arc::ptr_eq(&core.root, &dest_core.root) { return core.move_to(rel, dest_rel); } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 7a16eb2..89d4f03 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -21,7 +21,7 @@ use proton_drive_rs::{MemberRole, NodeKind}; use proton_drive_rs::Node; -use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str}; +use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str}; /// A node from a *shared* listing as a [`DirEntry`]. /// @@ -714,19 +714,10 @@ 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 { - // 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 + // Relative to whichever root the node sits under: a node browsed + // through a sync folder is under that fork's root, not My Files + // (`docs/BUGS.md` B74). + walk_to_root(&self.state(), uid) } } @@ -771,14 +762,18 @@ impl StopSharingOutcome { } } -/// 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 — +/// Walk the tree from `uid` up to the root it sits under, building the path. +/// `None` if the node has never been interned, 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 { let mut ino = *st.by_uid.get(uid)?; let mut parts = Vec::new(); - while ino != ROOT_INO { + while !st.roots.contains(&ino) { let entry = st.entries.get(&ino)?; + // An orphan is its own parent without being a root. + if entry.parent == ino { + return None; + } parts.push(entry.node.name.clone()); ino = entry.parent; } diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index 9cc1e16..79b9aa9 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -205,16 +205,19 @@ pub(crate) type DirListing = Vec<(u64, bool, String)>; /// out, so a fallback inode never names a node that has a row. pub(crate) const FALLBACK_INO: u64 = 1 << 48; -/// The inode of the node with local id `lid` ([`Db::lids_for`]), except where -/// it is a mount's root, which is always [`crate::ROOT_INO`]. One above the lid, -/// so no other node takes the root's number. The same in every mount and after -/// a restart. +/// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the +/// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its +/// own root. The same in every mount and after a restart. pub(crate) fn lid_ino(lid: i64) -> u64 { lid.unsigned_abs() + crate::ROOT_INO } /// Mutable inode bookkeeping, guarded by a mutex because fuser drives the /// `Filesystem` trait through `&self`. +/// +/// One tree for every mount of the daemon. Each FUSE session sees it from its +/// own root ([`State::roots`]) and calls that root inode 1; `ProtonFs` +/// translates at the kernel boundary. pub(crate) struct State { /// inode -> node metadata. pub(crate) entries: HashMap, @@ -223,6 +226,10 @@ pub(crate) struct State { /// Cached directory listings: parent inode -> child inodes. Presence of a /// key means the directory has been enumerated. pub(crate) children: HashMap>, + /// The inodes some session has been rooted at ([`State::install_root`]). + /// A root is its own parent. Kept after its session unmounts: the node is + /// still one of ours. + pub(crate) roots: HashSet, /// The next inode for a node the database could not give a local id, from /// a range lids never reach ([`FALLBACK_INO`]). pub(crate) next_ino: u64, @@ -246,7 +253,7 @@ pub(crate) struct State { /// intern/root refresh. Core drains this set and notifies the matching /// kernel only after releasing the State lock. pub(crate) access_changes: HashSet, - /// Persisted shared-root authority, loaded once per inode space and updated + /// Persisted shared-root authority, loaded once and updated /// on every explicit role/tombstone change. Descendant inheritance reads /// this map instead of issuing one SQLite query per interned inode. pub(crate) share_access: HashMap, @@ -314,15 +321,6 @@ pub(crate) struct StateGuard<'a> { db: &'a Arc, } -/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk -/// every live mount rather than this `Core`'s own. -pub(crate) fn lock_state<'a>( - state: &'a parking_lot::Mutex, - db: &'a Arc, -) -> StateGuard<'a> { - StateGuard::new(state.lock(), db) -} - impl<'a> StateGuard<'a> { pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc) -> Self { Self { @@ -389,14 +387,14 @@ impl State { self.db.clone() } - /// A fresh, empty inode space over `db`. A node's inode comes from its - /// local id ([`lid_ino`]); a real mount puts its root at - /// [`crate::ROOT_INO`]. + /// A fresh, empty tree over `db`. A node's inode comes from its local id + /// ([`lid_ino`]). pub(crate) fn new(db: Arc, share_access: HashMap) -> Self { Self { entries: HashMap::new(), by_uid: HashMap::new(), children: HashMap::new(), + roots: HashSet::new(), next_ino: FALLBACK_INO, active_writes: HashMap::new(), handles: HashMap::new(), @@ -408,18 +406,55 @@ impl State { outbox: Vec::new(), } } - /// Whether `uid` names an owned node currently reachable from this mount's - /// root. Resident entries can outlive their dentries for open-handle and - /// access-revocation semantics, so `by_uid` membership alone is not proof - /// that a control-socket operation may address the node. - pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool { - let Some(root) = self.entries.get(&crate::ROOT_INO) else { - return false; - }; - if uid.volume_id != root.uid.volume_id { - return false; + /// Make `root` the root of a session and return its inode, which comes + /// from its local id like any other node's. + /// + /// The root is always owned. It must never be classified as a shared root, + /// even when the API returns membership data (which happens when the owner + /// shares the folder with someone else), so a stale share row a downgrade + /// may have left for it is dropped. A node already in the tree keeps its + /// inode and its lookups, and stops naming a parent: a session's `..` at + /// its root is the root. + pub(crate) fn install_root(&mut self, root: Node) -> u64 { + if let Err(e) = self.db.upsert_node(&root) { + warn!(uid = %root.uid, error = %e, "db upsert root failed"); } + self.share_access.remove(&root.uid); + let _ = self.db.delete_share_access(&root.uid); + let ino = match self.by_uid.get(&root.uid) { + Some(&ino) => ino, + None => self.new_inos(&[&root])[&root.uid], + }; + if let Some(old) = self.entries.get(&ino).map(|e| e.parent) + && old != ino + && let Some(kids) = self.children.get_mut(&old) + { + kids.retain(|&k| k != ino); + } + self.by_uid.insert(root.uid.clone(), ino); + let lookup_count = self.entries.get(&ino).map_or(1, |e| e.lookup_count.max(1)); + self.entries.insert( + ino, + Entry { + uid: root.uid.clone(), + parent: ino, + node: root, + access: Access::Owner, + lookup_count, + open_count: 0, + unlinked: false, + }, + ); + self.roots.insert(ino); + ino + } + /// Whether `uid` names an owned node currently reachable from one of + /// `roots`, the roots of the sessions that are mounted. Resident entries + /// can outlive their dentries for open-handle and access-revocation + /// semantics, so `by_uid` membership alone is not proof that a + /// control-socket operation may address the node. + pub(crate) fn owns_visible_uid(&self, uid: &NodeUid, roots: &[u64]) -> bool { let Some(&target) = self.by_uid.get(uid) else { return false; }; @@ -432,7 +467,9 @@ impl State { let Some(entry) = self.entries.get(¤t) else { return false; }; - if entry.uid.volume_id != root.uid.volume_id + // The node's root scopes it to its own volume, excluding foreign + // shared-with-me residents from uid-addressed sharing. + if entry.uid.volume_id != uid.volume_id || entry.unlinked || entry.node.trashed || entry.uid != entry.node.uid @@ -440,7 +477,7 @@ impl State { { return false; } - if current == crate::ROOT_INO { + if roots.contains(¤t) { return true; } @@ -482,15 +519,17 @@ impl State { self.access_changes.drain().collect() } - /// Whether `uid` lives on the volume this mount is rooted in. + /// Whether `uid` lives on a volume some mount is rooted in. /// /// Used to decide how a node with no resolvable parent is classified: /// our own volume fails open (it is owned content), a foreign volume /// fails closed (it can only be there because of a share). pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool { - self.entries - .get(&crate::ROOT_INO) - .is_some_and(|root| root.uid.volume_id == uid.volume_id) + self.roots.iter().any(|ino| { + self.entries + .get(ino) + .is_some_and(|root| root.uid.volume_id == uid.volume_id) + }) } fn stored_share_access(&self, uid: &NodeUid) -> Option { @@ -1475,7 +1514,7 @@ mod tests { let root = uid("root").to_string(); let state = parking_lot::Mutex::new(st); { - let mut guard = lock_state(&state, &db); + let mut guard = StateGuard::new(state.lock(), &db); guard.intern(0, node("root", "none", "My Files", true)); assert!( db.node_by_uid(&root).unwrap().is_none(), @@ -1718,6 +1757,7 @@ mod tests { }) .collect(); st.next_ino = crate::ROOT_INO + nodes.len() as u64; + st.roots.insert(crate::ROOT_INO); // Reverse order so entries are inserted before their parents, matching // the hydration path's non-topological materialization. for (i, node) in nodes.iter().enumerate().rev() { diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index 7b1e612..88a642e 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -175,10 +175,9 @@ impl Core { return; } } - // 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). + // Leaving a conflict copy hooked up in the tree keeps a node we have + // just trashed remotely visible and readable in whichever mount shows + // it (`docs/BUGS.md` B74). self.for_each_state(|st| { st.forget_or_unlink(uid); }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1d8e3bb..e560052 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +Schema: **36**. Every node row gets a local id that never changes; existing rows keep their +row number as that id. Going back to 2.9 means rebuilding the cache. + +### Changed +- **A file keeps its inode number across daemon restarts.** Inodes are now the node's local id + instead of a counter that started over on every run. +- **Every mount serves from one tree.** An on-demand sync folder used to keep its own copy of the + nodes it showed, which background work had to visit one by one and could miss (B74). It now + shares the tree with My files; only the root it starts at is its own. + ### Fixed - **A file renamed or moved while its first upload is on the wire keeps its new name and folder.** It landed under the old name and the rename was lost (B136).