Fix FileSystemModel crash on stale filesystem-model indexes

- dispatch filesystem events on the main thread in FileSystemListener, preserving model, tree, document, and callback ordering
- add address + generation node identity to close the allocator-reuse hole
- return null/empty results for stale indexes instead of root data
- assert the main-thread contract and token-guard queued listener actions
- add attached-view and stale-selection crash regression tests

Fixes crash reported in discussion SpartanJ/ecode#952
This commit is contained in:
Martín Lucas Golini
2026-08-20 00:24:16 -03:00
parent 8448d579ab
commit 9c794fc12f
9 changed files with 392 additions and 75 deletions
+47 -1
View File
@@ -1,6 +1,10 @@
#include "filesystemlistener.hpp"
#include <eepp/scene/actionmanager.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/md5.hpp>
#include <eepp/system/time.hpp>
#include <eepp/window/engine.hpp>
namespace ecode {
@@ -22,7 +26,28 @@ std::string getFileSystemEventTypeName( FileSystemEventType action ) {
FileSystemListener::FileSystemListener( UICodeEditorSplitter* splitter,
std::shared_ptr<FileSystemModel> fileSystemModel,
const std::vector<std::string>& ignoreFiles ) :
mSplitter( splitter ), mFileSystemModel( fileSystemModel ), mIgnoredFiles( ignoreFiles ) {}
mSplitter( splitter ), mFileSystemModel( fileSystemModel ), mIgnoredFiles( ignoreFiles ) {
// Namespaced 64-bit tag: a stable class-name prefix plus an atomic instance
// counter, which minimizes (but cannot mathematically eliminate) collision
// with unrelated scene action tags.
static std::atomic<Uint64> nextEventActionTag{ 0 };
mEventActionTag =
( static_cast<Uint64>( EE::String::hash( "ecode::FileSystemListener" ) ) << 32 ) |
( ++nextEventActionTag & 0xFFFFFFFFULL );
mLifetime = std::make_shared<std::atomic<bool>>( true );
}
FileSystemListener::~FileSystemListener() {
// The lifetime token is complete only because destruction runs on the main
// thread, where queued actions also execute: a runnable cannot be
// interleaved between its token check and its use of `this`. Assert that
// invariant; cross-thread destruction would need a synchronized control
// block instead of an atomic flag.
eeASSERT( !Engine::existsSingleton() || Engine::isMainThread() );
*mLifetime = false;
if ( auto* scene = SceneManager::instance()->getUISceneNode() )
scene->getActionManager()->removeActionsByTagFromTarget( scene, mEventActionTag );
}
static inline bool endsWithSlash( const std::string& dir ) {
return !dir.empty() && ( dir.back() == '\\' || dir.back() == '/' );
@@ -31,6 +56,27 @@ static inline bool endsWithSlash( const std::string& dir ) {
void FileSystemListener::handleFileAction( efsw::WatchID, const std::string& dir,
const std::string& filename, efsw::Action action,
const std::string& oldFilename ) {
// The whole logical event (model, directory tree, open documents,
// callbacks) must run on the main thread in its original order: the model
// update used to complete synchronously before the other consequences, and
// all of them touch UI-owned state. The destructor cancels queued actions
// (tagged with mEventActionTag), so a runnable can never outlive the
// listener.
if ( !Engine::isMainThread() ) {
if ( auto* scene = SceneManager::instance()->getUISceneNode() ) {
scene->runOnMainThread(
[lifetime = mLifetime, this, dir, filename, action, oldFilename]() {
if ( !lifetime->load( std::memory_order_acquire ) )
return;
handleFileAction( 0, dir, filename, action, oldFilename );
},
Time::Zero, mEventActionTag );
return;
}
// No scene node: cannot safely touch UI state from this thread.
return;
}
FileInfo file( ( endsWithSlash( dir ) ? dir : ( dir + FileSystem::getOSSlash() ) ) + filename );
switch ( action ) {
+6 -1
View File
@@ -25,7 +25,7 @@ class FileSystemListener : public efsw::FileWatchListener {
std::shared_ptr<FileSystemModel> fileSystemModel,
const std::vector<std::string>& ignoreFiles );
virtual ~FileSystemListener() {}
virtual ~FileSystemListener();
void handleFileAction( efsw::WatchID, const std::string& dir, const std::string& filename,
efsw::Action action, const std::string& oldFilename );
@@ -46,6 +46,11 @@ class FileSystemListener : public efsw::FileWatchListener {
std::unordered_map<Uint64, FileEventFn> mCbs;
std::vector<std::string> mIgnoredFiles;
Mutex mCbsMutex;
// Tag of the queued main-thread file-event actions; the destructor cancels
// them and marks the lifetime token dead so a queued runnable can never
// dereference the destroyed listener.
Uint64 mEventActionTag{ 0 };
std::shared_ptr<std::atomic<bool>> mLifetime;
bool isFileOpen( const FileInfo& file );
+11 -8
View File
@@ -137,7 +137,9 @@ class UITreeViewCellFS : public UITreeViewCell {
}
const std::string& getCurrentPath() const {
return getModel()->node( getCurIndex() ).fullPath();
static const std::string empty;
const auto* node = getModel()->nodePtr( getCurIndex() );
return node ? node->fullPath() : empty;
}
protected:
@@ -343,9 +345,9 @@ void UITreeViewFS::copyFiles( const std::vector<std::string>& paths, const std::
}
std::string UITreeViewFS::getSelectionPath() const {
return static_cast<const FileSystemModel*>( getModel() )
->node( getSelection().first() )
.fullPath();
const auto* node =
static_cast<const FileSystemModel*>( getModel() )->nodePtr( getSelection().first() );
return node ? node->fullPath() : "";
}
std::string UITreeViewFS::getSelectionPathAtIndex( int index ) const {
@@ -353,9 +355,9 @@ std::string UITreeViewFS::getSelectionPathAtIndex( int index ) const {
if ( index < 0 || static_cast<size_t>( index ) >= static_cast<size_t>( selection.size() ) )
return "";
auto indexVec = selection.indexes();
return static_cast<const FileSystemModel*>( getModel() )
->node( indexVec[static_cast<size_t>( index )] )
.fullPath();
const auto* node = static_cast<const FileSystemModel*>( getModel() )
->nodePtr( indexVec[static_cast<size_t>( index )] );
return node ? node->fullPath() : "";
}
std::vector<FileInfo> UITreeViewFS::getSelectionsFileInfo() const {
@@ -363,7 +365,8 @@ std::vector<FileInfo> UITreeViewFS::getSelectionsFileInfo() const {
auto indexVec = getSelection().indexes();
auto model = static_cast<const FileSystemModel*>( getModel() );
for ( const auto& index : indexVec ) {
ret.emplace_back( FileInfo( model->node( index ).fullPath() ) );
if ( const auto* node = model->nodePtr( index ) )
ret.emplace_back( FileInfo( node->fullPath() ) );
}
return ret;
}