From 7275124ea4bea451755fa45ffd4089dac3f30a6b Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Wed, 19 Oct 2022 16:00:33 -0400 Subject: [PATCH] fs: add recursive watch to linux --- doc/api/fs.md | 4 - lib/fs.js | 27 ++- lib/internal/fs/linux_watcher.js | 192 ++++++++++++++++++ .../parallel/test-fs-watch-recursive-linux.js | 44 ++++ test/parallel/test-fs-watch-recursive.js | 6 - 5 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 lib/internal/fs/linux_watcher.js create mode 100644 test/parallel/test-fs-watch-recursive-linux.js diff --git a/doc/api/fs.md b/doc/api/fs.md index fa3368da281da4..c7f7b9b16a5eae 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -4377,10 +4377,6 @@ the returned {fs.FSWatcher}. The `fs.watch` API is not 100% consistent across platforms, and is unavailable in some situations. -The recursive option is only supported on macOS and Windows. -An `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` exception will be thrown -when the option is used on a platform that does not support it. - On Windows, no events will be emitted if the watched directory is moved or renamed. An `EPERM` error is reported when the watched directory is deleted. diff --git a/lib/fs.js b/lib/fs.js index 5e110eef17dd8b..5bd709874c2eaf 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -57,6 +57,7 @@ const { const pathModule = require('path'); const { isArrayBufferView } = require('internal/util/types'); +const linuxWatcher = require('internal/fs/linux_watcher'); // We need to get the statValues from the binding at the callsite since // it's re-initialized after deserialization. @@ -68,7 +69,6 @@ const { codes: { ERR_FS_FILE_TOO_LARGE, ERR_INVALID_ARG_VALUE, - ERR_FEATURE_UNAVAILABLE_ON_PLATFORM, }, AbortError, uvErrmapGet, @@ -161,7 +161,7 @@ let FileReadStream; let FileWriteStream; const isWindows = process.platform === 'win32'; -const isOSX = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; function showTruncateDeprecation() { @@ -2297,13 +2297,22 @@ function watch(filename, options, listener) { if (options.persistent === undefined) options.persistent = true; if (options.recursive === undefined) options.recursive = false; - if (options.recursive && !(isOSX || isWindows)) - throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('watch recursively'); - const watcher = new watchers.FSWatcher(); - watcher[watchers.kFSWatchStart](filename, - options.persistent, - options.recursive, - options.encoding); + + let watcher; + + // TODO(anonrig): Remove this when/if libuv supports it. + // libuv does not support recursive file watch on Linux due to + // the limitations of inotify. + if (options.recursive && isLinux) { + watcher = new linuxWatcher.FSWatcher(options); + watcher[linuxWatcher.kFSWatchStart](filename); + } else { + watcher = new watchers.FSWatcher(); + watcher[watchers.kFSWatchStart](filename, + options.persistent, + options.recursive, + options.encoding); + } if (listener) { watcher.addListener('change', listener); diff --git a/lib/internal/fs/linux_watcher.js b/lib/internal/fs/linux_watcher.js new file mode 100644 index 00000000000000..3f5e7b00e8c93f --- /dev/null +++ b/lib/internal/fs/linux_watcher.js @@ -0,0 +1,192 @@ +'use strict'; + +const { EventEmitter } = require('events'); +const path = require('path'); +const { SafeMap, Symbol, StringPrototypeStartsWith } = primordials; +const { validateObject } = require('internal/validators'); +const { kEmptyObject } = require('internal/util'); +const { ERR_FEATURE_UNAVAILABLE_ON_PLATFORM } = require('internal/errors'); + +const kFSWatchStart = Symbol('kFSWatchStart'); + +let internalSync; +let internalPromises; + +function lazyLoadFsPromises() { + internalPromises ??= require('fs/promises'); + return internalPromises; +} + +function lazyLoadFsSync() { + internalSync ??= require('fs'); + return internalSync; +} + +async function traverse(dir, files = new SafeMap()) { + const { stat, opendir } = lazyLoadFsPromises(); + + files.set(dir, await stat(dir)); + + try { + const directory = await opendir(dir); + + for await (const file of directory) { + const f = path.join(dir, file.name); + + try { + const stats = await stat(f); + + files.set(f, stats); + + if (stats.isDirectory()) { + await traverse(f, files); + } + } catch (error) { + if (error.code !== 'ENOENT' || error.code !== 'EPERM') { + this.emit('error', error); + } + } + + } + } catch (error) { + if (error.code !== 'EACCES') { + this.emit('error', error); + } + } + + return files; +} + +class FSWatcher extends EventEmitter { + #options = null; + #closed = false; + #files = new SafeMap(); + + /** + * @param {{ + * persistent?: boolean; + * recursive?: boolean; + * encoding?: string; + * signal?: AbortSignal; + * }} [options] + */ + constructor(options = kEmptyObject) { + super(); + + validateObject(options, 'options'); + this.#options = options; + } + + close() { + const { unwatchFile } = lazyLoadFsSync(); + this.#closed = true; + + for (const file of this.#files.keys()) { + unwatchFile(file); + } + + this.emit('close'); + } + + #unwatchFolder(file) { + const { unwatchFile } = lazyLoadFsSync(); + + for (const filename in this.#files) { + if (StringPrototypeStartsWith(filename, file)) { + unwatchFile(filename); + } + } + } + + async #watchFolder(folder) { + const { opendir, stat } = lazyLoadFsPromises(); + + try { + const files = await opendir(folder); + + for await (const file of files) { + const f = path.join(folder, file.name); + + if (this.#closed) { + return; + } + + if (!this.#files.has(f)) { + const fileStats = await stat(f); + this.#files.set(f, fileStats); + this.emit('change', 'rename', f); + this.#watchFile(f); + } + } + } catch (error) { + this.emit('error', error); + } + } + + /** + * @param {string} file + */ + #watchFile(file) { + const { watchFile } = lazyLoadFsSync(); + + if (this.#closed) { + return; + } + + const existingStat = this.#files.get(file); + + watchFile(file, { + persistent: this.#options.persistent, + }, (statWatcher, previousStatWatcher) => { + if (existingStat && !existingStat.isDirectory() && + statWatcher.nlink !== 0 && existingStat.mtime.getTime() === statWatcher.mtime.getTime()) { + return; + } + + this.#files.set(file, statWatcher); + + if (statWatcher.isDirectory()) { + this.#watchFolder(file); + } else if (statWatcher.birthtimeMs === 0 && previousStatWatcher.birthtimeMs !== 0) { + // The file is now deleted + this.#files.delete(file); + this.emit('change', 'rename', file); + + if (statWatcher.isDirectory()) { + this.#unwatchFolder(file); + } + } else { + this.emit('change', 'change', file); + } + }); + } + + /** + * @param {string | Buffer | URL} filename + */ + async [kFSWatchStart](filename) { + this.#closed = false; + this.#files = await traverse(filename); + + this.#watchFile(filename); + + for (const f in this.#files) { + this.#watchFile(f); + } + } + + ref() { + // This is kept to have the same API with FSWatcher + throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('ref'); + } + + unref() { + // This is kept to have the same API with FSWatcher + throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('unref'); + } +} + +module.exports = { + FSWatcher, + kFSWatchStart, +}; diff --git a/test/parallel/test-fs-watch-recursive-linux.js b/test/parallel/test-fs-watch-recursive-linux.js new file mode 100644 index 00000000000000..adeefadd4dcc9a --- /dev/null +++ b/test/parallel/test-fs-watch-recursive-linux.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +// Only run these tests on Linux. +if (!common.isLinux) { + return; +} + +const { randomUUID } = require('crypto'); +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +const testDir = tmpdir.path; +tmpdir.refresh(); + +{ + const file = `${randomUUID()}.txt`; + const testsubdir = fs.mkdtempSync(testDir + path.sep); + const filePath = path.join(testsubdir, file); + + const watcher = fs.watch(testsubdir, { recursive: true }); + + let watcherClosed = false; + watcher.on('change', function(event, filename) { + assert.ok(event === 'change' || event === 'rename'); + + watcher.close(); + watcherClosed = true; + }); + + setTimeout(() => { + fs.writeFileSync(filePath, 'world'); + }, 100); + + process.on('exit', function() { + assert(watcherClosed, 'watcher Object was not closed'); + }); +} diff --git a/test/parallel/test-fs-watch-recursive.js b/test/parallel/test-fs-watch-recursive.js index 70b413814e78f4..b9f9ed34c004d0 100644 --- a/test/parallel/test-fs-watch-recursive.js +++ b/test/parallel/test-fs-watch-recursive.js @@ -17,12 +17,6 @@ tmpdir.refresh(); const testsubdir = fs.mkdtempSync(testDir + path.sep); const relativePathOne = path.join(path.basename(testsubdir), filenameOne); const filepathOne = path.join(testsubdir, filenameOne); - -if (!common.isOSX && !common.isWindows) { - assert.throws(() => { fs.watch(testDir, { recursive: true }); }, - { code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' }); - return; -} const watcher = fs.watch(testDir, { recursive: true }); let watcherClosed = false;