From 3949dcfce95af4bd305f258ff5b170d7d50435f6 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 12 Apr 2026 21:04:15 -0500 Subject: VFS perf fixes, icon/stylesheet compat, download filename fix VFS: - Open backing fd at mo2_open time for read-only handles so mo2_read can splice without re-opening the file on every read call. - Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when the game keeps hundreds of BSAs open. - Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing unordered_map writes while multiple tree_mutex readers are active. - max_read=1MB + matching conn->max_read and raised max_readahead/max_write so the kernel merges Wine's small sequential reads into bigger FUSE requests. - Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS latency from game-side work. Proton launch: - Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile stalls on first run. UI / plugin compat: - Clamp IconDelegate's per-icon width to [8, 16] so narrow content columns don't trigger QIcon::pixmap(0,0) returning null and logging "failed to load icon" every repaint. - Pre-create lowercase symlinks in the stylesheet directory so QSS files authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg. - Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA so Qt's built-in PNG reader loads them uniformly. - Add mobase.Version.canonicalString shim for legacy Python plugins that still call the old VersionInfo method on appVersion()'s return. - BSPluginList: compare normalized plugin name lists instead of byte hashes so the post-run case-fix refresh stops spuriously firing the "load order changed" dialog. - BSPluginList disabled by default. Download manager: - Parse CDN URL with QUrl + QUrlQuery and read the filename from the response-content-disposition query param (RFC 6266 quoted / unquoted / ext-value forms), not QFileInfo on the raw URL. - When the API or URL gives us a CDN object key (no archive extension), prefer the actual Content-Disposition header from the live response in metaDataChanged and downloadFinished. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/src/downloadmanager.cpp | 100 +++++++++++--- src/src/fuseconnector.cpp | 10 +- src/src/icondelegate.cpp | 7 +- src/src/moapplication.cpp | 47 +++++++ src/src/pluginlist.cpp | 6 +- src/src/protonlauncher.cpp | 16 +++ src/src/resources/contents/empty-chessboard.png | Bin 537 -> 910 bytes src/src/resources/contents/facegen.png | Bin 246 -> 638 bytes src/src/vfs/mo2filesystem.cpp | 172 ++++++++++++++++++------ src/src/vfs/mo2filesystem.h | 13 +- 10 files changed, 309 insertions(+), 62 deletions(-) (limited to 'src') diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp index 1b5c6cb..91518ff 100644 --- a/src/src/downloadmanager.cpp +++ b/src/src/downloadmanager.cpp @@ -42,8 +42,10 @@ along with Mod Organizer. If not, see . #include #include #include +#include #include #include +#include #include #include @@ -55,6 +57,28 @@ using namespace MOBase; static const char UNFINISHED[] = ".unfinished"; +// Heuristic: does this filename look like a CDN object key that should be +// replaced by the server's Content-Disposition filename if available? +// Nexus v2 file metadata sometimes returns `file_name` as a CDN-path UUID +// (e.g. "91/83/bb/9183bbff-...") rather than the actual archive name. +static bool looksLikeCdnObjectKey(const QString& name) +{ + if (name.isEmpty()) { + return true; + } + // Real archive filenames always end in an archive suffix. If the name + // has no recognized archive extension, assume it's a CDN key. + static const QStringList kArchiveSuffixes = { + ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", + ".xz", ".exe", ".msi", ".bsa", ".ba2"}; + for (const auto& suf : kArchiveSuffixes) { + if (name.endsWith(suf, Qt::CaseInsensitive)) { + return false; + } + } + return true; +} + unsigned int DownloadManager::DownloadInfo::s_NextDownloadID = 1U; int DownloadManager::m_DirWatcherDisabler = 0; @@ -478,26 +502,50 @@ void DownloadManager::queryDownloadListInfo() bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID, int fileID, const ModRepositoryFileInfo* fileInfo) { - QString fileName = QFileInfo(URLs.first()).fileName(); + // Parse the URL properly instead of feeding it to QFileInfo — QFileInfo + // doesn't understand URLs, so its fileName() can pull in query-string junk + // on Linux and we end up saving downloads as UUIDs from the S3 object key. + const QUrl parsedUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); + + QString fileName; + + // Nexus S3-signed download URLs carry the desired filename in the + // `response-content-disposition` query parameter, e.g. + // response-content-disposition=attachment;filename="JohnnyGuitar NVSE - 5.06.7z" + // (percent-encoded on the wire). QUrlQuery decodes values for us. This + // must be checked first — the URL path itself is just the CDN object key, + // which looks like "9183bb...b-fdcf" and is useless as a filename. + const QUrlQuery query(parsedUrl); + const QString kDispoKey = QStringLiteral("response-content-disposition"); + if (query.hasQueryItem(kDispoKey)) { + const QString disposition = + query.queryItemValue(kDispoKey, QUrl::FullyDecoded); + // RFC 6266: filename="quoted" or filename=unquoted-token or filename*=ext-value. + static const QRegularExpression rxQuoted( + QStringLiteral("filename=\"([^\"]+)\"")); + static const QRegularExpression rxUnquoted( + QStringLiteral("filename=([^;\\s]+)")); + static const QRegularExpression rxExtValue( + QStringLiteral("filename\\*=[^']*'[^']*'([^;\\s]+)")); + QRegularExpressionMatch m = rxQuoted.match(disposition); + if (!m.hasMatch()) m = rxExtValue.match(disposition); + if (!m.hasMatch()) m = rxUnquoted.match(disposition); + if (m.hasMatch()) { + fileName = QUrl::fromPercentEncoding(m.captured(1).toUtf8()); + } + } + + // Fall back to the last path segment of the URL if the disposition trick + // didn't produce anything. if (fileName.isEmpty()) { - fileName = "unknown"; - } else { - fileName = QUrl::fromPercentEncoding(fileName.toUtf8()); + fileName = parsedUrl.fileName(); + if (!fileName.isEmpty()) { + fileName = QUrl::fromPercentEncoding(fileName.toUtf8()); + } } - // Temporary URLs for S3-compatible storage are signed for a single method, removing - // the ability to make HEAD requests to such URLs. We can use the - // response-content-disposition GET parameter, setting the Content-Disposition header, - // to predetermine intended file name without a subrequest. - if (fileName.contains("response-content-disposition=")) { - std::regex exp("filename=\"(.+)\""); - std::cmatch result; - if (std::regex_search(fileName.toStdString().c_str(), result, exp)) { - fileName = MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str())); - if (fileName.isEmpty()) { - fileName = "unknown"; - } - } + if (fileName.isEmpty()) { + fileName = "unknown"; } QUrl preferredUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); @@ -2365,7 +2413,10 @@ void DownloadManager::downloadFinished(int index) QString oldName = QFileInfo(info->m_Output).fileName(); startDisableDirWatcher(); - if (!newName.isEmpty() && (oldName.isEmpty())) { + // Rename to Content-Disposition if either we have no name yet, or the + // name we seeded from the API looks like a CDN object key. + if (!newName.isEmpty() && + (oldName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName))) { info->setName(getDownloadFileName(newName), true); } else { info->setName(m_OutputDirectory + "/" + info->m_FileName, @@ -2407,8 +2458,17 @@ void DownloadManager::metaDataChanged() DownloadInfo* info = findDownload(this->sender(), &index); if (info != nullptr) { - QString newName = getFileNameFromNetworkReply(info->m_Reply); - if (!newName.isEmpty() && (info->m_FileName.isEmpty())) { + const QString newName = getFileNameFromNetworkReply(info->m_Reply); + // Prefer Content-Disposition over whatever we seeded from the API when + // the seeded name looks like a CDN object key. Otherwise only take it + // if we have no name at all yet. + const bool shouldReplace = + !newName.isEmpty() && + (info->m_FileName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName)); + + if (shouldReplace) { + log::info("metaDataChanged: replacing '{}' with Content-Disposition '{}'", + info->m_FileName, newName); startDisableDirWatcher(); info->setName(getDownloadFileName(newName), true); endDisableDirWatcher(); diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index d890613..3e327bc 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -311,8 +311,16 @@ bool FuseConnector::mount( // NOTE: Do NOT include mount_point here — low-level API passes it // separately to fuse_session_mount(). Including it here causes // "fuse: unknown option(s)" error. + // + // max_read=1MB: raise per-read-request cap from default 128KB. Must + // match conn->max_read set in mo2_init() or libfuse rejects the mount + // with a max-read-mismatch error. Going higher than 1MB triggers + // "fuse: reading device: Invalid argument" on some kernels where + // libfuse's receive buffer is sized off max_write + header and the + // kernel reads don't fit. 1MB is the safe ceiling. std::vector argvStorage = { - "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime", "-o", "default_permissions"}; + "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime", + "-o", "default_permissions", "-o", "max_read=1048576"}; std::vector argv; argv.reserve(argvStorage.size()); diff --git a/src/src/icondelegate.cpp b/src/src/icondelegate.cpp index 5a26730..b422e39 100644 --- a/src/src/icondelegate.cpp +++ b/src/src/icondelegate.cpp @@ -23,6 +23,7 @@ along with Mod Organizer. If not, see . #include #include #include +#include #include using namespace MOBase; @@ -48,7 +49,11 @@ void IconDelegate::paintIcons(QPainter* painter, const QStyleOptionViewItem& opt painter->save(); int iconWidth = icons.size() > 0 ? ((option.rect.width() / icons.size()) - 4) : 16; - iconWidth = std::min(16, iconWidth); + // Clamp: narrow content columns can make the per-icon slot 0 or negative, + // which causes QIcon::pixmap() to return null and log "failed to load icon" + // spuriously. Keep at least 8px so the pixmap call always succeeds; excess + // icons may clip, which is better than paint-side failures. + iconWidth = std::clamp(iconWidth, 8, 16); const int margin = (option.rect.height() - iconWidth) / 2; diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp index 96d9854..08c5703 100644 --- a/src/src/moapplication.cpp +++ b/src/src/moapplication.cpp @@ -868,6 +868,48 @@ QString extractBaseStyleFromStyleSheet(QFile& stylesheet, const QString& default } // namespace +#ifndef _WIN32 +// Walk a directory and create lowercase symlinks for any file whose name +// contains uppercase letters, if the lowercase name doesn't already exist. +// QSS files authored on Windows often reference asset files with lowercase +// names but the actual files on disk use mixed case — on a case-sensitive +// filesystem Qt's url() resolver fails. Shimming lowercase symlinks lets +// the existing paths resolve without touching the original files. +static void createLowercaseStylesheetShims(const QString& dirPath) +{ + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(dirPath.toStdString(), ec) || + !fs::is_directory(dirPath.toStdString(), ec)) { + return; + } + for (auto it = fs::recursive_directory_iterator( + dirPath.toStdString(), + fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + const auto& entry = *it; + if (!entry.is_regular_file(ec)) continue; + + const std::string name = entry.path().filename().string(); + std::string lowerName = name; + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (lowerName == name) continue; // already lowercase + + const auto parent = entry.path().parent_path(); + const auto lowerPath = parent / lowerName; + if (fs::exists(lowerPath, ec)) continue; + + std::error_code lec; + fs::create_symlink(name, lowerPath, lec); + if (lec) { + log::debug("stylesheet shim: failed to link '{}' -> '{}': {}", + lowerPath.string(), name, lec.message()); + } + } +} +#endif + void MOApplication::updateStyle(const QString& fileName) { if (QStyleFactory::keys().contains(fileName)) { @@ -876,6 +918,11 @@ void MOApplication::updateStyle(const QString& fileName) } else { QFile stylesheet(fileName); if (stylesheet.exists()) { +#ifndef _WIN32 + // Pre-create lowercase shims so url(foo.svg) in the QSS resolves even + // when the on-disk file is Foo.svg. + createLowercaseStylesheetShims(QFileInfo(fileName).absolutePath()); +#endif setStyle(new ProxyStyle(QStyleFactory::create( extractBaseStyleFromStyleSheet(stylesheet, m_defaultStyle)))); setStyleSheet(QString("file:///%1").arg(fileName)); diff --git a/src/src/pluginlist.cpp b/src/src/pluginlist.cpp index 3961dff..72abbc4 100644 --- a/src/src/pluginlist.cpp +++ b/src/src/pluginlist.cpp @@ -1972,7 +1972,11 @@ PluginList::ESPInfo::ESPInfo(const QString& name, bool forceLoaded, bool forceEn #endif try { - ESP::File file(ToWString(parsePath)); + // Linux filesystem is UTF-8. ToWString → wstring → naive wchar_t->char + // copy in esptk truncates non-ASCII (ö, –), producing "file not found" + // on paths like "Mörskom Estate" or "Official Master Files – Cleaned". + // Pass UTF-8 bytes directly. + ESP::File file(parsePath.toStdString()); auto extension = name.right(3).toLower(); hasMasterExtension = (extension == "esm"); hasLightExtension = (extension == "esl"); diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 89338f4..4e9ed4d 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -525,6 +525,22 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const env.insert("DOTNET_ROOT", ""); env.insert("DOTNET_MULTILEVEL_LOOKUP", "0"); + // Force-disable DXVK graphics-pipeline-library. GPL causes very long shader + // compile stalls on first launch for heavily modded Bethesda games and the + // benefit is modest for us. We write a small dxvk.conf into the prefix and + // point DXVK_CONFIG_FILE at it so every DXVK-rendered process picks it up. + if (!m_prefixPath.isEmpty()) { + const QString dxvkConfPath = QDir(m_prefixPath).filePath("dxvk.conf"); + QFile dxvkConfFile(dxvkConfPath); + if (dxvkConfFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + dxvkConfFile.write("dxvk.enableGraphicsPipelineLibrary = False\n"); + dxvkConfFile.close(); + env.insert("DXVK_CONFIG_FILE", dxvkConfPath); + } else { + MOBase::log::warn("Failed to write dxvk.conf at '{}'", dxvkConfPath); + } + } + for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { env.insert(it.key(), it.value()); } diff --git a/src/src/resources/contents/empty-chessboard.png b/src/src/resources/contents/empty-chessboard.png index fccb3aa..8d3b776 100644 Binary files a/src/src/resources/contents/empty-chessboard.png and b/src/src/resources/contents/empty-chessboard.png differ diff --git a/src/src/resources/contents/facegen.png b/src/src/resources/contents/facegen.png index 8f5eeac..b05240d 100644 Binary files a/src/src/resources/contents/facegen.png and b/src/src/resources/contents/facegen.png differ diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index b8b56f1..ecae325 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -2,11 +2,13 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -33,6 +35,25 @@ void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, mode_t cached_mode = 0); void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath); +// RAII helper that records per-op wall-clock nanoseconds into a counter. +struct OpTimer +{ + std::atomic* sink; + std::chrono::steady_clock::time_point start; + explicit OpTimer(std::atomic* s) + : sink(s), start(std::chrono::steady_clock::now()) {} + ~OpTimer() + { + if (sink == nullptr) return; + const auto end = std::chrono::steady_clock::now(); + const uint64_t ns = static_cast( + std::chrono::duration_cast(end - start).count()); + sink->fetch_add(ns, std::memory_order_relaxed); + } + OpTimer(const OpTimer&) = delete; + OpTimer& operator=(const OpTimer&) = delete; +}; + void maybeLogCounters(Mo2FsContext* ctx) { if (ctx == nullptr) { @@ -44,25 +65,44 @@ void maybeLogCounters(Mo2FsContext* ctx) return; } + const uint64_t lc = ctx->lookup_count.load(std::memory_order_relaxed); + const uint64_t gc = ctx->getattr_count.load(std::memory_order_relaxed); + const uint64_t rc = ctx->readdir_count.load(std::memory_order_relaxed); + const uint64_t oc = ctx->open_count.load(std::memory_order_relaxed); + const uint64_t rdc = ctx->read_count.load(std::memory_order_relaxed); + const uint64_t ic = ctx->ioctl_count.load(std::memory_order_relaxed); + std::fprintf(stderr, "[VFS] ops lookup=%llu getattr=%llu readdir=%llu open=%llu read=%llu ioctl=%llu", - static_cast( - ctx->lookup_count.load(std::memory_order_relaxed)), - static_cast( - ctx->getattr_count.load(std::memory_order_relaxed)), - static_cast( - ctx->readdir_count.load(std::memory_order_relaxed)), - static_cast( - ctx->open_count.load(std::memory_order_relaxed)), - static_cast( - ctx->read_count.load(std::memory_order_relaxed)), - static_cast( - ctx->ioctl_count.load(std::memory_order_relaxed))); + static_cast(lc), + static_cast(gc), + static_cast(rc), + static_cast(oc), + static_cast(rdc), + static_cast(ic)); { std::scoped_lock lock(ctx->open_files_mutex); std::fprintf(stderr, " open_handles=%zu\n", ctx->open_files.size()); } + // Per-op wall-clock totals and averages (microseconds). + auto avgUs = [](uint64_t ns, uint64_t count) -> double { + return count == 0 ? 0.0 : (static_cast(ns) / 1000.0) / static_cast(count); + }; + const uint64_t lns = ctx->lookup_ns.load(std::memory_order_relaxed); + const uint64_t gns = ctx->getattr_ns.load(std::memory_order_relaxed); + const uint64_t rns = ctx->readdir_ns.load(std::memory_order_relaxed); + const uint64_t ons = ctx->open_ns.load(std::memory_order_relaxed); + const uint64_t rdns = ctx->read_ns.load(std::memory_order_relaxed); + std::fprintf(stderr, + "[VFS] time lookup=%.1fms/%.1fus-avg getattr=%.1fms/%.1fus readdir=%.1fms/%.1fus " + "open=%.1fms/%.1fus read=%.1fms/%.1fus\n", + lns / 1e6, avgUs(lns, lc), + gns / 1e6, avgUs(gns, gc), + rns / 1e6, avgUs(rns, rc), + ons / 1e6, avgUs(ons, oc), + rdns / 1e6, avgUs(rdns, rdc)); + auto logTop = [](const char* label, const std::unordered_map& m) { if (m.empty()) { return; @@ -167,23 +207,22 @@ void invalidateNodeCache(Mo2FsContext* ctx, const std::string& path) std::shared_lock lock(ctx->inode_mutex); ino = ctx->inodes->get(path); } + fuse_ino_t parentIno = 0; + 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). - const auto slash = path.rfind('/'); - if (slash != std::string::npos) { - const std::string parentPath = path.substr(0, slash); - fuse_ino_t parentIno = 0; - { - std::shared_lock lock(ctx->inode_mutex); - parentIno = ctx->inodes->get(parentPath); - } - if (parentIno != 0) { - ctx->node_cache.erase(parentIno); - } + if (parentIno != 0) { + ctx->node_cache.erase(parentIno); } } @@ -315,9 +354,12 @@ const VfsNode* resolveByInode(Mo2FsContext* ctx, fuse_ino_t ino) } // Check node_cache (fast path — no splitPath, no tree walk) - auto cacheIt = ctx->node_cache.find(ino); - if (cacheIt != ctx->node_cache.end()) { - return cacheIt->second; + { + std::scoped_lock nlock(ctx->node_cache_mutex); + auto cacheIt = ctx->node_cache.find(ino); + if (cacheIt != ctx->node_cache.end()) { + return cacheIt->second; + } } // Cache miss — resolve via inode→path→tree walk @@ -332,9 +374,12 @@ const VfsNode* resolveByInode(Mo2FsContext* ctx, fuse_ino_t ino) const VfsNode* node = ctx->tree->root.resolve(splitPath(path)); if (node != nullptr) { - // Safe: we hold tree_mutex shared, and mutations hold it exclusively - // so they can clear stale entries before any pointer becomes dangling. - const_cast&>(ctx->node_cache)[ino] = node; + // Validity of node pointer is tied to tree_mutex shared (held by caller) + // — mutations acquire tree_mutex exclusive and clear the cache before + // any pointer becomes dangling. Serialize map write against other + // concurrent shared readers. + std::scoped_lock nlock(ctx->node_cache_mutex); + ctx->node_cache[ino] = node; } return node; } @@ -735,6 +780,28 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn) { auto* ctx = static_cast(userdata); + // Bump RLIMIT_NOFILE. We hold one real fd per open file so games that + // stream hundreds of BSAs concurrently would otherwise hit the default + // 1024 soft limit. Raise to hard limit (or a sane cap). + { + struct rlimit rl{}; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0) { + rlim_t wanted = rl.rlim_max; + if (wanted == RLIM_INFINITY || wanted > 1048576) { + wanted = 1048576; + } + if (rl.rlim_cur < wanted) { + rl.rlim_cur = wanted; + if (setrlimit(RLIMIT_NOFILE, &rl) != 0) { + std::fprintf(stderr, "[VFS] setrlimit(NOFILE) failed: errno=%d\n", errno); + } else { + std::fprintf(stderr, "[VFS] RLIMIT_NOFILE raised to %llu\n", + static_cast(rl.rlim_cur)); + } + } + } + } + // ── Disable AUTO_INVAL_DATA (CRITICAL for performance) ── // AUTO_INVAL_DATA forces a getattr() on EVERY read() to check mtime, // completely bypassing attr_timeout. This alone causes ~4x throughput @@ -799,15 +866,23 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn) conn->max_background = 32767; conn->congestion_threshold = 24576; - // Request large read/write buffers. The kernel caps these at its own - // compiled-in maximum, so overshooting is harmless. - constexpr unsigned int EIGHT_MB = 8 * 1048576; - if (conn->max_readahead < EIGHT_MB) { - conn->max_readahead = EIGHT_MB; + // Request large read/write buffers. libfuse sizes its receive buffer at + // session creation time based on (max_write + header); overshooting here + // yields a mismatch where the kernel expects room for a big write but + // libfuse's buffer is smaller, and reads from /dev/fuse fail with EINVAL. + // Stay conservative: 1MB matches libfuse's bufsize ceiling on most kernels + // and is the largest value the kernel's FUSE driver typically accepts. + constexpr unsigned int ONE_MB = 1 * 1024 * 1024; + if (conn->max_readahead < ONE_MB) { + conn->max_readahead = ONE_MB; } - if (conn->max_write < EIGHT_MB) { - conn->max_write = EIGHT_MB; + if (conn->max_write < ONE_MB) { + conn->max_write = ONE_MB; } + // max_read MUST match the "-o max_read=..." mount option passed to + // fuse_session_new() or libfuse errors out with + // "init() and fuse_session_new() requested different maximum read size" + conn->max_read = ONE_MB; std::fprintf(stderr, "[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d " @@ -825,6 +900,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->lookup_ns); ctx->lookup_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); @@ -896,7 +972,8 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) const std::string key = normalizeForLookup(lr.canonical_name); auto it = parentNode->dir_info.children.find(key); if (it != parentNode->dir_info.children.end()) { - const_cast&>(ctx->node_cache)[childIno] = it->second.get(); + std::scoped_lock nlock(ctx->node_cache_mutex); + ctx->node_cache[childIno] = it->second.get(); } } } @@ -933,6 +1010,7 @@ void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* /*fi*/) fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->getattr_ns); ctx->getattr_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); @@ -1043,6 +1121,7 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->readdir_ns); ctx->readdir_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); @@ -1130,6 +1209,7 @@ void mo2_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->readdir_ns); ctx->readdir_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); @@ -1227,6 +1307,7 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->open_ns); ctx->open_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); @@ -1262,6 +1343,10 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) // 3. Everything else (untracked mod files, base-game files) → open R/O // now, lazy COW to Overwrite on first actual write(). This avoids // COW from Wine metadata ops (O_RDWR without writing). + // + // Read strategy: always open a real backing fd at open() time so mo2_read + // can splice from it without re-opening per read call. Avoids N syscalls + // per file for games that stream large BSAs in many small chunks. int fd = -1; if (writable) { @@ -1309,6 +1394,16 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) fuse_reply_err(req, EIO); return; } + } else { + // Read-only open: keep a real fd so mo2_read can splice without + // re-opening the backing file on every read() call. + if (isBacking && ctx->backing_dir_fd >= 0) { + fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY); + } else { + fd = open(realPath.c_str(), O_RDONLY); + } + // Non-fatal: fall back to lazy per-read open if we hit EMFILE etc. + // mo2_read handles fd=-1 by opening temporarily. } const uint64_t fh = ctx->next_fh.fetch_add(1, std::memory_order_relaxed); @@ -1339,6 +1434,7 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off, fuse_reply_err(req, EINVAL); return; } + OpTimer _t(&ctx->read_ns); ctx->read_count.fetch_add(1, std::memory_order_relaxed); maybeLogCounters(ctx); diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index e105c07..624b330 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -26,7 +26,11 @@ struct Mo2FsContext // Fast inode→VfsNode* cache. Pointers are valid while tree_mutex is held // (shared for reads). Invalidated under exclusive tree_mutex during mutations. - std::unordered_map node_cache; // protected by tree_mutex + // Access (read + write) is serialized by node_cache_mutex — tree_mutex-shared + // is NOT enough because multiple shared readers would race when populating + // the cache on miss. + std::unordered_map node_cache; + mutable std::mutex node_cache_mutex; std::unique_ptr overwrite; std::shared_ptr tracked_writes; @@ -111,6 +115,13 @@ struct Mo2FsContext std::atomic read_count{0}; std::atomic ioctl_count{0}; std::atomic op_tick{0}; + // Cumulative wall-clock nanoseconds spent inside each op handler. + // Paired with the *_count fields so we can report avg latency per op. + std::atomic lookup_ns{0}; + std::atomic getattr_ns{0}; + std::atomic readdir_ns{0}; + std::atomic open_ns{0}; + std::atomic read_ns{0}; std::unordered_map lookup_hit_paths; std::unordered_map lookup_miss_paths; std::unordered_map getattr_paths; -- cgit v1.3.1