diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 918017d..4439645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest - macos-latest - - windows-latest + - windows-2022 node_version: # - 14 # - 18 diff --git a/README.md b/README.md index 457fe2e..ad64f2a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ Watch files and directories for changes. > [!IMPORTANT] -> This library is used in [Pulsar][] in several places for compatibility reasons. The [nsfw](https://www.npmjs.com/package/nsfw) library is more robust and more widely used; it is available in Pulsar via `atom.watchPath` and is usually a better choice. +> This library is used in [Pulsar][] in several places for compatibility reasons. If you’re here because you want a general-purpose file-watching library for Node, use `nsfw` or `@parcel/watcher` instead. > -> If you’re here because you want a general-purpose file-watching library for Node, use `nsfw` instead. -> -> The purpose of this library’s continued inclusion in Pulsar is to provide the [File][] and [Directory][] classes that have long been available as exports via `require('atom')`. +> The purpose of this library’s continued inclusion in Pulsar is to provide the [File][] and [Directory][] classes that have long been available as exports via `require('atom')`. It also delivers reliable file-watching on macOS on volumes that `FSEvents` cannot support (i.e., network volumes and drives that use incompatible filesystems). ## Installing @@ -29,10 +27,12 @@ This module is context-aware and context-safe; it can be used from multiple work If you’re using it in an Electron renderer process, you must take extra care in page reloading scenarios. Be sure to use `closeAllWatchers` well before the page environment is terminated — e.g., by attaching a `beforeunload` listener. +Be sure to read the more specific file-watching caveats below. + ## Using ```js -const PathWatcher = require('pathwatcher'); +const { watch, closeAllWatchers, getWatchedPaths } = require('pathwatcher'); ``` ### `watch(filename, listener)` @@ -47,14 +47,11 @@ Returns an instance of `PathWatcher`. This instance is useful primarily for the #### Caveats -* Watching a specific file or directory will not notify you when that file or directory is created, since the file must already exist before you start watching the path. +* All watching is **non-recursive**. If you watch `/foo/bar`, you will be notified about a change to `/foo/bar/index.js`, or the creation of the directory `/foo/bar/baz`; but you will not be told about a change to `/foo/bar/baz/something.js`. +* You may not watch a nonexistent path. Thus watching a specific file or directory can never notify you when that file or directory is created. * When watching a file, `event` can be any of `rename`, `delete`, or `change`, where `change` means that the file’s contents changed somehow. -* When watching a directory, `event` can only be `change`, and in this context `change` signifies that one or more of the directory’s children changed (by being renamed, deleted, added, or modified). -* A watched directory will not report when it is renamed or deleted. If you want to detect when a given directory is deleted, watch its parent directory and test for the child directory’s existence when you receive a `change` event. - -### `PathWatcher::close()` - -Stop watching for changes on the given `PathWatcher`. +* When watching a directory, `event` can **only** be `change`, and in this context `change` signifies that one or more of the directory’s children changed (by being renamed, deleted, added, or modified). +* A watched directory will not report when it is renamed or deleted; it will simply stop reporting events. If you want to detect when a given directory is renamed or deleted, watch its parent directory and test for the child directory’s existence when you receive a `change` event. (But if the parent directory itself can possibly be renamed or deleted, you’re in a pickle! This scenario is better suited to a recursive watcher.) ### `closeAllWatchers()` @@ -62,9 +59,16 @@ Stop watching on all subscribed paths. All existing `PathWatcher` instances wil ### `getWatchedPaths()` -Returns an array of strings representing the actual paths that are being watched on disk. +Returns an array of strings representing the **actual paths** that are being watched on disk. -`pathwatcher` watches directories in all instances, since it’s easy to do so in a cross-platform manner. +These paths may not correlate to the paths that a consumer asked to watch for two reasons: + +* Some platforms, when asked to watch `/foo/bar.js`, watch `/foo` instead. This allows for watcher reuse in some scenarios and would explain why the number of watched paths may not correlate to the number of active watchers. +* `pathwatcher` does `realpath` resolution and watches at a path’s canonical location on disk — which may or may not match the path you asked to watch. + +### `PathWatcher::close()` + +Stop watching for changes on the given `PathWatcher`. ### `File` and `Directory` 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..330d059 --- /dev/null +++ b/lib/platform/InotifyFileWatcher.cpp @@ -0,0 +1,353 @@ + +#include "InotifyFileWatcher.hpp" + +#include +#include +#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. Nothing useful to do if this fails, but + // `write` is declared `warn_unused_result`, so discard the result + // explicitly rather than let it warn. + char byte = 0; + (void)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 += '/'; + + // We hold `mapMutex` across the `inotify_add_watch()` call below, just as + // `removeWatch()` does for its removal. The kernel can start queueing events + // for this watch the moment it exists, and the event loop needs the same + // lock to find the listeners for a wd — so taking it first means the event + // loop waits for us to finish registering rather than reading events for a + // wd we haven't recorded yet and dropping them on the floor. + std::lock_guard lock(mapMutex); + + 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; + } + } + + efsw::WatchID handle = nextHandleID++; + handlesToWatches[handle] = {dir, listener, wd}; + wdToHandles.insert({wd, handle}); + return handle; +} + +void InotifyFileWatcher::removeWatch(efsw::WatchID handle) { + // We hold `mapMutex` across the `inotify_rm_watch()` call below. That's + // load-bearing: it ensures we've recorded that we're expecting an + // IN_IGNORED before the event loop — which takes the same lock in + // `forgetWatch()` — can possibly read it. + std::lock_guard lock(mapMutex); + auto it = handlesToWatches.find(handle); + if (it == handlesToWatches.end()) + return; + int 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; + } + } + + // Other handles still care about this wd, so leave the kernel watch alone. + if (wdToHandles.find(wd) != wdToHandles.end()) + return; + + // Removing the watch makes the kernel queue an IN_IGNORED for this wd. Note + // that we're expecting it, so the event loop doesn't mistake it for a + // kernel-initiated removal of whatever watch holds this number by the time + // we get around to reading it. EINVAL means the kernel already dropped the + // watch on its own (e.g. the directory was deleted), in which case nothing + // new is coming from us and there's nothing to account for. + if (inotify_rm_watch(inotifyFd, wd) == 0) + pendingIgnored[wd]++; +} + +void InotifyFileWatcher::forgetWatch(int wd) { + std::lock_guard lock(mapMutex); + + auto pending = pendingIgnored.find(wd); + if (pending != pendingIgnored.end()) { + // This IN_IGNORED is the echo of our own `inotify_rm_watch()`; + // `removeWatch()` already dropped the bookkeeping. Anything registered + // under this wd now is a newer watch that reuses the number, so we leave + // it alone. + if (--pending->second <= 0) + pendingIgnored.erase(pending); + return; + } + + auto range = wdToHandles.equal_range(wd); + for (auto it = range.first; it != range.second;) { + handlesToWatches.erase(it->second); + it = wdToHandles.erase(it); + } +} + +void InotifyFileWatcher::stopWatch(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); + } + + // Same accounting as `removeWatch()`: the kernel queues an IN_IGNORED for + // every watch we remove, and `forgetWatch()` needs to know we asked for it. + // A failure here means the watch was already gone, so nothing is coming. + if (inotify_rm_watch(inotifyFd, wd) == 0) + pendingIgnored[wd]++; +} + +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() { + // `alignas` because we cast into this buffer to read `inotify_event`s out of + // it, and that type has stricter alignment than `char`. + alignas(struct inotify_event) 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; + // Any other poll() failure is not something we can recover from. + isValid = false; + break; + } + + if (stopping || (fds[1].revents & POLLIN)) + break; + + // An error on either descriptor is fatal, and we must not `continue` past + // it: poll() reports the condition immediately and forever, ignoring our + // timeout, so looping would spin at 100% CPU without ever reading + // anything. Better to stop the thread and mark ourselves invalid, which + // makes subsequent `addWatch()` calls fail loudly instead of silently + // never delivering events. + if ((fds[0].revents | fds[1].revents) & (POLLERR | POLLHUP | POLLNVAL)) { + isValid = false; + 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) { + // The kernel's event queue filled up and it dropped events to make + // room. There's nothing we can do to recover them, and no way to know + // what we missed, so every watch is potentially out of sync from here + // on. Worth logging, since it's otherwise invisible and would look + // like the watcher simply stopped noticing some edits. +#ifdef DEBUG + std::cout << "InotifyFileWatcher: event queue overflowed; some events " + "were dropped by the kernel" + << std::endl; +#endif + 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 watch is gone: either we removed it ourselves, or the kernel + // dropped it (directory deleted, filesystem unmounted). + // `forgetWatch` tells those two cases apart. + 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). + // + // This must happen before any teardown below, since `sendFileAction` + // finds its listeners by looking up `wd` in our bookkeeping. + sendFileAction(event->wd, "", efsw::Actions::Delete); + + if (event->mask & IN_MOVE_SELF) { + // Unlike a deletion, a move leaves the watch alive — the directory + // still exists, just somewhere else — and the kernel sends no + // IN_IGNORED. Left alone, it would keep reporting events for the + // directory's children under the stale path we recorded in + // `Watch::dir`. We've just told the caller the directory is gone, + // so make that true and shut the watch down. + stopWatch(event->wd); + } + 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..f96da96 --- /dev/null +++ b/lib/platform/InotifyFileWatcher.hpp @@ -0,0 +1,93 @@ +#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); + + // Atomic because the event loop thread clears it when it hits an + // unrecoverable poll() error, while `addWatch()` reads it on the main + // thread. + std::atomic 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 = ""); + + // Responds to an IN_IGNORED for `wd`. If we provoked it ourselves by + // calling `inotify_rm_watch()`, it's already accounted for and we do + // nothing. Otherwise the kernel dropped the watch on its own (e.g. the + // directory was deleted or unmounted), so we drop all bookkeeping for `wd`. + void forgetWatch(int wd); + + // Tears down a watch that the kernel is still holding open, dropping all + // bookkeeping for `wd` and telling the kernel to stop watching. Used when + // the watched directory moves (IN_MOVE_SELF): the watch survives the move, + // but it would report every subsequent event under the directory's old, + // now-wrong path, so we shut it down instead. + void stopWatch(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; + + // wd -> number of IN_IGNORED events we've asked the kernel for (via + // `inotify_rm_watch()`) but haven't read yet. Lets the event loop tell our + // own removals apart from kernel-initiated ones. + std::unordered_map pendingIgnored; +}; diff --git a/spec/pathwatcher-spec.js b/spec/pathwatcher-spec.js index 05f45b0..1d56f03 100644 --- a/spec/pathwatcher-spec.js +++ b/spec/pathwatcher-spec.js @@ -40,6 +40,38 @@ describe('PathWatcher', () => { expect(eventType).toBe('change'); expect(eventPath).toBe(''); }); + + it('keeps watching the file that replaced the original', async () => { + // An atomic save doesn't modify the watched file; it replaces it with a + // different one. Platforms that watch the file itself (rather than its + // parent directory) therefore have to notice this and re-attach to + // whatever now lives at that path — otherwise the save gets reported and + // nothing ever is again. + let spy = jasmine.createSpy('spy'); + PathWatcher.watch(tempFile, spy); + + await wait(20); + + let tempFileCopy = path.join(tempDir, 'file-copy'); + fs.writeFileSync(tempFileCopy, 'atomic save content'); + fs.renameSync(tempFileCopy, tempFile); + + await condition(() => spy.calls.count() > 0); + expect(spy).toHaveBeenCalledWith('change', ''); + + // Let any further events from the save itself land before we reset, so + // that a straggler can't be mistaken for the event we're about to + // provoke. + await wait(200); + spy.calls.reset(); + + // The real test: an ordinary write to the file that now occupies the + // watched path must still be reported. + fs.writeFileSync(tempFile, 'subsequent change'); + + await condition(() => spy.calls.count() > 0); + expect(spy).toHaveBeenCalledWith('change', ''); + }); }); describe('getWatchedPaths', () => { @@ -197,6 +229,61 @@ describe('PathWatcher', () => { }); } + // Once the directory containing a watched file is renamed away, the file we + // were asked to watch no longer exists at the path we were asked to watch. + // Writes to the file at its new location should not be reported as changes + // to the old path. This should hold on every platform, so this block also + // serves to keep the backends in agreement with each other. + // + // Linux got this wrong: we watch a file there by watching its parent + // directory, and `inotify` watches that directory itself rather than its + // name. A rename leaves the watch alive — the directory still exists, just + // somewhere else — while giving us no way to learn its new path, so the + // watch went on reporting events under the path the directory had when we + // started watching it. + describe('when the parent directory of a watched file is renamed', () => { + let movedDir; + + afterEach(() => { + if (movedDir && fs.existsSync(movedDir)) { + fs.rmSync(movedDir, { recursive: true }); + } + movedDir = null; + }); + + it('stops reporting changes against the old path', async () => { + let subDir = path.join(tempDir, 'renamed-parent'); + fs.mkdirSync(subDir); + let watchedFile = path.join(subDir, 'file'); + fs.writeFileSync(watchedFile, ''); + + let spy = jasmine.createSpy('spy'); + PathWatcher.watch(watchedFile, spy); + await wait(20); + + // First prove that the watcher works at all, so that the assertion at + // the end can't pass merely because we set something up wrong. + fs.writeFileSync(watchedFile, 'changed'); + await condition(() => spy.calls.count() > 0); + expect(spy).toHaveBeenCalledWith('change', ''); + + // Now rename the directory that contains the file we're watching. + movedDir = path.join(tempDir, 'renamed-parent-moved'); + fs.renameSync(subDir, movedDir); + await wait(200); + + spy.calls.reset(); + + // A write to the file at its _new_ path must not be reported as a change + // to the old one. + fs.writeFileSync(path.join(movedDir, 'file'), 'changed again'); + await wait(200); + + expect(fs.existsSync(watchedFile)).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + }); + describe('when a file under a watched directory is deleted', () => { it('fires the callback with the change event and empty path', async () => { let fileUnderDir = path.join(tempDir, 'file'); diff --git a/vendor/efsw/src/efsw/WatcherWin32.cpp b/vendor/efsw/src/efsw/WatcherWin32.cpp index 712419e..957f987 100644 --- a/vendor/efsw/src/efsw/WatcherWin32.cpp +++ b/vendor/efsw/src/efsw/WatcherWin32.cpp @@ -51,6 +51,34 @@ static void initReadDirectoryChangesEx() { } } +/// PULSAR PATCH: Returns the path that `handle` currently resolves to, or an +/// empty string if it can't be determined — which includes the case where the +/// directory has been deleted. Not part of upstream efsw. +static std::wstring GetHandlePath( HANDLE handle ) { + if ( NULL == handle || INVALID_HANDLE_VALUE == handle ) + return std::wstring(); + + // Called with a NULL buffer, this returns the length required *including* + // the null terminator; called with a buffer, it returns the number of + // characters written *excluding* it. + DWORD len = + GetFinalPathNameByHandleW( handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS ); + + if ( 0 == len ) + return std::wstring(); + + std::wstring path( len, L'\0' ); + + DWORD written = GetFinalPathNameByHandleW( handle, &path[0], len, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS ); + + if ( 0 == written || written >= len ) + return std::wstring(); + + path.resize( written ); + return path; +} + void WatchCallbackOld( WatcherWin32* pWatch ) { PFILE_NOTIFY_INFORMATION pNotify; size_t offset = 0; @@ -160,6 +188,19 @@ void CALLBACK WatchCallback( DWORD dwNumberOfBytesTransfered, LPOVERLAPPED lpOve WatcherStructWin32* tWatch = (WatcherStructWin32*)lpOverlapped; WatcherWin32* pWatch = tWatch->Watch; + // PULSAR PATCH: The directory handle stays valid when the directory is + // renamed or moved, so the watch keeps reporting changes to its children — + // but every path we report is rebuilt from the directory path we were given + // at watch time. Once the handle resolves somewhere else, every path we'd + // emit names a file that isn't there. Stop the watch instead: returning + // without reaching `RefreshWatch` below leaves it un-rearmed, so it goes + // quiet for good. The handle is released later, when the watch is removed. + if ( NULL != pWatch && !pWatch->CanonicalPath.empty() && + pWatch->CanonicalPath != GetHandlePath( pWatch->DirHandle ) ) { + pWatch->StopNow = true; + return; + } + if ( dwNumberOfBytesTransfered == 0 ) { if ( nullptr != pWatch && !pWatch->StopNow ) { RefreshWatch( tWatch ); @@ -246,6 +287,9 @@ WatcherStructWin32* CreateWatch( LPCWSTR szDirectory, bool recursive, CreateIoCompletionPort( pWatch->DirHandle, iocp, 0, 1 ) ) { pWatch->NotifyFilter = notifyFilter; pWatch->Recursive = recursive; + // PULSAR PATCH: remember where this handle points now, so `WatchCallback` + // can notice later if the directory has been moved out from under it. + pWatch->CanonicalPath = GetHandlePath( pWatch->DirHandle ); if ( RefreshResult::Failed != RefreshWatch( tWatch ) ) { return tWatch; diff --git a/vendor/efsw/src/efsw/WatcherWin32.hpp b/vendor/efsw/src/efsw/WatcherWin32.hpp index ea1e8e4..08da867 100644 --- a/vendor/efsw/src/efsw/WatcherWin32.hpp +++ b/vendor/efsw/src/efsw/WatcherWin32.hpp @@ -61,6 +61,12 @@ class WatcherWin32 : public Watcher { WatcherStructWin32* Struct; HANDLE DirHandle; + // PULSAR PATCH: the path `DirHandle` resolved to when the watch was + // created, as reported by `GetFinalPathNameByHandleW`. Compared against the + // handle's current path on each completion so that we can detect when the + // watched directory is renamed or moved out from under us. Not part of + // upstream efsw; see `WatchCallback`. + std::wstring CanonicalPath; std::vector Buffer; LPARAM lParam; DWORD NotifyFilter;