Upgrade project asset watcher to native Win32 notifications

This commit is contained in:
2026-04-10 21:36:53 +08:00
parent 2338e306bf
commit 1119af2e38
2 changed files with 314 additions and 10 deletions

View File

@@ -1,10 +1,15 @@
#define WIN32_LEAN_AND_MEAN
#include "Core/ProjectAssetWatcher.h" #include "Core/ProjectAssetWatcher.h"
#include "Core/IProjectManager.h" #include "Core/IProjectManager.h"
#include "Platform/Win32Utf8.h"
#include <windows.h>
#include <XCEngine/Core/Asset/ResourceManager.h> #include <XCEngine/Core/Asset/ResourceManager.h>
#include <XCEngine/Debug/Logger.h> #include <XCEngine/Debug/Logger.h>
#include <algorithm> #include <algorithm>
#include <array>
#include <cctype> #include <cctype>
#include <system_error> #include <system_error>
@@ -17,6 +22,14 @@ namespace {
constexpr float kScanIntervalSeconds = 0.75f; constexpr float kScanIntervalSeconds = 0.75f;
constexpr float kDebounceWindowSeconds = 0.35f; constexpr float kDebounceWindowSeconds = 0.35f;
constexpr float kNativeRestartIntervalSeconds = 2.0f;
constexpr DWORD kNativeWatchBufferSize = 64 * 1024;
constexpr DWORD kNativeNotifyFilter =
FILE_NOTIFY_CHANGE_FILE_NAME |
FILE_NOTIFY_CHANGE_DIR_NAME |
FILE_NOTIFY_CHANGE_SIZE |
FILE_NOTIFY_CHANGE_LAST_WRITE |
FILE_NOTIFY_CHANGE_CREATION;
std::string NormalizePathKey(const fs::path& path) { std::string NormalizePathKey(const fs::path& path) {
std::string key = path.lexically_normal().generic_string(); std::string key = path.lexically_normal().generic_string();
@@ -46,20 +59,44 @@ std::uint64_t GetFileWriteTimeValue(const fs::path& path) {
return static_cast<std::uint64_t>(writeTime.time_since_epoch().count()); return static_cast<std::uint64_t>(writeTime.time_since_epoch().count());
} }
void CloseHandleIfValid(void*& handle) {
if (handle == nullptr) {
return;
}
CloseHandle(static_cast<HANDLE>(handle));
handle = nullptr;
}
std::string PathToUtf8String(const fs::path& path) {
return Platform::WideToUtf8(path.wstring());
}
} // namespace } // namespace
ProjectAssetWatcher::~ProjectAssetWatcher() {
Detach();
}
void ProjectAssetWatcher::Attach(const std::string& projectPath) { void ProjectAssetWatcher::Attach(const std::string& projectPath) {
RefreshProjectBinding(projectPath); RefreshProjectBinding(projectPath);
} }
void ProjectAssetWatcher::Detach() { void ProjectAssetWatcher::Detach() {
StopNativeWatcher();
m_projectPath.clear(); m_projectPath.clear();
m_assetsRoot.clear(); m_assetsRoot.clear();
m_snapshot.clear(); m_snapshot.clear();
m_pendingChanges.Clear(); m_pendingChanges.Clear();
{
std::lock_guard<std::mutex> lock(m_nativeChangeMutex);
m_threadPendingChanges.Clear();
}
m_scanCooldownSeconds = 0.0f; m_scanCooldownSeconds = 0.0f;
m_nativeRestartCooldownSeconds = 0.0f;
m_debounceSeconds = 0.0f; m_debounceSeconds = 0.0f;
m_hasPendingRefresh = false; m_hasPendingRefresh = false;
m_pollingResyncRequested.store(false);
} }
void ProjectAssetWatcher::Update(IEditorContext& context, float dt) { void ProjectAssetWatcher::Update(IEditorContext& context, float dt) {
@@ -68,11 +105,24 @@ void ProjectAssetWatcher::Update(IEditorContext& context, float dt) {
return; return;
} }
ConsumeNativeChanges();
if (m_pollingResyncRequested.exchange(false)) {
ScanForChanges();
}
if (!m_nativeWatcherHealthy.load()) {
m_nativeRestartCooldownSeconds -= dt;
if (m_nativeRestartCooldownSeconds <= 0.0f) {
StartNativeWatcher();
}
m_scanCooldownSeconds -= dt; m_scanCooldownSeconds -= dt;
if (m_scanCooldownSeconds <= 0.0f) { if (m_scanCooldownSeconds <= 0.0f) {
ScanForChanges(); ScanForChanges();
m_scanCooldownSeconds = kScanIntervalSeconds; m_scanCooldownSeconds = kScanIntervalSeconds;
} }
}
if (!m_hasPendingRefresh) { if (!m_hasPendingRefresh) {
return; return;
@@ -89,25 +139,250 @@ void ProjectAssetWatcher::RefreshProjectBinding(const std::string& projectPath)
return; return;
} }
StopNativeWatcher();
m_projectPath = projectPath; m_projectPath = projectPath;
m_assetsRoot = projectPath.empty() ? fs::path() : (fs::path(projectPath) / "Assets"); m_assetsRoot = projectPath.empty() ? fs::path() : (fs::path(Platform::Utf8ToWide(projectPath)) / L"Assets");
m_snapshot.clear(); m_snapshot.clear();
m_pendingChanges.Clear(); m_pendingChanges.Clear();
m_scanCooldownSeconds = kScanIntervalSeconds; m_scanCooldownSeconds = kScanIntervalSeconds;
m_nativeRestartCooldownSeconds = 0.0f;
m_debounceSeconds = 0.0f; m_debounceSeconds = 0.0f;
m_hasPendingRefresh = false; m_hasPendingRefresh = false;
{
std::lock_guard<std::mutex> lock(m_nativeChangeMutex);
m_threadPendingChanges.Clear();
}
m_pollingResyncRequested.store(false);
if (!m_assetsRoot.empty()) { if (!m_assetsRoot.empty()) {
BuildSnapshot(m_assetsRoot, m_snapshot); BuildSnapshot(m_assetsRoot, m_snapshot);
StartNativeWatcher();
} }
} }
void ProjectAssetWatcher::ScanForChanges() { void ProjectAssetWatcher::StartNativeWatcher() {
Snapshot currentSnapshot; StopNativeWatcher();
BuildSnapshot(m_assetsRoot, currentSnapshot); m_nativeRestartCooldownSeconds = kNativeRestartIntervalSeconds;
const ChangeStats changes = DiffSnapshots(m_snapshot, currentSnapshot); std::error_code ec;
m_snapshot = std::move(currentSnapshot); if (m_assetsRoot.empty() || !fs::exists(m_assetsRoot, ec) || !fs::is_directory(m_assetsRoot, ec)) {
return;
}
HANDLE stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (stopEvent == nullptr) {
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Failed to create native watcher stop event. error=") +
std::to_string(GetLastError()) +
" fallback=polling").c_str());
return;
}
HANDLE directoryHandle = CreateFileW(
m_assetsRoot.c_str(),
FILE_LIST_DIRECTORY,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
nullptr);
if (directoryHandle == INVALID_HANDLE_VALUE) {
const DWORD error = GetLastError();
CloseHandle(stopEvent);
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Failed to start native watcher for ") +
PathToUtf8String(m_assetsRoot) +
" error=" +
std::to_string(error) +
" fallback=polling").c_str());
return;
}
m_nativeStopEvent = stopEvent;
m_nativeWatcherHealthy.store(true);
m_nativeWatcherThread =
std::thread(&ProjectAssetWatcher::RunNativeWatcher, this, static_cast<void*>(directoryHandle), m_assetsRoot);
Debug::Logger::Get().Info(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Started native watcher for ") + PathToUtf8String(m_assetsRoot)).c_str());
}
void ProjectAssetWatcher::StopNativeWatcher() {
m_nativeWatcherHealthy.store(false);
if (m_nativeStopEvent != nullptr) {
SetEvent(static_cast<HANDLE>(m_nativeStopEvent));
}
if (m_nativeWatcherThread.joinable()) {
m_nativeWatcherThread.join();
}
CloseHandleIfValid(m_nativeStopEvent);
}
void ProjectAssetWatcher::RunNativeWatcher(void* directoryHandleRaw, fs::path watchedRoot) {
HANDLE directoryHandle = static_cast<HANDLE>(directoryHandleRaw);
HANDLE stopEvent = static_cast<HANDLE>(m_nativeStopEvent);
HANDLE readEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (readEvent == nullptr) {
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Failed to create native watcher read event for ") +
PathToUtf8String(watchedRoot) +
" error=" +
std::to_string(GetLastError()) +
" fallback=polling").c_str());
CloseHandle(directoryHandle);
m_nativeWatcherHealthy.store(false);
m_pollingResyncRequested.store(true);
return;
}
std::array<std::byte, kNativeWatchBufferSize> buffer{};
while (WaitForSingleObject(stopEvent, 0) != WAIT_OBJECT_0) {
ResetEvent(readEvent);
OVERLAPPED overlapped{};
overlapped.hEvent = readEvent;
if (!ReadDirectoryChangesW(
directoryHandle,
buffer.data(),
static_cast<DWORD>(buffer.size()),
TRUE,
kNativeNotifyFilter,
nullptr,
&overlapped,
nullptr)) {
const DWORD error = GetLastError();
if (error != ERROR_OPERATION_ABORTED) {
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Native watcher failed for ") +
PathToUtf8String(watchedRoot) +
" error=" +
std::to_string(error) +
" fallback=polling").c_str());
m_pollingResyncRequested.store(true);
}
m_nativeWatcherHealthy.store(false);
break;
}
HANDLE waitHandles[] = { stopEvent, readEvent };
const DWORD waitResult = WaitForMultipleObjects(2, waitHandles, FALSE, INFINITE);
if (waitResult == WAIT_OBJECT_0) {
CancelIoEx(directoryHandle, &overlapped);
DWORD ignoredBytes = 0;
GetOverlappedResult(directoryHandle, &overlapped, &ignoredBytes, TRUE);
break;
}
if (waitResult != (WAIT_OBJECT_0 + 1)) {
CancelIoEx(directoryHandle, &overlapped);
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Native watcher wait failed for ") +
PathToUtf8String(watchedRoot) +
" error=" +
std::to_string(GetLastError()) +
" fallback=polling").c_str());
m_nativeWatcherHealthy.store(false);
m_pollingResyncRequested.store(true);
break;
}
DWORD bytesReturned = 0;
if (!GetOverlappedResult(directoryHandle, &overlapped, &bytesReturned, FALSE)) {
const DWORD error = GetLastError();
if (error == ERROR_NOTIFY_ENUM_DIR) {
m_pollingResyncRequested.store(true);
continue;
}
if (error == ERROR_OPERATION_ABORTED) {
break;
}
Debug::Logger::Get().Warning(
Debug::LogCategory::FileSystem,
(std::string("[ProjectAssetWatcher] Native watcher overlapped result failed for ") +
PathToUtf8String(watchedRoot) +
" error=" +
std::to_string(error) +
" fallback=polling").c_str());
m_nativeWatcherHealthy.store(false);
m_pollingResyncRequested.store(true);
break;
}
if (bytesReturned == 0) {
m_pollingResyncRequested.store(true);
continue;
}
ChangeStats changes;
bool requiresResync = false;
for (DWORD offset = 0; offset < bytesReturned;) {
const auto* notify = reinterpret_cast<const FILE_NOTIFY_INFORMATION*>(
reinterpret_cast<const std::byte*>(buffer.data()) + offset);
switch (notify->Action) {
case FILE_ACTION_ADDED:
++changes.added;
break;
case FILE_ACTION_REMOVED:
++changes.removed;
break;
case FILE_ACTION_MODIFIED:
++changes.modified;
break;
case FILE_ACTION_RENAMED_OLD_NAME:
++changes.removed;
break;
case FILE_ACTION_RENAMED_NEW_NAME:
++changes.added;
break;
default:
requiresResync = true;
break;
}
if (notify->NextEntryOffset == 0) {
break;
}
offset += notify->NextEntryOffset;
}
if (requiresResync) {
m_pollingResyncRequested.store(true);
}
if (changes.HasChanges()) {
std::lock_guard<std::mutex> lock(m_nativeChangeMutex);
m_threadPendingChanges.Accumulate(changes);
}
}
CloseHandle(readEvent);
CloseHandle(directoryHandle);
}
void ProjectAssetWatcher::ConsumeNativeChanges() {
ChangeStats changes;
{
std::lock_guard<std::mutex> lock(m_nativeChangeMutex);
changes = m_threadPendingChanges;
m_threadPendingChanges.Clear();
}
ScheduleRefresh(changes);
}
void ProjectAssetWatcher::ScheduleRefresh(const ChangeStats& changes) {
if (!changes.HasChanges()) { if (!changes.HasChanges()) {
return; return;
} }
@@ -117,6 +392,15 @@ void ProjectAssetWatcher::ScanForChanges() {
m_debounceSeconds = kDebounceWindowSeconds; m_debounceSeconds = kDebounceWindowSeconds;
} }
void ProjectAssetWatcher::ScanForChanges() {
Snapshot currentSnapshot;
BuildSnapshot(m_assetsRoot, currentSnapshot);
const ChangeStats changes = DiffSnapshots(m_snapshot, currentSnapshot);
m_snapshot = std::move(currentSnapshot);
ScheduleRefresh(changes);
}
void ProjectAssetWatcher::FlushPendingRefresh(IEditorContext& context) { void ProjectAssetWatcher::FlushPendingRefresh(IEditorContext& context) {
if (!m_hasPendingRefresh) { if (!m_hasPendingRefresh) {
return; return;
@@ -124,6 +408,7 @@ void ProjectAssetWatcher::FlushPendingRefresh(IEditorContext& context) {
Resources::ResourceManager::Get().RefreshProjectAssets(); Resources::ResourceManager::Get().RefreshProjectAssets();
context.GetProjectManager().RefreshCurrentFolder(); context.GetProjectManager().RefreshCurrentFolder();
BuildSnapshot(m_assetsRoot, m_snapshot);
Debug::Logger::Get().Info( Debug::Logger::Get().Info(
Debug::LogCategory::FileSystem, Debug::LogCategory::FileSystem,
@@ -132,7 +417,9 @@ void ProjectAssetWatcher::FlushPendingRefresh(IEditorContext& context) {
" modified=" + " modified=" +
std::to_string(m_pendingChanges.modified) + std::to_string(m_pendingChanges.modified) +
" removed=" + " removed=" +
std::to_string(m_pendingChanges.removed)).c_str()); std::to_string(m_pendingChanges.removed) +
" backend=" +
(m_nativeWatcherHealthy.load() ? "native" : "polling")).c_str());
m_pendingChanges.Clear(); m_pendingChanges.Clear();
m_debounceSeconds = 0.0f; m_debounceSeconds = 0.0f;

View File

@@ -2,9 +2,12 @@
#include "Core/IEditorContext.h" #include "Core/IEditorContext.h"
#include <atomic>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <mutex>
#include <string> #include <string>
#include <thread>
#include <unordered_map> #include <unordered_map>
namespace XCEngine { namespace XCEngine {
@@ -12,6 +15,8 @@ namespace Editor {
class ProjectAssetWatcher { class ProjectAssetWatcher {
public: public:
~ProjectAssetWatcher();
void Attach(const std::string& projectPath); void Attach(const std::string& projectPath);
void Detach(); void Detach();
void Update(IEditorContext& context, float dt); void Update(IEditorContext& context, float dt);
@@ -54,6 +59,11 @@ private:
using Snapshot = std::unordered_map<std::string, AssetEntryState>; using Snapshot = std::unordered_map<std::string, AssetEntryState>;
void RefreshProjectBinding(const std::string& projectPath); void RefreshProjectBinding(const std::string& projectPath);
void StartNativeWatcher();
void StopNativeWatcher();
void RunNativeWatcher(void* directoryHandle, std::filesystem::path watchedRoot);
void ConsumeNativeChanges();
void ScheduleRefresh(const ChangeStats& changes);
void ScanForChanges(); void ScanForChanges();
void FlushPendingRefresh(IEditorContext& context); void FlushPendingRefresh(IEditorContext& context);
static void BuildSnapshot(const std::filesystem::path& assetsRoot, Snapshot& outSnapshot); static void BuildSnapshot(const std::filesystem::path& assetsRoot, Snapshot& outSnapshot);
@@ -64,8 +74,15 @@ private:
Snapshot m_snapshot; Snapshot m_snapshot;
ChangeStats m_pendingChanges; ChangeStats m_pendingChanges;
float m_scanCooldownSeconds = 0.0f; float m_scanCooldownSeconds = 0.0f;
float m_nativeRestartCooldownSeconds = 0.0f;
float m_debounceSeconds = 0.0f; float m_debounceSeconds = 0.0f;
bool m_hasPendingRefresh = false; bool m_hasPendingRefresh = false;
std::thread m_nativeWatcherThread;
std::mutex m_nativeChangeMutex;
ChangeStats m_threadPendingChanges;
std::atomic<bool> m_nativeWatcherHealthy{false};
std::atomic<bool> m_pollingResyncRequested{false};
void* m_nativeStopEvent = nullptr;
}; };
} // namespace Editor } // namespace Editor