Skip to content

Commit 5f7dbf4

Browse files
authored
fs: allow correct handling of burst in fs-events with AsyncIterator
PR-URL: #58490 Reviewed-By: Joyee Cheung <[email protected]> Reviewed-By: Ethan Arrowood <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]>
1 parent be2120f commit 5f7dbf4

File tree

7 files changed

+131
-8
lines changed

7 files changed

+131
-8
lines changed

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,13 @@ Path is a directory.
13651365
An attempt has been made to read a file whose size is larger than the maximum
13661366
allowed size for a `Buffer`.
13671367

1368+
<a id="ERR_FS_WATCH_QUEUE_OVERFLOW"></a>
1369+
1370+
### `ERR_FS_WATCH_QUEUE_OVERFLOW`
1371+
1372+
The number of file system events queued without being handled exceeded the size specified in
1373+
`maxQueue` in `fs.watch()`.
1374+
13681375
<a id="ERR_HTTP2_ALTSVC_INVALID_ORIGIN"></a>
13691376

13701377
### `ERR_HTTP2_ALTSVC_INVALID_ORIGIN`

doc/api/fs.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,11 @@ added:
17971797
filename passed to the listener. **Default:** `'utf8'`.
17981798
* `signal` {AbortSignal} An {AbortSignal} used to signal when the watcher
17991799
should stop.
1800+
* `maxQueue` {number} Specifies the number of events to queue between iterations
1801+
of the {AsyncIterator} returned. **Default:** `2048`.
1802+
* `overflow` {string} Either `'ignore'` or `'throw'` when there are more events to be
1803+
queued than `maxQueue` allows. `'ignore'` means overflow events are dropped and a
1804+
warning is emitted, while `'throw'` means to throw an exception. **Default:** `'ignore'`.
18001805
* Returns: {AsyncIterator} of objects with the properties:
18011806
* `eventType` {string} The type of change
18021807
* `filename` {string|Buffer|null} The name of the file changed.

lib/internal/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY',
12191219
E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type', SystemError);
12201220
E('ERR_FS_EISDIR', 'Path is a directory', SystemError, HideStackFramesError);
12211221
E('ERR_FS_FILE_TOO_LARGE', 'File size (%s) is greater than 2 GiB', RangeError);
1222+
E('ERR_FS_WATCH_QUEUE_OVERFLOW', 'fs.watch() queued more than %d events', Error);
12221223
E('ERR_HTTP2_ALTSVC_INVALID_ORIGIN',
12231224
'HTTP/2 ALTSVC frames require a valid origin', TypeError);
12241225
E('ERR_HTTP2_ALTSVC_LENGTH',

lib/internal/fs/watchers.js

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
'use strict';
22

33
const {
4+
ArrayPrototypePush,
5+
ArrayPrototypeShift,
6+
Error,
47
FunctionPrototypeCall,
58
ObjectDefineProperty,
69
ObjectSetPrototypeOf,
@@ -12,9 +15,11 @@ const {
1215
AbortError,
1316
UVException,
1417
codes: {
18+
ERR_FS_WATCH_QUEUE_OVERFLOW,
1519
ERR_INVALID_ARG_VALUE,
1620
},
1721
} = require('internal/errors');
22+
1823
const {
1924
kEmptyObject,
2025
} = require('internal/util');
@@ -45,6 +50,8 @@ const {
4550
validateBoolean,
4651
validateObject,
4752
validateUint32,
53+
validateInteger,
54+
validateOneOf,
4855
} = require('internal/validators');
4956

5057
const {
@@ -309,11 +316,15 @@ async function* watch(filename, options = kEmptyObject) {
309316
persistent = true,
310317
recursive = false,
311318
encoding = 'utf8',
319+
maxQueue = 2048,
320+
overflow = 'ignore',
312321
signal,
313322
} = options;
314323

315324
validateBoolean(persistent, 'options.persistent');
316325
validateBoolean(recursive, 'options.recursive');
326+
validateInteger(maxQueue, 'options.maxQueue');
327+
validateOneOf(overflow, 'options.overflow', ['ignore', 'error']);
317328
validateAbortSignal(signal, 'options.signal');
318329

319330
if (encoding && !isEncoding(encoding)) {
@@ -325,10 +336,11 @@ async function* watch(filename, options = kEmptyObject) {
325336
throw new AbortError(undefined, { cause: signal.reason });
326337

327338
const handle = new FSEvent();
328-
let { promise, resolve, reject } = PromiseWithResolvers();
339+
let { promise, resolve } = PromiseWithResolvers();
340+
const queue = [];
329341
const oncancel = () => {
330342
handle.close();
331-
reject(new AbortError(undefined, { cause: signal?.reason }));
343+
resolve();
332344
};
333345

334346
try {
@@ -345,11 +357,20 @@ async function* watch(filename, options = kEmptyObject) {
345357
});
346358
error.filename = filename;
347359
handle.close();
348-
reject(error);
360+
ArrayPrototypePush(queue, error);
361+
resolve();
349362
return;
350363
}
351-
352-
resolve({ eventType, filename });
364+
if (queue.length < maxQueue) {
365+
ArrayPrototypePush(queue, { __proto__: null, eventType, filename });
366+
resolve();
367+
} else if (overflow === 'error') {
368+
queue.length = 0;
369+
ArrayPrototypePush(queue, new ERR_FS_WATCH_QUEUE_OVERFLOW(maxQueue));
370+
resolve();
371+
} else {
372+
process.emitWarning('fs.watch maxQueue exceeded');
373+
}
353374
};
354375

355376
const err = handle.start(path, persistent, recursive, encoding);
@@ -367,10 +388,20 @@ async function* watch(filename, options = kEmptyObject) {
367388
}
368389

369390
while (!signal?.aborted) {
370-
yield await promise;
371-
({ promise, resolve, reject } = PromiseWithResolvers());
391+
await promise;
392+
while (queue.length) {
393+
const item = ArrayPrototypeShift(queue);
394+
if (item instanceof Error) {
395+
throw item;
396+
} else {
397+
yield item;
398+
}
399+
}
400+
({ promise, resolve } = PromiseWithResolvers());
401+
}
402+
if (signal?.aborted) {
403+
throw new AbortError(undefined, { cause: signal?.reason });
372404
}
373-
throw new AbortError(undefined, { cause: signal?.reason });
374405
} finally {
375406
handle.close();
376407
signal?.removeEventListener('abort', oncancel);

test/parallel/parallel.status

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ test-domain-throw-error-then-throw-from-uncaught-exception-handler: PASS, FLAKY
7777
test-domain-with-abort-on-uncaught-exception: PASS, FLAKY
7878
# https://github.com/nodejs/node/issues/54346
7979
test-esm-loader-hooks-inspect-wait: PASS, FLAKY
80+
test-fs-promises-watch-iterator: SKIP
8081
# https://github.com/nodejs/node/issues/50050
8182
test-tick-processor-arguments: SKIP
8283
# https://github.com/nodejs/node/issues/54534
@@ -85,6 +86,7 @@ test-runner-run-watch: PASS, FLAKY
8586
[$system==freebsd]
8687
# https://github.com/nodejs/node/issues/54346
8788
test-esm-loader-hooks-inspect-wait: PASS, FLAKY
89+
test-fs-promises-watch-iterator: SKIP
8890

8991
[$system==aix]
9092
# https://github.com/nodejs/node/issues/54346
@@ -95,6 +97,7 @@ test-esm-loader-hooks-inspect-wait: PASS, FLAKY
9597
test-child-process-fork-net-server: SKIP
9698
test-cli-node-options: SKIP
9799
test-cluster-shared-leak: SKIP
100+
test-fs-promises-watch-iterator: SKIP
98101
test-http-writable-true-after-close: SKIP
99102
test-http2-connect-method: SKIP
100103
test-net-error-twice: SKIP
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use strict';
2+
// This tests that when there is a burst of fs watch events, the events
3+
// emitted after the consumer receives the initial event and before the
4+
// control returns back to fs.watch() can be queued up and show up
5+
// in the next iteration.
6+
const common = require('../common');
7+
const { watch, writeFile } = require('fs/promises');
8+
const fs = require('fs');
9+
const assert = require('assert');
10+
const { join } = require('path');
11+
const { setTimeout } = require('timers/promises');
12+
const tmpdir = require('../common/tmpdir');
13+
14+
class WatchTestCase {
15+
constructor(dirName, files) {
16+
this.dirName = dirName;
17+
this.files = files;
18+
}
19+
get dirPath() { return tmpdir.resolve(this.dirName); }
20+
filePath(fileName) { return join(this.dirPath, fileName); }
21+
22+
async run() {
23+
await Promise.all([this.watchFiles(), this.writeFiles()]);
24+
assert(!this.files.length);
25+
}
26+
async watchFiles() {
27+
const watcher = watch(this.dirPath);
28+
for await (const evt of watcher) {
29+
const idx = this.files.indexOf(evt.filename);
30+
if (idx < 0) continue;
31+
this.files.splice(idx, 1);
32+
await setTimeout(common.platformTimeout(100));
33+
if (!this.files.length) break;
34+
}
35+
}
36+
async writeFiles() {
37+
for (const fileName of [...this.files]) {
38+
await writeFile(this.filePath(fileName), Date.now() + fileName.repeat(1e4));
39+
}
40+
await setTimeout(common.platformTimeout(100));
41+
}
42+
}
43+
44+
const kCases = [
45+
// Watch on a directory should callback with a filename on supported systems
46+
new WatchTestCase(
47+
'watch1',
48+
['foo', 'bar', 'baz']
49+
),
50+
];
51+
52+
tmpdir.refresh();
53+
54+
for (const testCase of kCases) {
55+
fs.mkdirSync(testCase.dirPath);
56+
testCase.run().then(common.mustCall());
57+
}

test/parallel/test-fs-promises-watch.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,25 @@ assert.rejects(
123123
},
124124
{ code: 'ERR_INVALID_ARG_TYPE' }).then(common.mustCall());
125125

126+
assert.rejects(
127+
async () => {
128+
// eslint-disable-next-line no-unused-vars, no-empty
129+
for await (const _ of watch('', { maxQueue: 'silly' })) { }
130+
},
131+
{ code: 'ERR_INVALID_ARG_TYPE' }).then(common.mustCall());
132+
assert.rejects(
133+
async () => {
134+
// eslint-disable-next-line no-unused-vars, no-empty
135+
for await (const _ of watch('', { overflow: 1 })) { }
136+
},
137+
{ code: 'ERR_INVALID_ARG_VALUE' }).then(common.mustCall());
138+
assert.rejects(
139+
async () => {
140+
// eslint-disable-next-line no-unused-vars, no-empty
141+
for await (const _ of watch('', { overflow: 'barf' })) { }
142+
},
143+
{ code: 'ERR_INVALID_ARG_VALUE' }).then(common.mustCall());
144+
126145
(async () => {
127146
const ac = new AbortController();
128147
const { signal } = ac;

0 commit comments

Comments
 (0)