aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/src/fuseconnector.cpp58
-rw-r--r--src/src/vfs/mo2filesystem.cpp299
-rw-r--r--src/src/vfs/mo2filesystem.h15
-rw-r--r--src/src/vfs/overwritemanager.cpp13
-rw-r--r--src/src/vfs/overwritemanager.h2
-rw-r--r--src/tests/CMakeLists.txt26
-rw-r--r--src/tests/test_vfs_file_ops.cpp96
7 files changed, 467 insertions, 42 deletions
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 679ae34..cf13ba1 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -309,6 +309,25 @@ void wrap_releasedir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
MO2_TRY_REPLY(req, "releasedir", ino, EIO)
}
+void wrap_forget(fuse_req_t req, fuse_ino_t ino, uint64_t nlookup) noexcept
+{
+ try { mo2_forget(req, ino, nlookup); }
+ catch (...) { fuse_reply_none(req); }
+}
+
+void wrap_flush(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept
+{
+ try { mo2_flush(req, ino, fi); }
+ MO2_TRY_REPLY(req, "flush", ino, EIO)
+}
+
+void wrap_fsync(fuse_req_t req, fuse_ino_t ino, int datasync,
+ struct fuse_file_info* fi) noexcept
+{
+ try { mo2_fsync(req, ino, datasync, fi); }
+ MO2_TRY_REPLY(req, "fsync", ino, EIO)
+}
+
#undef MO2_TRY_REPLY
} // namespace
@@ -318,6 +337,7 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
std::memset(ops, 0, sizeof(struct fuse_lowlevel_ops));
ops->init = wrap_init;
ops->lookup = wrap_lookup;
+ ops->forget = wrap_forget;
ops->getattr = wrap_getattr;
ops->opendir = wrap_opendir;
ops->readdir = wrap_readdir;
@@ -333,6 +353,8 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->rmdir = wrap_rmdir;
ops->release = wrap_release;
ops->releasedir = wrap_releasedir;
+ ops->flush = wrap_flush;
+ ops->fsync = wrap_fsync;
// access handler removed: default_permissions mount option lets the kernel
// handle permission checks in-kernel, eliminating access() round-trips.
}
@@ -736,6 +758,24 @@ void FuseConnector::rebuild(
std::scoped_lock const lock(m_context->open_dirs_mutex);
m_context->open_dirs.clear();
}
+ {
+ std::scoped_lock const lock(m_context->dir_cache_mutex);
+ m_context->dir_cache.clear();
+ m_context->readdir_blob_cache.clear();
+ m_context->readdirplus_blob_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->lookup_cache_mutex);
+ m_context->lookup_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->attr_cache_mutex);
+ m_context->attr_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->node_cache_mutex);
+ m_context->node_cache.clear();
+ }
}
void FuseConnector::updateMapping(const MappingType& mapping)
@@ -1144,6 +1184,24 @@ void FuseConnector::flushStagingLive()
std::scoped_lock const lock(m_context->open_dirs_mutex);
m_context->open_dirs.clear();
}
+ {
+ std::scoped_lock const lock(m_context->dir_cache_mutex);
+ m_context->dir_cache.clear();
+ m_context->readdir_blob_cache.clear();
+ m_context->readdirplus_blob_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->lookup_cache_mutex);
+ m_context->lookup_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->attr_cache_mutex);
+ m_context->attr_cache.clear();
+ }
+ {
+ std::scoped_lock const lock(m_context->node_cache_mutex);
+ m_context->node_cache.clear();
+ }
// Re-create OverwriteManager with fresh staging dir
m_context->overwrite = std::make_unique<OverwriteManager>(m_stagingDir, m_overwriteDir);
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 013fff5..e34258c 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -26,6 +26,7 @@ namespace fs = std::filesystem;
constexpr double TTL_SECONDS = 86400.0; // 24 hours
constexpr double NEGATIVE_TTL_SECONDS = 3600.0; // 1 hour — Wine probes many non-existent files
constexpr double ATTR_CACHE_SECONDS = 86400.0;
+constexpr size_t MAX_RETAINED_RO_FDS = 1024;
void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid);
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
@@ -34,6 +35,7 @@ void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
const std::string& real_path = {},
mode_t cached_mode = 0);
void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath);
+void invalidateAttrCache(Mo2FsContext* ctx, fuse_ino_t ino);
bool pathTouchesMutation(const std::string& cachedPath,
const std::string& changedPath);
@@ -86,6 +88,21 @@ void maybeLogCounters(Mo2FsContext* ctx)
std::scoped_lock lock(ctx->open_files_mutex);
std::fprintf(stderr, " open_handles=%zu\n", ctx->open_files.size());
}
+ std::fprintf(stderr,
+ "[VFS] cache lookup_hit=%llu lookup_miss=%llu lookup_inval=%llu "
+ "lazy_ro_open=%llu ro_fd_hit=%llu ro_fd_evict=%llu\n",
+ static_cast<unsigned long long>(
+ ctx->lookup_cache_hits.load(std::memory_order_relaxed)),
+ static_cast<unsigned long long>(
+ ctx->lookup_cache_misses.load(std::memory_order_relaxed)),
+ static_cast<unsigned long long>(
+ ctx->lookup_cache_invalidations.load(std::memory_order_relaxed)),
+ static_cast<unsigned long long>(
+ ctx->lazy_ro_fd_opens.load(std::memory_order_relaxed)),
+ static_cast<unsigned long long>(
+ ctx->retained_ro_fd_hits.load(std::memory_order_relaxed)),
+ static_cast<unsigned long long>(
+ ctx->retained_ro_fd_evictions.load(std::memory_order_relaxed)));
// Per-op wall-clock totals and averages (microseconds).
auto avgUs = [](uint64_t ns, uint64_t count) -> double {
@@ -232,8 +249,26 @@ void invalidateDirCache(Mo2FsContext* ctx, const std::string& dirPath)
// need to remove all entries with the given parent inode.
void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath)
{
- (void)ctx;
(void)dirPath;
+ if (ctx == nullptr) {
+ return;
+ }
+
+ std::scoped_lock lock(ctx->lookup_cache_mutex);
+ if (!ctx->lookup_cache.empty()) {
+ ctx->lookup_cache.clear();
+ ctx->lookup_cache_invalidations.fetch_add(1, std::memory_order_relaxed);
+ }
+}
+
+void invalidateAttrCache(Mo2FsContext* ctx, fuse_ino_t ino)
+{
+ if (ctx == nullptr) {
+ return;
+ }
+
+ std::scoped_lock lock(ctx->attr_cache_mutex);
+ ctx->attr_cache.erase(ino);
}
bool isStrictDescendantPath(const std::string& path, const std::string& parent)
@@ -765,6 +800,46 @@ std::shared_ptr<std::vector<char>> getOrBuildReaddirPlusBlob(
return built;
}
+void pruneRetainedReadOnlyFds(Mo2FsContext* ctx)
+{
+ if (ctx == nullptr) {
+ return;
+ }
+
+ std::scoped_lock lock(ctx->open_files_mutex);
+ size_t retained = 0;
+ for (const auto& [fh, of] : ctx->open_files) {
+ (void)fh;
+ if (!of.writable && of.fd >= 0) {
+ ++retained;
+ }
+ }
+ if (retained <= MAX_RETAINED_RO_FDS) {
+ return;
+ }
+
+ while (retained > MAX_RETAINED_RO_FDS) {
+ auto victim = ctx->open_files.end();
+ for (auto it = ctx->open_files.begin(); it != ctx->open_files.end(); ++it) {
+ if (it->second.writable || it->second.fd < 0) {
+ continue;
+ }
+ if (victim == ctx->open_files.end() ||
+ it->second.last_read_tick < victim->second.last_read_tick) {
+ victim = it;
+ }
+ }
+ if (victim == ctx->open_files.end()) {
+ return;
+ }
+
+ close(victim->second.fd);
+ victim->second.fd = -1;
+ ctx->retained_ro_fd_evictions.fetch_add(1, std::memory_order_relaxed);
+ --retained;
+ }
+}
+
void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid)
{
std::memset(st, 0, sizeof(struct stat));
@@ -862,6 +937,16 @@ void updateFileNode(Mo2FsContext* ctx, const std::string& relative,
ctx->tree->root.insertFile(splitPath(relative), realPath, ec ? 0 : size, mtime,
origin);
invalidateNodeCache(ctx, relative);
+ lock.unlock();
+
+ fuse_ino_t ino = 0;
+ {
+ std::shared_lock ilock(ctx->inode_mutex);
+ ino = ctx->inodes->get(relative);
+ }
+ if (ino != 0) {
+ invalidateAttrCache(ctx, ino);
+ }
}
} // namespace
@@ -905,14 +990,20 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
conn->want |= FUSE_CAP_EXPLICIT_INVAL_DATA;
}
- // Force readdirplus always (unset adaptive mode). Our stat info is
- // free (in-memory tree), so always return full entries to pre-populate
- // the kernel dentry + attr caches on every directory listing.
+ // Plain readdir is the conservative default for large modlists. Wine and
+ // game startup often enumerate large directories but only stat/open a small
+ // subset, so forced readdirplus can send a lot of unused metadata.
if (conn->capable & FUSE_CAP_READDIRPLUS) {
- conn->want |= FUSE_CAP_READDIRPLUS;
+ conn->want &= ~FUSE_CAP_READDIRPLUS;
conn->want &= ~FUSE_CAP_READDIRPLUS_AUTO;
}
+#ifdef FUSE_CAP_NO_OPENDIR_SUPPORT
+ if (conn->capable & FUSE_CAP_NO_OPENDIR_SUPPORT) {
+ conn->want |= FUSE_CAP_NO_OPENDIR_SUPPORT;
+ }
+#endif
+
// NOTE: FUSE_CAP_WRITEBACK_CACHE intentionally NOT enabled.
// It causes extra getattr calls for cache coherency, which hurts
// our read-heavy VFS more than the write buffering helps.
@@ -950,6 +1041,12 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
conn->want |= FUSE_CAP_EXPIRE_ONLY;
}
+#ifdef FUSE_CAP_OVER_IO_URING
+ if (conn->capable & FUSE_CAP_OVER_IO_URING) {
+ std::fprintf(stderr, "[VFS] init: kernel reports FUSE over io_uring support\n");
+ }
+#endif
+
// Maximize async I/O slots (default is 12). The kernel will still only
// dispatch as many as there are actual concurrent requests, so higher
// values just raise the ceiling without wasting memory.
@@ -976,10 +1073,15 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
std::fprintf(stderr,
"[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d "
- "max_bg=%u max_readahead=%u\n",
+ "no_opendir=%d max_bg=%u max_readahead=%u\n",
(conn->want & FUSE_CAP_AUTO_INVAL_DATA) ? 1 : 0,
(conn->want & FUSE_CAP_EXPLICIT_INVAL_DATA) ? 1 : 0,
(conn->want & FUSE_CAP_READDIRPLUS) ? 1 : 0,
+#ifdef FUSE_CAP_NO_OPENDIR_SUPPORT
+ (conn->want & FUSE_CAP_NO_OPENDIR_SUPPORT) ? 1 : 0,
+#else
+ 0,
+#endif
conn->max_background, conn->max_readahead);
}
@@ -994,10 +1096,19 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
ctx->lookup_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
- // Do the full lookup every time. The userspace lookup cache was a
- // performance shortcut, but crash reports showed corruption while updating
- // its reverse index under concurrent Proton startup probes. Kernel FUSE
- // dcache still handles repeated exact-case lookups.
+ const auto cacheKey =
+ std::make_pair(parent, normalizeForLookup(std::string(name)));
+ {
+ std::scoped_lock lock(ctx->lookup_cache_mutex);
+ auto it = ctx->lookup_cache.find(cacheKey);
+ if (it != ctx->lookup_cache.end()) {
+ ctx->lookup_cache_hits.fetch_add(1, std::memory_order_relaxed);
+ fuse_reply_entry(req, &it->second.entry);
+ return;
+ }
+ }
+ ctx->lookup_cache_misses.fetch_add(1, std::memory_order_relaxed);
+
const auto lr = lookupChild(ctx, parent, name);
if (!lr.found) {
@@ -1007,6 +1118,10 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
e.attr_timeout = NEGATIVE_TTL_SECONDS;
e.entry_timeout = NEGATIVE_TTL_SECONDS;
+ {
+ std::scoped_lock lock(ctx->lookup_cache_mutex);
+ ctx->lookup_cache[cacheKey] = Mo2FsContext::LookupCacheEntry{.child_ino=0, .entry=e};
+ }
fuse_reply_entry(req, &e);
return;
}
@@ -1058,6 +1173,11 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
lr.snap.real_path);
}
+ {
+ std::scoped_lock lock(ctx->lookup_cache_mutex);
+ ctx->lookup_cache[cacheKey] =
+ Mo2FsContext::LookupCacheEntry{.child_ino=childIno, .entry=e};
+ }
fuse_reply_entry(req, &e);
}
@@ -1244,13 +1364,8 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
if ((*entries)[i].is_dir) {
st.st_mode = S_IFDIR | 0755;
} else {
- mode_t mode = 0644;
- if (!(*entries)[i].real_path.empty()) {
- struct stat real_st;
- if (::stat((*entries)[i].real_path.c_str(), &real_st) == 0) {
- mode = real_st.st_mode & 0777;
- }
- }
+ mode_t mode = (*entries)[i].cached_mode != 0 ? (*entries)[i].cached_mode
+ : static_cast<mode_t>(0644);
st.st_mode = S_IFREG | mode;
}
@@ -1354,7 +1469,8 @@ void mo2_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
fillStatForDir(&e.attr, (*entries)[i].ino, ctx->uid, ctx->gid);
} else {
fillStatForFile(&e.attr, (*entries)[i].ino, ctx->uid, ctx->gid,
- (*entries)[i].size, (*entries)[i].mtime);
+ (*entries)[i].size, (*entries)[i].mtime,
+ (*entries)[i].real_path, (*entries)[i].cached_mode);
}
const size_t ent = fuse_add_direntry_plus(
@@ -1467,15 +1583,10 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
return;
}
} else {
- // Read-only open: keep a real fd so mo2_read can splice without
- // re-opening the backing file on every read() call.
- if (isBacking && ctx->backing_dir_fd >= 0) {
- fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
- } else {
- fd = open(realPath.c_str(), O_RDONLY);
- }
- // Non-fatal: fall back to lazy per-read open if we hit EMFILE etc.
- // mo2_read handles fd=-1 by opening temporarily.
+ // Read-only open: delay the real fd until first read. Wine often opens
+ // files only to probe metadata, and retaining those fds can exhaust the
+ // process limit on large modlists.
+ fd = -1;
}
const uint64_t fh = ctx->next_fh.fetch_add(1, std::memory_order_relaxed);
@@ -1513,6 +1624,7 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
int fd = -1;
std::string realPath;
bool isBacking = false;
+ bool writable = false;
{
std::shared_lock lock(ctx->open_files_mutex);
auto it = ctx->open_files.find(fi->fh);
@@ -1523,10 +1635,10 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
fd = it->second.fd;
realPath = it->second.real_path;
isBacking = it->second.is_backing;
+ writable = it->second.writable;
}
int localFd = fd;
- bool openedTempFd = false;
if (localFd < 0) {
if (isBacking && ctx->backing_dir_fd >= 0) {
localFd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
@@ -1537,7 +1649,34 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
fuse_reply_err(req, EIO);
return;
}
- openedTempFd = true;
+ ctx->lazy_ro_fd_opens.fetch_add(1, std::memory_order_relaxed);
+ if (!writable) {
+ const uint64_t tick = ctx->fd_lru_tick.fetch_add(1, std::memory_order_relaxed) + 1;
+ bool retained = false;
+ {
+ std::scoped_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fi->fh);
+ if (it != ctx->open_files.end() && it->second.fd < 0 && !it->second.writable) {
+ it->second.fd = localFd;
+ it->second.last_read_tick = tick;
+ retained = true;
+ }
+ }
+ if (!retained) {
+ close(localFd);
+ fuse_reply_err(req, EBADF);
+ return;
+ }
+ pruneRetainedReadOnlyFds(ctx);
+ }
+ } else if (!writable) {
+ ctx->retained_ro_fd_hits.fetch_add(1, std::memory_order_relaxed);
+ const uint64_t tick = ctx->fd_lru_tick.fetch_add(1, std::memory_order_relaxed) + 1;
+ std::scoped_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fi->fh);
+ if (it != ctx->open_files.end()) {
+ it->second.last_read_tick = tick;
+ }
}
// Zero-copy read: splice data directly from backing fd to /dev/fuse,
@@ -1547,10 +1686,6 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
buf.buf[0].fd = localFd;
buf.buf[0].pos = off;
fuse_reply_data(req, &buf, FUSE_BUF_SPLICE_MOVE);
-
- if (openedTempFd) {
- close(localFd);
- }
}
void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
@@ -1810,7 +1945,7 @@ void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name,
try {
if (oldSnap.is_backing && ctx->backing_dir_fd >= 0) {
newRealPath = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd,
- newRelative);
+ oldRelative, newRelative);
} else {
newRealPath = ctx->overwrite->copyOnWrite(oldSnap.real_path, newRelative);
}
@@ -1822,6 +1957,13 @@ void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name,
}
}
+ fuse_ino_t oldIno = 0;
+ fuse_ino_t newIno = 0;
+ {
+ std::shared_lock lock(ctx->inode_mutex);
+ oldIno = ctx->inodes->get(oldRelative);
+ newIno = ctx->inodes->get(newRelative);
+ }
{
std::unique_lock lock(ctx->tree_mutex);
invalidateNodeCache(ctx, oldRelative);
@@ -1845,6 +1987,12 @@ void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name,
std::unique_lock lock(ctx->inode_mutex);
ctx->inodes->rename(oldRelative, newRelative);
}
+ if (oldIno != 0) {
+ invalidateAttrCache(ctx, oldIno);
+ }
+ if (newIno != 0 && newIno != oldIno) {
+ invalidateAttrCache(ctx, newIno);
+ }
invalidateDirCache(ctx, parentPath);
if (newParentPath != parentPath) {
invalidateDirCache(ctx, newParentPath);
@@ -2049,6 +2197,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
}
}
+ invalidateAttrCache(ctx, ino);
const auto snap = snapshotForPath(ctx, path);
if (!snap.found) {
fuse_reply_err(req, ENOENT);
@@ -2082,10 +2231,18 @@ void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name)
const std::string relative = joinPath(parentPath, canonicalChildName(ctx, parentPath, name));
if (!ctx->overwrite->removeFile(relative)) {
- fuse_reply_err(req, EACCES);
- return;
+ const auto snap = snapshotForPath(ctx, relative);
+ if (!snap.found || snap.is_directory) {
+ fuse_reply_err(req, snap.found ? EISDIR : ENOENT);
+ return;
+ }
}
+ fuse_ino_t removedIno = 0;
+ {
+ std::shared_lock lock(ctx->inode_mutex);
+ removedIno = ctx->inodes->get(relative);
+ }
{
std::unique_lock lock(ctx->tree_mutex);
invalidateNodeCache(ctx, relative);
@@ -2093,6 +2250,9 @@ void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name)
ctx->tree->file_count = ctx->tree->file_count > 0 ? ctx->tree->file_count - 1 : 0;
}
}
+ if (removedIno != 0) {
+ invalidateAttrCache(ctx, removedIno);
+ }
invalidateDirCache(ctx, parentPath);
fuse_reply_err(req, 0);
@@ -2247,6 +2407,73 @@ void mo2_releasedir(fuse_req_t req, fuse_ino_t /*ino*/, struct fuse_file_info* f
fuse_reply_err(req, 0);
}
+void mo2_forget(fuse_req_t req, fuse_ino_t ino, uint64_t /*nlookup*/)
+{
+ Mo2FsContext* ctx = getContext(req);
+ if (ctx != nullptr) {
+ invalidateAttrCache(ctx, ino);
+ }
+ fuse_reply_none(req);
+}
+
+void mo2_flush(fuse_req_t req, fuse_ino_t /*ino*/, struct fuse_file_info* fi)
+{
+ Mo2FsContext* ctx = getContext(req);
+ if (ctx == nullptr || fi == nullptr) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+
+ int fd = -1;
+ bool writable = false;
+ {
+ std::shared_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fi->fh);
+ if (it == ctx->open_files.end()) {
+ fuse_reply_err(req, EBADF);
+ return;
+ }
+ fd = it->second.fd;
+ writable = it->second.writable;
+ }
+
+ if (writable && fd >= 0 && fdatasync(fd) != 0) {
+ fuse_reply_err(req, errno);
+ return;
+ }
+ fuse_reply_err(req, 0);
+}
+
+void mo2_fsync(fuse_req_t req, fuse_ino_t /*ino*/, int datasync,
+ struct fuse_file_info* fi)
+{
+ Mo2FsContext* ctx = getContext(req);
+ if (ctx == nullptr || fi == nullptr) {
+ fuse_reply_err(req, EINVAL);
+ return;
+ }
+
+ int fd = -1;
+ {
+ std::shared_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fi->fh);
+ if (it == ctx->open_files.end()) {
+ fuse_reply_err(req, EBADF);
+ return;
+ }
+ fd = it->second.fd;
+ }
+
+ if (fd >= 0) {
+ const int rc = datasync ? fdatasync(fd) : fsync(fd);
+ if (rc != 0) {
+ fuse_reply_err(req, errno);
+ return;
+ }
+ }
+ fuse_reply_err(req, 0);
+}
+
#if FUSE_USE_VERSION < 35
void mo2_ioctl(fuse_req_t req, fuse_ino_t /*ino*/, int cmd, void* /*arg*/,
struct fuse_file_info* /*fi*/, unsigned /*flags*/,
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index 2c256b8..39e4b32 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -46,6 +46,7 @@ struct Mo2FsContext
bool cow_pending = false; // true = opened R/O, will COW on first write()
bool is_tracked = false; // true = user moved this from Overwrite to a mod
std::string relative_path;
+ uint64_t last_read_tick = 0;
};
std::unordered_map<uint64_t, OpenFile> open_files;
@@ -102,12 +103,10 @@ struct Mo2FsContext
}
};
std::unordered_map<std::pair<fuse_ino_t, std::string>, LookupCacheEntry, PairHash> lookup_cache;
- // Reverse index: parent_ino → set of normalized child names in lookup_cache.
- // Makes invalidation O(children) instead of O(total_cache_size).
- std::unordered_map<fuse_ino_t, std::vector<std::string>> lookup_cache_by_parent;
mutable std::mutex lookup_cache_mutex;
std::atomic<uint64_t> next_dh{1};
+ std::atomic<uint64_t> fd_lru_tick{1};
std::atomic<uint64_t> lookup_count{0};
std::atomic<uint64_t> getattr_count{0};
std::atomic<uint64_t> readdir_count{0};
@@ -122,6 +121,12 @@ struct Mo2FsContext
std::atomic<uint64_t> readdir_ns{0};
std::atomic<uint64_t> open_ns{0};
std::atomic<uint64_t> read_ns{0};
+ std::atomic<uint64_t> lookup_cache_hits{0};
+ std::atomic<uint64_t> lookup_cache_misses{0};
+ std::atomic<uint64_t> lookup_cache_invalidations{0};
+ std::atomic<uint64_t> lazy_ro_fd_opens{0};
+ std::atomic<uint64_t> retained_ro_fd_hits{0};
+ std::atomic<uint64_t> retained_ro_fd_evictions{0};
// CPU snapshot from previous stats tick (microseconds, from getrusage).
// Used to compute per-tick CPU delta so we can distinguish disk-bound vs
// CPU-bound slowness in the VFS layer.
@@ -164,6 +169,10 @@ void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode)
void mo2_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name);
void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
void mo2_releasedir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
+void mo2_forget(fuse_req_t req, fuse_ino_t ino, uint64_t nlookup);
+void mo2_flush(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi);
+void mo2_fsync(fuse_req_t req, fuse_ino_t ino, int datasync,
+ struct fuse_file_info* fi);
#if FUSE_USE_VERSION < 35
void mo2_ioctl(fuse_req_t req, fuse_ino_t ino, int cmd, void* arg,
struct fuse_file_info* fi, unsigned flags, const void* in_buf,
diff --git a/src/src/vfs/overwritemanager.cpp b/src/src/vfs/overwritemanager.cpp
index 7824704..229dfae 100644
--- a/src/src/vfs/overwritemanager.cpp
+++ b/src/src/vfs/overwritemanager.cpp
@@ -80,7 +80,14 @@ std::string OverwriteManager::copyOnWrite(const std::string& source_path,
std::string OverwriteManager::copyOnWriteFromFd(int dir_fd,
const std::string& relative_path) const
{
- const fs::path dest = stagingPath(relative_path);
+ return copyOnWriteFromFd(dir_fd, relative_path, relative_path);
+}
+
+std::string OverwriteManager::copyOnWriteFromFd(
+ int dir_fd, const std::string& source_relative_path,
+ const std::string& dest_relative_path) const
+{
+ const fs::path dest = stagingPath(dest_relative_path);
std::error_code ec;
fs::create_directories(dest.parent_path(), ec);
@@ -89,7 +96,7 @@ std::string OverwriteManager::copyOnWriteFromFd(int dir_fd,
return dest.string();
}
- const std::string rel = sanitizeRelative(relative_path);
+ const std::string rel = sanitizeRelative(source_relative_path);
const int src_fd = openat(dir_fd, rel.c_str(), O_RDONLY);
if (src_fd < 0) {
// Source doesn't exist in backing dir, create empty file
@@ -112,6 +119,8 @@ std::string OverwriteManager::copyOnWriteFromFd(int dir_fd,
}
close(src_fd);
+ out.close();
+ fs::permissions(dest, fs::perms::owner_write, fs::perm_options::add, ec);
return dest.string();
}
diff --git a/src/src/vfs/overwritemanager.h b/src/src/vfs/overwritemanager.h
index ff23e18..4b479ee 100644
--- a/src/src/vfs/overwritemanager.h
+++ b/src/src/vfs/overwritemanager.h
@@ -14,6 +14,8 @@ public:
const std::string& relative_path) const;
std::string copyOnWriteFromFd(int dir_fd, const std::string& relative_path) const;
+ std::string copyOnWriteFromFd(int dir_fd, const std::string& source_relative_path,
+ const std::string& dest_relative_path) const;
std::string writeFile(const std::string& relative_path,
const std::vector<uint8_t>& data) const;
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 24c4597..9a7972b 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -69,6 +69,30 @@ target_link_libraries(test_overwrite_duplicates PRIVATE
)
add_test(NAME test_overwrite_duplicates COMMAND test_overwrite_duplicates)
+# ---------------------------------------------------------------------------
+# VFS file-operation regression tests
+# ---------------------------------------------------------------------------
+add_executable(test_vfs_file_ops EXCLUDE_FROM_ALL
+ test_vfs_file_ops.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/vfs/inodetable.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/vfs/overwritemanager.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/vfs/vfstree.cpp
+)
+set_target_properties(test_vfs_file_ops PROPERTIES
+ AUTOMOC OFF
+ CXX_STANDARD 23
+ CXX_STANDARD_REQUIRED ON
+)
+target_include_directories(test_vfs_file_ops PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/src
+)
+target_link_libraries(test_vfs_file_ops PRIVATE
+ GTest::gtest
+ GTest::gtest_main
+)
+add_test(NAME test_vfs_file_ops COMMAND test_vfs_file_ops)
+
# Register a "fluorine-tests" umbrella target that builds every test exe.
add_custom_target(fluorine-tests
- DEPENDS test_metainiutils test_external_dir_mapping test_overwrite_duplicates)
+ DEPENDS test_metainiutils test_external_dir_mapping test_overwrite_duplicates
+ test_vfs_file_ops)
diff --git a/src/tests/test_vfs_file_ops.cpp b/src/tests/test_vfs_file_ops.cpp
new file mode 100644
index 0000000..32b82d2
--- /dev/null
+++ b/src/tests/test_vfs_file_ops.cpp
@@ -0,0 +1,96 @@
+#include "vfs/inodetable.h"
+#include "vfs/overwritemanager.h"
+
+#include <fcntl.h>
+#include <unistd.h>
+
+#include <gtest/gtest.h>
+
+#include <filesystem>
+#include <fstream>
+#include <string>
+
+namespace fs = std::filesystem;
+
+namespace
+{
+
+class TempRoot
+{
+public:
+ TempRoot()
+ {
+ char tmpl[] = "/tmp/fluorine-vfs-ops-XXXXXX";
+ const char* d = mkdtemp(tmpl);
+ if (d != nullptr) {
+ m_path = d;
+ }
+ }
+
+ ~TempRoot()
+ {
+ if (!m_path.empty()) {
+ std::error_code ec;
+ fs::remove_all(m_path, ec);
+ }
+ }
+
+ const fs::path& path() const { return m_path; }
+
+private:
+ fs::path m_path;
+};
+
+void writeText(const fs::path& path, const std::string& text)
+{
+ fs::create_directories(path.parent_path());
+ std::ofstream out(path, std::ios::binary | std::ios::trunc);
+ ASSERT_TRUE(out.is_open());
+ out << text;
+}
+
+std::string readText(const fs::path& path)
+{
+ std::ifstream in(path, std::ios::binary);
+ return std::string(std::istreambuf_iterator<char>(in),
+ std::istreambuf_iterator<char>());
+}
+
+} // namespace
+
+TEST(VfsFileOps, CopyOnWriteFromFdCanCopyBackingSourceToDifferentDestination)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+
+ const fs::path backing = tmp.path() / "Data";
+ const fs::path staging = tmp.path() / "staging";
+ const fs::path overwrite = tmp.path() / "overwrite";
+ writeText(backing / "Plugin.esp.save_2026_05_27", "saved plugin");
+ writeText(backing / "Plugin.esp", "old plugin");
+
+ const int dirFd = open(backing.c_str(), O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(dirFd, 0);
+
+ OverwriteManager overwriteManager(staging.string(), overwrite.string());
+ const std::string staged = overwriteManager.copyOnWriteFromFd(
+ dirFd, "Plugin.esp.save_2026_05_27", "Plugin.esp");
+ close(dirFd);
+
+ EXPECT_EQ(fs::path(staged), staging / "Plugin.esp");
+ EXPECT_EQ(readText(staging / "Plugin.esp"), "saved plugin");
+}
+
+TEST(VfsFileOps, InodeRenameMovesDescendants)
+{
+ InodeTable table;
+ const uint64_t parent = table.getOrCreate("meshes/actors");
+ const uint64_t child = table.getOrCreate("meshes/actors/character/body.nif");
+
+ table.rename("meshes/actors", "meshes/actors_backup");
+
+ EXPECT_EQ(table.get("meshes/actors"), 0u);
+ EXPECT_EQ(table.get("meshes/actors_backup"), parent);
+ EXPECT_EQ(table.get("meshes/actors_backup/character/body.nif"), child);
+ EXPECT_EQ(table.getPath(child), "meshes/actors_backup/character/body.nif");
+}