diff --git a/binding.gyp b/binding.gyp index 1fe7ebd..058c809 100644 --- a/binding.gyp +++ b/binding.gyp @@ -121,7 +121,11 @@ "conditions": [ ['OS=="mac"', { "sources+": [ - "lib/platform/FSEventsFileWatcher.cpp" + "lib/platform/FSEventsFileWatcher.cpp", + "lib/platform/KqueueFileWatcher.cpp" + ], + "defines+": [ + "USE_KQUEUE" ] }], ['OS=="win"', { diff --git a/lib/core.cc b/lib/core.cc index 50d3235..5b93cfe 100644 --- a/lib/core.cc +++ b/lib/core.cc @@ -1,8 +1,8 @@ #include "core.h" #include "include/efsw/efsw.hpp" #include "napi.h" -#include #include +#include #ifdef DEBUG #include @@ -31,10 +31,9 @@ static timeval Now() { // timestamp predates the Unix timestamp. Used to compare creation/modification // times to arbitrary points in time. static bool PredatesWatchStart(struct timespec fileSpec, timeval startTime) { - bool fileEventOlder = fileSpec.tv_sec < startTime.tv_sec || ( - (fileSpec.tv_sec == startTime.tv_sec) && - ((fileSpec.tv_nsec / 1000) < startTime.tv_usec) - ); + bool fileEventOlder = fileSpec.tv_sec < startTime.tv_sec || + ((fileSpec.tv_sec == startTime.tv_sec) && + ((fileSpec.tv_nsec / 1000) < startTime.tv_usec)); return fileEventOlder; } #endif @@ -56,34 +55,36 @@ static efsw::WatchID BigIntToWatcherHandle(Napi::BigInt value) { static std::string EventType(efsw::Action action, bool isChild) { switch (action) { - case efsw::Actions::Add: - return isChild ? "child-create" : "create"; - case efsw::Actions::Delete: - return isChild ? "child-delete" : "delete"; - case efsw::Actions::Modified: - return isChild ? "child-change" : "change"; - case efsw::Actions::Moved: - return isChild ? "child-rename" : "rename"; - default: - return "unknown"; + case efsw::Actions::Add: + return isChild ? "child-create" : "create"; + case efsw::Actions::Delete: + return isChild ? "child-delete" : "delete"; + case efsw::Actions::Modified: + return isChild ? "child-change" : "change"; + case efsw::Actions::Moved: + return isChild ? "child-rename" : "rename"; + default: + return "unknown"; } } // This is a bit hacky, but it allows us to stop invoking callbacks more // quickly when the environment is terminating. static bool EnvIsStopping(Napi::Env env) { - PathWatcher* pw = env.GetInstanceData(); + PathWatcher *pw = env.GetInstanceData(); return pw->isStopping; } // Ensure a given path has a trailing separator for comparison purposes. static std::string NormalizePath(std::string path) { - if (path.back() == PATH_SEPARATOR) return path; + if (path.back() == PATH_SEPARATOR) + return path; return path + PATH_SEPARATOR; } -static void StripTrailingSlashFromPath(std::string& path) { - if (path.empty() || (path.back() != '/')) return; +static void StripTrailingSlashFromPath(std::string &path) { + if (path.empty() || (path.back() != '/')) + return; path.pop_back(); } @@ -94,11 +95,8 @@ static bool PathsAreEqual(std::string pathA, std::string pathB) { // This is the main-thread function that receives all `ThreadSafeFunction` // calls. It converts the `PathWatcherEvent` struct into JS values before // invoking our callback. -static void ProcessEvent( - Napi::Env env, - Napi::Function callback, - PathWatcherEvent* event -) { +static void ProcessEvent(Napi::Env env, Napi::Function callback, + PathWatcherEvent *event) { // Translate the event type to the expected event name in the JS code. // // NOTE: This library previously envisioned that some platforms would allow @@ -111,7 +109,8 @@ static void ProcessEvent( // if we're watching a directory and that directory itself is deleted, then // that should be `delete` rather than `child-delete`. Right now we deal with // that in JavaScript, but we could handle it here instead. - if (EnvIsStopping(env)) return; + if (EnvIsStopping(env)) + return; std::string newPath; std::string oldPath; @@ -136,33 +135,33 @@ static void ProcessEvent( std::string eventName = EventType(event->type, isChildEvent); try { - callback.Call({ - Napi::String::New(env, eventName), - WatcherHandleToBigInt(env, event->handle), - Napi::String::New(env, newPath), - Napi::String::New(env, oldPath) - }); - } catch (const Napi::Error& e) { + callback.Call({Napi::String::New(env, eventName), + WatcherHandleToBigInt(env, event->handle), + Napi::String::New(env, newPath), + Napi::String::New(env, oldPath)}); + } catch (const Napi::Error &e) { // TODO: Unsure why this would happen. - Napi::TypeError::New(env, "Unknown error handling filesystem event").ThrowAsJavaScriptException(); + Napi::TypeError::New(env, "Unknown error handling filesystem event") + .ThrowAsJavaScriptException(); } } -PathWatcherListener::PathWatcherListener( - Napi::Env env, - Napi::ThreadSafeFunction tsfn -): tsfn(tsfn) {} +PathWatcherListener::PathWatcherListener(Napi::Env env, + Napi::ThreadSafeFunction tsfn) + : tsfn(tsfn) {} void PathWatcherListener::Stop() { - if (isShuttingDown) return; + if (isShuttingDown) + return; // Prevent responders from acting while we shut down. std::lock_guard lock(shutdownMutex); - if (isShuttingDown) return; + if (isShuttingDown) + return; isShuttingDown = true; } -void PathWatcherListener::Stop(FileWatcher* fileWatcher) { - for (auto& it : paths) { +void PathWatcherListener::Stop(FileWatcher *fileWatcher) { + for (auto &it : paths) { fileWatcher->removeWatch(it.first); } paths.clear(); @@ -170,7 +169,8 @@ void PathWatcherListener::Stop(FileWatcher* fileWatcher) { } // Correlate a watch ID to a path/timestamp pair. -void PathWatcherListener::AddPath(PathTimestampPair pair, efsw::WatchID handle) { +void PathWatcherListener::AddPath(PathTimestampPair pair, + efsw::WatchID handle) { std::lock_guard lock(pathsMutex); paths[handle] = pair; pathsToHandles[pair.path] = handle; @@ -179,15 +179,18 @@ void PathWatcherListener::AddPath(PathTimestampPair pair, efsw::WatchID handle) // Remove metadata for a given watch ID. void PathWatcherListener::RemovePath(efsw::WatchID handle) { std::string path; - if (isShuttingDown) return; + if (isShuttingDown) + return; { std::lock_guard lock(pathsMutex); auto it = paths.find(handle); #ifdef DEBUG - std::cout << "Unwatching handle: [" << handle << "] path: [" << it->second.path << "]" << std::endl; + std::cout << "Unwatching handle: [" << handle << "] path: [" + << it->second.path << "]" << std::endl; #endif - if (it == paths.end()) return; + if (it == paths.end()) + return; path = it->second.path; paths.erase(it); } @@ -195,7 +198,8 @@ void PathWatcherListener::RemovePath(efsw::WatchID handle) { { std::lock_guard lock(pathsToHandlesMutex); auto itp = pathsToHandles.find(path); - if (itp == pathsToHandles.end()) return; + if (itp == pathsToHandles.end()) + return; pathsToHandles.erase(itp); } } @@ -217,23 +221,25 @@ bool PathWatcherListener::IsEmpty() { return paths.empty(); } -void PathWatcherListener::handleFileAction( - efsw::WatchID watchId, - const std::string& dir, - const std::string& filename, - efsw::Action action, - std::string oldFilename -) { +void PathWatcherListener::handleFileAction(efsw::WatchID watchId, + const std::string &dir, + const std::string &filename, + efsw::Action action, + std::string oldFilename) { #ifdef DEBUG - std::cout << "PathWatcherListener::handleFileAction dir: " << dir << " filename: " << filename << " action: " << EventType(action, true) << std::endl; + std::cout << "PathWatcherListener::handleFileAction dir: " << dir + << " filename: " << filename << " oldFilename: " << filename + << " action: " << EventType(action, true) << std::endl; #endif // Don't try to proceed if we've already started the shutdown process… - if (isShuttingDown) return; + if (isShuttingDown) + return; // …but if we haven't, make sure that shutdown doesn’t happen until we’re // done. std::lock_guard lock(shutdownMutex); - if (isShuttingDown) return; + if (isShuttingDown) + return; // Extract the expected watcher path and (on macOS) the start time of the // watcher. @@ -270,7 +276,8 @@ void PathWatcherListener::handleFileAction( // weeds out most of the false positives. { struct stat file; - if (stat(newPathStr.c_str(), &file) != 0 && action != efsw::Action::Delete) { + if (stat(newPathStr.c_str(), &file) != 0 && + action != efsw::Action::Delete) { // If this was a delete action, the file is _expected_ not to exist // anymore. Otherwise it's a strange outcome and it means we should // ignore this event. @@ -289,14 +296,18 @@ void PathWatcherListener::handleFileAction( // file actions that happened before we started watching. if (PredatesWatchStart(file.st_birthtimespec, startTime)) { #ifdef DEBUG - std::cout << "File was created before we started this path watcher! (skipping)" << std::endl; + std::cout << "File was created before we started this path watcher! " + "(skipping)" + << std::endl; #endif return; } } else if (action == efsw::Action::Modified) { if (PredatesWatchStart(file.st_mtimespec, startTime)) { #ifdef DEBUG - std::cout << "File was modified before we started this path watcher! (skipping)" << std::endl; + std::cout << "File was modified before we started this path watcher! " + "(skipping)" + << std::endl; #endif return; } @@ -310,7 +321,8 @@ void PathWatcherListener::handleFileAction( oldPath.assign(oldPathStr.begin(), oldPathStr.end()); } - if (!tsfn) return; + if (!tsfn) + return; napi_status status = tsfn.Acquire(); if (status != napi_ok) { // We couldn't acquire the `tsfn`; it might be in the process of being @@ -318,7 +330,8 @@ void PathWatcherListener::handleFileAction( return; } - PathWatcherEvent* event = new PathWatcherEvent(action, watchId, newPath, oldPath, realPath); + PathWatcherEvent *event = + new PathWatcherEvent(action, watchId, newPath, oldPath, realPath); // TODO: Instead of calling `BlockingCall` once per event, throttle them by // some small amount of time (like 50-100ms). That will allow us to deliver @@ -344,11 +357,10 @@ PathWatcher::PathWatcher(Napi::Env env, Napi::Object exports) { std::cout << "Initializing PathWatcher" << std::endl; #endif - DefineAddon(exports, { - InstanceMethod("watch", &PathWatcher::Watch), - InstanceMethod("unwatch", &PathWatcher::Unwatch), - InstanceMethod("setCallback", &PathWatcher::SetCallback) - }); + DefineAddon(exports, + {InstanceMethod("watch", &PathWatcher::Watch), + InstanceMethod("unwatch", &PathWatcher::Unwatch), + InstanceMethod("setCallback", &PathWatcher::SetCallback)}); env.SetInstanceData(this); } @@ -359,7 +371,7 @@ PathWatcher::~PathWatcher() { } // Watch a given path. Returns a handle. -Napi::Value PathWatcher::Watch(const Napi::CallbackInfo& info) { +Napi::Value PathWatcher::Watch(const Napi::CallbackInfo &info) { auto env = info.Env(); // Record the current timestamp as early as possible. We'll use this as a way // of ignoring file-watcher events that happened before we started watching. @@ -402,36 +414,38 @@ Napi::Value PathWatcher::Watch(const Napi::CallbackInfo& info) { #ifdef DEBUG std::cout << " Creating ThreadSafeFunction and FileWatcher" << std::endl; #endif + int myGeneration = ++watchGeneration; tsfn = Napi::ThreadSafeFunction::New( - env, - callback.Value(), - "pathwatcher-efsw-listener", - 0, - 1, - [this](Napi::Env env) { - // This is unexpected. We should try to do some cleanup before the - // environment terminates. - StopAllListeners(); - } - ); + env, callback.Value(), "pathwatcher-efsw-listener", 0, 1, + [this, myGeneration](Napi::Env env) { + // This is unexpected. We should try to do some cleanup before the + // environment terminates. + // + // We retain a "generation" value; if it increments by the time this + // finalizer runs, that means the watcher has started up again and we + // should skip teardown. + if (watchGeneration == myGeneration) { + StopAllListeners(); + } + }); listener = new PathWatcherListener(env, tsfn); #ifdef __APPLE__ - fileWatcher = new FSEventsFileWatcher(); + fileWatcher = new FileWatcher(); #else - fileWatcher = new efsw::FileWatcher(); - fileWatcher->followSymlinks(true); - fileWatcher->watch(); + fileWatcher = new efsw::FileWatcher(); + fileWatcher->followSymlinks(true); + fileWatcher->watch(); #endif isWatching = true; } - // EFSW represents watchers as unsigned `int`s; we can easily convert these // to JavaScript. - WatcherHandle handle = fileWatcher->addWatch(cppPath, listener, useRecursiveWatcher); + WatcherHandle handle = + fileWatcher->addWatch(cppPath, listener, useRecursiveWatcher); #ifdef DEBUG std::cout << " handle: [" << handle << "]" << std::endl; @@ -440,7 +454,7 @@ Napi::Value PathWatcher::Watch(const Napi::CallbackInfo& info) { if (handle >= 0) { // For each new watched path, remember both the normalized path and the // time we started watching it. - PathTimestampPair pair = { cppPath, now }; + PathTimestampPair pair = {cppPath, now}; listener->AddPath(pair, handle); } else { auto error = Napi::Error::New(env, "Failed to add watch; unknown error"); @@ -460,7 +474,7 @@ Napi::Value PathWatcher::Watch(const Napi::CallbackInfo& info) { } // Unwatch the given handle. -Napi::Value PathWatcher::Unwatch(const Napi::CallbackInfo& info) { +Napi::Value PathWatcher::Unwatch(const Napi::CallbackInfo &info) { auto env = info.Env(); if (!isWatching) { @@ -471,11 +485,13 @@ Napi::Value PathWatcher::Unwatch(const Napi::CallbackInfo& info) { } if (!info[0].IsBigInt()) { - Napi::TypeError::New(env, "Argument must be a BigInt").ThrowAsJavaScriptException(); + Napi::TypeError::New(env, "Argument must be a BigInt") + .ThrowAsJavaScriptException(); return env.Null(); } - if (!listener) return env.Undefined(); + if (!listener) + return env.Undefined(); efsw::WatchID handle = BigIntToWatcherHandle(info[0].As()); @@ -503,8 +519,10 @@ void PathWatcher::StopAllListeners() { // environment is terminating. At that point, it's not safe to try to release // any `ThreadSafeFunction`s; but we can do the rest of the cleanup work // here. - if (!isWatching) return; - if (!listener) return; + if (!isWatching) + return; + if (!listener) + return; listener->Stop(fileWatcher); delete fileWatcher; @@ -516,7 +534,7 @@ void PathWatcher::StopAllListeners() { // The user-facing API allows for an arbitrary number of different callbacks; // this is an internal API for the wrapping JavaScript to use. That internal // callback can multiplex to however many other callbacks need to be invoked. -void PathWatcher::SetCallback(const Napi::CallbackInfo& info) { +void PathWatcher::SetCallback(const Napi::CallbackInfo &info) { auto env = info.Env(); if (!info[0].IsFunction()) { Napi::TypeError::New(env, "Function required").ThrowAsJavaScriptException(); diff --git a/lib/core.h b/lib/core.h index e373787..8db334e 100644 --- a/lib/core.h +++ b/lib/core.h @@ -1,15 +1,21 @@ #pragma once -#include -#include +#include "../vendor/efsw/include/efsw/efsw.hpp" #include #include +#include +#include #include -#include "../vendor/efsw/include/efsw/efsw.hpp" #ifdef __APPLE__ +#ifdef USE_KQUEUE +#include "./platform/KqueueFileWatcher.hpp" +typedef KqueueFileWatcher FileWatcher; +#else #include "./platform/FSEventsFileWatcher.hpp" -#endif +typedef FSEventsFileWatcher FileWatcher; +#endif // USE_KQUEUE +#endif // __APPLE__ #ifndef _WIN32 #include @@ -21,9 +27,7 @@ #define PATH_SEPARATOR '/' #endif -#ifdef __APPLE__ -typedef FSEventsFileWatcher FileWatcher; -#else +#ifndef __APPLE__ typedef efsw::FileWatcher FileWatcher; #endif @@ -53,20 +57,18 @@ struct PathWatcherEvent { PathWatcherEvent() = default; // Constructor - PathWatcherEvent( - efsw::Action t, - efsw::WatchID h, - const std::vector& np, - const std::vector& op = std::vector(), - const std::string& wp = "" - ) : type(t), handle(h), new_path(np), old_path(op), watcher_path(wp) {} + PathWatcherEvent(efsw::Action t, efsw::WatchID h, const std::vector &np, + const std::vector &op = std::vector(), + const std::string &wp = "") + : type(t), handle(h), new_path(np), old_path(op), watcher_path(wp) {} // Copy constructor - PathWatcherEvent(const PathWatcherEvent& other) - : type(other.type), handle(other.handle), new_path(other.new_path), old_path(other.old_path), watcher_path(other.watcher_path) {} + PathWatcherEvent(const PathWatcherEvent &other) + : type(other.type), handle(other.handle), new_path(other.new_path), + old_path(other.old_path), watcher_path(other.watcher_path) {} // Copy assignment operator - PathWatcherEvent& operator=(const PathWatcherEvent& other) { + PathWatcherEvent &operator=(const PathWatcherEvent &other) { if (this != &other) { type = other.type; handle = other.handle; @@ -78,12 +80,14 @@ struct PathWatcherEvent { } // Move constructor - PathWatcherEvent(PathWatcherEvent&& other) noexcept - : type(other.type), handle(other.handle), - new_path(std::move(other.new_path)), old_path(std::move(other.old_path)), watcher_path(std::move(other.watcher_path)) {} + PathWatcherEvent(PathWatcherEvent &&other) noexcept + : type(other.type), handle(other.handle), + new_path(std::move(other.new_path)), + old_path(std::move(other.old_path)), + watcher_path(std::move(other.watcher_path)) {} // Move assignment operator - PathWatcherEvent& operator=(PathWatcherEvent&& other) noexcept { + PathWatcherEvent &operator=(PathWatcherEvent &&other) noexcept { if (this != &other) { type = other.type; handle = other.handle; @@ -95,20 +99,13 @@ struct PathWatcherEvent { } }; -class PathWatcherListener: public efsw::FileWatchListener { +class PathWatcherListener : public efsw::FileWatchListener { public: - PathWatcherListener( - Napi::Env env, - Napi::ThreadSafeFunction tsfn - ); - - void handleFileAction( - efsw::WatchID watchId, - const std::string& dir, - const std::string& filename, - efsw::Action action, - std::string oldFilename - ) override; + PathWatcherListener(Napi::Env env, Napi::ThreadSafeFunction tsfn); + + void handleFileAction(efsw::WatchID watchId, const std::string &dir, + const std::string &filename, efsw::Action action, + std::string oldFilename) override; void AddPath(PathTimestampPair pair, efsw::WatchID handle); void RemovePath(efsw::WatchID handle); @@ -116,7 +113,7 @@ class PathWatcherListener: public efsw::FileWatchListener { efsw::WatchID GetHandleForPath(std::string path); bool IsEmpty(); void Stop(); - void Stop(FileWatcher* fileWatcher); + void Stop(FileWatcher *fileWatcher); private: std::atomic isShuttingDown{false}; @@ -132,22 +129,22 @@ class PathWatcher : public Napi::Addon { public: PathWatcher(Napi::Env env, Napi::Object exports); ~PathWatcher(); - bool isStopping = false; private: - Napi::Value Watch(const Napi::CallbackInfo& info); - Napi::Value Unwatch(const Napi::CallbackInfo& info); - void SetCallback(const Napi::CallbackInfo& info); + Napi::Value Watch(const Napi::CallbackInfo &info); + Napi::Value Unwatch(const Napi::CallbackInfo &info); + void SetCallback(const Napi::CallbackInfo &info); void Cleanup(Napi::Env env); void StopAllListeners(); int envId; bool isFinalizing = false; bool isWatching = false; + int watchGeneration = 0; Napi::FunctionReference callback; Napi::ThreadSafeFunction tsfn; - PathWatcherListener* listener; + PathWatcherListener *listener; - FileWatcher* fileWatcher = nullptr; + FileWatcher *fileWatcher = nullptr; }; diff --git a/lib/platform/FSEventsFileWatcher.cpp b/lib/platform/FSEventsFileWatcher.cpp index 22e2c0d..b913d2c 100644 --- a/lib/platform/FSEventsFileWatcher.cpp +++ b/lib/platform/FSEventsFileWatcher.cpp @@ -1,4 +1,5 @@ #include "../core.h" +#include #include #include "FSEventsFileWatcher.hpp" @@ -196,11 +197,23 @@ efsw::WatchID FSEventsFileWatcher::addWatch( // The `_useRecursion` flag is ignored; it's present for API compatibility. bool _useRecursion ) { + // FSEvents watches directories, not individual files. If the caller passes a + // file path (which the JS layer now does when watching a specific file on + // macOS), silently promote it to the parent directory. The stream will then + // deliver per-file events for the whole directory, and the existing + // event-matching logic — which already keys on the parent directory — will + // route events for the right file to the right handle. + struct stat st; + std::string watchDir = directory; + if (stat(directory.c_str(), &st) == 0 && !S_ISDIR(st.st_mode)) { + watchDir = PathWithoutFileName(directory, false); + } + efsw::WatchID handle = nextHandleID++; { std::lock_guard lock(mapMutex); - handlesToPaths[handle] = directory; - pathsToHandles[directory] = handle; + handlesToPaths[handle] = watchDir; + pathsToHandles[watchDir] = handle; handlesToListeners[handle] = listener; } @@ -208,8 +221,26 @@ efsw::WatchID FSEventsFileWatcher::addWatch( if (!didStart) { removeHandle(handle); - // TODO: Debug information? - return -handle; + + // FSEventStreamStart() doesn't expose a reason for failure. Inspect the + // newly-added path post-hoc to return the most specific error code we can. + // (All previously-watched paths were already working in the prior stream, + // so the new path is the most likely culprit.) + if (stat(watchDir.c_str(), &st) != 0) { + if (errno == EACCES || errno == EPERM) { + return efsw::Errors::FileNotReadable; + } + return efsw::Errors::FileNotFound; + } + + struct statfs sfsb; + if (statfs(watchDir.c_str(), &sfsb) == 0) { + if (!(sfsb.f_flags & MNT_LOCAL)) { + return efsw::Errors::FileRemote; + } + } + + return efsw::Errors::WatcherFailed; } return handle; @@ -418,13 +449,24 @@ void FSEventsFileWatcher::handleActions(std::vector& events) { sendFileAction(handle, dirPath, filePath, efsw::Actions::Moved, newFilepath); } } else { - // This is a move from one directory to another, so we'll treat - // it as one deletion and one creation. - sendFileAction(handle, dirPath, filePath, efsw::Actions::Delete); - sendFileAction(handle, newDir, newFilepath, efsw::Actions::Add); - - if (nEvent.flags & shorthandFSEventsModified) { - sendFileAction(handle, dirPath, filePath, efsw::Actions::Modified); + // This is a move from one directory to another. Use PathExists to + // determine which path is the source (moved away from) and which + // is the destination (moved to), since FSEvents doesn't guarantee + // event ordering for cross-directory renames. This matters for + // atomic saves, where a temp file from outside the watched + // directory is renamed over the target file inside it. + if (!PathExists(event.path)) { + // event is the source; the file moved out of our watched dir. + sendFileAction(handle, dirPath, filePath, efsw::Actions::Delete); + sendFileAction(handle, newDir, newFilepath, efsw::Actions::Add); + } else { + // event is the destination; the file was moved into our watched + // dir (e.g. an atomic save from a temp directory). + sendFileAction(handle, newDir, newFilepath, efsw::Actions::Delete); + sendFileAction(handle, dirPath, filePath, efsw::Actions::Add); + if (nEvent.flags & shorthandFSEventsModified) { + sendFileAction(handle, dirPath, filePath, efsw::Actions::Modified); + } } } } else { @@ -616,6 +658,10 @@ bool FSEventsFileWatcher::startNewStream() { ); FSEventStreamSetDispatchQueue(nextEventStream, queue); + // The stream now holds its own retain on the queue; release ours so the + // queue is freed when the stream is eventually torn down. + dispatch_release(queue); + bool didStart = FSEventStreamStart(nextEventStream); // Release all the strings we just created. @@ -634,6 +680,13 @@ bool FSEventsFileWatcher::startNewStream() { } currentEventStream = nextEventStream; nextEventStream = nullptr; + } else { + // The stream was created but never started. The Stop→Invalidate→Release + // sequence is only valid for started streams; for an un-started one, just + // unschedule it from the dispatch queue (passing NULL) and release it. + FSEventStreamSetDispatchQueue(nextEventStream, NULL); + FSEventStreamRelease(nextEventStream); + nextEventStream = nullptr; } return didStart; diff --git a/lib/platform/FSEventsFileWatcher.hpp b/lib/platform/FSEventsFileWatcher.hpp index cdbdc2c..3d2a180 100644 --- a/lib/platform/FSEventsFileWatcher.hpp +++ b/lib/platform/FSEventsFileWatcher.hpp @@ -84,7 +84,7 @@ class FSEventsFileWatcher { size_t removeHandle(efsw::WatchID handle); bool startNewStream(); - long nextHandleID; + long nextHandleID = 1; std::atomic isProcessing{false}; std::atomic pendingDestruction{false}; std::mutex processingMutex; diff --git a/lib/platform/KqueueFileWatcher.cpp b/lib/platform/KqueueFileWatcher.cpp new file mode 100644 index 0000000..c49e842 --- /dev/null +++ b/lib/platform/KqueueFileWatcher.cpp @@ -0,0 +1,320 @@ +#include "KqueueFileWatcher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// O_EVTONLY: open for event notification only, without preventing the volume +// from being unmounted and without requiring read permission on the path. +#ifndef O_EVTONLY +#define O_EVTONLY O_RDONLY +#endif + +// F_GETPATH: retrieve the current filesystem path of an open fd. On macOS +// this follows the inode, so after a rename it returns the new path. +#ifndef F_GETPATH +#define F_GETPATH 50 +#endif + +// Split "/foo/bar/baz.txt" into {"/foo/bar/", "baz.txt"}. +// Trailing slashes on the input are stripped before splitting, so +// "/foo/bar/" and "/foo/bar" both produce {"/foo/", "bar"}. +static std::pair SplitPath(const std::string &path) { + std::string p = path; + while (p.size() > 1 && p.back() == '/') + p.pop_back(); + size_t pos = p.find_last_of('/'); + if (pos == std::string::npos) + return {"", p}; + return {p.substr(0, pos + 1), p.substr(pos + 1)}; +} + +// Raise the process soft fd limit to the hard limit. On macOS the hard limit +// for unprivileged processes is KERN_MAXFILESPERPROC (10240 by default), which +// is sufficient headroom for any realistic editor workload. This is a +// process-wide side effect, but the alternative — silently failing to watch +// paths once the default soft limit of 256 is reached — is worse. +static void RaiseFdLimit() { + struct rlimit rl; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) { + rl.rlim_cur = rl.rlim_max; + setrlimit(RLIMIT_NOFILE, &rl); + } +} + +KqueueFileWatcher::KqueueFileWatcher() { + RaiseFdLimit(); + + kqueueFd = kqueue(); + if (kqueueFd == -1) { + isValid = false; + return; + } + + // A pipe lets the destructor unblock kevent() cleanly without signals. + if (pipe(wakeupPipe) == -1) { + close(kqueueFd); + kqueueFd = -1; + isValid = false; + return; + } + + struct kevent ev; + EV_SET(&ev, wakeupPipe[0], EVFILT_READ, EV_ADD, 0, 0, nullptr); + kevent(kqueueFd, &ev, 1, nullptr, 0, nullptr); + + eventThread = std::thread(&KqueueFileWatcher::eventLoop, this); +} + +KqueueFileWatcher::~KqueueFileWatcher() { + isValid = false; + stopping = true; + + // Unblock the event loop thread. + char byte = 0; + write(wakeupPipe[1], &byte, 1); + + if (eventThread.joinable()) { + eventThread.join(); + } + + { + std::lock_guard lock(mapMutex); + for (auto &pair : handlesToFds) { + close(pair.second); + } + handlesToFds.clear(); + fdsToHandles.clear(); + } + + close(wakeupPipe[0]); + close(wakeupPipe[1]); + close(kqueueFd); +} + +efsw::WatchID KqueueFileWatcher::addWatch(const std::string &path, + efsw::FileWatchListener *listener, + bool /* _useRecursion */ +) { + if (!isValid) { + return efsw::Errors::WatcherFailed; + } + + int fd = open(path.c_str(), O_EVTONLY); + if (fd < 0) { + switch (errno) { + case ENOENT: + return efsw::Errors::FileNotFound; + case EACCES: + case EPERM: + return efsw::Errors::FileNotReadable; + default: + return efsw::Errors::WatcherFailed; + } + } + + efsw::WatchID handle; + { + std::lock_guard lock(mapMutex); + handle = nextHandleID++; + handlesToFds[handle] = fd; + fdsToHandles[fd] = handle; + handlesToPaths[handle] = path; + handlesToListeners[handle] = listener; + } + + // Store the handle in udata so the event loop can identify the watch even if + // the fd has been reused (the handle will no longer be in handlesToPaths, so + // the event will be safely ignored). + struct kevent ev; + int fflags = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME | NOTE_ATTRIB; + EV_SET(&ev, fd, EVFILT_VNODE, EV_ADD | EV_ENABLE | EV_CLEAR, fflags, 0, + reinterpret_cast(static_cast(handle))); + + if (kevent(kqueueFd, &ev, 1, nullptr, 0, nullptr) == -1) { + std::lock_guard lock(mapMutex); + handlesToFds.erase(handle); + fdsToHandles.erase(fd); + handlesToPaths.erase(handle); + handlesToListeners.erase(handle); + close(fd); + return efsw::Errors::WatcherFailed; + } + + return handle; +} + +void KqueueFileWatcher::removeWatch(efsw::WatchID handle) { + int fd = -1; + { + std::lock_guard lock(mapMutex); + auto it = handlesToFds.find(handle); + if (it != handlesToFds.end()) { + fd = it->second; + handlesToFds.erase(it); + fdsToHandles.erase(fd); + } + // Always erase these; the event loop may have already closed the fd + // (e.g. after NOTE_RENAME/NOTE_DELETE) but left the handle in place. + handlesToPaths.erase(handle); + handlesToListeners.erase(handle); + } + // Closing the fd automatically removes its EVFILT_VNODE filter from the + // kqueue. + if (fd >= 0) + close(fd); +} + +void KqueueFileWatcher::sendFileAction(efsw::WatchID handle, + const std::string &dir, + const std::string &filename, + efsw::Action action, + const std::string &oldFilename) { + efsw::FileWatchListener *listener = nullptr; + { + std::lock_guard lock(mapMutex); + auto it = handlesToListeners.find(handle); + if (it == handlesToListeners.end()) + return; + listener = it->second; + } + listener->handleFileAction(handle, dir, filename, action, oldFilename); +} + +void KqueueFileWatcher::closeFd(efsw::WatchID handle, int fd) { + { + std::lock_guard lock(mapMutex); + handlesToFds.erase(handle); + fdsToHandles.erase(fd); + } + close(fd); +} + +bool KqueueFileWatcher::reopenFd(efsw::WatchID handle, + const std::string &path) { + int newFd = open(path.c_str(), O_EVTONLY); + if (newFd < 0) + return false; + + { + std::lock_guard lock(mapMutex); + handlesToFds[handle] = newFd; + fdsToHandles[newFd] = handle; + } + + struct kevent ev; + int fflags = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME | NOTE_ATTRIB; + EV_SET(&ev, newFd, EVFILT_VNODE, EV_ADD | EV_ENABLE | EV_CLEAR, fflags, 0, + reinterpret_cast(static_cast(handle))); + + if (kevent(kqueueFd, &ev, 1, nullptr, 0, nullptr) == -1) { + std::lock_guard lock(mapMutex); + handlesToFds.erase(handle); + fdsToHandles.erase(newFd); + close(newFd); + return false; + } + + return true; +} + +void KqueueFileWatcher::eventLoop() { + while (!stopping) { + struct kevent event; + int r; + do { + r = kevent(kqueueFd, nullptr, 0, &event, 1, nullptr); + } while (r == -1 && errno == EINTR); + + if (r <= 0 || stopping) + break; + + // Wakeup pipe: destructor is signalling us to exit. + if (static_cast(event.ident) == wakeupPipe[0]) + break; + + int fd = static_cast(event.ident); + + // Recover the handle from the udata we stored at registration time. + // This avoids a map lookup on the fd, which could be stale if the fd + // was closed and its number reused by the OS. + efsw::WatchID handle = + static_cast(reinterpret_cast(event.udata)); + + // Confirm the handle is still registered. If removeWatch() was called + // between the event being queued and us processing it, skip. + std::string watchedPath; + { + std::lock_guard lock(mapMutex); + auto it = handlesToPaths.find(handle); + if (it == handlesToPaths.end()) + continue; + watchedPath = it->second; + } + + std::pair parts = SplitPath(watchedPath); + const std::string &dir = parts.first; + const std::string &filename = parts.second; + + if (event.fflags & NOTE_RENAME) { + // The inode we were watching has been renamed. F_GETPATH returns its + // current (new) path. We must call it before closing the fd. + char newPathBuf[MAXPATHLEN] = {0}; + bool gotPath = (fcntl(fd, F_GETPATH, newPathBuf) == 0); + std::string newPath(newPathBuf); + + closeFd(handle, fd); + + if (gotPath && !newPath.empty() && newPath != watchedPath) { + // The file moved to a genuinely different path. Report a move, using + // the old basename as context. The handle is now effectively without + // an active fd; the caller should removeWatch() and addWatch() again + // at the new path if it wants to keep following it. + std::pair newParts = SplitPath(newPath); + if (parts.first != newParts.first) { + // Treat moves outside of the directory as deletions. + sendFileAction(handle, dir, filename, efsw::Actions::Delete); + } else { + sendFileAction(handle, newParts.first, newParts.second, + efsw::Actions::Moved, filename); + } + } else { + // F_GETPATH failed or returned the same path — treat as a deletion. + sendFileAction(handle, dir, filename, efsw::Actions::Delete); + } + + } else if (event.fflags & NOTE_DELETE) { + // The inode was deleted. This also fires when an atomic save replaces + // the file: rename(tmp, target) unlinks target's inode, then places + // tmp's inode at target's path. Because rename(2) is atomic, by the + // time kevent delivers NOTE_DELETE the new file is already in place. + closeFd(handle, fd); + + struct stat st; + if (stat(watchedPath.c_str(), &st) == 0 && + reopenFd(handle, watchedPath)) { + // A new file appeared at the same path: atomic save. + sendFileAction(handle, dir, filename, efsw::Actions::Modified); + } else { + // The path is genuinely gone. + sendFileAction(handle, dir, filename, efsw::Actions::Delete); + } + + } else if (event.fflags & NOTE_WRITE) { + sendFileAction(handle, dir, filename, efsw::Actions::Modified); + } else if (event.fflags & NOTE_ATTRIB) { + // macOS sometimes skips NOTE_WRITE when a file is truncated to empty, + // firing NOTE_ATTRIB instead. Detect this by seeking to the end. + if (lseek(fd, 0, SEEK_END) == 0) { + sendFileAction(handle, dir, filename, efsw::Actions::Modified); + } + } + } +} diff --git a/lib/platform/KqueueFileWatcher.hpp b/lib/platform/KqueueFileWatcher.hpp new file mode 100644 index 0000000..aca1c9e --- /dev/null +++ b/lib/platform/KqueueFileWatcher.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "../../vendor/efsw/include/efsw/efsw.hpp" + +// An API-compatible replacement for FSEventsFileWatcher that uses kqueue +// instead of FSEvents. Intended for experimentation; swap in via core.h. +// +// Key differences from FSEventsFileWatcher: +// - No daemon dependency (no fseventsd); pure kernel interface. +// - Consumes one file descriptor per watched path (using O_EVTONLY). +// - Raises the process soft fd limit to the hard limit on construction. +// - Watches inode identity, not path identity: when a file is renamed, the +// fd follows the inode. Atomic saves (which replace the inode) are detected +// via stat() after NOTE_DELETE and reported as Modified rather than Delete. +// - No recursive watching; the _useRecursion flag is ignored. +class KqueueFileWatcher { +public: + KqueueFileWatcher(); + ~KqueueFileWatcher(); + + efsw::WatchID addWatch( + const std::string& path, + efsw::FileWatchListener* listener, + bool _useRecursion = false + ); + + void removeWatch(efsw::WatchID handle); + + bool isValid = true; + +private: + void eventLoop(); + + void sendFileAction( + efsw::WatchID handle, + const std::string& dir, + const std::string& filename, + efsw::Action action, + const std::string& oldFilename = "" + ); + + // Closes the fd and removes it from the fd maps, but leaves the handle in + // handlesToPaths / handlesToListeners so that removeWatch() still works. + void closeFd(efsw::WatchID handle, int fd); + + // Opens a new fd for the given path and registers it with kqueue under an + // existing handle. Used after an atomic save replaces the watched inode. + bool reopenFd(efsw::WatchID handle, const std::string& path); + + long nextHandleID = 1; + int kqueueFd = -1; + int wakeupPipe[2] = {-1, -1}; + std::atomic stopping{false}; + std::mutex mapMutex; + std::thread eventThread; + + std::unordered_map handlesToFds; + std::unordered_map fdsToHandles; + std::unordered_map handlesToPaths; + std::unordered_map handlesToListeners; +}; diff --git a/spec/pathwatcher-spec.js b/spec/pathwatcher-spec.js index edc61a4..bd62589 100644 --- a/spec/pathwatcher-spec.js +++ b/spec/pathwatcher-spec.js @@ -22,7 +22,10 @@ describe('PathWatcher', () => { describe('getWatchedPaths', () => { it('returns an array of all watched paths', () => { let realTempFilePath = fs.realpathSync(tempFile); - let expectedWatchPath = path.dirname(realTempFilePath); + let expectedWatchPath = realTempFilePath; + if (process.platform !== 'darwin') { + expectedWatchPath = path.dirname(realTempFilePath); + } expect(PathWatcher.getWatchedPaths()).toEqual([]); @@ -47,7 +50,10 @@ describe('PathWatcher', () => { describe('closeAllWatchers', () => { it('closes all watched paths', () => { let realTempFilePath = fs.realpathSync(tempFile); - let expectedWatchPath = path.dirname(realTempFilePath); + let expectedWatchPath = realTempFilePath; + if (process.platform !== 'darwin') { + expectedWatchPath = path.dirname(realTempFilePath); + } expect(PathWatcher.getWatchedPaths()).toEqual([]); PathWatcher.watch(tempFile, EMPTY); expect(PathWatcher.getWatchedPaths()).toEqual([expectedWatchPath]); diff --git a/src/file.js b/src/file.js index fdf40c2..0525076 100644 --- a/src/file.js +++ b/src/file.js @@ -250,8 +250,12 @@ class File { // // * `path` {String} The new path to set; should be absolute. setPath (path) { + let pathDidChange = path !== this.path; this.path = path; this.realPath = null; + if (pathDidChange && this.watchSubscription) { + this.unsubscribeFromNativeChangeEvents(); + } } // Public: Returns a {Promise} that resolves to this file’s completely @@ -482,6 +486,7 @@ class File { return; case 'rename': this.setPath(eventPath); + this.subscribeToNativeChangeEvents(); if (Grim.includeDeprecatedAPIs) { this.emit('moved'); } @@ -514,6 +519,8 @@ class File { subscribeToNativeChangeEvents () { PathWatcher ??= require('./main'); + if (this.watchedPath === this.path) return; + this.watchedPath = this.path; this.watchSubscription ??= PathWatcher.watch( this.path, (...args) => { @@ -526,6 +533,7 @@ class File { unsubscribeFromNativeChangeEvents () { this.watchSubscription?.close(); this.watchSubscription &&= null; + this.watchedPath &&= null; } } diff --git a/src/main.js b/src/main.js index 840fc86..9e77527 100644 --- a/src/main.js +++ b/src/main.js @@ -180,7 +180,7 @@ let PathWatcherId = 10; // about; it’s the `PathWatcher`’s job to filter this stream and ignore the // irrelevant events. // -// For instance, a `NativeWatcher` can only watch a directory, but a +// For instance, a `NativeWatcher` may only watch a directory, but a // `PathWatcher` can watch a specific file in the directory. In that case, it’s // up to the `PathWatcher` to ignore any events that do not pertain to that // file. @@ -221,16 +221,23 @@ class PathWatcher { // dependence on this library and move its consumers to a file-watcher // contract with an asynchronous API. this.normalizedPath = getRealFilePath(watchedPath); + this.isDirectory = false; + try { + let stat = fs.statSync(this.normalizedPath); + this.isDirectory = stat.isDirectory(); + } catch (err) { + this.isDirectory = false; + } // try { // this.normalizedPath = fs.realpathSync(watchedPath) ?? watchedPath; // } catch (err) { // this.normalizedPath = watchedPath; // } - // We must watch a directory. If this is a file, we must watch its parent. + // In certain scenarios, we must watch a directory — meaning, if this is a file, we must watch its parent. // If this is a directory, we can watch it directly. This flag helps us // keep track of it. - this.isWatchingParent = !isDirectory(this.normalizedPath); + this.isWatchingParent = process.platform !== 'darwin' && !isDirectory(this.normalizedPath); // `originalNormalizedPath` will always contain the resolved (real path on // disk) file path that we care about. @@ -407,18 +414,31 @@ class PathWatcher { return; } - switch (newEvent.action) { case 'rename': // This event needs no alteration… as long as it relates to the file // we care about. - if (!eventPathIsEqual && !eventOldPathIsEqual) return; + if (!eventPathIsEqual && !eventOldPathIsEqual) { + return; + } break; case 'change': + // Must relate to the file we care about. + if (!eventPathIsEqual) return; + if (!this.isWatchingParent) { + newEvent.path = ''; + } + break; case 'delete': + if (!eventPathIsEqual) return; + if (this.isDirectory) { + // Do nothing. If you're watching a directory, you don't get notified + // when the directory gets deleted. This matches existing behavior. + return; + } + break; case 'create': - // These events need no alteration… as long as they relate to the file - // we care about. + // Must relate to the file we care about. if (!eventPathIsEqual) return; break; case 'child-create': @@ -497,7 +517,10 @@ class PathWatcher { this.moveToPath(event.path); } - if (oldWatched && newWatched) { + let isSameDirectory = !this.isWatchingParent && + path.dirname(event.path) == path.dirname(event.oldPath); + + if ((oldWatched && newWatched) || isSameDirectory) { // We can keep tabs on both file paths from here, so this will // be treated as a rename. newEvent.action = 'rename';