Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a947eb1
Add non-blocking async image loading to prevent UI stalls on NAS
XenuIsWatching Feb 23, 2026
2f118ba
Make VLC media parsing async to eliminate busy-wait stall on video load
XenuIsWatching Feb 24, 2026
3492507
Write gamelist.xml asynchronously to avoid blocking UI on NAS I/O
XenuIsWatching Feb 24, 2026
4872ae0
Add AsyncFileIO setting to gate async image loads and gamelist writes
XenuIsWatching Feb 26, 2026
2a4a41d
Move blocking VLC stop/cleanup to a persistent background worker thread
XenuIsWatching Feb 27, 2026
5cb2d00
Remove blocking fileExists() checks on NAS-backed paths
XenuIsWatching Feb 28, 2026
349abed
Fix FileSystem::exists() deadlock on NAS paths
XenuIsWatching Feb 28, 2026
a74c71e
Fix infinite retry loop, VRAM exhaustion, and thread shutdown for mis…
claude Feb 28, 2026
f9395e7
Log texture path on async load completion in ImageComponent
XenuIsWatching Mar 1, 2026
7e55fa7
Log texture path in async-pending render skip message
XenuIsWatching Mar 1, 2026
d28cd59
Clear mAsyncPending in render when load has failed
XenuIsWatching Mar 1, 2026
91b5953
Replace isLoaded/hasLoadFailed/isLoadedOrFailed with LoadStatus enum
XenuIsWatching Mar 1, 2026
333c990
Fix mAsyncPending stuck for background game list views
XenuIsWatching Mar 1, 2026
8857b50
Pause video start delay while snapshot image is still loading
XenuIsWatching Mar 1, 2026
0541690
Log elapsed time in ms for async image loads
XenuIsWatching Mar 3, 2026
d609100
Fix non-stop texture flickering and unclean process exit
claude Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions es-app/src/Gamelist.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#include "Gamelist.h"

#include <algorithm>
#include <chrono>
#include <fstream>
#include <future>
#include <mutex>
#include <sstream>

#include "utils/FileSystemUtil.h"
#include "FileData.h"
Expand All @@ -9,6 +14,11 @@
#include "Settings.h"
#include "SystemData.h"
#include <pugixml.hpp>
#include <unordered_map>

// Async gamelist write infrastructure
static std::mutex sGamelistWriteMutex;
static std::vector<std::future<void>> sGamelistPendingWrites;

FileData* findOrCreateFile(SystemData* system, const std::string& path, FileType type)
{
Expand Down Expand Up @@ -292,6 +302,7 @@ void updateGamelist(SystemData* system)
if(!pathNode)
{
LOG(LogError) << "<" << tag << "> node contains no <path> child!";
fileNode = nextNode;
continue;
}

Expand Down Expand Up @@ -325,22 +336,70 @@ void updateGamelist(SystemData* system)
// now write the file

if (numUpdated > 0) {
const auto startTs = std::chrono::system_clock::now();

//make sure the folders leading up to this path exist (or the write will fail)
std::string xmlWritePath(system->getGamelistPath(true));
Utils::FileSystem::createDirectory(Utils::FileSystem::getParent(xmlWritePath));

LOG(LogInfo) << "Added/Updated " << numUpdated << " entities in '" << xmlReadPath << "'";

if (!doc.save_file(xmlWritePath.c_str())) {
LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << system->getName() << ")!";
if (!Settings::getInstance()->getBool("AsyncFileIO"))
{
const auto startTs = std::chrono::system_clock::now();
if (!doc.save_file(xmlWritePath.c_str()))
LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << system->getName() << ")!";
const auto endTs = std::chrono::system_clock::now();
LOG(LogInfo) << "Saved gamelist.xml for system \"" << system->getName() << "\" in " << std::chrono::duration_cast<std::chrono::milliseconds>(endTs - startTs).count() << " ms";
}
else
{
// Serialize the XML document to a string on the main thread,
// then write the string to file on a background thread to
// avoid blocking the UI on slow NAS I/O.
std::stringstream ss;
doc.save(ss);
std::string xmlContent = ss.str();
std::string sysName = system->getName();

const auto endTs = std::chrono::system_clock::now();
LOG(LogInfo) << "Saved gamelist.xml for system \"" << system->getName() << "\" in " << std::chrono::duration_cast<std::chrono::milliseconds>(endTs - startTs).count() << " ms";
{
// Clean up finished futures
std::lock_guard<std::mutex> lock(sGamelistWriteMutex);
sGamelistPendingWrites.erase(
std::remove_if(sGamelistPendingWrites.begin(), sGamelistPendingWrites.end(),
[](std::future<void>& f) {
return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
}),
sGamelistPendingWrites.end());

sGamelistPendingWrites.push_back(std::async(std::launch::async,
[xmlContent, xmlWritePath, sysName]() {
const auto startTs = std::chrono::system_clock::now();

std::ofstream outFile(xmlWritePath, std::ios::out | std::ios::trunc);
if (outFile.is_open()) {
outFile << xmlContent;
outFile.close();
} else {
LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << sysName << ")!";
}

const auto endTs = std::chrono::system_clock::now();
LOG(LogInfo) << "Saved gamelist.xml for system \"" << sysName << "\" in " << std::chrono::duration_cast<std::chrono::milliseconds>(endTs - startTs).count() << " ms";
}));
}
}
}
}else{
LOG(LogError) << "Found no root folder for system \"" << system->getName() << "\"!";
}
}

void waitForGamelistWrites()
{
std::lock_guard<std::mutex> lock(sGamelistWriteMutex);
for (auto& f : sGamelistPendingWrites)
{
if (f.valid())
f.wait();
}
sGamelistPendingWrites.clear();
}
4 changes: 4 additions & 0 deletions es-app/src/Gamelist.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ void parseGamelist(SystemData* system);
// Writes currently loaded metadata for a SystemData to gamelist.xml.
void updateGamelist(SystemData* system);

// Blocks until all pending async gamelist writes have completed.
// Must be called before process exit to avoid data loss.
void waitForGamelistWrites();

#endif // ES_APP_GAME_LIST_H
5 changes: 5 additions & 0 deletions es-app/src/guis/GuiMenu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,11 @@ void GuiMenu::openOtherSettings()
s->addWithLabel("PARSE GAMESLISTS ONLY", parse_gamelists);
s->addSaveFunc([parse_gamelists] { Settings::getInstance()->setBool("ParseGamelistOnly", parse_gamelists->getState()); });

auto async_file_io = std::make_shared<SwitchComponent>(mWindow);
async_file_io->setState(Settings::getInstance()->getBool("AsyncFileIO"));
s->addWithLabel("ASYNC FILE IO", async_file_io);
s->addSaveFunc([async_file_io] { Settings::getInstance()->setBool("AsyncFileIO", async_file_io->getState()); });

auto local_art = std::make_shared<SwitchComponent>(mWindow);
local_art->setState(Settings::getInstance()->getBool("LocalArt"));
s->addWithLabel("SEARCH FOR LOCAL ART", local_art);
Expand Down
8 changes: 8 additions & 0 deletions es-app/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "views/ViewController.h"
#include "CollectionSystemManager.h"
#include "EmulationStation.h"
#include "Gamelist.h"
#include "InputManager.h"
#include "Log.h"
#include "MameNames.h"
Expand All @@ -17,6 +18,7 @@
#include "Settings.h"
#include "SystemData.h"
#include "SystemScreenSaver.h"
#include "components/VideoVlcComponent.h"
#include <SDL_events.h>
#include <SDL_main.h>
#include <SDL_timer.h>
Expand Down Expand Up @@ -482,8 +484,14 @@ int main(int argc, char* argv[])
InputManager::getInstance()->deinit();
window.deinit();

// Join the VLC cleanup worker and release the VLC instance. Must happen after
// window.deinit() so all VideoVlcComponents are destroyed and their final
// stopVideo() cleanup tasks are already posted to the queue.
VideoVlcComponent::deinit();

MameNames::deinit();
CollectionSystemManager::deinit();
waitForGamelistWrites();
SystemData::deleteSystems();

// call this ONLY when linking with FreeImage as a static library
Expand Down
6 changes: 3 additions & 3 deletions es-app/src/views/gamelist/DetailedGameListView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,9 @@ void DetailedGameListView::updateInfoPanel()
//mDescription.setText("");
fadingOut = true;
}else{
mThumbnail.setImage(file->getThumbnailPath());
mMarquee.setImage(file->getMarqueePath());
mImage.setImage(file->getImagePath());
mThumbnail.setImageAsync(file->getThumbnailPath());
mImage.setImageAsync(file->getImagePath());
mMarquee.setImageAsync(file->getMarqueePath());
mDescription.setText(file->metadata.get("desc"));
mDescContainer.reset();

Expand Down
6 changes: 3 additions & 3 deletions es-app/src/views/gamelist/GridGameListView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,9 @@ void GridGameListView::updateInfoPanel()
}
mVideoPlaying = true;

mVideo->setImage(file->getThumbnailPath());
mMarquee.setImage(file->getMarqueePath());
mImage.setImage(file->getImagePath());
mVideo->setImageAsync(file->getThumbnailPath());
mMarquee.setImageAsync(file->getMarqueePath());
mImage.setImageAsync(file->getImagePath());

mDescription.setText(file->metadata.get("desc"));
mDescContainer.reset();
Expand Down
8 changes: 4 additions & 4 deletions es-app/src/views/gamelist/VideoGameListView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,10 @@ void VideoGameListView::updateInfoPanel()
}
mVideoPlaying = true;

mVideo->setImage(file->getThumbnailPath());
mThumbnail.setImage(file->getThumbnailPath());
mMarquee.setImage(file->getMarqueePath());
mImage.setImage(file->getImagePath());
mVideo->setImageAsync(file->getThumbnailPath());
mThumbnail.setImageAsync(file->getThumbnailPath());
mMarquee.setImageAsync(file->getMarqueePath());
mImage.setImageAsync(file->getImagePath());

mDescription.setText(file->metadata.get("desc"));
mDescContainer.reset();
Expand Down
1 change: 1 addition & 0 deletions es-core/src/Settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ void Settings::setDefaults()

mBoolMap["BackgroundJoystickInput"] = false;
mBoolMap["ParseGamelistOnly"] = false;
mBoolMap["AsyncFileIO"] = false;
mBoolMap["ShowHiddenFiles"] = false;
mBoolMap["DrawFramerate"] = false;
mBoolMap["ShowExit"] = true;
Expand Down
9 changes: 7 additions & 2 deletions es-core/src/components/IList.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,13 @@ class IList : public GuiComponent
if(mScrollVelocity == 0 || size() < 2)
return;

mScrollCursorAccumulator += deltaTime;
mScrollTierAccumulator += deltaTime;
// Cap the delta time used for scrolling to prevent multiple scroll jumps
// after a long-blocking frame (e.g., slow NAS I/O)
const int maxScrollDelta = mTierList.tiers[mScrollTier].scrollDelay;
int scrollDelta = (deltaTime > maxScrollDelta) ? maxScrollDelta : deltaTime;

mScrollCursorAccumulator += scrollDelta;
mScrollTierAccumulator += scrollDelta;

// we delay scrolling until after scroll tier has updated so isScrolling() returns accurately during onCursorChanged callbacks
// we don't just do scroll tier first because it would not catch the scrollDelay == tier length case
Expand Down
91 changes: 87 additions & 4 deletions es-core/src/components/ImageComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "Log.h"
#include "Settings.h"
#include "ThemeData.h"
#include <SDL_timer.h>

Vector2i ImageComponent::getTextureSize() const
{
Expand All @@ -21,7 +22,8 @@ Vector2f ImageComponent::getSize() const
ImageComponent::ImageComponent(Window* window, bool forceLoad, bool dynamic) : GuiComponent(window),
mTargetIsMax(false), mTargetIsMin(false), mFlipX(false), mFlipY(false), mTargetSize(0, 0), mColorShift(0xFFFFFFFF),
mColorShiftEnd(0xFFFFFFFF), mColorGradientHorizontal(true), mForceLoad(forceLoad), mDynamic(dynamic),
mFadeOpacity(0), mFading(false), mRotateByTargetSize(false), mTopLeftCrop(0.0f, 0.0f), mBottomRightCrop(1.0f, 1.0f)
mFadeOpacity(0), mFading(false), mRotateByTargetSize(false), mTopLeftCrop(0.0f, 0.0f), mBottomRightCrop(1.0f, 1.0f),
mAsyncPending(false)
{
updateColors();
}
Expand Down Expand Up @@ -132,9 +134,13 @@ void ImageComponent::setDefaultImage(std::string path)

void ImageComponent::setImage(std::string path, bool tile)
{
if(path.empty() || !ResourceManager::getInstance()->fileExists(path))
mAsyncPending = false;

// Skip fileExists() — it calls stat64 which blocks on NAS paths.
// TextureData::load() handles missing files gracefully (returns empty texture).
if(path.empty())
{
if(mDefaultPath.empty() || !ResourceManager::getInstance()->fileExists(mDefaultPath))
if(mDefaultPath.empty())
mTexture.reset();
else
mTexture = TextureResource::get(mDefaultPath, tile, mForceLoad, mDynamic);
Expand All @@ -158,9 +164,71 @@ void ImageComponent::setImage(const char* path, size_t length, bool tile)
void ImageComponent::setImage(const std::shared_ptr<TextureResource>& texture)
{
mTexture = texture;
mAsyncPending = false;
resize();
}

void ImageComponent::setImageAsync(std::string path, bool tile)
{
if (!Settings::getInstance()->getBool("AsyncFileIO"))
{
setImage(path, tile);
return;
}

mAsyncPending = false;
mTexturePath = path;

// Skip the fileExists() check used by setImage() — it calls stat64 which blocks
// on NAS. Instead, just hand the path to TextureResource and let the background
// thread's load() handle missing files gracefully.
if(path.empty())
{
if(mDefaultPath.empty())
mTexture.reset();
else
mTexture = TextureResource::get(mDefaultPath, tile, mForceLoad, mDynamic, false);
} else {
mTexture = TextureResource::get(path, tile, mForceLoad, mDynamic, false);
}

if(mTexture)
{
// Check if the texture is already loaded (e.g. cache hit)
if(mTexture->updateTextureSize())
{
LOG(LogDebug) << "setImageAsync: immediate load for " << path;
resize();
}
else
{
// Texture is loading in background - resize() will be called from update() when ready
LOG(LogDebug) << "setImageAsync: queued async for " << path;
mAsyncPending = true;
mAsyncStartTime = SDL_GetTicks();
}
}
else
{
LOG(LogDebug) << "setImageAsync: no texture for " << path;
}
}

void ImageComponent::update(int deltaTime)
{
GuiComponent::update(deltaTime);

if(mAsyncPending && mTexture)
{
if(mTexture->updateTextureSize())
{
LOG(LogDebug) << "ImageComponent::update: async load complete for " << mTexturePath << " size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y() << " time=" << (SDL_GetTicks() - mAsyncStartTime) << "ms";
mAsyncPending = false;
resize();
}
}
}

void ImageComponent::setResize(float width, float height)
{
mTargetSize = Vector2f(width, height);
Expand Down Expand Up @@ -325,7 +393,7 @@ void ImageComponent::render(const Transform4x4f& parentTrans)
Transform4x4f trans = parentTrans * getTransform();
Renderer::setMatrix(trans);

if(mTexture && mOpacity > 0)
if(mTexture && mOpacity > 0 && !mAsyncPending)
{
if(Settings::getInstance()->getBool("DebugImage")) {
Vector2f targetSizePos = (mTargetSize - mSize) * mOrigin * -1;
Expand All @@ -346,6 +414,21 @@ void ImageComponent::render(const Transform4x4f& parentTrans)
mTexture.reset();
}
}
else if(mTexture && mAsyncPending)
{
// Resolve mAsyncPending for failed loads even when update() isn't being called
// (e.g. game list rendered in background during system carousel transition).
if(mTexture->updateTextureSize())
{
mAsyncPending = false;
}
else
{
static int skipCount = 0;
if(++skipCount % 60 == 1) // log every ~1 second at 60fps
LOG(LogDebug) << "render: skipping due to mAsyncPending for " << mTexturePath << " mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity;
}
}

GuiComponent::renderChildren(trans);
}
Expand Down
Loading