From f39c7e06d2465258371412c4adac48ba72fddbb9 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 16 May 2026 13:45:46 -0500 Subject: ui: recover from stale FUSE mounts without bothering the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary changes against ENOTCONN — "Transport endpoint is not connected" — from a wedged FUSE mount left by a previous crashed or unclean session. MOApplication::notify already caught filesystem_error and called FuseConnector::tryCleanupStaleMount on the affected path, but it still went on to show the user a hard error dialog about the exception. If we've already cleared the wedged mount the workflow that triggered the iterator (refresh, restore backup, etc.) just needs to be retried — the dialog is noise on a state we just recovered from. Probe stat() after cleanup; if the path is no longer wedged, log + swallow. OrganizerCore::afterRun's post-game chmod loop used the throwing recursive_directory_iterator constructor with no error_code, so a single ENOTCONN under the game dir unwound out of afterRun and left the rest of the post-run sync (save/INI/plugin sync-back, load-order refresh, FinishedRun callback) un-run. Switch to error_code-based iteration; on ENOTCONN, call tryCleanupStaleMount on the game dir and continue cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/src/moapplication.cpp | 42 +++++++++++++++++++++++++++++++++--------- src/src/organizercore.cpp | 28 ++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp index dda52e7..7216e7c 100644 --- a/src/src/moapplication.cpp +++ b/src/src/moapplication.cpp @@ -36,6 +36,7 @@ along with Mod Organizer. If not, see . #include #include +#include #include "shared/appconfig.h" #include "shared/util.h" #include "thread_utils.h" @@ -745,16 +746,39 @@ bool MOApplication::notify(QObject* receiver, QEvent* event) receiver->objectName(), event->type(), fe.what()); // ENOTCONN = stale FUSE mount. Attempt recovery so MO2 can continue. + // If we manage to clear the wedged mount, suppress the user-facing + // dialog — the iteration that failed will be retried by whatever + // workflow triggered it (refresh, restore, etc.) and showing a hard + // error on a state we just recovered from is just noise. if (fe.code().value() == ENOTCONN) { - const auto& p1 = fe.path1(); - if (!p1.empty()) { - log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p1.string()); - FuseConnector::tryCleanupStaleMount(QString::fromStdString(p1.string())); - } - const auto& p2 = fe.path2(); - if (!p2.empty()) { - log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p2.string()); - FuseConnector::tryCleanupStaleMount(QString::fromStdString(p2.string())); + bool recovered = false; + auto attemptCleanup = [&recovered](const std::filesystem::path& p) { + if (p.empty()) { + return; + } + const QString qpath = QString::fromStdString(p.string()); + log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", + p.string()); + FuseConnector::tryCleanupStaleMount(qpath); + // Probe the path: if stat() no longer returns ENOTCONN, we cleared + // it. Even if the path is now missing (ENOENT), that's recovery + // from MO2's perspective — the iterator can succeed (or skip). + struct stat st; + const bool stillWedged = + ::stat(qpath.toLocal8Bit().constData(), &st) != 0 && + errno == ENOTCONN; + if (!stillWedged) { + recovered = true; + } + }; + attemptCleanup(fe.path1()); + attemptCleanup(fe.path2()); + + if (recovered) { + log::info( + "stale FUSE mount recovered; suppressing error dialog. " + "The triggering operation will need to be retried."); + return false; } } diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 0ce5d90..d42b1ac 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -2760,17 +2760,37 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) // Restore write permissions on the game directory. In rare cases // (crashes, unclean Wine shutdown) file permissions can be changed to // read-only, preventing subsequent launches from working. + // + // Use error_code-based iteration: a previous run may have left a stale + // FUSE mount under the game dir (zombie Wine process holding open + // handles), and the throwing iterator would unwind out of afterRun() + // leaving the rest of the post-run sync untouched. { const auto t0 = std::chrono::steady_clock::now(); const QString gameDir = managedGame()->gameDirectory().absolutePath(); namespace fs = std::filesystem; - std::error_code ec; + std::error_code permsEc; + std::error_code iterEc; for (auto it = fs::recursive_directory_iterator( - gameDir.toStdString(), fs::directory_options::skip_permission_denied); - it != fs::recursive_directory_iterator(); ++it) { + gameDir.toStdString(), + fs::directory_options::skip_permission_denied, iterEc); + !iterEc && it != fs::recursive_directory_iterator(); + it.increment(iterEc)) { fs::permissions(it->path(), fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec, - fs::perm_options::add, ec); + fs::perm_options::add, permsEc); + } + if (iterEc) { + if (iterEc.value() == ENOTCONN) { + log::warn( + "afterRun: stale FUSE mount encountered under '{}' " + "(errno={}); skipping remaining permission restoration", + gameDir.toStdString(), iterEc.value()); + FuseConnector::tryCleanupStaleMount(gameDir); + } else { + log::warn("afterRun: directory iteration aborted at error: {}", + iterEc.message()); + } } const auto ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); -- cgit v1.3.1