aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-16 13:45:36 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-16 13:45:36 -0500
commit62fd9437c7d750cde9c0ae91c4eb9ed4d69ecf35 (patch)
tree0e5612688ba8f8f5965012f9060a0f683f6ee094
parent48a00723e4d309018309f4fb40870c8f0403f812 (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.cpp54
-rw-r--r--src/src/vfs/mo2filesystem.cpp61
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);
}
}