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.
This commit is contained in:
nvrl committed 2026-10-03 16:53:31 +02:00
1 parent ea27f2bcd0
commit ad8b225cfd
12 files changed
+580 -622

No files matched your search

+9 -11
View File
@@ -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 /// 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. /// 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 /// Applied to the tree every mount shares, and told to **every** mounted
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid, /// session. A `DriveEvent` names a uid, not a mount, so a file trashed from
/// not a mount — so a file trashed from another device has to be withdrawn from /// another device has to be withdrawn from whichever of our sessions is showing
/// whichever of our sessions is showing it, which for anything under a sync /// it — which for anything under a sync folder is a [`Core::fork_state`] fork.
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for /// Telling the primary session alone left forks serving a deleted file, the
/// the primary state alone left forks serving a deleted file indefinitely, the /// read-side twin of `docs/BUGS.md` B74. [`Core::for_each_mount`] sends what
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each /// the kernel needs to hear to every session.
/// 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, dirty: &mut DirtyParents) -> pdfs_core::Result<()> { 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 foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
let serializes_shared_publication = let serializes_shared_publication =
@@ -362,8 +360,8 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
/// only on fatal error. /// only on fatal error.
/// ///
/// Takes the whole `Core` rather than the primary mount's pieces: an event has /// 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 /// to reach every mounted session, and the registry that enumerates them lives
/// them lives on the `Core` (see [`apply_event`]). /// on the `Core` (see [`apply_event`]).
pub(super) async fn run_event_sync( pub(super) async fn run_event_sync(
client: Arc<dyn DriveApi>, client: Arc<dyn DriveApi>,
scope: DriveEventScopeId, scope: DriveEventScopeId,
+8 -15
View File
@@ -27,7 +27,7 @@ use tracing::{info, warn};
use super::sync::{self, base_name}; use super::sync::{self, base_name};
use super::{ 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, 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, 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, /// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
/// activity, mount registries) but gets a **fresh, empty `State`** — its own /// folder (devices.md Phase 3). It shares everything with this one,
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session /// the tree included, except what belongs to a session: its root and its
/// at an `ondemand` sync folder without colliding with the main mount's inodes /// kernel channel, both filled in when the session is built.
/// (devices.md Phase 3).
pub(crate) fn fork_state(&self) -> Core { pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone(); let mut fork = self.clone();
fork.primary = false; fork.primary = false;
let share_access = self.state().share_access.clone(); fork.root = Arc::new(OnceLock::new());
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.notifier = Arc::new(OnceLock::new()); fork.notifier = Arc::new(OnceLock::new());
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false)); 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 fork
} }
@@ -934,8 +927,8 @@ impl Core {
} }
} }
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked /// Spawn a secondary FUSE session rooted at `root` over `local` from a
/// inode space. Clears any stale kernel mount first (a crashed run can leave /// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY). /// one, which would fail the fresh mount with EBUSY).
pub(crate) fn spawn_ondemand_mount( pub(crate) fn spawn_ondemand_mount(
&self, &self,
+10 -25
View File
@@ -44,7 +44,7 @@ use super::state::{Intervals, PendingRevision};
use super::transfers::CountingReader; use super::transfers::CountingReader;
use super::{ use super::{
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, DRAIN_REVISION_DEBOUNCE, 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, is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
now_millis, now_secs, parse_node_uid, 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 /// 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 /// Applied to the tree every mount shares. A node created inside an
/// created inside an on-demand sync folder lives in that fork's `state`, /// on-demand sync folder once lived in that fork's own state, where the
/// while the drain that lands its create belongs to the primary mount — so /// primary mount's drain never looked, and stayed on its `local~` uid —
/// reaching for `self.state` here found nothing and left the fork's entry on /// an empty file for as long as the daemon ran (`docs/BUGS.md` B74).
/// 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.
/// ///
/// `adopt` sees the node as Drive made it, before anything is rebased onto /// `adopt` sees the node as Drive made it, before anything is rebased onto
/// it. /// it.
@@ -1559,25 +1556,13 @@ 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 {
// written inside a sync folder is interned in that fork's inode space, let st = self.state();
// 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()
{ {
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 // 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
@@ -1612,8 +1597,8 @@ impl Core {
/// root yet, which is not where the drain runs; the drain must not panic /// 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. /// over it regardless, since that would stop the queue for good.
pub(crate) fn root_uid(&self) -> Option<NodeUid> { pub(crate) fn root_uid(&self) -> Option<NodeUid> {
let st = self.state(); let root = self.root_ino();
st.entries.get(&ROOT_INO).map(|e| e.uid.clone()) self.state().entries.get(&root).map(|e| e.uid.clone())
} }
/// Upload a staged revision of a file the server already knows about. /// Upload a staged revision of a file the server already knows about.
+60 -50
View File
@@ -72,7 +72,7 @@ impl Filesystem for ProtonFs {
} }
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { 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) { let name = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -95,11 +95,13 @@ impl Filesystem for ProtonFs {
} }
fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) { fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) {
let ino = self.tree(ino);
let mut st = self.core.state(); let mut st = self.core.state();
st.forget_lookup(ino.0, nlookup); st.forget_lookup(ino.0, nlookup);
} }
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) { fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
let ino = self.tree(ino);
let (attr, provisional_in) = { let (attr, provisional_in) = {
let st = self.core.state(); let st = self.core.state();
match st.entries.get(&ino.0) { 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 /// Enumerating a cold folder is a remote call, so it goes to a worker for
/// the same reason `lookup` and `readdir` do. /// the same reason `lookup` and `readdir` do.
fn opendir(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) { 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) { if self.core.children_cached(ino) {
self.serve_opendir(ino, reply); self.serve_opendir(ino, reply);
return; return;
@@ -194,13 +196,13 @@ impl Filesystem for ProtonFs {
offset: u64, offset: u64,
reply: ReplyDirectory, 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 // 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 // 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 // worker. Only a handle whose `opendir` failed to snapshot (or a kernel
// that issued `readdir` without one) falls back to the live path. // that issued `readdir` without one) falls back to the live path.
if let Some(listing) = self.core.state().dir_snapshots.get(&fh.0).cloned() { 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; return;
} }
// Same split as `lookup`: only the cold, remote-enumerating path pays // 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 /// 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. /// stalling every other request on the mount behind it.
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
let ino = self.tree(ino);
if flags.acc_mode() == OpenAccMode::O_RDONLY { if flags.acc_mode() == OpenAccMode::O_RDONLY {
self.serve_open(ino, flags, reply); self.serve_open(ino, flags, reply);
return; return;
@@ -253,6 +256,7 @@ impl Filesystem for ProtonFs {
_lock_owner: Option<LockOwner>, _lock_owner: Option<LockOwner>,
reply: ReplyData, reply: ReplyData,
) { ) {
let ino = self.tree(ino);
// A file open for writing is served from its handle so reads see the // A file open for writing is served from its handle so reads see the
// in-flight (possibly unsaved) content: authored bytes come from the // in-flight (possibly unsaved) content: authored bytes come from the
// scratch file, untouched bytes from the base. // scratch file, untouched bytes from the base.
@@ -364,7 +368,7 @@ impl Filesystem for ProtonFs {
_flags: i32, _flags: i32,
reply: ReplyCreate, reply: ReplyCreate,
) { ) {
let parent = parent.0; let parent = self.tree(parent).0;
if let Err(error) = self.core.require_writable(parent) { if let Err(error) = self.core.require_writable(parent) {
reply.error(error); reply.error(error);
return; return;
@@ -400,6 +404,7 @@ impl Filesystem for ProtonFs {
_lock_owner: Option<LockOwner>, _lock_owner: Option<LockOwner>,
reply: ReplyWrite, reply: ReplyWrite,
) { ) {
let ino = self.tree(ino);
if let Err(error) = self.core.require_writable(ino.0) { if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error); reply.error(error);
return; return;
@@ -477,6 +482,7 @@ impl Filesystem for ProtonFs {
_flags: Option<BsdFileFlags>, _flags: Option<BsdFileFlags>,
reply: ReplyAttr, reply: ReplyAttr,
) { ) {
let ino = self.tree(ino);
if let Err(error) = self.core.require_writable(ino.0) { if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error); reply.error(error);
return; return;
@@ -544,6 +550,7 @@ impl Filesystem for ProtonFs {
mode: i32, mode: i32,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
let ino = self.tree(ino);
if let Err(error) = self.core.require_writable(ino.0) { if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error); reply.error(error);
return; return;
@@ -640,16 +647,17 @@ impl Filesystem for ProtonFs {
fn release( fn release(
&self, &self,
_req: &Request, _req: &Request,
_ino: INodeNo, ino: INodeNo,
fh: FileHandle, fh: FileHandle,
_flags: OpenFlags, _flags: OpenFlags,
_lock_owner: Option<LockOwner>, _lock_owner: Option<LockOwner>,
_flush: bool, _flush: bool,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
let ino = self.tree(ino);
let (handle, unlinked_uid) = { let (handle, unlinked_uid) = {
let mut st = self.core.state(); 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 ino = st.handles.remove(&fh.0);
let h = ino.and_then(|i| { let h = ino.and_then(|i| {
let aw = st.active_writes.get_mut(&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) { 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) { if let Err(error) = self.core.require_writable(parent) {
reply.error(error); reply.error(error);
return; return;
@@ -700,7 +708,7 @@ impl Filesystem for ProtonFs {
} }
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { 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) { if let Err(error) = self.core.require_writable(parent) {
reply.error(error); reply.error(error);
return; return;
@@ -729,7 +737,7 @@ impl Filesystem for ProtonFs {
_umask: u32, _umask: u32,
reply: ReplyEntry, reply: ReplyEntry,
) { ) {
let parent = parent.0; let parent = self.tree(parent).0;
if let Err(error) = self.core.require_writable(parent) { if let Err(error) = self.core.require_writable(parent) {
reply.error(error); reply.error(error);
return; return;
@@ -760,8 +768,8 @@ impl Filesystem for ProtonFs {
flags: RenameFlags, flags: RenameFlags,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
let parent = parent.0; let parent = self.tree(parent).0;
let newparent = newparent.0; let newparent = self.tree(newparent).0;
if let Err(error) = self if let Err(error) = self
.core .core
.require_writable(parent) .require_writable(parent)
@@ -794,6 +802,7 @@ impl Filesystem for ProtonFs {
} }
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) { fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
let ino = self.tree(ino);
let state = self.core.state(); let state = self.core.state();
let Some(entry) = state.entries.get(&ino.0) else { let Some(entry) = state.entries.get(&ino.0) else {
reply.error(Errno::ENOENT); 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) { 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 // 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 // that stats a directory issues one of these per file per advertised
// name — inline, that serialized the whole mount behind ~186 ms of // 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) { fn listxattr(&self, _req: &Request, ino: INodeNo, size: u32, reply: ReplyXattr) {
let ino = self.tree(ino);
self.serve_listxattr(ino, size, reply); self.serve_listxattr(ino, size, reply);
} }
@@ -968,6 +979,9 @@ pub struct ProtonFs {
core: Core, core: Core,
uid: u32, uid: u32,
gid: 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` /// 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 /// 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 /// continues exactly where the last one stopped — which is only true because
/// the listing behind those indexes is a snapshot (bugs.md B43). /// 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) { for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
let ft = if *is_dir { let ft = if *is_dir {
FileType::Directory FileType::Directory
} else { } else {
FileType::RegularFile 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; break;
} }
} }
reply.ok(); 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 { pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
(!mask.contains(AccessFlags::W_OK) || access.writable()) (!mask.contains(AccessFlags::W_OK) || access.writable())
&& (!mask.contains(AccessFlags::X_OK) || is_dir) && (!mask.contains(AccessFlags::X_OK) || is_dir)
} }
impl ProtonFs { 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 { pub(super) fn new(core: Core, root: Node) -> Self {
{ let root = core.state().install_root(root);
let mut st = core.state(); let _ = core.root.set(root);
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,
},
);
}
// SAFETY: geteuid/getegid are infallible and have no preconditions. // SAFETY: geteuid/getegid are infallible and have no preconditions.
let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; 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. /// The body of [`Filesystem::lookup`], on whichever thread ends up serving it.
@@ -1169,7 +1179,7 @@ impl ProtonFs {
let st = self.core.state(); let st = self.core.state();
let listing = build_listing(&st, ino); let listing = build_listing(&st, ino);
drop(st); drop(st);
reply_listing(&listing, offset, reply); reply_listing(&listing, offset, self.root, reply);
} }
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr { fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
@@ -1182,7 +1192,7 @@ impl ProtonFs {
let mtime = unix_secs(node.modification_time); let mtime = unix_secs(node.modification_time);
let crtime = unix_secs(node.creation_time); let crtime = unix_secs(node.creation_time);
FileAttr { FileAttr {
ino: INodeNo(ino), ino: kernel_ino(self.root, ino),
size, size,
blocks: size.div_ceil(512), blocks: size.div_ceil(512),
atime: mtime, atime: mtime,
@@ -1994,11 +2004,11 @@ impl ProtonFs {
// and retain an empty destination readdir page. // and retain an empty destination readdir page.
reply.ok(); reply.ok();
if let Some(notifier) = self.core.notifier.get() { if let Some(notifier) = self.core.notifier.get() {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name)); let _ = notifier.inval_entry(parent, OsStr::new(&name));
let _ = notifier.inval_entry(INodeNo(newparent), OsStr::new(&newname)); let _ = notifier.inval_entry(newparent, OsStr::new(&newname));
let _ = notifier.inval_inode(INodeNo(parent), 0, 0); let _ = notifier.inval_inode(parent);
if newparent != parent { if newparent != parent {
let _ = notifier.inval_inode(INodeNo(newparent), 0, 0); let _ = notifier.inval_inode(newparent);
} }
} }
} }
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -17,7 +17,7 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError}; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
use tracing::{debug, info, warn}; 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 /// How long a remote call a FUSE handler is waiting on may take before the link
/// is treated as down. /// is treated as down.
@@ -258,8 +258,9 @@ impl Core {
match probe { match probe {
Ok(root) => { Ok(root) => {
{ {
let root_ino = self.root_ino();
let mut st = self.state(); 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(); e.node = root.clone();
} }
} }
+7 -5
View File
@@ -276,13 +276,14 @@ pub(super) fn spawn_session(
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location"); info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
let fs = ProtonFs::new(core.clone(), root); let fs = ProtonFs::new(core.clone(), root);
if core.primary { if core.primary {
// Only My Files rehydrates the daemon-wide node cache. A secondary // Only My Files rehydrates the daemon-wide node cache, once. A
// inode space is rooted at one device subtree and must not import every // secondary mount shares the tree it fills.
// unrelated persisted node.
core.hydrate(); core.hydrate();
} }
let session = Session::new(fs, mountpoint, &config)?.spawn()?; 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); core.session_live.store(true, Ordering::Release);
Ok(session) Ok(session)
} }
@@ -410,6 +411,7 @@ pub(crate) fn mount_with(
primary_root_uid: root.uid.clone(), primary_root_uid: root.uid.clone(),
primary: true, primary: true,
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))), state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
root: Arc::new(OnceLock::new()),
cache: Arc::new(cache), cache: Arc::new(cache),
readers: Arc::new(Mutex::new(HashMap::new())), readers: Arc::new(Mutex::new(HashMap::new())),
block_ring: Arc::new(Mutex::new(BlockRing::default())), block_ring: Arc::new(Mutex::new(BlockRing::default())),
@@ -467,7 +469,7 @@ pub(crate) fn mount_with(
states: Arc::new(StateRegistry::default()), states: Arc::new(StateRegistry::default()),
}; };
// Before anything can queue work against it: the drain thread below reaches // 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.register_state(mountpoint);
core.transfers.set_limits(upload_limit, download_limit); core.transfers.set_limits(upload_limit, download_limit);
+3 -3
View File
@@ -35,7 +35,7 @@ const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
/// Where one side of a move lives. /// Where one side of a move lives.
enum Place { 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. /// that mount and `rel` is relative to its mountpoint.
Mounted { core: Box<Core>, rel: PathBuf }, Mounted { core: Box<Core>, rel: PathBuf },
/// A mirror folder: a local directory kept in step by the sync engine. /// 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, /// are absolute local paths and may be in different locations: My files,
/// an on-demand folder, or a mirror folder. Returns the moved name. /// 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 /// is a server-side move followed by local bookkeeping; see the module
/// documentation. Anything the move could lose is refused up front. /// documentation. Anything the move could lose is refused up front.
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> { pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
@@ -154,7 +154,7 @@ impl Core {
rel: dest_rel, rel: dest_rel,
}, },
) = (&source, &dest) ) = (&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); return core.move_to(rel, dest_rel);
} }
+12 -17
View File
@@ -21,7 +21,7 @@ use proton_drive_rs::{MemberRole, NodeKind};
use proton_drive_rs::Node; 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`]. /// 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) — /// 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> {
// Searched across every mount: a node browsed through a sync folder is // Relative to whichever root the node sits under: a node browsed
// interned in that fork's inode space, so the primary state alone had no // through a sync folder is under that fork's root, not My Files
// entry for it and the caller silently showed an empty path // (`docs/BUGS.md` B74).
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds walk_to_root(&self.state(), uid)
// 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
} }
} }
@@ -771,14 +762,18 @@ impl StopSharingOutcome {
} }
} }
/// Walk one inode space from `uid` up to its root, building the path. `None` if /// Walk the tree from `uid` up to the root it sits under, building the path.
/// that mount has never interned the node, or if the chain breaks partway — /// `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. /// a broken chain is a missing path, never a partial one.
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> { 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 !st.roots.contains(&ino) {
let entry = st.entries.get(&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()); parts.push(entry.node.name.clone());
ino = entry.parent; ino = entry.parent;
} }
+74 -34
View File
@@ -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. /// out, so a fallback inode never names a node that has a row.
pub(crate) const FALLBACK_INO: u64 = 1 << 48; pub(crate) const FALLBACK_INO: u64 = 1 << 48;
/// The inode of the node with local id `lid` ([`Db::lids_for`]), except where /// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the
/// it is a mount's root, which is always [`crate::ROOT_INO`]. One above the lid, /// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its
/// so no other node takes the root's number. The same in every mount and after /// own root. The same in every mount and after a restart.
/// a restart.
pub(crate) fn lid_ino(lid: i64) -> u64 { pub(crate) fn lid_ino(lid: i64) -> u64 {
lid.unsigned_abs() + crate::ROOT_INO lid.unsigned_abs() + crate::ROOT_INO
} }
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the /// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`. /// `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 { pub(crate) struct State {
/// inode -> node metadata. /// inode -> node metadata.
pub(crate) entries: HashMap<u64, Entry>, pub(crate) entries: HashMap<u64, Entry>,
@@ -223,6 +226,10 @@ pub(crate) struct State {
/// Cached directory listings: parent inode -> child inodes. Presence of a /// Cached directory listings: parent inode -> child inodes. Presence of a
/// key means the directory has been enumerated. /// key means the directory has been enumerated.
pub(crate) children: HashMap<u64, Vec<u64>>, pub(crate) children: HashMap<u64, Vec<u64>>,
/// 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<u64>,
/// The next inode for a node the database could not give a local id, from /// The next inode for a node the database could not give a local id, from
/// a range lids never reach ([`FALLBACK_INO`]). /// a range lids never reach ([`FALLBACK_INO`]).
pub(crate) next_ino: u64, pub(crate) next_ino: u64,
@@ -246,7 +253,7 @@ pub(crate) struct State {
/// intern/root refresh. Core drains this set and notifies the matching /// intern/root refresh. Core drains this set and notifies the matching
/// kernel only after releasing the State lock. /// kernel only after releasing the State lock.
pub(crate) access_changes: HashSet<u64>, pub(crate) access_changes: HashSet<u64>,
/// 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 /// on every explicit role/tombstone change. Descendant inheritance reads
/// this map instead of issuing one SQLite query per interned inode. /// this map instead of issuing one SQLite query per interned inode.
pub(crate) share_access: HashMap<NodeUid, Access>, pub(crate) share_access: HashMap<NodeUid, Access>,
@@ -314,15 +321,6 @@ pub(crate) struct StateGuard<'a> {
db: &'a Arc<Db>, db: &'a Arc<Db>,
} }
/// 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<State>,
db: &'a Arc<Db>,
) -> StateGuard<'a> {
StateGuard::new(state.lock(), db)
}
impl<'a> StateGuard<'a> { impl<'a> StateGuard<'a> {
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self { pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
Self { Self {
@@ -389,14 +387,14 @@ impl State {
self.db.clone() self.db.clone()
} }
/// A fresh, empty inode space over `db`. A node's inode comes from its /// A fresh, empty tree over `db`. A node's inode comes from its local id
/// local id ([`lid_ino`]); a real mount puts its root at /// ([`lid_ino`]).
/// [`crate::ROOT_INO`].
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self { pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self {
Self { Self {
entries: HashMap::new(), entries: HashMap::new(),
by_uid: HashMap::new(), by_uid: HashMap::new(),
children: HashMap::new(), children: HashMap::new(),
roots: HashSet::new(),
next_ino: FALLBACK_INO, next_ino: FALLBACK_INO,
active_writes: HashMap::new(), active_writes: HashMap::new(),
handles: HashMap::new(), handles: HashMap::new(),
@@ -408,18 +406,55 @@ impl State {
outbox: Vec::new(), outbox: Vec::new(),
} }
} }
/// Whether `uid` names an owned node currently reachable from this mount's /// Make `root` the root of a session and return its inode, which comes
/// root. Resident entries can outlive their dentries for open-handle and /// from its local id like any other node's.
/// access-revocation semantics, so `by_uid` membership alone is not proof ///
/// that a control-socket operation may address the node. /// The root is always owned. It must never be classified as a shared root,
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool { /// even when the API returns membership data (which happens when the owner
let Some(root) = self.entries.get(&crate::ROOT_INO) else { /// shares the folder with someone else), so a stale share row a downgrade
return false; /// 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
if uid.volume_id != root.uid.volume_id { /// its root is the root.
return false; 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 { let Some(&target) = self.by_uid.get(uid) else {
return false; return false;
}; };
@@ -432,7 +467,9 @@ impl State {
let Some(entry) = self.entries.get(&current) else { let Some(entry) = self.entries.get(&current) else {
return false; 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.unlinked
|| entry.node.trashed || entry.node.trashed
|| entry.uid != entry.node.uid || entry.uid != entry.node.uid
@@ -440,7 +477,7 @@ impl State {
{ {
return false; return false;
} }
if current == crate::ROOT_INO { if roots.contains(&current) {
return true; return true;
} }
@@ -482,15 +519,17 @@ impl State {
self.access_changes.drain().collect() 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: /// Used to decide how a node with no resolvable parent is classified:
/// our own volume fails open (it is owned content), a foreign volume /// our own volume fails open (it is owned content), a foreign volume
/// fails closed (it can only be there because of a share). /// fails closed (it can only be there because of a share).
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool { pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
self.entries self.roots.iter().any(|ino| {
.get(&crate::ROOT_INO) self.entries
.is_some_and(|root| root.uid.volume_id == uid.volume_id) .get(ino)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
})
} }
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> { fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
@@ -1475,7 +1514,7 @@ mod tests {
let root = uid("root").to_string(); let root = uid("root").to_string();
let state = parking_lot::Mutex::new(st); 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)); guard.intern(0, node("root", "none", "My Files", true));
assert!( assert!(
db.node_by_uid(&root).unwrap().is_none(), db.node_by_uid(&root).unwrap().is_none(),
@@ -1718,6 +1757,7 @@ mod tests {
}) })
.collect(); .collect();
st.next_ino = crate::ROOT_INO + nodes.len() as u64; 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 // Reverse order so entries are inserted before their parents, matching
// the hydration path's non-topological materialization. // the hydration path's non-topological materialization.
for (i, node) in nodes.iter().enumerate().rev() { for (i, node) in nodes.iter().enumerate().rev() {
+3 -4
View File
@@ -175,10 +175,9 @@ impl Core {
return; return;
} }
} }
// Every mount, not just the primary: a conflict copy inside a sync // Leaving a conflict copy hooked up in the tree keeps a node we have
// folder lives in that fork's inode space, and leaving it hooked up // just trashed remotely visible and readable in whichever mount shows
// there keeps a node we have just trashed remotely visible and // it (`docs/BUGS.md` B74).
// readable there (`docs/BUGS.md` B74).
self.for_each_state(|st| { self.for_each_state(|st| {
st.forget_or_unlink(uid); st.forget_or_unlink(uid);
}); });
+10
View File
@@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [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 ### Fixed
- **A file renamed or moved while its first upload is on the wire keeps its new name and folder.** - **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). It landed under the old name and the rename was lost (B136).