diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-16 13:45:36 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-16 13:45:36 -0500 |
| commit | 62fd9437c7d750cde9c0ae91c4eb9ed4d69ecf35 (patch) | |
| tree | 0e5612688ba8f8f5965012f9060a0f683f6ee094 | |
| parent | 48a00723e4d309018309f4fb40870c8f0403f812 (diff) | |
vfs: invalidate full subtree from node_cache on mutation (fixes #210)
invalidateNodeCache's docstring promised "Clear all node_cache entries
whose path starts with the given prefix" but the implementation only
cleared the exact path's entry and its parent. removeFromTree on a
directory destroys every descendant VfsNode via cascading unique_ptr
destruction; every descendant inode still cached in node_cache became
a dangling pointer.
Next time Wine called getattr on a cached descendant ino,
resolveByInode returned the dangling pointer and snapshotFromNode
read freed memory — depending on what reused the allocation, this
manifested as either std::bad_alloc (corrupted string length in
real_path triggers a huge allocation request) or outright SIGSEGV.
ENOMEM bubbled up to Wine's NT loader as ERROR_MOD_NOT_FOUND (0x7E),
which is exactly the F4SE "couldn't load plugin (0000007E)" pattern
reported in #210 after mod-state transitions.
Rewrite invalidateNodeCache to actually walk node_cache and erase
every entry whose path is the target or a descendant under target/.
Lock order is tree_mutex (exclusive, caller) → inode_mutex (shared)
→ node_cache_mutex (exclusive), consistent with existing sites.
Top-level mutations also invalidate root (inode 1) since its
children map changed.
Also enrich the FUSE wrap_* exception macro to log the inode being
processed alongside the exception text. Future "[VFS] getattr(ino=N):
caught exception: ..." entries point straight at the path that
triggered cache/tree corruption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | src/src/fuseconnector.cpp | 54 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 61 |
2 files changed, 72 insertions, 43 deletions
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index f66d1bb..12635f3 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -165,26 +165,34 @@ buildModsFromMapping(const MappingType& mapping, const QString& dataDir, namespace { -void replyExceptionError(fuse_req_t req, const char* op, const std::exception* e) noexcept +void replyExceptionError(fuse_req_t req, const char* op, + unsigned long long ino, const std::exception* e) noexcept { // fuse_reply_err itself shouldn't allocate but log first in case it does. if (e != nullptr) { - std::fprintf(stderr, "[VFS] %s: caught exception: %s\n", op, e->what()); + std::fprintf(stderr, "[VFS] %s(ino=%llu): caught exception: %s\n", op, ino, + e->what()); } else { - std::fprintf(stderr, "[VFS] %s: caught unknown exception\n", op); + std::fprintf(stderr, "[VFS] %s(ino=%llu): caught unknown exception\n", op, ino); } // ENOMEM for bad_alloc, EIO otherwise — distinguished at call site. } -#define MO2_TRY_REPLY(req, op, errno_) \ +// The handlers below capture the inode (or parent inode for lookup) so a +// std::bad_alloc / std::exception log line identifies which path was being +// processed — essential for diagnosing cache/tree corruption (e.g. #210). +#define MO2_TRY_REPLY(req, op, ino, errno_) \ catch (const std::bad_alloc& e) { \ - replyExceptionError((req), (op), &e); \ + replyExceptionError((req), (op), \ + static_cast<unsigned long long>(ino), &e); \ fuse_reply_err((req), ENOMEM); \ } catch (const std::exception& e) { \ - replyExceptionError((req), (op), &e); \ + replyExceptionError((req), (op), \ + static_cast<unsigned long long>(ino), &e); \ fuse_reply_err((req), (errno_)); \ } catch (...) { \ - replyExceptionError((req), (op), nullptr); \ + replyExceptionError((req), (op), \ + static_cast<unsigned long long>(ino), nullptr); \ fuse_reply_err((req), (errno_)); \ } @@ -201,104 +209,104 @@ void wrap_init(void* userdata, struct fuse_conn_info* conn) noexcept void wrap_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) noexcept { try { mo2_lookup(req, parent, name); } - MO2_TRY_REPLY(req, "lookup", EIO) + MO2_TRY_REPLY(req, "lookup", parent, EIO) } void wrap_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept { try { mo2_getattr(req, ino, fi); } - MO2_TRY_REPLY(req, "getattr", EIO) + MO2_TRY_REPLY(req, "getattr", ino, EIO) } void wrap_opendir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept { try { mo2_opendir(req, ino, fi); } - MO2_TRY_REPLY(req, "opendir", EIO) + MO2_TRY_REPLY(req, "opendir", ino, EIO) } void wrap_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, struct fuse_file_info* fi) noexcept { try { mo2_readdir(req, ino, size, off, fi); } - MO2_TRY_REPLY(req, "readdir", EIO) + MO2_TRY_REPLY(req, "readdir", ino, EIO) } void wrap_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, struct fuse_file_info* fi) noexcept { try { mo2_readdirplus(req, ino, size, off, fi); } - MO2_TRY_REPLY(req, "readdirplus", EIO) + MO2_TRY_REPLY(req, "readdirplus", ino, EIO) } void wrap_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept { try { mo2_open(req, ino, fi); } - MO2_TRY_REPLY(req, "open", EIO) + MO2_TRY_REPLY(req, "open", ino, EIO) } void wrap_read(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, struct fuse_file_info* fi) noexcept { try { mo2_read(req, ino, size, off, fi); } - MO2_TRY_REPLY(req, "read", EIO) + MO2_TRY_REPLY(req, "read", ino, EIO) } void wrap_write(fuse_req_t req, fuse_ino_t ino, const char* buf, size_t size, off_t off, struct fuse_file_info* fi) noexcept { try { mo2_write(req, ino, buf, size, off, fi); } - MO2_TRY_REPLY(req, "write", EIO) + MO2_TRY_REPLY(req, "write", ino, EIO) } void wrap_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode, struct fuse_file_info* fi) noexcept { try { mo2_create(req, parent, name, mode, fi); } - MO2_TRY_REPLY(req, "create", EIO) + MO2_TRY_REPLY(req, "create", parent, EIO) } void wrap_rename(fuse_req_t req, fuse_ino_t parent, const char* name, fuse_ino_t newparent, const char* newname, unsigned int flags) noexcept { try { mo2_rename(req, parent, name, newparent, newname, flags); } - MO2_TRY_REPLY(req, "rename", EIO) + MO2_TRY_REPLY(req, "rename", parent, EIO) } void wrap_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, struct fuse_file_info* fi) noexcept { try { mo2_setattr(req, ino, attr, to_set, fi); } - MO2_TRY_REPLY(req, "setattr", EIO) + MO2_TRY_REPLY(req, "setattr", ino, EIO) } void wrap_unlink(fuse_req_t req, fuse_ino_t parent, const char* name) noexcept { try { mo2_unlink(req, parent, name); } - MO2_TRY_REPLY(req, "unlink", EIO) + MO2_TRY_REPLY(req, "unlink", parent, EIO) } void wrap_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode) noexcept { try { mo2_mkdir(req, parent, name, mode); } - MO2_TRY_REPLY(req, "mkdir", EIO) + MO2_TRY_REPLY(req, "mkdir", parent, EIO) } void wrap_rmdir(fuse_req_t req, fuse_ino_t parent, const char* name) noexcept { try { mo2_rmdir(req, parent, name); } - MO2_TRY_REPLY(req, "rmdir", EIO) + MO2_TRY_REPLY(req, "rmdir", parent, EIO) } void wrap_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept { try { mo2_release(req, ino, fi); } - MO2_TRY_REPLY(req, "release", EIO) + MO2_TRY_REPLY(req, "release", ino, EIO) } void wrap_releasedir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) noexcept { try { mo2_releasedir(req, ino, fi); } - MO2_TRY_REPLY(req, "releasedir", EIO) + MO2_TRY_REPLY(req, "releasedir", ino, EIO) } #undef MO2_TRY_REPLY diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 5de9e70..f44179d 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -234,36 +234,57 @@ void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath) } } -// Clear all node_cache entries whose path starts with the given prefix. -// Must be called while tree_mutex is held exclusively (during mutations). +// Clear all node_cache entries whose path is |path| or any descendant +// under |path|/. Must be called while tree_mutex is held exclusively (during +// mutations). The parent of |path| is also invalidated because its +// children-map has changed. +// +// SUBTREE invalidation matters for any mutation that destroys VfsNodes +// (removeFromTree on a directory, rename of a directory): every descendant +// VfsNode is destroyed too, so every cached pointer into that subtree is +// now dangling. The original implementation only cleared the exact path +// and its parent — that left dangling pointers for descendants, which the +// next getattr/readdir would dereference and crash with bad_alloc / +// SIGSEGV (manifesting as F4SE "couldn't load plugin (0000007E)" in #210). void invalidateNodeCache(Mo2FsContext* ctx, const std::string& path) { if (ctx == nullptr) { return; } - // Look up inode for this path and remove its cache entry. - fuse_ino_t ino = 0; - { - std::shared_lock lock(ctx->inode_mutex); - ino = ctx->inodes->get(path); + // Lock order is tree (exclusive, held by caller) → inode (shared) → + // node_cache (exclusive). Keep that order consistent everywhere. + std::shared_lock ilock(ctx->inode_mutex); + std::scoped_lock nlock(ctx->node_cache_mutex); + + // Sweep node_cache for any entry whose path is the prefix or a descendant. + // O(N) over the cache, but each ino→path lookup is O(1) and N is bounded + // by the cache size; mutations are infrequent compared to reads. + const std::string prefixSlash = path + "/"; + for (auto it = ctx->node_cache.begin(); it != ctx->node_cache.end();) { + const std::string entryPath = ctx->inodes->getPath(it->first); + const bool inSubtree = + entryPath == path || + (entryPath.size() > prefixSlash.size() && + entryPath.compare(0, prefixSlash.size(), prefixSlash) == 0); + if (inSubtree) { + it = ctx->node_cache.erase(it); + } else { + ++it; + } } - fuse_ino_t parentIno = 0; + + // Invalidate the parent's cache entry too (its children map changed). const auto slash = path.rfind('/'); if (slash != std::string::npos) { const std::string parentPath = path.substr(0, slash); - std::shared_lock lock(ctx->inode_mutex); - parentIno = ctx->inodes->get(parentPath); - } - - std::scoped_lock nlock(ctx->node_cache_mutex); - if (ino != 0) { - ctx->node_cache.erase(ino); - } - // Also invalidate the parent directory's cache entry since its children - // map has changed (new/removed child). - if (parentIno != 0) { - ctx->node_cache.erase(parentIno); + const fuse_ino_t parentIno = ctx->inodes->get(parentPath); + if (parentIno != 0) { + ctx->node_cache.erase(parentIno); + } + } else { + // Top-level entry — parent is root (inode 1). + ctx->node_cache.erase(1); } } |
