Skip to content

Commit

Permalink
fs: add recursive watch to linux
Browse files Browse the repository at this point in the history
  • Loading branch information
anonrig committed Oct 21, 2022
1 parent fdadea8 commit 7275124
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 19 deletions.
4 changes: 0 additions & 4 deletions doc/api/fs.md
Expand Up @@ -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.
Expand Down
27 changes: 18 additions & 9 deletions lib/fs.js
Expand Up @@ -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.
Expand All @@ -68,7 +69,6 @@ const {
codes: {
ERR_FS_FILE_TOO_LARGE,
ERR_INVALID_ARG_VALUE,
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM,
},
AbortError,
uvErrmapGet,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
192 changes: 192 additions & 0 deletions 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,
};
44 changes: 44 additions & 0 deletions 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');
});
}
6 changes: 0 additions & 6 deletions test/parallel/test-fs-watch-recursive.js
Expand Up @@ -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;
Expand Down

0 comments on commit 7275124

Please sign in to comment.