Skip to content

Commit a7b310d

Browse files
committed
fs: add recursive watch to linux
1 parent fdadea8 commit a7b310d

File tree

5 files changed

+364
-20
lines changed

5 files changed

+364
-20
lines changed

doc/api/fs.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4377,10 +4377,6 @@ the returned {fs.FSWatcher}.
43774377
The `fs.watch` API is not 100% consistent across platforms, and is
43784378
unavailable in some situations.
43794379
4380-
The recursive option is only supported on macOS and Windows.
4381-
An `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` exception will be thrown
4382-
when the option is used on a platform that does not support it.
4383-
43844380
On Windows, no events will be emitted if the watched directory is moved or
43854381
renamed. An `EPERM` error is reported when the watched directory is deleted.
43864382

lib/fs.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const {
5757

5858
const pathModule = require('path');
5959
const { isArrayBufferView } = require('internal/util/types');
60+
const linuxWatcher = require('internal/fs/linux_watcher');
6061

6162
// We need to get the statValues from the binding at the callsite since
6263
// it's re-initialized after deserialization.
@@ -68,7 +69,6 @@ const {
6869
codes: {
6970
ERR_FS_FILE_TOO_LARGE,
7071
ERR_INVALID_ARG_VALUE,
71-
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM,
7272
},
7373
AbortError,
7474
uvErrmapGet,
@@ -161,7 +161,7 @@ let FileReadStream;
161161
let FileWriteStream;
162162

163163
const isWindows = process.platform === 'win32';
164-
const isOSX = process.platform === 'darwin';
164+
const isLinux = process.platform === 'linux';
165165

166166

167167
function showTruncateDeprecation() {
@@ -2297,13 +2297,22 @@ function watch(filename, options, listener) {
22972297

22982298
if (options.persistent === undefined) options.persistent = true;
22992299
if (options.recursive === undefined) options.recursive = false;
2300-
if (options.recursive && !(isOSX || isWindows))
2301-
throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('watch recursively');
2302-
const watcher = new watchers.FSWatcher();
2303-
watcher[watchers.kFSWatchStart](filename,
2304-
options.persistent,
2305-
options.recursive,
2306-
options.encoding);
2300+
2301+
let watcher;
2302+
2303+
// TODO(anonrig): Remove this when/if libuv supports it.
2304+
// libuv does not support recursive file watch on Linux due to
2305+
// the limitations of inotify.
2306+
if (options.recursive && isLinux) {
2307+
watcher = new linuxWatcher.FSWatcher(options);
2308+
watcher[linuxWatcher.kFSWatchStart](filename);
2309+
} else {
2310+
watcher = new watchers.FSWatcher();
2311+
watcher[watchers.kFSWatchStart](filename,
2312+
options.persistent,
2313+
options.recursive,
2314+
options.encoding);
2315+
}
23072316

23082317
if (listener) {
23092318
watcher.addListener('change', listener);

lib/internal/fs/linux_watcher.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
'use strict';
2+
3+
const { EventEmitter } = require('events');
4+
const path = require('path');
5+
const { SafeMap, Symbol, StringPrototypeStartsWith } = primordials;
6+
const { validateObject } = require('internal/validators');
7+
const { kEmptyObject } = require('internal/util');
8+
const { ERR_FEATURE_UNAVAILABLE_ON_PLATFORM } = require('internal/errors');
9+
10+
const kFSWatchStart = Symbol('kFSWatchStart');
11+
12+
let internalSync;
13+
let internalPromises;
14+
15+
function lazyLoadFsPromises() {
16+
internalPromises ??= require('fs/promises');
17+
return internalPromises;
18+
}
19+
20+
function lazyLoadFsSync() {
21+
internalSync ??= require('fs');
22+
return internalSync;
23+
}
24+
25+
async function traverse(dir, files = new SafeMap()) {
26+
const { stat, opendir } = lazyLoadFsPromises();
27+
28+
files.set(dir, await stat(dir));
29+
30+
try {
31+
const directory = await opendir(dir);
32+
33+
for await (const file of directory) {
34+
const f = path.join(dir, file.name);
35+
36+
try {
37+
const stats = await stat(f);
38+
39+
files.set(f, stats);
40+
41+
if (stats.isDirectory()) {
42+
await traverse(f, files);
43+
}
44+
} catch (error) {
45+
if (error.code !== 'ENOENT' || error.code !== 'EPERM') {
46+
this.emit('error', error);
47+
}
48+
}
49+
50+
}
51+
} catch (error) {
52+
if (error.code !== 'EACCES') {
53+
this.emit('error', error);
54+
}
55+
}
56+
57+
return files;
58+
}
59+
60+
class FSWatcher extends EventEmitter {
61+
#options = null;
62+
#closed = false;
63+
#files = new SafeMap();
64+
#rootPath = path.resolve();
65+
66+
/**
67+
* @param {{
68+
* persistent?: boolean;
69+
* recursive?: boolean;
70+
* encoding?: string;
71+
* signal?: AbortSignal;
72+
* }} [options]
73+
*/
74+
constructor(options = kEmptyObject) {
75+
super();
76+
77+
validateObject(options, 'options');
78+
this.#options = options;
79+
}
80+
81+
close() {
82+
const { unwatchFile } = lazyLoadFsSync();
83+
this.#closed = true;
84+
85+
for (const file of this.#files.keys()) {
86+
unwatchFile(file);
87+
}
88+
89+
this.emit('close');
90+
}
91+
92+
#getPath(file) {
93+
if (file === this.#rootPath) {
94+
return this.#rootPath;
95+
}
96+
97+
return path.relative(this.#rootPath, file);
98+
}
99+
100+
#unwatchFolder(file) {
101+
const { unwatchFile } = lazyLoadFsSync();
102+
103+
for (const filename of this.#files.keys()) {
104+
if (StringPrototypeStartsWith(filename, file)) {
105+
unwatchFile(filename);
106+
}
107+
}
108+
}
109+
110+
async #watchFolder(folder) {
111+
const { opendir, stat } = lazyLoadFsPromises();
112+
113+
try {
114+
const files = await opendir(folder);
115+
116+
for await (const file of files) {
117+
const f = path.join(folder, file.name);
118+
119+
if (this.#closed) {
120+
break;
121+
}
122+
123+
if (!this.#files.has(f)) {
124+
const fileStats = await stat(f);
125+
126+
this.#files.set(f, fileStats);
127+
this.emit('change', 'rename', this.#getPath(f));
128+
129+
if (fileStats.isDirectory()) {
130+
await this.#watchFolder(f);
131+
} else {
132+
this.#watchFile(f);
133+
}
134+
}
135+
}
136+
} catch (error) {
137+
this.emit('error', error);
138+
}
139+
}
140+
141+
/**
142+
* @param {string} file
143+
*/
144+
#watchFile(file) {
145+
const { watchFile } = lazyLoadFsSync();
146+
147+
if (this.#closed) {
148+
return;
149+
}
150+
151+
const existingStat = this.#files.get(file);
152+
153+
watchFile(file, {
154+
persistent: this.#options.persistent,
155+
}, (statWatcher, previousStatWatcher) => {
156+
if (existingStat && !existingStat.isDirectory() &&
157+
statWatcher.nlink !== 0 && existingStat.mtime.getTime() === statWatcher.mtime.getTime()) {
158+
return;
159+
}
160+
161+
this.#files.set(file, statWatcher);
162+
163+
if (statWatcher.birthtimeMs === 0 && previousStatWatcher.birthtimeMs !== 0) {
164+
// The file is now deleted
165+
this.#files.delete(file);
166+
this.emit('change', 'rename', this.#getPath(file));
167+
168+
if (statWatcher.isDirectory()) {
169+
this.#unwatchFolder(file);
170+
}
171+
} else if (statWatcher.isDirectory()) {
172+
this.#watchFolder(file);
173+
this.emit('change', 'change', this.#getPath(file));
174+
} else {
175+
this.emit('change', 'change', this.#getPath(file));
176+
}
177+
});
178+
}
179+
180+
/**
181+
* @param {string | Buffer | URL} filename
182+
*/
183+
async [kFSWatchStart](filename) {
184+
this.#rootPath = filename;
185+
this.#closed = false;
186+
this.#files = await traverse(filename);
187+
188+
for (const f of this.#files.keys()) {
189+
this.#watchFile(f);
190+
}
191+
}
192+
193+
ref() {
194+
// This is kept to have the same API with FSWatcher
195+
throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('ref');
196+
}
197+
198+
unref() {
199+
// This is kept to have the same API with FSWatcher
200+
throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('unref');
201+
}
202+
}
203+
204+
module.exports = {
205+
FSWatcher,
206+
kFSWatchStart,
207+
};

0 commit comments

Comments
 (0)