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
+572 -614

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
/// 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<dyn DriveApi>,
scope: DriveEventScopeId,
+8 -15
View File
@@ -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,
+10 -25
View File
@@ -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<NodeUid> {
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.
+60 -50
View File
@@ -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<FileHandle>, 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<LockOwner>,
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<LockOwner>,
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<BsdFileFlags>,
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<LockOwner>,
_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);
}
}
}
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 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();
}
}
+7 -5
View File
@@ -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);
+3 -3
View File
@@ -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<Core>, 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<String> {
@@ -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);
}
+12 -17
View File
@@ -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<String> {
// 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<String> {
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;
}
+71 -31
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.
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<u64, Entry>,
@@ -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<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
/// 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<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
/// this map instead of issuing one SQLite query per interned inode.
pub(crate) share_access: HashMap<NodeUid, Access>,
@@ -314,15 +321,6 @@ pub(crate) struct StateGuard<'a> {
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> {
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> 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<Db>, share_access: HashMap<NodeUid, Access>) -> 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;
/// 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 uid.volume_id != root.uid.volume_id {
return false;
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(&current) 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(&current) {
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.roots.iter().any(|ino| {
self.entries
.get(&crate::ROOT_INO)
.get(ino)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
})
}
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
@@ -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() {
+3 -4
View File
@@ -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);
});
+10
View File
@@ -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).