You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/10/26 10:34:50 UTC
(opendal-yinyang) 01/01: Merge foundation 8ccf8ff into the writable layer
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/linux-fuse-writable
in repository https://gitbox.apache.org/repos/asf/opendal-yinyang.git
commit 00487906de01260a7d73ccd27456b2aa1c5506b4
Merge: 88cf08c 8ccf8ff
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 26 18:34:43 2026 +0800
Merge foundation 8ccf8ff into the writable layer
Keep shared staged-attribute lookup semantics while recording positive
lookup dentries for explicit refresh. Retain writable callback and contract
documentation alongside the notifier-backed SIGHUP lifecycle.
Co-authored-by: Chojan Shang <ps...@apache.org>
Cargo.lock | 1 +
Cargo.toml | 1 +
specs/linux-fuse.md | 46 ++++++++++++--
src/file_cli.rs | 6 +-
src/fuse/frontend.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++++++--
src/fuse/inode.rs | 6 ++
src/fuse/mod.rs | 85 +++++++++++++++++---------
7 files changed, 271 insertions(+), 39 deletions(-)
diff --cc specs/linux-fuse.md
index 0eb2698,ea8024b..31e29c0
--- a/specs/linux-fuse.md
+++ b/specs/linux-fuse.md
@@@ -91,55 -89,43 +91,90 @@@ version remains coherent until a later
that open's metadata and content never disagree. The frontend does not call
`Mount::refresh` while references exist, because refresh rebases live handles;
a writable layer that in-place refreshes must first define its open-fd and
-kernel-cache invalidation policy.
+kernel-cache invalidation policy. Before a new open or a path attribute, the
+adapter retries reclaiming a clean unreferenced NodeId; a successful reclaim
+makes the next observation use the latest remote version, while Busy retains
+all still-pinned or dirty/frozen/error state.
+
+## Writable callbacks
+
+`write` stages bytes in the shared per-NodeId Mount handle. Each request uses
+the file flags supplied by the kernel, so `fcntl(F_SETFL)` may toggle
+`O_APPEND` and `O_SYNC`/`O_DSYNC` for later writes. An append request uses the
+node's append path, which chooses its offset under the same per-node lock as
+other writes; a regular write uses the kernel offset. The writable access mode
+is fixed by the open request. `O_TRUNC` stages a zero length before the open
+reply. A write with `O_SYNC`/`O_DSYNC` in its current flags attempts
+publication before acknowledging the write. Unsupported write flags return an
+explicit error instead of silently changing semantics.
+
+`flush` publishes a writable handle and reports the real remote outcome to the
+closing descriptor. `release` never publishes and never discards staging.
+Flush or fsync on a read-only reference is a successful no-op even when another
+writable reference shares the node, so a reader close cannot publish somebody
+else's dirty bytes. A failed publication remains attached to the shared handle
+and volume ledger; later fsync/flush retries the original frozen request.
+
+`create` and `mkdir` use the Runtime helpers that return the NodeId produced by
+the committing Planner, avoiding a post-commit path lookup. `unlink`, `rmdir`,
+and path renames use the path-addressed Runtime methods, so lookup and commit
+share one guarded Snapshot. Plain `rename` replaces an existing destination;
+`RENAME_NOREPLACE` uses the no-replace form. Exchange and whiteout renames are
+explicitly unsupported.
+
+`setattr` supports size and the core executable flag only. Size on an open
+descriptor is staged for that descriptor's later flush/fsync; path truncation
+publishes before returning success. A mode request is accepted only when it is
+one of the synthesized modes above (`0444`/`0555` or `0644`/`0755` according to
+read-only policy and the executable bit); other permission combinations return
+`EOPNOTSUPP`. Linux may include `S_IFREG`/`S_IFDIR` type bits in the mode;
+they are accepted when they match the node, while setuid/setgid/sticky and
+other permission combinations are rejected. Kernel bookkeeping timestamps
+attached to a chmod or truncate request are accepted and ignored, and the reply
+always carries revision-derived timestamps. Standalone timestamp changes and
+ownership changes remain explicitly unsupported. Before changing the
+executable flag, the shared node lock
+publishes dirty content, commits a Planner based on the handle's recorded
+content revision, and advances only that clean handle to the receipt revision.
+An external content change therefore produces Conflict rather than rebasing
+old staged bytes onto new content. Ownership, timestamps and other mode bits
+are not persisted.
+ ## Explicit refresh
+
+ `SIGHUP` triggers one explicit refresh; this is a manual invalidation entry
+ point, not an automatic change watch. There is no background polling, no
+ change-feed watcher, and no whole-tree enumeration. The refresh observes the
+ latest snapshot once and then:
+
+ - reclaims every clean, unreferenced node through the shared `Mount` contract,
+ so the next open materializes the current remote revision; nodes with live
+ references or retained state are left pinned (`Busy`);
+ - invalidates known inode attributes with `inval_inode(ino, -1, 0)` and
+ replied dentries with `inval_entry(parent, name)` through the fuser
+ `Notifier`. Only dentries this adapter actually replied to the kernel are
+ recorded: successful `LOOKUP` results. Negative lookups carry no timeout in
+ FUSE replies and are not recorded, and plain `READDIR` does not install
+ dentries without `READDIRPLUS`, so directory names are not recorded.
+
+ The adapter never calls `Mount::refresh`, because that replaces the shared
+ staged handle and would rebase open file descriptors. The refresh must also
+ hold no adapter lock while reclaiming nodes or sending notifications; targets
+ are collected under short locks, then the sends run on a blocking thread. At
+ most one refresh runs at a time and duplicate `SIGHUP`s while one is running
+ are coalesced by the signal stream. The SIGHUP loop is a tracked Tokio task:
+ the signal handler is registered synchronously before the mount becomes
+ visible, teardown sends it a stop signal, and the session join and the refresh
+ task share the same five-second grace period; a refresh already in progress
+ may finish inside that window. A `NotFound` notification result is expected
+ (the kernel already dropped the entry or inode) and is counted separately, so
+ only explicit connection-ended errors (`ENODEV`, `ENOTCONN`, `EPIPE`,
+ `ECONNRESET`, `ESHUTDOWN`) stop the batch before it would otherwise finish.
+
+ Stable `yy: fuse refresh:` log lines report the observed revision, the
+ reclaimed/active node counts, and the native notification counts at
+ completion, so an acceptance harness can confirm a refresh finished.
+
## Pinned directory cursors
`opendir` pins one `Snapshot` revision and one directory identity. `readdir`
@@@ -202,17 -190,15 +237,16 @@@ there is no mid-session cancellation si
- Every open materializes the complete file through the existing shared Mount
staging contract; there is no partial-download or mmap coherence claim, and
`FOPEN_DIRECT_IO` does not provide page-cache-backed mmap behavior.
-- Writes, create, mkdir, unlink, rename, links, xattrs, ioctl, poll, locking
- and capacity accounting are not implemented; the kernel read-only mount and
- the callback errno policy reject them.
+- Hard links, symlinks, mknod, xattrs, ioctl, poll, fallocate and capacity
+ accounting are not implemented and return explicit `EOPNOTSUPP`/`ENOSYS`.
+- The frontend does not negotiate `FUSE_POSIX_LOCKS` or `FLOCK_LOCKS`, and its
+ getlk/setlk/flock callbacks are not implemented. If the kernel still provides
+ POSIX or flock locks, they are local to this mount instance and carry no
+ cross-mount or cross-client meaning; no distributed lock semantics are
+ claimed. A writable mount must not claim that locking is rejected outright,
+ because the kernel may return success for its own local lock manager.
- - The shared Mount node map is not held across first materialization or other
- authority/staging I/O; the adapter adds no mount-wide lock across those
- awaits.
- - Extension points for the next layer are the runtime namespace operations,
- `Mount::refresh` guarded by reference counts, and the session `Notifier`
- for kernel entry/inode invalidation once an in-place refresh policy exists.
+ - The module keeps existing core Mount behavior, including the node-map lock
+ that already spans first materialization; the adapter adds no mount-wide
+ lock across authority or staging I/O.
+ - The session `Notifier` is used only by the explicit `SIGHUP` refresh above;
- there is no automatic external-change watch, and its capability set does
- not claim recoverable staging or any write-side operation beyond the
- read-only contract. Remaining extension points for the next layer are the
- runtime namespace operations and `Mount::refresh` guarded by reference
- counts.
++ there is no automatic external-change watch.
diff --cc src/fuse/frontend.rs
index 5253bb5,239982b..a4e7142
--- a/src/fuse/frontend.rs
+++ b/src/fuse/frontend.rs
@@@ -46,11 -47,12 +46,12 @@@ use super::inode::{CachedInfo, InodeTab
use crate::mount::{Mount, MountFile};
use fuser::{
AccessFlags, BsdFileFlags, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation,
- INodeNo, InitFlags, KernelConfig, LockOwner, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr,
- ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyStatfs,
- ReplyWrite, ReplyXattr, Request, TimeOrNow, WriteFlags,
- INodeNo, InitFlags, KernelConfig, LockOwner, Notifier, OpenAccMode, OpenFlags, ReplyAttr,
- ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyStatfs, ReplyWrite,
- ReplyXattr, Request, TimeOrNow, WriteFlags,
++ INodeNo, InitFlags, KernelConfig, LockOwner, Notifier, OpenAccMode, OpenFlags, RenameFlags,
++ ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen,
++ ReplyStatfs, ReplyWrite, ReplyXattr, Request, TimeOrNow, WriteFlags,
};
- use std::collections::BTreeMap;
+ use std::collections::{BTreeMap, BTreeSet};
+ use std::ffi::OsStr;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@@ -67,9 -69,12 +68,13 @@@ pub(super) struct Inner
inodes: Mutex<InodeTable>,
files: Mutex<BTreeMap<u64, Arc<OpenFile>>>,
dirs: Mutex<BTreeMap<u64, Arc<AsyncMutex<DirCursor>>>>,
+ /// Dentry names this adapter actually replied to the kernel; only those
+ /// can be cached and invalidated by an explicit refresh.
+ dentries: Mutex<BTreeSet<(u64, String)>>,
+ notifier: Mutex<Option<Notifier>>,
next_handle: AtomicU64,
runtime: Handle,
+ read_only: bool,
}
/// One kernel open maps to one reference on the node's shared staged version.
@@@ -141,9 -113,10 +146,11 @@@ impl Inner
inodes: Mutex::new(InodeTable::new(root)),
files: Mutex::new(BTreeMap::new()),
dirs: Mutex::new(BTreeMap::new()),
+ dentries: Mutex::new(BTreeSet::new()),
+ notifier: Mutex::new(None),
next_handle: AtomicU64::new(0),
runtime,
+ read_only,
}
}
@@@ -290,8 -366,13 +421,13 @@@
Ok(None) => return reply.error(fuser::Errno::ENOENT),
Err(error) => return reply.error(core_errno(&error)),
};
+ // A positive LOOKUP reply may install a kernel dentry; remember it
+ // for the explicit refresh. Negative results carry no timeout and are
+ // skipped, and a plain READDIR does not install positive dentries
+ // (that is READDIRPLUS), so scanned names are not recorded.
+ self.record_dentry(parent, &name);
- match self.attr_for(&snapshot, entry.node_id).await {
- Ok(attr) => {
+ match self.shared_attr(entry.node_id).await {
+ Ok(Some(attr)) => {
self.remember_lookup(attr.ino);
reply.entry(&ATTR_TTL, &attr, Generation(0));
}
diff --cc src/fuse/mod.rs
index 2ba4675,f655759..fda4ab0
--- a/src/fuse/mod.rs
+++ b/src/fuse/mod.rs
@@@ -90,26 -85,34 +90,38 @@@ pub async fn run
)
.into());
}
- let inner = Arc::new(frontend::Inner::new(
+ let inner = Arc::new(frontend::Inner::with_read_only(
mount,
tokio::runtime::Handle::current(),
+ read_only,
));
- let filesystem = frontend::FuseFrontend { inner };
+ let filesystem = frontend::FuseFrontend {
+ inner: inner.clone(),
+ };
let mut options = Config::default();
options.mount_options = vec![
- MountOption::RO,
MountOption::FSName(format!("yinyang-{}", config.name)),
MountOption::Subtype("yinyang".into()),
];
+ if read_only {
+ options.mount_options.insert(0, MountOption::RO);
+ }
options.acl = SessionACL::Owner;
+ // Register SIGHUP before the mount becomes externally visible so a
+ // refresh signal cannot fall through to the default handler while the loop
+ // task waits for its first poll.
+ let hangup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) {
+ Ok(signal) => Some(signal),
+ Err(error) => {
+ eprintln!("yy: fuse refresh: cannot handle SIGHUP: {error}");
+ None
+ }
+ };
let session = Session::new(filesystem, &mountpoint, &options)?;
println!(
- "yy: mounted {} read-only at {} (fuse.yinyang)",
+ "yy: mounted {} {} at {} (fuse.yinyang)",
config.name,
+ if read_only { "read-only" } else { "read-write" },
mountpoint.display()
);
// `Session::spawn` moves the mount object into `BackgroundSession`, so a