From 6614e3cfce63935ce1c8d0b82b465fa693370176 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Wed, 10 Jun 2026 17:05:35 -0700 Subject: [PATCH] =?UTF-8?q?Introduce=20an=20alternative=20Linux=20implemen?= =?UTF-8?q?tation=20using=20`inotify`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …that fits our feature set better than EFSW’s does and doesn’t have the complexity of recursive watching. --- binding.gyp | 5 + lib/core.cc | 2 +- lib/core.h | 7 +- lib/platform/InotifyFileWatcher.cpp | 268 ++++++++++++++++++++++++++++ lib/platform/InotifyFileWatcher.hpp | 77 ++++++++ 5 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 lib/platform/InotifyFileWatcher.cpp create mode 100644 lib/platform/InotifyFileWatcher.hpp diff --git a/binding.gyp b/binding.gyp index 058c809..6e63c06 100644 --- a/binding.gyp +++ b/binding.gyp @@ -119,6 +119,11 @@ "vendor/efsw", ], "conditions": [ + ['OS=="linux"', { + "sources+": [ + "lib/platform/InotifyFileWatcher.cpp" + ], + }], ['OS=="mac"', { "sources+": [ "lib/platform/FSEventsFileWatcher.cpp", diff --git a/lib/core.cc b/lib/core.cc index 5b93cfe..880c470 100644 --- a/lib/core.cc +++ b/lib/core.cc @@ -431,7 +431,7 @@ Napi::Value PathWatcher::Watch(const Napi::CallbackInfo &info) { listener = new PathWatcherListener(env, tsfn); -#ifdef __APPLE__ +#if defined(__APPLE__) || defined(__linux__) fileWatcher = new FileWatcher(); #else fileWatcher = new efsw::FileWatcher(); diff --git a/lib/core.h b/lib/core.h index 8db334e..3414983 100644 --- a/lib/core.h +++ b/lib/core.h @@ -17,6 +17,11 @@ typedef FSEventsFileWatcher FileWatcher; #endif // USE_KQUEUE #endif // __APPLE__ +#ifdef __linux__ +#include "./platform/InotifyFileWatcher.hpp" +typedef InotifyFileWatcher FileWatcher; +#endif // __linux__ + #ifndef _WIN32 #include #endif @@ -27,7 +32,7 @@ typedef FSEventsFileWatcher FileWatcher; #define PATH_SEPARATOR '/' #endif -#ifndef __APPLE__ +#if !defined(__APPLE__) && !defined(__linux__) typedef efsw::FileWatcher FileWatcher; #endif diff --git a/lib/platform/InotifyFileWatcher.cpp b/lib/platform/InotifyFileWatcher.cpp new file mode 100644 index 0000000..d8a75c7 --- /dev/null +++ b/lib/platform/InotifyFileWatcher.cpp @@ -0,0 +1,268 @@ + +#include "InotifyFileWatcher.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef DEBUG +#include +#endif + +namespace { + +// IN_CREATE/IN_DELETE/IN_MOVED_*: children appearing, disappearing, or being +// renamed within the watched directory. +// IN_MODIFY/IN_CLOSE_WRITE: content changes to children. +// IN_DELETE_SELF/IN_MOVE_SELF: the watched directory itself goes away. +constexpr uint32_t kWatchMask = IN_CREATE | IN_DELETE | IN_DELETE_SELF | + IN_MOVE_SELF | IN_MOVED_FROM | IN_MOVED_TO | + IN_MODIFY | IN_CLOSE_WRITE; + +constexpr size_t kEventBufLen = + 64 * (sizeof(struct inotify_event) + NAME_MAX + 1); + +} // namespace + +InotifyFileWatcher::InotifyFileWatcher() { + inotifyFd = inotify_init1(IN_CLOEXEC | IN_NONBLOCK); + if (inotifyFd == -1) { + isValid = false; + return; + } + + // A pipe lets the destructor unblock poll() cleanly without signals. + if (pipe(wakeupPipe) == -1) { + close(inotifyFd); + inotifyFd = -1; + isValid = false; + return; + } + + eventThread = std::thread(&InotifyFileWatcher::eventLoop, this); +} + +InotifyFileWatcher::~InotifyFileWatcher() { + isValid = false; + stopping = true; + + // Unblock the event loop thread. + char byte = 0; + write(wakeupPipe[1], &byte, 1); + + if (eventThread.joinable()) + eventThread.join(); + + close(wakeupPipe[0]); + close(wakeupPipe[1]); + // Closing the inotify fd automatically removes all of its watches. + if (inotifyFd >= 0) + close(inotifyFd); +} + +efsw::WatchID InotifyFileWatcher::addWatch(const std::string &path, + efsw::FileWatchListener *listener, + bool /* _useRecursion */) { +#ifdef DEBUG + std::cout << "InotifyFileWatcher::addWatch: " << path << std::endl; +#endif + if (!isValid) + return efsw::Errors::WatcherFailed; + + std::string dir = path; + if (dir.empty() || dir.back() != '/') + dir += '/'; + + int wd = inotify_add_watch(inotifyFd, dir.c_str(), kWatchMask); + if (wd < 0) { + switch (errno) { + case ENOENT: + return efsw::Errors::FileNotFound; + case EACCES: + return efsw::Errors::FileNotReadable; + default: + return efsw::Errors::WatcherFailed; + } + } + + std::lock_guard lock(mapMutex); + efsw::WatchID handle = nextHandleID++; + handlesToWatches[handle] = {dir, listener, wd}; + wdToHandles.insert({wd, handle}); + return handle; +} + +void InotifyFileWatcher::removeWatch(efsw::WatchID handle) { + int wd = -1; + bool wasLastHandleForWd = false; + { + std::lock_guard lock(mapMutex); + auto it = handlesToWatches.find(handle); + if (it == handlesToWatches.end()) + return; + wd = it->second.wd; + handlesToWatches.erase(it); + + auto range = wdToHandles.equal_range(wd); + for (auto wit = range.first; wit != range.second; ++wit) { + if (wit->second == handle) { + wdToHandles.erase(wit); + break; + } + } + wasLastHandleForWd = (wdToHandles.find(wd) == wdToHandles.end()); + } + + if (wasLastHandleForWd) { + // EINVAL here just means the kernel already removed this watch (e.g. the + // directory was deleted); safe to ignore. + inotify_rm_watch(inotifyFd, wd); + } +} + +void InotifyFileWatcher::forgetWatch(int wd) { + std::lock_guard lock(mapMutex); + auto range = wdToHandles.equal_range(wd); + for (auto it = range.first; it != range.second;) { + handlesToWatches.erase(it->second); + it = wdToHandles.erase(it); + } +} + +void InotifyFileWatcher::sendFileAction(int wd, const std::string &filename, + efsw::Action action, + const std::string &oldFilename) { + // Copy out (handle, dir, listener) under the lock, then call into listener + // code without holding it. + std::vector> + targets; + { + std::lock_guard lock(mapMutex); + auto range = wdToHandles.equal_range(wd); + for (auto it = range.first; it != range.second; ++it) { + auto hit = handlesToWatches.find(it->second); + if (hit != handlesToWatches.end()) + targets.emplace_back(it->second, hit->second.dir, hit->second.listener); + } + } + + for (auto &[handle, dir, listener] : targets) + listener->handleFileAction(handle, dir, filename, action, oldFilename); +} + +void InotifyFileWatcher::eventLoop() { + char buf[kEventBufLen]; + + // State for pairing IN_MOVED_FROM with a following IN_MOVED_TO that shares + // its cookie and watch — i.e. a rename within the same directory. + bool hasPendingMove = false; + int pendingWd = -1; + uint32_t pendingCookie = 0; + std::string pendingFilename; + + auto flushPendingMove = [&]() { + if (!hasPendingMove) + return; + // No matching IN_MOVED_TO arrived: the file was moved somewhere we're not + // watching (or out of the filesystem entirely). + sendFileAction(pendingWd, pendingFilename, efsw::Actions::Delete); + hasPendingMove = false; + }; + + while (!stopping) { + struct pollfd fds[2]; + fds[0] = {inotifyFd, POLLIN, 0}; + fds[1] = {wakeupPipe[0], POLLIN, 0}; + + // If we're sitting on an unpaired IN_MOVED_FROM, don't block forever — + // give its IN_MOVED_TO a brief window to show up, then resolve it. + int timeoutMs = hasPendingMove ? 10 : -1; + + int r = poll(fds, 2, timeoutMs); + if (r < 0) { + if (errno == EINTR) + continue; + break; + } + + if (stopping || (fds[1].revents & POLLIN)) + break; + + if (r == 0) { + flushPendingMove(); + continue; + } + + if (!(fds[0].revents & POLLIN)) + continue; + + ssize_t len = read(inotifyFd, buf, sizeof(buf)); + if (len <= 0) + continue; + + ssize_t i = 0; + while (i < len) { + auto *event = reinterpret_cast(&buf[i]); + i += sizeof(struct inotify_event) + event->len; + + if (event->mask & IN_Q_OVERFLOW) + continue; + + std::string filename = event->len > 0 ? std::string(event->name) : ""; + + if (hasPendingMove) { + if ((event->mask & IN_MOVED_TO) && event->wd == pendingWd && + event->cookie == pendingCookie) { + // Same-directory rename — almost always an atomic save. + sendFileAction(event->wd, filename, efsw::Actions::Moved, + pendingFilename); + hasPendingMove = false; + continue; + } + flushPendingMove(); + // fall through and process this event normally + } + + if (event->mask & IN_IGNORED) { + // The kernel already removed this watch (explicit removeWatch, + // directory deleted, or filesystem unmounted). + forgetWatch(event->wd); + continue; + } + + if (event->mask & IN_MOVED_FROM) { + hasPendingMove = true; + pendingWd = event->wd; + pendingCookie = event->cookie; + pendingFilename = filename; + continue; + } + + if (event->mask & (IN_DELETE_SELF | IN_MOVE_SELF)) { + // The watched directory itself is gone or has moved. Report it as a + // deletion of the directory; the caller can re-addWatch if it cares + // to keep following it (we have no way to learn its new path). + sendFileAction(event->wd, "", efsw::Actions::Delete); + continue; + } + + if (event->mask & IN_CREATE) { + sendFileAction(event->wd, filename, efsw::Actions::Add); + } else if (event->mask & IN_DELETE) { + sendFileAction(event->wd, filename, efsw::Actions::Delete); + } else if (event->mask & IN_MOVED_TO) { + // Arrived from outside this directory (or its IN_MOVED_FROM partner + // already timed out): treat it as a brand-new file. + sendFileAction(event->wd, filename, efsw::Actions::Add); + sendFileAction(event->wd, filename, efsw::Actions::Modified); + } else if (event->mask & (IN_MODIFY | IN_CLOSE_WRITE)) { + sendFileAction(event->wd, filename, efsw::Actions::Modified); + } + } + } + + flushPendingMove(); +} diff --git a/lib/platform/InotifyFileWatcher.hpp b/lib/platform/InotifyFileWatcher.hpp new file mode 100644 index 0000000..277e1e5 --- /dev/null +++ b/lib/platform/InotifyFileWatcher.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "../../vendor/efsw/include/efsw/efsw.hpp" +#include +#include +#include +#include +#include + +// An API-compatible replacement for `efsw::FileWatcher` that talks to inotify +// directly. Plays the same role on Linux that `KqueueFileWatcher` and +// `FSEventsFileWatcher` play on macOS. +// +// Key differences from `FileWatcherInotify`: +// +// * A single `inotify` instance backs every watch; `addWatch()` just calls +// `inotify_add_watch()` against it and gets back a watch descriptor (wd). No +// per-path file descriptors, and no fd-limit juggling. +// * No recursion: the `_useRecursion` flag is ignored, and only the directory +// passed to addWatch() is watched. The existing JS layer only ever calls +// `addWatch()` with a directory path — either the directory being watched +// directly, or the parent of a watched file — so this matches how the old +// inotify backend was used in practice anyway. +// * Renames are reconstructed from `IN_MOVED_FROM`/`IN_MOVED_TO` pairs that +// share a cookie and land on the same watch. This is how editors' atomic +// saves (write tmp file, rename over target) show up, and it's reported as +// `Moved`, which the existing JS rename-handling collapses into a `change` +// event for the watched file. Cross-directory moves are reported as a Delete +// (on the source watch) plus an Add+Modified (on the destination watch), +// matching the old inotify-based behavior. +// * If the watched directory itself is removed or renamed (`IN_DELETE_SELF` / +// `IN_MOVE_SELF`), we report a single Delete for the directory. `inotify` +// gives us no way to recover the new path of a moved directory, so the +// caller must re-`addWatch` if it wants to keep following it. +// +class InotifyFileWatcher { +public: + InotifyFileWatcher(); + ~InotifyFileWatcher(); + + efsw::WatchID addWatch(const std::string &path, + efsw::FileWatchListener *listener, + bool _useRecursion = false); + + void removeWatch(efsw::WatchID handle); + + bool isValid = true; + +private: + struct Watch { + std::string dir; // always ends with '/' + efsw::FileWatchListener *listener; + int wd; + }; + + void eventLoop(); + + // Looks up every handle registered for `wd` and dispatches `action` to each + // of their listeners. + void sendFileAction(int wd, const std::string &filename, efsw::Action action, + const std::string &oldFilename = ""); + + // Drops all bookkeeping for `wd` without calling `inotify_rm_watch()`; used + // when the kernel tells us (via IN_IGNORED) that it already dropped the + // watch on its own (e.g. the directory was deleted or unmounted). + void forgetWatch(int wd); + + long nextHandleID = 1; + int inotifyFd = -1; + int wakeupPipe[2] = {-1, -1}; + std::atomic stopping{false}; + std::mutex mapMutex; + std::thread eventThread; + + std::unordered_map handlesToWatches; + std::unordered_multimap wdToHandles; +};