From 5a506edb574455ef111d48177497279394e18282 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 25 Apr 2026 13:51:44 -0500 Subject: VFS: harden crash path so FUSE bad_alloc no longer deadlocks system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap every FUSE callback in a noexcept thunk that catches std::bad_alloc (→ ENOMEM) and other exceptions (→ EIO) so they never unwind into libfuse3 (C, no unwind support). Stops std::terminate from orphaning the mount. Crash handler: replace raise(sig) with _exit(128+sig) — raise can hang when the signal is masked on libfuse worker threads. Try direct umount2(MNT_DETACH) before fork+exec fusermount3 -uz, and double-fork the fallback so we don't waitpid on a stuck child. Switch signal() to sigaction(SA_RESETHAND), add SIGBUS, install std::set_terminate. Cap readdir/readdirplus kernel-supplied size at 1 MiB to defend against oversized vector(size) allocations. --- src/src/fuseconnector.cpp | 181 ++++++++++++++++++++++++++++++++++++++---- src/src/main.cpp | 93 ++++++++++++++++++---- src/src/vfs/mo2filesystem.cpp | 11 +++ 3 files changed, 251 insertions(+), 34 deletions(-) diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index 705bed8..71cf7bc 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -157,26 +157,173 @@ buildModsFromMapping(const MappingType& mapping, const QString& dataDir, return mods; } +// Exception barrier between libfuse3 (C, no unwind support) and our C++ +// callbacks. An uncaught exception unwinding into libfuse3 hits std::terminate +// and aborts the process, leaving the FUSE mount orphaned and wedging anything +// that touches it in D-state. Reply with ENOMEM/EIO and stay alive instead. +namespace +{ + +void replyExceptionError(fuse_req_t req, const char* op, 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()); + } else { + std::fprintf(stderr, "[VFS] %s: caught unknown exception\n", op); + } + // ENOMEM for bad_alloc, EIO otherwise — distinguished at call site. +} + +#define MO2_TRY_REPLY(req, op, errno_) \ + catch (const std::bad_alloc& e) { \ + replyExceptionError((req), (op), &e); \ + fuse_reply_err((req), ENOMEM); \ + } catch (const std::exception& e) { \ + replyExceptionError((req), (op), &e); \ + fuse_reply_err((req), (errno_)); \ + } catch (...) { \ + replyExceptionError((req), (op), nullptr); \ + fuse_reply_err((req), (errno_)); \ + } + +void wrap_init(void* userdata, struct fuse_conn_info* conn) noexcept +{ + try { mo2_init(userdata, conn); } + catch (const std::exception& e) { + std::fprintf(stderr, "[VFS] init: caught exception: %s\n", e.what()); + } catch (...) { + std::fprintf(stderr, "[VFS] init: caught unknown exception\n"); + } +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +#undef MO2_TRY_REPLY + +} // namespace + void setupFuseOps(struct fuse_lowlevel_ops* ops) { std::memset(ops, 0, sizeof(struct fuse_lowlevel_ops)); - ops->init = mo2_init; - ops->lookup = mo2_lookup; - ops->getattr = mo2_getattr; - ops->opendir = mo2_opendir; - ops->readdir = mo2_readdir; - ops->readdirplus = mo2_readdirplus; - ops->open = mo2_open; - ops->read = mo2_read; - ops->write = mo2_write; - ops->create = mo2_create; - ops->rename = mo2_rename; - ops->setattr = mo2_setattr; - ops->unlink = mo2_unlink; - ops->mkdir = mo2_mkdir; - ops->rmdir = mo2_rmdir; - ops->release = mo2_release; - ops->releasedir = mo2_releasedir; + ops->init = wrap_init; + ops->lookup = wrap_lookup; + ops->getattr = wrap_getattr; + ops->opendir = wrap_opendir; + ops->readdir = wrap_readdir; + ops->readdirplus = wrap_readdirplus; + ops->open = wrap_open; + ops->read = wrap_read; + ops->write = wrap_write; + ops->create = wrap_create; + ops->rename = wrap_rename; + ops->setattr = wrap_setattr; + ops->unlink = wrap_unlink; + ops->mkdir = wrap_mkdir; + ops->rmdir = wrap_rmdir; + ops->release = wrap_release; + ops->releasedir = wrap_releasedir; // access handler removed: default_permissions mount option lets the kernel // handle permission checks in-kernel, eliminating access() round-trips. } diff --git a/src/src/main.cpp b/src/src/main.cpp index b3e431c..f418246 100644 --- a/src/src/main.cpp +++ b/src/src/main.cpp @@ -16,10 +16,13 @@ #ifdef _WIN32 #include #else +#include #include #include #include +#include #include +#include #include #include #endif @@ -274,23 +277,37 @@ void setExceptionHandlers() // char[] so reading it in a signal handler is async-signal-safe. extern const char* getFuseMountPointForCrashCleanup(); -// Attempt to unmount FUSE from a signal handler context. -// fork()+exec() is async-signal-safe on Linux. -static void emergencyFuseUnmount() +// Async-signal-safe-ish: try direct umount2(MNT_DETACH) first (single syscall, +// no fork/malloc), then fall back to fusermount3 -uz. Lazy detach is what +// actually keeps us out of D-state — a normal unmount blocks indefinitely if +// the mount is busy, which is exactly when we crash. +static void emergencyFuseUnmount() noexcept { const char* mp = getFuseMountPointForCrashCleanup(); - if (mp == nullptr) { + if (mp == nullptr || mp[0] == '\0') { return; } + // Step 1: direct lazy unmount. Works without fusermount3 setuid helper if + // we own the mount (we do — it's our session). Single syscall, no heap. + if (umount2(mp, MNT_DETACH) == 0) { + return; + } + + // Step 2: fall back to fusermount3 -uz. Don't waitpid() — a hung child + // would wedge the crash handler. Detach with double-fork so PID 1 reaps it. const pid_t child = fork(); if (child == 0) { - // Child — try lazy unmount so it always succeeds even if busy. - execlp("fusermount3", "fusermount3", "-uz", mp, nullptr); - execlp("fusermount", "fusermount", "-uz", mp, nullptr); - _exit(1); + // Intermediate child: fork again then exit so the grandchild is reparented. + const pid_t grand = fork(); + if (grand == 0) { + execlp("fusermount3", "fusermount3", "-uz", mp, nullptr); + execlp("fusermount", "fusermount", "-uz", mp, nullptr); + _exit(1); + } + _exit(0); } else if (child > 0) { - // Parent — wait briefly for the child to finish. + // Reap the intermediate child only — it exits immediately. int status = 0; waitpid(child, &status, 0); } @@ -318,8 +335,11 @@ static void linuxCrashHandler(int sig) backtrace_symbols_fd(frames, count, STDERR_FILENO); fprintf(stderr, "=== END BACKTRACE ===\n"); - // Re-raise for core dump - raise(sig); + // Force-terminate the whole process group. raise(sig) only delivers to the + // current thread and depends on the signal not being masked process-wide; + // libfuse blocks signals on its workers, so raise() can hang. _exit() always + // terminates immediately and reaps every thread. + _exit(128 + sig); } static void linuxTermHandler(int sig) @@ -327,16 +347,55 @@ static void linuxTermHandler(int sig) // Graceful shutdown: unmount FUSE and exit cleanly. signal(sig, SIG_DFL); emergencyFuseUnmount(); - raise(sig); + _exit(128 + sig); +} + +// std::terminate fires for uncaught C++ exceptions (including bad_alloc thrown +// out of a noexcept boundary). The default handler aborts via SIGABRT, but if +// SIGABRT is masked on the throwing thread the process can hang. Run our FUSE +// cleanup first, then call abort() ourselves. +static void linuxTerminateHandler() noexcept +{ + static std::atomic entered{false}; + if (entered.exchange(true)) { + // Recursion (terminate during our own cleanup) — die immediately. + _exit(134); + } + + emergencyFuseUnmount(); + + fprintf(stderr, "\n=== MO2 std::terminate ===\n"); + void* frames[64]; + int count = backtrace(frames, 64); + fprintf(stderr, "Backtrace (%d frames):\n", count); + backtrace_symbols_fd(frames, count, STDERR_FILENO); + fprintf(stderr, "=== END BACKTRACE ===\n"); + + std::abort(); } void setExceptionHandlers() { - signal(SIGSEGV, linuxCrashHandler); - signal(SIGABRT, linuxCrashHandler); - signal(SIGFPE, linuxCrashHandler); - signal(SIGTERM, linuxTermHandler); - signal(SIGINT, linuxTermHandler); + // sigaction with SA_RESETHAND + no SA_RESTART; preferred over signal() since + // signal() semantics vary. Don't block other fatal signals while handling + // one — we want a second crash to terminate immediately rather than recurse. + struct sigaction sa{}; + sa.sa_handler = linuxCrashHandler; + sa.sa_flags = SA_RESETHAND; + sigemptyset(&sa.sa_mask); + sigaction(SIGSEGV, &sa, nullptr); + sigaction(SIGABRT, &sa, nullptr); + sigaction(SIGFPE, &sa, nullptr); + sigaction(SIGBUS, &sa, nullptr); + + struct sigaction term{}; + term.sa_handler = linuxTermHandler; + term.sa_flags = SA_RESETHAND; + sigemptyset(&term.sa_mask); + sigaction(SIGTERM, &term, nullptr); + sigaction(SIGINT, &term, nullptr); + + std::set_terminate(linuxTerminateHandler); } #endif diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 3d39de2..4ef9be5 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -1210,6 +1210,13 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, } samplePathStat(ctx, "readdir", path); + // Cap the buffer. size comes from the kernel and is normally bounded by + // FUSE protocol limits, but a corrupt/oversized request would otherwise + // trigger a multi-MB allocation and risk std::bad_alloc. + constexpr size_t kReaddirBufMax = 1 * 1024 * 1024; + if (size > kReaddirBufMax) { + size = kReaddirBufMax; + } std::vector buf(size); size_t used = 0; @@ -1312,6 +1319,10 @@ void mo2_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, } samplePathStat(ctx, "readdir", path); + constexpr size_t kReaddirPlusBufMax = 1 * 1024 * 1024; + if (size > kReaddirPlusBufMax) { + size = kReaddirPlusBufMax; + } std::vector buf(size); size_t used = 0; -- cgit v1.3.1