Skip to content

Commit d368702

Browse files
authored
File Watching Stability Fixes (#64)
- [use std::atomic<bool> in the filewatcher tests to avoid potential d…](af81af9) - use `constexpr` constants to clean up test cases - wait and poll in these test case to allow early exit - increase some max waits in the test cases to be safer, though they can finish earlier due to polling - [make linux file watcher use std::atomic<bool> for the running var…](cb48547) Fixes #52 Fixes #32
1 parent 5b75273 commit d368702

File tree

5 files changed

+90
-44
lines changed

5 files changed

+90
-44
lines changed

include/filewatcher/linux_filewatcher.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#ifndef LINUX_FILEWATCHER_H
22
#define LINUX_FILEWATCHER_H
33
#include "filewatcher.h"
4+
#include <atomic>
45
#include <string>
56
#include <thread>
67

@@ -18,6 +19,6 @@ class LinuxFileWatcher : public FileWatcher {
1819
std::string filename; // Relative filname we watch
1920
int fd = -1; // File descriptor
2021
int wd = -1; // Watch descriptor
21-
bool running = true;
22+
std::atomic<bool> running{true};
2223
};
2324
#endif

src/filewatcher/linux_filewatcher.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ void LinuxFileWatcher::watchFile() {
2525
auto lastEventTime =
2626
std::chrono::steady_clock::now() - std::chrono::seconds(100);
2727

28-
while (running) {
28+
while (running.load(std::memory_order_relaxed)) {
2929
char buffer[BUF_LEN];
3030
ssize_t length = read(fd, buffer, BUF_LEN);
3131
if (length < 0)
@@ -79,7 +79,8 @@ void LinuxFileWatcher::startWatching(const std::string &filepath,
7979
spdlog::info("Watching dirPath: {} for file path {}", dirPath,
8080
path.string());
8181

82-
wd = inotify_add_watch(fd, dirPath.c_str(), IN_MODIFY);
82+
wd = inotify_add_watch(fd, dirPath.c_str(),
83+
IN_CLOSE_WRITE | IN_MOVED_TO);
8384
if (wd == -1) {
8485
close(fd);
8586
throw std::runtime_error("Failed to initialize watch " +
@@ -91,7 +92,7 @@ void LinuxFileWatcher::startWatching(const std::string &filepath,
9192

9293
void LinuxFileWatcher::stopWatching() {
9394
spdlog ::debug("Stop watching");
94-
running = false;
95+
running.store(false, std::memory_order_relaxed);
9596
if (wd != -1)
9697
inotify_rm_watch(fd, wd);
9798
// Now it should receive 1 final event which is the

src/filewatcher/mac_filewatcher.cpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ void MacFileWatcher::fsEventsCallback(
4747
void MacFileWatcher::startWatching(const std::string &path,
4848
FileChangeCallback cb) {
4949
this->callback = cb;
50-
running = true;
50+
running.store(true, std::memory_order_relaxed);
5151
std::filesystem::path abspath(std::filesystem::absolute(path));
5252
// So /tmp -> /private/tmp and matchs with the fseventstream paths
5353
std::filesystem::path canonicalPath = std::filesystem::canonical(abspath);
@@ -76,7 +76,9 @@ void MacFileWatcher::startWatching(const std::string &path,
7676
FSEventStreamStart(stream);
7777

7878
std::unique_lock<std::mutex> lock(mtx);
79-
cv.wait(lock, [this] { return !running; });
79+
cv.wait(lock, [this] {
80+
return !running.load(std::memory_order_relaxed);
81+
});
8082

8183
FSEventStreamStop(stream);
8284
FSEventStreamInvalidate(stream);
@@ -89,7 +91,7 @@ void MacFileWatcher::startWatching(const std::string &path,
8991
}
9092

9193
void MacFileWatcher::stopWatching() {
92-
running = false;
94+
running.store(false, std::memory_order_relaxed);
9395
cv.notify_one(); // Signal the condition variable to unblock the thread
9496

9597
if (watchThread.joinable()) {

src/filewatcher/windows_filewatcher.cpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ void WindowsFileWatcher::watchFile() {
3939
auto lastEventTime = std::chrono::steady_clock::now() - INITIAL_TIME_OFFSET;
4040

4141
// Main loop runs until stopWatching flips the running flag.
42-
while (running) {
42+
while (running.load(std::memory_order_relaxed)) {
4343
// Kick off async directory monitoring for writes and renames.
4444
BOOL success = ReadDirectoryChangesW(
4545
hDirectory, buffer, BUFFER_SIZE, FALSE,
@@ -184,16 +184,16 @@ void WindowsFileWatcher::startWatching(const std::string &filepath,
184184
std::to_string(GetLastError()));
185185
}
186186

187-
running = true;
187+
running.store(true, std::memory_order_relaxed);
188188
watcherThread = std::thread{&WindowsFileWatcher::watchFile, this};
189189
}
190190

191191
void WindowsFileWatcher::stopWatching() {
192192
spdlog::debug("Stop watching (Windows)");
193-
if (!running)
193+
if (!running.load(std::memory_order_relaxed))
194194
return;
195195

196-
running = false;
196+
running.store(false, std::memory_order_relaxed);
197197

198198
// Signal the stop event to wake up the watching thread
199199
if (hStopEvent != INVALID_HANDLE_VALUE) {

tests/filewatcher/test_filewatcher.cpp

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,22 @@
99
#include <stdexcept>
1010
#include <thread>
1111

12-
// How long to wait for the callback to be called
13-
#define THREAD_WAIT_TIME_MS 50
12+
// A unit for small waits in test actions.
13+
constexpr int kThreadWaitTimeMs = 50;
14+
// Wait long enough to confirm no change occurred.
15+
constexpr int kNoChangeWaitMs = kThreadWaitTimeMs * 2;
16+
// Max wait to confirm a change occurred.
17+
// We don't have to wait this long due to kPollIntervalMs below,
18+
// So early stoppage on an action almost always make tests faster.
19+
constexpr int kCallbackMaxWaitMs = kThreadWaitTimeMs * 20;
20+
// A small delay between repeated file replacements.
21+
constexpr int kBetweenReplacementsWaitMs = kThreadWaitTimeMs;
22+
// Wait long enough for safe-save rename callback to occur.
23+
constexpr int kSafeSaveWaitMs = kThreadWaitTimeMs * 4;
24+
// Polling avoids a fixed long sleep so tests can finish early when callbacks
25+
// are fast. eg. we dont need to wait for the full kCallbackMaxWaitMs
26+
// if action returns true early.
27+
constexpr int kPollIntervalMs = 5;
1428

1529
// Helper function to simulate file modification
1630

@@ -47,8 +61,7 @@ void safeSaveFile(const std::string &path, const std::string &content) {
4761
ec.clear();
4862
std::filesystem::rename(tempPath, original, ec);
4963
if (ec) {
50-
throw std::runtime_error("Failed to rename temp file: " +
51-
ec.message());
64+
throw std::runtime_error("Failed to rename temp file: " + ec.message());
5265
}
5366
}
5467

@@ -71,90 +84,115 @@ class FileWatcherTest : public ::testing::Test {
7184
};
7285

7386
TEST_F(FileWatcherTest, NoChangeCallbackNotCalled) {
74-
bool callbackCalled = false;
75-
auto callback = [&callbackCalled]() { callbackCalled = true; };
87+
std::atomic<bool> callbackCalled{false};
88+
auto callback = [&callbackCalled]() { callbackCalled.store(true); };
7689
createFile(testFilePath, "New content");
7790
createFile(differentFilePath, "Different content");
78-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
91+
std::this_thread::sleep_for(std::chrono::milliseconds(kNoChangeWaitMs));
7992

8093
auto watcher = filewatcher_factory::createFileWatcher();
8194
watcher->startWatching(testFilePath, callback);
8295
appendToFile(differentFilePath, "New content");
83-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
96+
std::this_thread::sleep_for(std::chrono::milliseconds(kNoChangeWaitMs));
8497
watcher->stopWatching();
8598

86-
EXPECT_FALSE(callbackCalled);
99+
EXPECT_FALSE(callbackCalled.load());
87100
}
88101

89102
TEST_F(FileWatcherTest, FileModifiedCallbackCalled) {
90-
bool callbackCalled = false;
91-
auto callback = [&callbackCalled]() { callbackCalled = true; };
103+
std::atomic<bool> callbackCalled{false};
104+
auto callback = [&callbackCalled]() { callbackCalled.store(true); };
92105
createFile(testFilePath, "New content");
93106

94107
auto watcher = filewatcher_factory::createFileWatcher();
95108
watcher->startWatching(testFilePath, callback);
96-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
109+
std::this_thread::sleep_for(std::chrono::milliseconds(kThreadWaitTimeMs));
97110
appendToFile(testFilePath, "New content");
98-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
111+
112+
// This one sometimes takes a bit longer to trigger eg. on Mac
113+
// so use kCallbackMaxWaitMs.
114+
// But usually it just finishes really early
115+
for (int waitedMs = 0;
116+
waitedMs < kCallbackMaxWaitMs && !callbackCalled.load();
117+
waitedMs += kPollIntervalMs) {
118+
std::this_thread::sleep_for(std::chrono::milliseconds(kPollIntervalMs));
119+
}
99120
watcher->stopWatching();
100121

101-
EXPECT_TRUE(callbackCalled);
122+
EXPECT_TRUE(callbackCalled.load());
102123
}
103124

104125
TEST_F(FileWatcherTest, FileDeletedAndReplacedCallbackCalled) {
105-
bool callbackCalled = false;
106-
auto callback = [&callbackCalled]() { callbackCalled = true; };
126+
std::atomic<bool> callbackCalled{false};
127+
auto callback = [&callbackCalled]() { callbackCalled.store(true); };
107128
createFile(testFilePath, "New content");
108129

109130
auto watcher = filewatcher_factory::createFileWatcher();
110131
watcher->startWatching(testFilePath, callback);
111-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
132+
std::this_thread::sleep_for(std::chrono::milliseconds(kThreadWaitTimeMs));
112133
replaceFile(testFilePath, "Replacement content");
113-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
134+
135+
// This one sometimes takes a bit longer to trigger eg. on Mac
136+
// so use kCallbackMaxWaitMs.
137+
// But usually it just finishes really early
138+
for (int waitedMs = 0;
139+
waitedMs < kCallbackMaxWaitMs && !callbackCalled.load();
140+
waitedMs += kPollIntervalMs) {
141+
std::this_thread::sleep_for(std::chrono::milliseconds(kPollIntervalMs));
142+
}
114143
watcher->stopWatching();
115144

116-
EXPECT_TRUE(callbackCalled);
145+
EXPECT_TRUE(callbackCalled.load());
117146
}
118147

119148
TEST_F(FileWatcherTest, FileReplacedMultipleTimesCallbackCalled) {
120-
int callbackCount = 0;
121-
auto callback = [&callbackCount]() { callbackCount++; };
149+
std::atomic<int> callbackCount{0};
150+
auto callback = [&callbackCount]() { callbackCount.fetch_add(1); };
122151

123152
createFile(testFilePath, "New content");
124153
auto watcher = filewatcher_factory::createFileWatcher();
125154
watcher->startWatching(testFilePath, callback);
126-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
155+
std::this_thread::sleep_for(std::chrono::milliseconds(kThreadWaitTimeMs));
127156
for (int i = 0; i < 10; ++i) {
128157
replaceFile(testFilePath, "Content " + std::to_string(i));
129158
std::this_thread::sleep_for(
130-
std::chrono::milliseconds(50)); // Wait a bit between replacements
159+
std::chrono::milliseconds(kBetweenReplacementsWaitMs));
160+
}
161+
for (int waitedMs = 0;
162+
waitedMs < kCallbackMaxWaitMs && callbackCount.load() < 10;
163+
waitedMs += kPollIntervalMs) {
164+
std::this_thread::sleep_for(std::chrono::milliseconds(kPollIntervalMs));
131165
}
132166
watcher->stopWatching();
133167

134-
EXPECT_GE(callbackCount, 10); // Ensure callback was called at least once
168+
EXPECT_GE(callbackCount.load(),
169+
10); // Ensure callback was called at least once
135170
}
136171

137-
// This can be Windows only for now
172+
// Windows/Linux only for now.
138173
// In any case the Shader Compiler will raise if it can't find the file
139-
// So this doesn't seem to be too important eg. on Mac
140-
#if defined(_WIN32)
174+
// So this doesn't seem to be too important eg. on Mac.
175+
// maybe macOS FSEvents stream doesn’t guarantee a clean “removed” flag for
176+
// every delete, and deletes are maybe reported as a rename (move to trash) or
177+
// maybe a metadata change before the remove flag appears??
178+
#if defined(_WIN32) || defined(__linux__)
141179
TEST_F(FileWatcherTest, FileDeletedDoesNotTriggerCallback) {
142180
bool callbackCalled = false;
143181
auto callback = [&callbackCalled]() { callbackCalled = true; };
144182
createFile(testFilePath, "Initial content");
145183

146184
auto watcher = filewatcher_factory::createFileWatcher();
147185
watcher->startWatching(testFilePath, callback);
148-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
186+
std::this_thread::sleep_for(std::chrono::milliseconds(kThreadWaitTimeMs));
149187
std::remove(testFilePath.c_str());
150-
std::this_thread::sleep_for(
151-
std::chrono::milliseconds(THREAD_WAIT_TIME_MS * 2));
188+
std::this_thread::sleep_for(std::chrono::milliseconds(kNoChangeWaitMs));
152189
watcher->stopWatching();
153190

154191
EXPECT_FALSE(callbackCalled);
155192
}
156193

157194
TEST_F(FileWatcherTest, SafeSaveRenameCallbackSeesFile) {
195+
// expects at least one callback and no “file couldn’t open” failures
158196
std::atomic<int> callbackCount{0};
159197
std::atomic<int> failedOpenCount{0};
160198
auto callback = [&]() {
@@ -169,10 +207,14 @@ TEST_F(FileWatcherTest, SafeSaveRenameCallbackSeesFile) {
169207
createFile(testFilePath, "Initial content");
170208
auto watcher = filewatcher_factory::createFileWatcher();
171209
watcher->startWatching(testFilePath, callback);
172-
std::this_thread::sleep_for(std::chrono::milliseconds(THREAD_WAIT_TIME_MS));
210+
std::this_thread::sleep_for(std::chrono::milliseconds(kThreadWaitTimeMs));
173211
safeSaveFile(testFilePath, "Updated content");
174-
std::this_thread::sleep_for(
175-
std::chrono::milliseconds(THREAD_WAIT_TIME_MS * 4));
212+
for (int waitedMs = 0;
213+
waitedMs < kSafeSaveWaitMs && callbackCount.load() == 0 &&
214+
failedOpenCount.load() == 0;
215+
waitedMs += kPollIntervalMs) {
216+
std::this_thread::sleep_for(std::chrono::milliseconds(kPollIntervalMs));
217+
}
176218
watcher->stopWatching();
177219

178220
EXPECT_EQ(failedOpenCount.load(), 0);

0 commit comments

Comments
 (0)