aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-25 13:51:44 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-25 13:51:44 -0500
commit5a506edb574455ef111d48177497279394e18282 (patch)
tree66fd26ca2d049c5ba36f22cff4554b42b9abefab
parent0c3ec1dc38b48e8b4adfafd97967322201e91e7b (diff)
VFS: harden crash path so FUSE bad_alloc no longer deadlocks system
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.
-rw-r--r--src/src/fuseconnector.cpp181
-rw-r--r--src/src/main.cpp93
-rw-r--r--src/src/vfs/mo2filesystem.cpp11
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 <windows.h>
#else
+#include <atomic>
#include <csignal>
#include <cstdlib>
#include <cstring>
+#include <exception>
#include <execinfo.h>
+#include <sys/mount.h>
#include <sys/wait.h>
#include <unistd.h>
#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<bool> 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<char> 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<char> buf(size);
size_t used = 0;