Ticket 4 — VFSFileWatcher: Platform File-Watch + Debounce¶
Priority: P2 — Implement after Ticket 3
Status: Implemented; detailed ticket sketches are historical where they differ from the shipped C-style callback API.
Depends on: vfs-ticket3-scanner-memory-backend.md
Blocks: vfs-ticket5, vfs-ticket6, import-pipeline.md (OnStale hook)
Goal: Deliver a cross-platform file-watcher that fires debounced VFSWatchEvent notifications
into the engine's existing callback system, driving VFSDirectoryCache::Invalidate() and
VFSScanner::Scan() without polling the filesystem from user code.
Current behavior:
VFSContext::Tick()invalidates relevant cache entries, callsAssetRegistry::OnAssetModified(path)and queues immediate imports for modified non-.metafiles, handles delete/rename registry updates, then asks an idle scanner to rescan the affected directory. The current watcher usesWatchCallback = std::function<void(const VFSWatchEvent&)>; the platform watcher below it uses a C-style raw-event callback. Treat detailed ticket declarations as historical only where they differ from those shipped types.
1. Public API¶
1.1 VFSWatchEvent¶
// ZEngine/VFS/VFSWatchEvent.h
#pragma once
#include <Core/ZEngineDef.h>
namespace ZEngine::Core::VFS
{
enum class WatchEventKind : uint8_t
{
Created = 0,
Modified = 1,
Deleted = 2,
Renamed = 3, // OldPath → NewPath
Overflow = 4, // kernel buffer overflowed; do full rescan
};
struct VFSWatchEvent
{
char Path[MAX_FILE_PATH_COUNT] = {};
char OldPath[MAX_FILE_PATH_COUNT] = {}; // only for Renamed
WatchEventKind Kind = WatchEventKind::Created;
bool IsDirectory = false;
};
}
1.2 WatchHandle¶
// ZEngine/VFS/IVFSPlatformWatcher.h
#pragma once
#include <cstdint>
namespace ZEngine::Core::VFS
{
using WatchHandle = uint32_t;
constexpr WatchHandle INVALID_WATCH_HANDLE = 0xFFFF'FFFFu;
}
1.3 IVFSPlatformWatcher¶
// ZEngine/VFS/IVFSPlatformWatcher.h (continued)
#include <functional>
#include <VFS/VFSWatchEvent.h>
namespace ZEngine::Core::VFS
{
using RawEventCallback = void (*)(void* ctx, const VFSWatchEvent&);
class IVFSPlatformWatcher
{
public:
virtual ~IVFSPlatformWatcher() = default;
// Add a directory to watch (non-recursive unless the platform makes it cheap)
virtual WatchHandle AddWatch(const char* native_path, bool recursive) = 0;
virtual void RemoveWatch(WatchHandle handle) = 0;
// Called once per frame (or from a dedicated watcher thread) to drain
// pending OS events and fire cb for each one.
virtual void Poll(void* ctx, RawEventCallback cb) = 0;
// Start/stop a background thread that calls Poll internally
virtual void StartThread() = 0;
virtual void StopThread() = 0;
};
}
1.4 VFSFileWatcher (debounce layer)¶
// ZEngine/VFS/VFSFileWatcher.h
#pragma once
#include <atomic>
#include <chrono>
#include <functional>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <VFS/IVFSPlatformWatcher.h>
#include <VFS/VFSWatchEvent.h>
namespace ZEngine::Core::VFS
{
using WatchCallback = std::function<void(const VFSWatchEvent&)>;
struct DebounceEntry
{
VFSWatchEvent Event;
std::chrono::steady_clock::time_point LastSeen;
VFSPath Owner;
};
class VFSFileWatcher
{
public:
explicit VFSFileWatcher(
IVFSPlatformWatcher* platform,
std::chrono::milliseconds debounce_window = std::chrono::milliseconds{80});
~VFSFileWatcher();
void Initialize(Core::Memory::ArenaAllocator* arena, uint32_t capacity);
WatchHandle Watch(const char* native_path, bool recursive, WatchCallback cb);
void Unwatch(WatchHandle handle);
// Called once per frame from the main/editor thread
void Tick();
uint32_t PendingCount() const;
private:
void OnRawEvent(const VFSWatchEvent& ev);
void EmitReady(std::chrono::steady_clock::time_point now);
IVFSPlatformWatcher* m_platform;
std::chrono::milliseconds m_window;
std::mutex m_mtx;
std::unordered_map<std::string, DebounceEntry> m_pending; // key = Path
std::unordered_map<WatchHandle, WatchCallback> m_callbacks;
};
}
2. Debounce Algorithm¶
Phase A – Absorb (background or poll thread):
for each raw OS event E:
lock m_mtx
entry = m_pending[E.Path]
entry.Event = E // last-write-wins within window
entry.LastSeen = now()
unlock m_mtx
Phase B – Emit (Tick(), called from editor/main thread, ~60 Hz):
lock m_mtx
now = steady_clock::now()
for each (path, entry) in m_pending:
if (now - entry.LastSeen) >= m_window:
dispatch entry.Event through m_callbacks[entry.Event's WatchHandle]
erase entry
unlock m_mtx
Key invariant: an event fires exactly once, after the path has been "quiet" for m_window ms.
A burst of saves to the same file (common with IDE auto-save) collapses to one notification.
3. Platform Backends¶
3.1 macOS — VFSFSEventsWatcher (VFSFSEventsWatcher.mm)¶
FSEvents delivers directory-level events, so the backend walks modified directories to find changed files (similar to how Xcode's source editor works).
// ZEngine/VFS/Platform/VFSFSEventsWatcher.h
#pragma once
#if defined(__APPLE__)
#include <CoreServices/CoreServices.h>
#include <VFS/IVFSPlatformWatcher.h>
#include <unordered_map>
#include <atomic>
namespace ZEngine::Core::VFS
{
class VFSFSEventsWatcher final : public IVFSPlatformWatcher
{
public:
VFSFSEventsWatcher();
~VFSFSEventsWatcher() override;
WatchHandle AddWatch(const char* native_path, bool recursive) override;
void RemoveWatch(WatchHandle handle) override;
void Poll(void* ctx, RawEventCallback cb) override;
void StartThread() override;
void StopThread() override;
private:
static void FSEventsCallback(
ConstFSEventStreamRef, void* ctx,
size_t num, void* paths,
const FSEventStreamEventFlags* flags,
const FSEventStreamEventId* ids);
void RebuildStream();
void DrainQueue(const RawEventCallback& cb);
struct WatchEntry { char Path[MAX_FILE_PATH_COUNT]; bool Recursive; };
std::unordered_map<WatchHandle, WatchEntry> m_watches;
WatchHandle m_next_handle = 1;
FSEventStreamRef m_stream = nullptr;
CFRunLoopRef m_run_loop = nullptr;
std::thread m_thread;
std::atomic<bool> m_running{false};
// Staging queue filled by FSEventsCallback, drained in Poll/DrainQueue
std::mutex m_queue_mtx;
Core::Containers::Array<VFSWatchEvent> m_queue;
};
}
#endif // __APPLE__
Implementation notes (.mm file):
- Create stream with kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer
- Latency: 0.05 seconds (FSEvents batches within this window before calling back)
- Run the stream on a dedicated CFRunLoop on m_thread; StartThread creates the thread, StopThread calls CFRunLoopStop and joins
- FSEventsCallback pushes raw paths into m_queue; Poll drains m_queue and classifies events using stat() (exists → Created/Modified; ENOENT → Deleted)
- For Renamed: FSEvents fires two events (old path vanishes, new path appears) which the debounce layer naturally handles as Deleted + Created
3.2 Linux — VFSInotifyWatcher¶
// ZEngine/VFS/Platform/VFSInotifyWatcher.h
#pragma once
#if defined(__linux__)
#include <VFS/IVFSPlatformWatcher.h>
#include <unordered_map>
#include <atomic>
namespace ZEngine::Core::VFS
{
class VFSInotifyWatcher final : public IVFSPlatformWatcher
{
public:
VFSInotifyWatcher();
~VFSInotifyWatcher() override;
WatchHandle AddWatch(const char* native_path, bool recursive) override;
void RemoveWatch(WatchHandle handle) override;
void Poll(void* ctx, RawEventCallback cb) override;
void StartThread() override;
void StopThread() override;
private:
int m_inotify_fd = -1;
int m_pipe_fd[2] = {-1, -1}; // wake pipe for StopThread
struct WatchEntry
{
char Path[MAX_FILE_PATH_COUNT];
int WatchDescriptor;
WatchHandle Handle;
};
std::unordered_map<int, WatchEntry> m_wd_to_entry;
std::unordered_map<WatchHandle, int> m_handle_to_wd;
WatchHandle m_next_handle = 1;
std::thread m_thread;
std::atomic<bool> m_running{false};
std::mutex m_queue_mtx;
Core::Containers::Array<VFSWatchEvent> m_queue;
};
}
#endif // __linux__
Implementation notes:
- inotify_init1(IN_NONBLOCK | IN_CLOEXEC) in constructor
- AddWatch calls inotify_add_watch(fd, path, IN_CREATE | IN_MODIFY | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | IN_ONLYDIR)
- Background thread: poll({inotify_fd, POLLIN}, ...) with wake pipe; reads inotify_event structs, pushes to m_queue
- IN_MOVED_FROM / IN_MOVED_TO with matching cookie → synthesize WatchEventKind::Renamed
- IN_Q_OVERFLOW → push one event with Kind = WatchEventKind::Overflow
3.3 Windows — VFSRDCWatcher¶
// ZEngine/VFS/Platform/VFSRDCWatcher.h
#pragma once
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <VFS/IVFSPlatformWatcher.h>
#include <unordered_map>
#include <atomic>
namespace ZEngine::Core::VFS
{
class VFSRDCWatcher final : public IVFSPlatformWatcher
{
public:
VFSRDCWatcher();
~VFSRDCWatcher() override;
WatchHandle AddWatch(const char* native_path, bool recursive) override;
void RemoveWatch(WatchHandle handle) override;
void Poll(void* ctx, RawEventCallback cb) override;
void StartThread() override;
void StopThread() override;
private:
HANDLE m_iocp = INVALID_HANDLE_VALUE;
struct WatchEntry
{
char Path[MAX_FILE_PATH_COUNT];
HANDLE DirHandle;
OVERLAPPED Overlapped;
BYTE Buffer[32768];
WatchHandle Handle;
bool Recursive;
};
std::unordered_map<WatchHandle, WatchEntry*> m_entries;
WatchHandle m_next_handle = 1;
std::thread m_thread;
std::atomic<bool> m_running{false};
std::mutex m_queue_mtx;
Core::Containers::Array<VFSWatchEvent> m_queue;
void ReissueRead(WatchEntry* e);
void ProcessNotifications(WatchEntry* e, DWORD bytes);
};
}
#endif // _WIN32
Implementation notes:
- CreateIoCompletionPort with one worker thread
- Each watched directory: CreateFile(path, FILE_LIST_DIRECTORY, FILE_SHARE_*, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, NULL)
- ReadDirectoryChangesW(handle, buffer, sizeof(buffer), recursive, FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE, NULL, &overlapped, NULL)
- IOCP thread: GetQueuedCompletionStatus loop; FILE_ACTION_RENAMED_OLD_NAME + FILE_ACTION_RENAMED_NEW_NAME → coalesce into Renamed
4. CMake Changes¶
# ZEngine/ZEngine/CMakeLists.txt additions
if(APPLE)
target_sources(zEngineLib PRIVATE
VFS/Platform/VFSFSEventsWatcher.mm # .mm must be listed explicitly
)
target_link_libraries(zEngineLib PRIVATE
"-framework CoreServices"
)
elseif(UNIX)
target_sources(zEngineLib PRIVATE
VFS/Platform/VFSInotifyWatcher.cpp
)
elseif(WIN32)
target_sources(zEngineLib PRIVATE
VFS/Platform/VFSRDCWatcher.cpp
)
endif()
Because the existing CMakeLists.txt uses GLOB_RECURSE for .cpp files, only the .mm
file needs an explicit entry. The Linux and Windows .cpp files will be auto-discovered —
but adding them explicitly is safer and makes the platform split obvious to reviewers.
5. Integration with Ticket 3 Components¶
// VFSContext::InitWatcher() — called during editor startup
void VFSContext::InitWatcher()
{
#if defined(__APPLE__)
m_platform_watcher = std::make_unique<VFSFSEventsWatcher>();
#elif defined(__linux__)
m_platform_watcher = std::make_unique<VFSInotifyWatcher>();
#elif defined(_WIN32)
m_platform_watcher = std::make_unique<VFSRDCWatcher>();
#endif
m_file_watcher = std::make_unique<VFSFileWatcher>(m_platform_watcher.get());
// Watch the project root; scanner already knows the subtree
m_file_watcher->Watch(
m_project_root_native.c_str(),
/*recursive=*/true,
[this](const VFSWatchEvent& ev) {
m_directory_cache->Invalidate(ev.Path);
m_scanner->Scan(VFSPath::FromNative(ev.Path).Value());
});
m_platform_watcher->StartThread();
}
// VFSContext::Tick() — called once per frame from the editor loop
void VFSContext::Tick()
{
m_file_watcher->Tick(); // drains debounce window, fires callbacks
}
6. Unit Tests¶
File: ZEngine/tests/VFS/VFSFileWatcherTest.cpp
Add VFS/*.cpp to the glob in tests/CMakeLists.txt:
file(GLOB TEST_SOURCES
Memory/*.cpp
Containers/*.cpp
Maths/*.cpp
Misc/*.cpp
VFS/*.cpp) # ADD THIS LINE
Test 1 — Debounce collapses burst into one event¶
TEST(VFSFileWatcher, DebounceBurstCollapses)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{50});
int fired = 0;
watcher.Watch("/project", true, [&](const VFSWatchEvent&) { ++fired; });
// Simulate 10 rapid raw events for the same file
for (int i = 0; i < 10; ++i)
mock.InjectEvent({"/project/foo.glb", {}, WatchEventKind::Modified, false});
// Tick before window expires — nothing fires
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_EQ(fired, 0);
// Tick after window expires — exactly one fires
std::this_thread::sleep_for(std::chrono::milliseconds{60});
watcher.Tick();
EXPECT_EQ(fired, 1);
}
Test 2 — Two distinct files fire two events¶
TEST(VFSFileWatcher, TwoDistinctFilesFireTwice)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{50});
int fired = 0;
watcher.Watch("/project", true, [&](const VFSWatchEvent&) { ++fired; });
mock.InjectEvent({"/project/a.glb", {}, WatchEventKind::Modified, false});
mock.InjectEvent({"/project/b.glb", {}, WatchEventKind::Modified, false});
std::this_thread::sleep_for(std::chrono::milliseconds{60});
watcher.Tick();
EXPECT_EQ(fired, 2);
}
Test 3 — Unwatch stops events¶
TEST(VFSFileWatcher, UnwatchStopsEvents)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
int fired = 0;
WatchHandle h = watcher.Watch("/project", true, [&](const VFSWatchEvent&) { ++fired; });
watcher.Unwatch(h);
mock.InjectEvent({"/project/foo.glb", {}, WatchEventKind::Created, false});
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_EQ(fired, 0);
}
Test 4 — Overflow event triggers full rescan flag¶
TEST(VFSFileWatcher, OverflowSetsKind)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
WatchEventKind received = WatchEventKind::Created;
watcher.Watch("/project", true, [&](const VFSWatchEvent& ev) {
received = ev.Kind;
});
mock.InjectEvent({"", {}, WatchEventKind::Overflow, false});
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_EQ(received, WatchEventKind::Overflow);
}
Test 5 — Rename event preserves OldPath¶
TEST(VFSFileWatcher, RenamePreservesOldPath)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
VFSWatchEvent received{};
watcher.Watch("/project", true, [&](const VFSWatchEvent& ev) {
received = ev;
});
VFSWatchEvent ev{};
snprintf(ev.Path, sizeof(ev.Path), "/project/new.glb");
snprintf(ev.OldPath, sizeof(ev.OldPath), "/project/old.glb");
ev.Kind = WatchEventKind::Renamed;
mock.InjectEvent(ev);
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_STREQ(received.OldPath, "/project/old.glb");
EXPECT_STREQ(received.Path, "/project/new.glb");
}
Test 6 — Multiple watches, correct callback routing¶
TEST(VFSFileWatcher, MultipleWatchesRouteCorrectly)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
int fired_a = 0, fired_b = 0;
watcher.Watch("/projectA", false, [&](const VFSWatchEvent&) { ++fired_a; });
watcher.Watch("/projectB", false, [&](const VFSWatchEvent&) { ++fired_b; });
mock.InjectEvent({"/projectA/x.png", {}, WatchEventKind::Created, false});
mock.InjectEvent({"/projectB/y.png", {}, WatchEventKind::Created, false});
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_EQ(fired_a, 1);
EXPECT_EQ(fired_b, 1);
}
Test 7 — Zero ticks, no spurious fires¶
TEST(VFSFileWatcher, NoTickNoFire)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
int fired = 0;
watcher.Watch("/project", true, [&](const VFSWatchEvent&) { ++fired; });
mock.InjectEvent({"/project/foo.glb", {}, WatchEventKind::Modified, false});
// Deliberately do NOT call watcher.Tick()
EXPECT_EQ(fired, 0);
}
Test 8 — IsDirectory flag propagated¶
TEST(VFSFileWatcher, IsDirectoryFlagPropagated)
{
MockPlatformWatcher mock;
VFSFileWatcher watcher(&mock, std::chrono::milliseconds{10});
bool got_dir = false;
watcher.Watch("/project", true, [&](const VFSWatchEvent& ev) {
got_dir = ev.IsDirectory;
});
mock.InjectEvent({"/project/newfolder", {}, WatchEventKind::Created, true});
std::this_thread::sleep_for(std::chrono::milliseconds{20});
watcher.Tick();
EXPECT_TRUE(got_dir);
}
7. Deliverables Checklist¶
- [x]
ZEngine/VFS/VFSWatchEvent.h - [x]
ZEngine/VFS/IVFSPlatformWatcher.h - [x]
ZEngine/VFS/VFSFileWatcher.h+VFSFileWatcher.cpp - [x]
ZEngine/VFS/Platform/VFSFSEventsWatcher.h+VFSFSEventsWatcher.mm - [x]
ZEngine/VFS/Platform/VFSInotifyWatcher.h+VFSInotifyWatcher.cpp - [x]
ZEngine/VFS/Platform/VFSRDCWatcher.h+VFSRDCWatcher.cpp - [x] CMake:
-framework CoreServices,.mmexplicit source entry - [x]
tests/VFS/VFSFileWatcherTest.cpp(8 tests, MockPlatformWatcher) - [x]
VFSContext::InitWatcher()andVFSContext::Tick()wired up - [x] Manual smoke test: rename a
.glbin Finder → single notification within ~100 ms