Skip to content

Commit a295353

Browse files
watch: debounce restart in watch mode
1 parent 384fd17 commit a295353

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

lib/internal/debounce_iterable.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypePushApply,
5+
ObjectGetPrototypeOf,
6+
ObjectSetPrototypeOf,
7+
Promise,
8+
PromiseResolve,
9+
SymbolAsyncIterator,
10+
SymbolIterator,
11+
} = primordials;
12+
13+
const {
14+
codes: { ERR_INVALID_ARG_TYPE },
15+
} = require('internal/errors');
16+
const FixedQueue = require('internal/fixed_queue');
17+
18+
const AsyncIteratorPrototype = ObjectGetPrototypeOf(
19+
ObjectGetPrototypeOf(async function* () {}).prototype
20+
);
21+
22+
/**
23+
* Wraps an iterable in a debounced iterable. When trying to get the next item,
24+
* the debounced iterable will group all items that are returned less than
25+
* `delay` milliseconds apart into a single batch.
26+
*
27+
* The debounced iterable will only start consuming the original iterable when
28+
* the first consumer requests the next item, and will stop consuming the
29+
* original iterable when no more items are requested (through `next` calls).
30+
*
31+
* Each debounced iterable item will be an array of items from the original
32+
* iterable, and will always contain at least one item. This allows the consumer
33+
* to decide how to handle the batch of items (e.g. take tha latest only, ensure
34+
* unicity, etc.).
35+
*
36+
* @template T
37+
* @param {Iterable<T> | AsyncIterable<T>} iterable
38+
* @param {number} delay
39+
* @returns {AsyncIterableIterator<[T, ...T[]]>}
40+
*/
41+
exports.debounceIterable = function debounceIterable(iterable, delay) {
42+
const innerIterator =
43+
SymbolAsyncIterator in iterable
44+
? iterable[SymbolAsyncIterator]()
45+
: iterable[SymbolIterator]();
46+
47+
let doneProducing = false;
48+
let doneConsuming = false;
49+
let consuming = false;
50+
let error = null;
51+
let timer = null;
52+
53+
const unconsumedPromises = new FixedQueue();
54+
let unconsumedValues = [];
55+
56+
return ObjectSetPrototypeOf(
57+
{
58+
[SymbolAsyncIterator]() {
59+
return this;
60+
},
61+
62+
next() {
63+
return new Promise((resolve, reject) => {
64+
unconsumedPromises.push({ resolve, reject });
65+
startConsuming();
66+
});
67+
},
68+
69+
return() {
70+
return closeHandler();
71+
},
72+
73+
throw(err) {
74+
if (!err || !(err instanceof Error)) {
75+
throw new ERR_INVALID_ARG_TYPE('AsyncIterator.throw', 'Error', err);
76+
}
77+
errorHandler(err);
78+
},
79+
},
80+
AsyncIteratorPrototype
81+
);
82+
83+
async function startConsuming() {
84+
if (consuming) return;
85+
86+
consuming = true;
87+
88+
while (!doneProducing && !doneConsuming && !unconsumedPromises.isEmpty()) {
89+
try {
90+
// if `result` takes longer than `delay` to resolve, make sure any
91+
// unconsumedValue are flushed.
92+
scheduleFlush();
93+
94+
const result = await innerIterator.next();
95+
96+
// A new value just arrived. Make sure we wont flush just yet.
97+
unscheduleFlush();
98+
99+
if (result.done) {
100+
doneProducing = true;
101+
} else if (!doneConsuming) {
102+
ArrayPrototypePushApply(unconsumedValues, result.value);
103+
}
104+
} catch (err) {
105+
doneProducing = true;
106+
error ||= err;
107+
}
108+
}
109+
110+
flushNow();
111+
112+
consuming = false;
113+
}
114+
115+
function scheduleFlush() {
116+
if (timer == null) {
117+
timer = setTimeout(flushNow, delay).unref();
118+
}
119+
}
120+
121+
function unscheduleFlush() {
122+
if (timer != null) {
123+
clearTimeout(timer);
124+
timer = null;
125+
}
126+
}
127+
128+
function flushNow() {
129+
unscheduleFlush();
130+
131+
if (!doneConsuming) {
132+
if (unconsumedValues.length > 0 && !unconsumedPromises.isEmpty()) {
133+
unconsumedPromises
134+
.shift()
135+
.resolve({ done: false, value: unconsumedValues });
136+
unconsumedValues = [];
137+
}
138+
if (doneProducing && unconsumedValues.length === 0) {
139+
doneConsuming = true;
140+
}
141+
}
142+
143+
while (doneConsuming && !unconsumedPromises.isEmpty()) {
144+
const { resolve, reject } = unconsumedPromises.shift();
145+
if (error) reject(error);
146+
else resolve({ done: true, value: undefined });
147+
}
148+
}
149+
150+
function errorHandler(err) {
151+
error ||= err;
152+
153+
closeHandler();
154+
}
155+
156+
function closeHandler() {
157+
doneConsuming = true;
158+
unconsumedValues = [];
159+
160+
flushNow();
161+
162+
if (!doneProducing) {
163+
doneProducing = true;
164+
innerIterator.return?.();
165+
}
166+
167+
return PromiseResolve({ done: true, value: undefined });
168+
}
169+
};

lib/internal/main/watch_mode.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const {
1717
triggerUncaughtException,
1818
exitCodes: { kNoFailure },
1919
} = internalBinding('errors');
20+
const { debounceIterable } = require('internal/debounce_iterable');
2021
const { getOptionValue } = require('internal/options');
2122
const { emitExperimentalWarning } = require('internal/util');
2223
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
@@ -44,7 +45,7 @@ const args = ArrayPrototypeFilter(process.execArgv, (arg, i, arr) =>
4445
arg !== '--watch' && !StringPrototypeStartsWith(arg, '--watch=') && arg !== '--watch-preserve-output');
4546
ArrayPrototypePushApply(args, kCommand);
4647

47-
const watcher = new FilesWatcher({ debounce: 200, mode: kShouldFilterModules ? 'filter' : 'all' });
48+
const watcher = new FilesWatcher({ mode: kShouldFilterModules ? 'filter' : 'all' });
4849
ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));
4950

5051
let graceTimer;
@@ -117,7 +118,7 @@ async function restart() {
117118
start();
118119

119120
// eslint-disable-next-line no-unused-vars
120-
for await (const _ of on(watcher, 'changed')) {
121+
for await (const _ of debounceIterable(on(watcher, 'changed'), 200)) {
121122
await restart();
122123
}
123124
} catch (error) {

0 commit comments

Comments
 (0)