diff --git a/README.md b/README.md index 3da23411..1718b1a5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ const w = await watcher.watchPath('/var/log', {}, events => { // Absolute path to the filesystem entry that was touched console.log(`Event path: ${event.path}`) - // "file", "directory", or "unknown" + // "file", "directory", "symlink", or "unknown" console.log(`Event entry kind: ${event.kind}`) if (event.action === 'renamed') { @@ -111,7 +111,7 @@ The _options_ argument configures the nature of the watch. Pass `{}` to accept t The _callback_ argument will be called repeatedly with each batch of filesystem events that are delivered until the [`.dispose() method`](#pathwatcherdispose) is called. Event batches are `Arrays` containing objects with the following keys: * `action`: a `String` describing the filesystem action that occurred. One of `"created"`, `"modified"`, `"deleted"`, or `"renamed"`. -* `kind`: a `String` distinguishing the type of filesystem entry that was acted upon, if known. One of `"file"`, `"directory"`, or `"unknown"`. +* `kind`: a `String` distinguishing the type of filesystem entry that was acted upon, if known. One of `"file"`, `"directory"`, `"symlink"`, or `"unknown"`. * `path`: a `String` containing the absolute path to the filesystem entry that was acted upon. In the event of a rename, this is the _new_ path of the entry. * `oldPath`: a `String` containing the former absolute path of a renamed filesystem entry. Omitted when action is not `"renamed"`. diff --git a/lib/native-watcher.js b/lib/native-watcher.js index ad15ac69..93c561cc 100644 --- a/lib/native-watcher.js +++ b/lib/native-watcher.js @@ -11,7 +11,8 @@ const ACTIONS = new Map([ const ENTRIES = new Map([ [0, 'file'], [1, 'directory'], - [2, 'unknown'] + [2, 'symlink'], + [3, 'unknown'] ]) // Private: Possible states of a {NativeWatcher}. diff --git a/package.json b/package.json index d80302ce..57f46eb7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "format:cpp": "script/c++-format", "format:js": "standard --fix", "build:debug": "node --harmony script/helper/gen-compilation-db.js rebuild --debug", - "test": "mocha --require test/global.js --require mocha-stress --recursive --harmony", + "test": "mocha", "test:lldb": "lldb -- node --harmony ./node_modules/.bin/_mocha --require test/global.js --require mocha-stress --recursive", "test:gdb": "gdb --args node --harmony ./node_modules/.bin/_mocha --require test/global.js --require mocha-stress --recursive", "ci:appveyor": "npm run test -- --fgrep ^windows --invert --reporter mocha-appveyor-reporter --reporter-options appveyorBatchSize=5 --timeout 30000", diff --git a/src/helper/libuv.h b/src/helper/libuv.h index e428a351..1bcb136e 100644 --- a/src/helper/libuv.h +++ b/src/helper/libuv.h @@ -35,6 +35,7 @@ inline bool ts_not_equal(const uv_timespec_t &left, const uv_timespec_t &right) inline EntryKind kind_from_stat(const uv_stat_t &st) { + if ((st.st_mode & S_IFLNK) == S_IFLNK) return KIND_SYMLINK; if ((st.st_mode & S_IFDIR) == S_IFDIR) return KIND_DIRECTORY; if ((st.st_mode & S_IFREG) == S_IFREG) return KIND_FILE; return KIND_UNKNOWN; diff --git a/src/message.cpp b/src/message.cpp index 8c81be03..9f561c8d 100644 --- a/src/message.cpp +++ b/src/message.cpp @@ -30,6 +30,7 @@ ostream &operator<<(ostream &out, EntryKind kind) switch (kind) { case KIND_FILE: out << "file"; break; case KIND_DIRECTORY: out << "directory"; break; + case KIND_SYMLINK: out << "symlink"; break; case KIND_UNKNOWN: out << "unknown"; break; default: out << "!! EntryKind=" << static_cast(kind); } diff --git a/src/message.h b/src/message.h index 526a429e..2085ea6a 100644 --- a/src/message.h +++ b/src/message.h @@ -14,7 +14,8 @@ enum EntryKind { KIND_FILE = 0, KIND_DIRECTORY = 1, - KIND_UNKNOWN = 2, + KIND_SYMLINK = 2, + KIND_UNKNOWN = 3, KIND_MIN = KIND_FILE, KIND_MAX = KIND_UNKNOWN }; diff --git a/src/worker/linux/linux_worker_platform.cpp b/src/worker/linux/linux_worker_platform.cpp index 85f70b31..c6910400 100644 --- a/src/worker/linux/linux_worker_platform.cpp +++ b/src/worker/linux/linux_worker_platform.cpp @@ -8,6 +8,7 @@ #include "../../log.h" #include "../../message.h" #include "../../result.h" +#include "../recent_file_cache.h" #include "../worker_platform.h" #include "../worker_thread.h" #include "cookie_jar.h" @@ -21,11 +22,13 @@ using std::string; using std::unique_ptr; using std::vector; +const size_t DEFAULT_CACHE_SIZE = 4096; + // Platform-specific worker implementation for Linux systems. class LinuxWorkerPlatform : public WorkerPlatform { public: - LinuxWorkerPlatform(WorkerThread *thread) : WorkerPlatform(thread) + LinuxWorkerPlatform(WorkerThread *thread) : WorkerPlatform(thread), cache{DEFAULT_CACHE_SIZE} { report_errable(pipe); report_errable(registry); @@ -67,7 +70,7 @@ class LinuxWorkerPlatform : public WorkerPlatform if ((to_poll[1].revents & (POLLIN | POLLERR)) != 0u) { MessageBuffer messages; - Result<> cr = registry.consume(messages, jar); + Result<> cr = registry.consume(messages, jar, cache); if (cr.is_error()) LOGGER << cr << endl; if (!messages.empty()) { @@ -128,6 +131,7 @@ class LinuxWorkerPlatform : public WorkerPlatform Pipe pipe; WatchRegistry registry; CookieJar jar; + RecentFileCache cache; }; unique_ptr WorkerPlatform::for_worker(WorkerThread *thread) diff --git a/src/worker/linux/watch_registry.cpp b/src/worker/linux/watch_registry.cpp index 62e79c96..168e6334 100644 --- a/src/worker/linux/watch_registry.cpp +++ b/src/worker/linux/watch_registry.cpp @@ -16,6 +16,7 @@ #include "../../message.h" #include "../../message_buffer.h" #include "../../result.h" +#include "../recent_file_cache.h" #include "cookie_jar.h" #include "side_effect.h" #include "watch_registry.h" @@ -219,7 +220,7 @@ Result<> WatchRegistry::remove(ChannelID channel_id) return ok_result(); } -Result<> WatchRegistry::consume(MessageBuffer &messages, CookieJar &jar) +Result<> WatchRegistry::consume(MessageBuffer &messages, CookieJar &jar, RecentFileCache &cache) { Timer t; const size_t BUFSIZE = 2048 * sizeof(inotify_event); @@ -285,7 +286,7 @@ Result<> WatchRegistry::consume(MessageBuffer &messages, CookieJar &jar) for (shared_ptr &watched_directory : watched_directories) { SideEffect side; - Result<> r = watched_directory->accept_event(messages, jar, side, *event); + Result<> r = watched_directory->accept_event(messages, jar, side, cache, *event); if (r.is_error()) LOGGER << "Unable to process event: " << r << "." << endl; side.enact_in(watched_directory, this, messages); } diff --git a/src/worker/linux/watch_registry.h b/src/worker/linux/watch_registry.h index dc238832..77eab7f9 100644 --- a/src/worker/linux/watch_registry.h +++ b/src/worker/linux/watch_registry.h @@ -10,6 +10,7 @@ #include "../../errable.h" #include "../../message_buffer.h" #include "../../result.h" +#include "../recent_file_cache.h" #include "cookie_jar.h" #include "side_effect.h" #include "watched_directory.h" @@ -50,8 +51,9 @@ class WatchRegistry : public Errable // Interpret all inotify events created since the previous call to consume(), until the // read() call would block. Buffer messages corresponding to each inotify event. Use the - // CookieJar to match pairs of rename events across event batches. - Result<> consume(MessageBuffer &messages, CookieJar &jar); + // CookieJar to match pairs of rename events across event batches and the RecentFileCache to + // identify symlinks without doing a stat for every event. + Result<> consume(MessageBuffer &messages, CookieJar &jar, RecentFileCache &cache); // Return the file descriptor that should be polled to wake up when inotify events are // available. diff --git a/src/worker/linux/watched_directory.cpp b/src/worker/linux/watched_directory.cpp index b5037776..f99f98e0 100644 --- a/src/worker/linux/watched_directory.cpp +++ b/src/worker/linux/watched_directory.cpp @@ -7,6 +7,7 @@ #include "../../message.h" #include "../../message_buffer.h" #include "../../result.h" +#include "../recent_file_cache.h" #include "cookie_jar.h" #include "side_effect.h" #include "watched_directory.h" @@ -33,12 +34,22 @@ WatchedDirectory::WatchedDirectory(int wd, Result<> WatchedDirectory::accept_event(MessageBuffer &buffer, CookieJar &jar, SideEffect &side, + RecentFileCache &cache, const inotify_event &event) { - EntryKind kind = (event.mask & IN_ISDIR) == IN_ISDIR ? KIND_DIRECTORY : KIND_FILE; string basename{event.name}; string path = absolute_event_path(event); + bool dir_hint = (event.mask & IN_ISDIR) == IN_ISDIR; + + // Read or refresh the cached lstat() entry primarily to determine if this entry is a symlink or not. + shared_ptr stat = cache.former_at_path(path, !dir_hint, dir_hint, false); + if (stat->is_absent()) { + stat = cache.current_at_path(path, !dir_hint, dir_hint, false); + cache.apply(); + } + EntryKind kind = stat->get_entry_kind(); + if ((event.mask & IN_CREATE) == IN_CREATE) { // create entry inside directory if (kind == KIND_DIRECTORY && recursive) { @@ -50,6 +61,7 @@ Result<> WatchedDirectory::accept_event(MessageBuffer &buffer, if ((event.mask & IN_DELETE) == IN_DELETE) { // delete entry inside directory + cache.evict(path); buffer.deleted(channel_id, move(path), kind); return ok_result(); } @@ -63,6 +75,7 @@ Result<> WatchedDirectory::accept_event(MessageBuffer &buffer, if ((event.mask & (IN_DELETE_SELF | IN_UNMOUNT)) != 0u) { if (is_root()) { side.remove_channel(channel_id); + cache.evict(get_absolute_path()); buffer.deleted(channel_id, get_absolute_path(), KIND_DIRECTORY); } return ok_result(); @@ -72,6 +85,7 @@ Result<> WatchedDirectory::accept_event(MessageBuffer &buffer, // directory itself was renamed if (is_root()) { side.remove_channel(channel_id); + cache.evict(get_absolute_path()); buffer.deleted(channel_id, get_absolute_path(), KIND_DIRECTORY); } return ok_result(); @@ -79,6 +93,7 @@ Result<> WatchedDirectory::accept_event(MessageBuffer &buffer, if ((event.mask & IN_MOVED_FROM) == IN_MOVED_FROM) { // rename source for directory or entry inside directory + cache.evict(path); jar.moved_from(buffer, channel_id, event.cookie, move(path), kind); return ok_result(); } diff --git a/src/worker/linux/watched_directory.h b/src/worker/linux/watched_directory.h index 5999c459..3d7924bd 100644 --- a/src/worker/linux/watched_directory.h +++ b/src/worker/linux/watched_directory.h @@ -9,6 +9,7 @@ #include "../../message_buffer.h" #include "../../result.h" +#include "../recent_file_cache.h" #include "cookie_jar.h" #include "side_effect.h" @@ -26,7 +27,11 @@ class WatchedDirectory // Interpret a single inotify event. Buffer messages, store or resolve rename Cookies from the CookieJar, and // enqueue SideEffects based on the event's mask. - Result<> accept_event(MessageBuffer &buffer, CookieJar &jar, SideEffect &side, const inotify_event &event); + Result<> accept_event(MessageBuffer &buffer, + CookieJar &jar, + SideEffect &side, + RecentFileCache &cache, + const inotify_event &event); // A parent WatchedDirectory reported that this directory was renamed. Update our internal state immediately so // that events on child paths will be reported with the correct path. diff --git a/src/worker/macos/batch_handler.cpp b/src/worker/macos/batch_handler.cpp index 73234452..2b1ad7b0 100644 --- a/src/worker/macos/batch_handler.cpp +++ b/src/worker/macos/batch_handler.cpp @@ -68,9 +68,9 @@ bool Event::skip_recursive_event() void Event::collect_info() { if (!former) { - former = cache().former_at_path(event_path, flag_file(), flag_directory()); + former = cache().former_at_path(event_path, flag_file(), flag_directory(), flag_symlink()); } - current = cache().current_at_path(get_stat_path(), flag_file(), flag_directory()); + current = cache().current_at_path(get_stat_path(), flag_file(), flag_directory(), flag_symlink()); } bool Event::should_defer() diff --git a/src/worker/macos/batch_handler.h b/src/worker/macos/batch_handler.h index 96797864..86d3975d 100644 --- a/src/worker/macos/batch_handler.h +++ b/src/worker/macos/batch_handler.h @@ -34,6 +34,8 @@ class Event bool flag_directory() { return (flags & IS_DIRECTORY) != 0; } + bool flag_symlink() { return (flags & IS_SYMLINK) != 0; } + bool is_recursive(); const std::string &root_path(); diff --git a/src/worker/macos/flags.h b/src/worker/macos/flags.h index d5eed9c6..d3b0378b 100644 --- a/src/worker/macos/flags.h +++ b/src/worker/macos/flags.h @@ -19,6 +19,8 @@ const FSEventStreamEventFlags IS_FILE = kFSEventStreamEventFlagItemIsFile; const FSEventStreamEventFlags IS_DIRECTORY = kFSEventStreamEventFlagItemIsDir; +const FSEventStreamEventFlags IS_SYMLINK = kFSEventStreamEventFlagItemIsSymlink; + const CFTimeInterval RENAME_TIMEOUT = 0.05; #endif diff --git a/src/worker/recent_file_cache.cpp b/src/worker/recent_file_cache.cpp index ee7ac7bd..4af2abec 100644 --- a/src/worker/recent_file_cache.cpp +++ b/src/worker/recent_file_cache.cpp @@ -30,7 +30,7 @@ using std::static_pointer_cast; using std::string; using std::vector; -shared_ptr StatResult::at(string &&path, bool file_hint, bool directory_hint) +shared_ptr StatResult::at(string &&path, bool file_hint, bool directory_hint, bool symlink_hint) { FSReq lstat_req; @@ -49,8 +49,9 @@ shared_ptr StatResult::at(string &&path, bool file_hint, bool direct } EntryKind guessed_kind = KIND_UNKNOWN; - if (file_hint && !directory_hint) guessed_kind = KIND_FILE; - if (!file_hint && directory_hint) guessed_kind = KIND_DIRECTORY; + if (symlink_hint) guessed_kind = KIND_SYMLINK; + if (file_hint && !directory_hint && !symlink_hint) guessed_kind = KIND_FILE; + if (!file_hint && directory_hint && !symlink_hint) guessed_kind = KIND_DIRECTORY; return shared_ptr(new AbsentEntry(move(path), guessed_kind)); } @@ -187,27 +188,34 @@ RecentFileCache::RecentFileCache(size_t maximum_size) : maximum_size{maximum_siz // } -shared_ptr RecentFileCache::current_at_path(const string &path, bool file_hint, bool directory_hint) +shared_ptr RecentFileCache::current_at_path(const string &path, + bool file_hint, + bool directory_hint, + bool symlink_hint) { auto maybe_pending = pending.find(path); if (maybe_pending != pending.end()) { return maybe_pending->second; } - shared_ptr stat_result = StatResult::at(string(path), file_hint, directory_hint); + shared_ptr stat_result = StatResult::at(string(path), file_hint, directory_hint, symlink_hint); if (stat_result->is_present()) { pending.emplace(path, static_pointer_cast(stat_result)); } return stat_result; } -shared_ptr RecentFileCache::former_at_path(const string &path, bool file_hint, bool directory_hint) +shared_ptr RecentFileCache::former_at_path(const string &path, + bool file_hint, + bool directory_hint, + bool symlink_hint) { auto maybe = by_path.find(path); if (maybe == by_path.end()) { EntryKind kind = KIND_UNKNOWN; - if (file_hint && !directory_hint) kind = KIND_FILE; - if (!file_hint && directory_hint) kind = KIND_DIRECTORY; + if (symlink_hint) kind = KIND_SYMLINK; + if (file_hint && !directory_hint && !symlink_hint) kind = KIND_FILE; + if (!file_hint && directory_hint && !symlink_hint) kind = KIND_DIRECTORY; return shared_ptr(new AbsentEntry(string(path), kind)); } @@ -338,10 +346,11 @@ size_t RecentFileCache::prepopulate_helper(const string &root, size_t max, bool string entry_name(dirent.name); string entry_path(path_join(current_root, entry_name)); + bool symlink_hint = dirent.type == UV_DIRENT_LINK; bool file_hint = dirent.type == UV_DIRENT_FILE; bool dir_hint = dirent.type == UV_DIRENT_DIR; - shared_ptr r = current_at_path(entry_path, file_hint, dir_hint); + shared_ptr r = current_at_path(entry_path, file_hint, dir_hint, symlink_hint); if (r->is_present()) { entries++; if (recursive && r->get_entry_kind() == KIND_DIRECTORY) next_roots.push(entry_path); diff --git a/src/worker/recent_file_cache.h b/src/worker/recent_file_cache.h index 74810416..7be95765 100644 --- a/src/worker/recent_file_cache.h +++ b/src/worker/recent_file_cache.h @@ -16,7 +16,7 @@ class StatResult { public: - static std::shared_ptr at(std::string &&path, bool file_hint, bool directory_hint); + static std::shared_ptr at(std::string &&path, bool file_hint, bool directory_hint, bool symlink_hint); virtual ~StatResult() = default; @@ -111,9 +111,15 @@ class RecentFileCache ~RecentFileCache() = default; - std::shared_ptr current_at_path(const std::string &path, bool file_hint, bool directory_hint); + std::shared_ptr current_at_path(const std::string &path, + bool file_hint, + bool directory_hint, + bool symlink_hint); - std::shared_ptr former_at_path(const std::string &path, bool file_hint, bool directory_hint); + std::shared_ptr former_at_path(const std::string &path, + bool file_hint, + bool directory_hint, + bool symlink_hint); void evict(const std::string &path); diff --git a/src/worker/windows/windows_worker_platform.cpp b/src/worker/windows/windows_worker_platform.cpp index 3f9580fe..5e1d05fa 100644 --- a/src/worker/windows/windows_worker_platform.cpp +++ b/src/worker/windows/windows_worker_platform.cpp @@ -328,11 +328,11 @@ class WindowsWorkerPlatform : public WorkerPlatform shared_ptr stat; if (info->Action == FILE_ACTION_REMOVED || info->Action == FILE_ACTION_RENAMED_OLD_NAME) { - stat = cache.former_at_path(path, false, false); + stat = cache.former_at_path(path, false, false, false); } else { - stat = cache.current_at_path(path, false, false); + stat = cache.current_at_path(path, false, false, false); if (stat->is_absent()) { - stat = cache.former_at_path(path, false, false); + stat = cache.former_at_path(path, false, false, false); } } EntryKind kind = stat->get_entry_kind(); diff --git a/test/events/basic.test.js b/test/events/basic.test.js index 8d802f72..e5c72a00 100644 --- a/test/events/basic.test.js +++ b/test/events/basic.test.js @@ -126,6 +126,66 @@ const {EventMatcher} = require('../matcher'); )) }) + it('when a symlink is created', async function () { + const originalName = fixture.watchPath('original-file.txt') + const symlinkName = fixture.watchPath('symlink.txt') + await fs.writeFile(originalName, 'contents\n') + + await until('file creation event arrives', matcher.allEvents( + {action: 'created', kind: 'file', path: originalName} + )) + + await fs.symlink(originalName, symlinkName) + + await until('symlink creation event arrives', matcher.allEvents( + {action: 'created', kind: 'symlink', path: symlinkName} + )) + }) + + it('when a symlink is deleted', async function () { + const originalName = fixture.watchPath('original-file.txt') + const symlinkName = fixture.watchPath('symlink.txt') + await fs.writeFile(originalName, 'contents\n') + await fs.symlink(originalName, symlinkName) + + await until('file and symlink creation events arrive', matcher.allEvents( + {action: 'created', kind: 'file', path: originalName}, + {action: 'created', kind: 'symlink', path: symlinkName} + )) + + await fs.unlink(symlinkName) + + await until('symlink deletion event arrives', matcher.allEvents( + {action: 'deleted', kind: 'symlink', path: symlinkName} + )) + }) + + it('when a symlink is renamed', async function () { + const targetName = fixture.watchPath('target.txt') + const originalName = fixture.watchPath('original.txt') + const finalName = fixture.watchPath('final.txt') + await fs.writeFile(targetName, 'contents\n') + await fs.symlink(targetName, originalName) + + await until('file and symlink creation events arrive', matcher.allEvents( + {action: 'created', kind: 'file', path: targetName}, + {action: 'created', kind: 'symlink', path: originalName} + )) + + fs.rename(originalName, finalName) + + if (poll) { + await until('symlink deletion and creation arrive', matcher.allEvents( + {action: 'deleted', kind: 'symlink', path: originalName}, + {action: 'created', kind: 'symlink', path: finalName} + )) + } else { + await until('rename event arrives', matcher.allEvents( + {action: 'renamed', kind: 'symlink', oldPath: originalName, path: finalName} + )) + } + }) + if (process.platform === 'win32') { it('reports events with the long file name when possible', async function () { const longName = fixture.watchPath('file-with-a-long-name.txt') diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 00000000..89bdea8e --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,4 @@ +--require test/global.js +--require mocha-stress +--recursive +--harmony