I wrote a file sytem watcher class that is used to monitor file modification and then execute user-defined handler. Other file system events like moving or deleting don't need to be monitored. The usage is pretty straightforward:
const auto watcher = fswatcher.on_modify("file.txt", [] () {
std::cout << "Modified" << std::endl;
});
/* ... */
fswatcher.stop_watcher(watcher);
I came from a C background so C++ is fairly new to me especially its modern features (C++11 and later).
I am a big fan of simplicity so my design principles were the following: KISS, YAGNI, and Do The Simplest Thing That Could Possibly Work.
Also since my application is neither intended to be used in a critical-safety systems nor just needs to be robust I decided to follow the principle „succeed or die“ — the application just terminates if an error occurs. But I am still trying to check for errors when using I/O APIs like inotify
or poll
to give user meaningful error messages.
You might notice a lot of comments in the source code. This is because I use so called the headlines technique. In my opinion it helps to visually split the function's body into several chunks making it easier to read and understand. See also this SoftwareEngineering.SE thread on topic.
I know that this approach have some disadvantages. First of all, the comments could become outdated and, therefore, misleading. Also writting comments takes time as well as maintaining them. But I think that those are better than crazy amount of small functions because:
- there are less entities to keep track of;
- you don't lose context while jumping from one such function to another;
- you don't have possible overhead of function call and parameters passing (yeah, compilers are smart in inlining, but it is a nice bonus to have anyway).
In my opinion splitting the code into several functions makes sense only if they are relatively big and can be reused later.
So what do you think about this commenting style?
I also want to port this class to Windows platform later. Do you think that I need to create another level of abstraction and split the FSWatcher
code into back-end and front-end: one that works with low-level OS API (external world) and one that manages high-level stuff like watchers and the monitor thread?
In this case I can easily replace the back-end later to whatever I want, but this approach adds an entire another level of abstraction that might make the code harder to understand.
Since the amount of code is really small (176 SLOC) it seems to me that is better not to inroduce back-end part. What do you think?
So, basically I am looking for advices on:
- modern C++ usage;
- the headlines technique mentioned earlier;
- Linux API usage (
poll
,inotify
,eventfd
); - easiness of porting to another platform;
- code simplicity.
include/rosa/fswatcher.h
:
// This file contains declaration of the rosa::FSWatcher class.
// This class allows to create a file system watcher on file modification. For
// example:
//
// std::string file = "vertex.glsl";
// std::string vertex_shader = read_file(file);
//
// fswatcher.on_modify(file, [vertex_shader, file] () {
// vertex_shader = read_file(file);
// });
//
// So in this case the shader file "vertex.glsl" will be re-read each time it is
// modified.
//
// The FSWatcher class works ONLY with modification events.
//
// If the file that is being watched is deleted then the correspondig file
// watcher is removed.
//
// The only supported OS for now is Linux.
#ifndef ROSA_FSWATCHER_H
#define ROSA_FSWATCHER_H
// TODO: implement Windows support.
#if !defined(__linux__)
#error "Only Linux is supported."
#endif
#include <string>
#include <functional>
#include <vector>
#include <thread>
#include <atomic>
#include <poll.h>
namespace rosa {
class FSWatcher {
public:
FSWatcher();
~FSWatcher();
int on_modify(const std::string&, std::function<void()>);
void stop_watcher(const int);
private:
int inotify_fd;
int self_event_fd;
std::array<struct pollfd, 2> poll_fds;
std::thread monitor;
std::atomic_bool monitor_should_stop;
typedef struct {
int id;
int inotify_wd;
std::string filename;
std::function<void()> handler;
} watcher;
std::vector<rosa::FSWatcher::watcher> watchers;
void start_monitoring_thread();
void stop_monitoring_thread();
int create_watcher(const std::string& filename, std::function<void()>);
int create_inotify_watch(const std::string&);
void handle_self_event();
void handle_inotify_events();
void handle_inotify_event(const struct inotify_event *);
};
}
#endif // ROSA_FSWATCHER_H
src/rosa/fswatcher.cpp
:
// This file contains definition of the rosa::FSWatcher class.
// The filesystem watcher is implemented using Linux-specific things:
//
// 1. inotify;
// 2. eventfd;
// 3. poll.
//
// Check out the following man pages for details:
//
// 1. https://man7.org/linux/man-pages/man7/inotify.7.html;
// 2. https://man7.org/linux/man-pages/man2/eventfd.2.html;
// 3. https://man7.org/linux/man-pages/man2/poll.2.html.
//
// No other OSes are supported for now.
#include <cassert>
#include <ranges>
#include <algorithm>
#include <filesystem>
#include <sys/inotify.h>
#include <sys/eventfd.h>
#include <limits.h>
#include <unistd.h>
#include <rosa/utils.h>
#include <rosa/fswatcher.h>
rosa::FSWatcher::FSWatcher()
{
// Create inotify instance.
//
// We need `IN_NONBLOCK` flag since we don't want the `read` call in the
// `handle_inotify_events` to block the execution.
inotify_fd = inotify_init1(IN_NONBLOCK);
if (inotify_fd == -1)
rosa::utils::die("can't init inotify: {}", strerror(errno));
// Create eventfd descriptor.
//
// Flag `EFD_NONBLOCK` have same purpose as `IN_NONBLOCK` above but in
// function `handle_self_event`.
self_event_fd = eventfd(0, EFD_NONBLOCK);
if (self_event_fd == -1)
rosa::utils::die("can't create eventfd: {}", strerror(errno));
// Add the corresponding pollfds.
poll_fds = {{
{
.fd = inotify_fd,
.events = POLLIN
},
{
.fd = self_event_fd,
.events = POLLIN
}
}};
}
rosa::FSWatcher::~FSWatcher()
{
// Stop monitoring.
stop_monitoring_thread();
// Close the file descriptors.
close(self_event_fd);
close(inotify_fd);
}
int
rosa::FSWatcher::on_modify(const std::string& filename, std::function<void()> handler)
{
// Create new watcher.
const auto id = rosa::FSWatcher::create_watcher(filename, handler);
// Start monitoring as soons as we have any watchers to monitor.
if (watchers.size() == 1)
start_monitoring_thread();
// Return the watcher ID so the caller can stop it later using the
// `stop_watcher` function.
return id;
}
void
rosa::FSWatcher::stop_watcher(const int id)
{
// Remove the watcher from list of all the watchers.
std::erase_if(watchers, [id] (const auto& w) { return w.id == id; });
// Send some arbitrary data to the `self_event_fd` file descriptor.
// Actual data doesn't matter since they are ignored by
// `handle_self_event`.
//
// We need to send an event in order to let the monitor thread to
// terminate if there is no watchers left.
const uint64_t dummy = 0xDEADBEEF;
write(self_event_fd, &dummy, sizeof dummy);
}
int
rosa::FSWatcher::create_watcher(const std::string& filename, std::function<void()> handler)
{
// Add new watcher to the vector.
watchers.push_back({
.id = (int) watchers.size(),
.inotify_wd = rosa::FSWatcher::create_inotify_watch(filename),
.filename = filename,
.handler = handler,
});
// Return its ID.
return watchers.back().id;
}
int
rosa::FSWatcher::create_inotify_watch(const std::string& filename)
{
// Create a watch. We are only interested in file modification, so we
// mask all other events.
auto w = inotify_add_watch(inotify_fd, filename.c_str(), IN_MODIFY);
if (w == -1)
rosa::utils::die("can't add inotify watch");
// Return the watch.
return w;
}
void
rosa::FSWatcher::handle_self_event()
{
// Dummy read to empty the `self_event_fd` file descriptor to make sure
// that `poll` don't react on this event again and again.
uint64_t dummy;
read(self_event_fd, &dummy, sizeof dummy);
}
void
rosa::FSWatcher::handle_inotify_events()
{
// This buffer size is enough to read at least one event. See the
// inotify(7) manpage for details.
constexpr std::size_t len = sizeof(struct inotify_event) + NAME_MAX + 1;
char buf[len];
// Perform a non-blocking read.
const ssize_t n = read(inotify_fd, buf, sizeof buf);
// Check for an explicit error. `EAGAIN` and `EWOULDBLOCK` indicate that
// the call was successfull but there is nothing to read yet.
if (n == -1 && errno != EAGAIN && errno != EWOULDBLOCK) {
rosa::utils::warn("some error happened on read call: {}", strerror(errno));
return;
}
// This means that `read` faced EOF.
//
// TODO: what does it mean for `read` to face EOF on reading an inotify
// file descriptor? What should we do in this case?
if (n <= 0) {
rosa::utils::warn("read call returned someting <= 0: {}", strerror(errno));
return;
}
// Iterate throught all the event that inotify sent to the buffer.
for (const char *p = buf; p < buf + n; ) {
// Extract an event from the buffer, handle it, and then advance
// the pointer to the next event in raw buffer.
auto e = (const struct inotify_event *) p;
rosa::FSWatcher::handle_inotify_event(e);
p += sizeof *e + e->len;
}
}
void
rosa::FSWatcher::handle_inotify_event(const struct inotify_event *e)
{
// Look up the watcher that got an event.
auto w = std::ranges::find_if(watchers, [e] (const auto& w) {
return e->wd == w.inotify_wd;
});
// We should always find it.
assert(w != std::end(watchers));
// I don't know why but deleting the file also causes inotify event even
// if the watcher was created with IN_MODIFY mask.
//
// So we need to check either file still exists or not. And if not then
// we remove its watcher.
if (!std::filesystem::exists(w->filename)) {
inotify_rm_watch(inotify_fd, w->inotify_wd);
rosa::FSWatcher::stop_watcher(w->id);
return;
}
// If we got an event for this watcher and this is a real modification
// event then we should call the user-defined handler.
w->handler();
// Reinit the inotify watcher.
//
// I've spent several hours trying to undestand why inotify stop working
// after reporting once. Probably the reason is that when you modify the
// file it is not just being `write` which should send IN_MODIFY event.
//
// Some editors may close the file and create a new one with the same
// name that probably changes the file's inode and, therefore, inotify
// loses sight of the file.
//
// Also it seems that inotify always sets the IN_IGNORED bit in the
// event's mask.
//
// See also this SO thread:
//
// https://stackoverflow.com/q/13409843/8086115
//
// TODO: May be there is a better way of doing this. Check it out later.
inotify_rm_watch(inotify_fd, w->inotify_wd);
w->inotify_wd = rosa::FSWatcher::create_inotify_watch(w->filename);
}
void
rosa::FSWatcher::start_monitoring_thread()
{
// Unset the stop-flag.
monitor_should_stop = false;
// Create the thread.
monitor = std::thread([this] () {
// Poll events while stop-flag is not set and there are any
// watchers to monitor.
while (!monitor_should_stop && watchers.size() > 0) {
// Poll the events.
const int r = poll(poll_fds.data(), poll_fds.size(), -1 /* no timeout */);
if (r == -1)
rosa::utils::warn("some error on poll: {}", strerror(errno));
// React on inotify event.
if (poll_fds[0].revents & POLLIN)
handle_inotify_events();
// React on our own event.
if (poll_fds[1].revents & POLLIN)
handle_self_event();
}
});
}
void
rosa::FSWatcher::stop_monitoring_thread()
{
// Set stop-flag. The thread will check it after getting an event.
monitor_should_stop = true;
// We need to send a dummy event in order to let the monitor thread to
// resume execution after blocking `poll` call.
const uint64_t dummy = 0xDEADBEEF;
write(self_event_fd, &dummy, sizeof dummy);
// Join the thread.
monitor.join();
}