diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-27 23:36:07 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-27 23:36:07 -0500 |
| commit | 2978356bebd38a3645e82188e94a987f7981d7a3 (patch) | |
| tree | b42268a8508b9d23e74d3fda3efb07c9335dd167 /src | |
| parent | 66ebc04543c1ef6c6530d0fbef6900cbda8c265a (diff) | |
Improve FUSE VFS performance and rename handling
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/fuseconnector.cpp | 58 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 299 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 15 | ||||
| -rw-r--r-- | src/src/vfs/overwritemanager.cpp | 13 | ||||
| -rw-r--r-- | src/src/vfs/overwritemanager.h | 2 | ||||
| -rw-r--r-- | src/tests/CMakeLists.txt | 26 | ||||
| -rw-r--r-- | src/tests/test_vfs_file_ops.cpp | 96 |
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"); +} |
