Skip to content

Commit 17d545c

Browse files
fix(watch): coalesce in-flight lazy and file generations
1 parent 6f0d4e3 commit 17d545c

7 files changed

Lines changed: 157 additions & 18 deletions

File tree

packages/rspack/src/MultiCompiler.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,12 @@ export class MultiCompiler {
431431
if (node.state === 'done') {
432432
node.state = 'pending';
433433
} else if (node.state === 'running') {
434-
node.state = 'running-outdated';
434+
// Watching will coalesce its own in-flight invalidation before
435+
// delivering `done`; keep the node runnable so that generation can
436+
// finish and unblock its already-invalidated dependents.
437+
if (!node.compiler.watching?.invalid) {
438+
node.state = 'running-outdated';
439+
}
435440
}
436441
for (const child of node.children) {
437442
nodeInvalidFromParent(child);

packages/rspack/src/Watching.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ export class Watching {
153153
},
154154
(fileName, changeTime) => {
155155
this.#recordInvalidation('normal');
156+
if (this.running) {
157+
// The aggregate callback can arrive after an in-flight compilation
158+
// finishes. Suppress that stale generation and drain the paused
159+
// watcher before starting the coalesced rebuild.
160+
this.invalid = true;
161+
}
156162
if (!this.#invalidReported) {
157163
this.#invalidReported = true;
158164
this.compiler.hooks.invalid.call(fileName, changeTime);
@@ -294,6 +300,7 @@ export class Watching {
294300
if (this.suspended || (this.isBlocked() && (this.blocked = true))) {
295301
return;
296302
}
303+
this.blocked = false;
297304

298305
if (this.running) {
299306
this.invalid = true;
@@ -339,14 +346,7 @@ export class Watching {
339346
this.compiler.fileTimestamps = fileTimeInfoEntries;
340347
this.compiler.contextTimestamps = contextTimeInfoEntries;
341348
} else if (this.pausedWatcher) {
342-
const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } =
343-
this.pausedWatcher.getInfo();
344-
if (changes.size > 0 || removals.size > 0) {
345-
this.#recordInvalidation('normal');
346-
}
347-
this.#mergeWithCollected(changes, removals);
348-
this.compiler.fileTimestamps = fileTimeInfoEntries;
349-
this.compiler.contextTimestamps = contextTimeInfoEntries;
349+
this.#drainPausedWatcher();
350350
}
351351

352352
this.compiler.__internal__watchInvalidationKind =
@@ -451,6 +451,21 @@ export class Watching {
451451

452452
stats = new Stats(compilation);
453453

454+
const watcherStartTime = Date.now();
455+
if (
456+
!this.invalid &&
457+
this.pausedWatcher?.hasPendingEvents?.() !== false &&
458+
this.#drainPausedWatcher()
459+
) {
460+
// Watchpack continues collecting while paused but does not deliver an
461+
// invalid callback. Coalesce those changes before publishing the stale
462+
// generation and advance the baseline so they are not replayed.
463+
this.lastWatcherStartTime = watcherStartTime;
464+
this.invalid = true;
465+
this.#notifyInvalid();
466+
this.onInvalid();
467+
}
468+
454469
if (
455470
this.invalid &&
456471
!this.suspended &&
@@ -513,6 +528,21 @@ export class Watching {
513528
});
514529
}
515530

531+
#drainPausedWatcher() {
532+
if (!this.pausedWatcher) return false;
533+
534+
const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } =
535+
this.pausedWatcher.getInfo();
536+
const hasChanges = changes.size > 0 || removals.size > 0;
537+
if (hasChanges) {
538+
this.#recordInvalidation('normal');
539+
}
540+
this.#mergeWithCollected(changes, removals);
541+
this.compiler.fileTimestamps = fileTimeInfoEntries;
542+
this.compiler.contextTimestamps = contextTimeInfoEntries;
543+
return hasChanges;
544+
}
545+
516546
#mergeWithCollected(
517547
changedFiles?: ReadonlySet<string>,
518548
removedFiles?: ReadonlySet<string>,

packages/rspack/src/node/NodeWatchFileSystem.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,24 @@ export default class NodeWatchFileSystem implements WatchFileSystem {
189189
"Watcher.getContextTimeInfoEntries is deprecated in favor of Watcher.getInfo since that's more performant.",
190190
'DEP_WEBPACK_WATCHER_CONTEXT_TIME_INFO_ENTRIES',
191191
),
192+
hasPendingEvents: () => {
193+
const watcher = this.watcher;
194+
return Boolean(
195+
watcher &&
196+
(watcher.aggregatedChanges.size > 0 ||
197+
watcher.aggregatedRemovals.size > 0),
198+
);
199+
},
192200
getInfo: () => {
193-
const removals = this.watcher?.aggregatedRemovals ?? new Set();
194-
const changes = this.watcher?.aggregatedChanges ?? new Set();
201+
const watcher = this.watcher;
202+
const { changes, removals } = watcher
203+
? watcher.paused
204+
? watcher.getAggregated()
205+
: {
206+
changes: watcher.aggregatedChanges,
207+
removals: watcher.aggregatedRemovals,
208+
}
209+
: { changes: new Set<string>(), removals: new Set<string>() };
195210
if (this.inputFileSystem?.purge) {
196211
const fs = this.inputFileSystem;
197212
if (removals) {

packages/rspack/src/util/fs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface Watcher {
1919
getAggregatedRemovals?(): Set<string>; // get current aggregated removals that have not yet send to callback
2020
getFileTimeInfoEntries?(): Map<string, FileSystemInfoEntry | 'ignore'>; // get info about files
2121
getContextTimeInfoEntries?(): Map<string, FileSystemInfoEntry | 'ignore'>; // get info about directories
22+
hasPendingEvents?(): boolean; // cheaply checks for changes collected while paused
2223
getInfo(): WatcherInfo; // get info about timestamps and changes
2324
}
2425

tests/rspack-test/LazyCompilationArtifactChain.test.js

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ async function withTimeout(promise, description) {
3131
}
3232

3333
describe("lazy MultiCompiler artifact provenance", () => {
34-
it("keeps dependent and coalesced file-backed generations normal", async () => {
34+
async function runArtifactChain(_watcherName, nativeWatcher) {
3535
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-lazy-chain-"));
3636
const sourceFile = path.join(root, "source.js");
3737
const coalescedEntry = path.join(root, "coalesced-source.js");
3838
const stampFile = name => path.join(root, name, "stamp.txt");
3939
const generations = Object.fromEntries(names.map(name => [name, 0]));
4040
const events = [];
41+
const invalidations = [];
4142
let delayedSource;
4243
let resolveFileInvalidation;
4344
let server;
@@ -56,6 +57,14 @@ describe("lazy MultiCompiler artifact provenance", () => {
5657
coalescedEntry,
5758
'export const coalesced = "coalesced";\n'
5859
);
60+
// Keep Watchpack's initial scan from treating fresh fixture files as edits.
61+
const beforeWatch = new Date(Date.now() - 3000);
62+
await Promise.all(
63+
[
64+
...names.map(name => path.join(root, `${name}.js`)),
65+
coalescedEntry
66+
].map(file => fs.utimes(file, beforeWatch, beforeWatch))
67+
);
5968

6069
const config = name => ({
6170
context: root,
@@ -65,7 +74,7 @@ describe("lazy MultiCompiler artifact provenance", () => {
6574
name === "source"
6675
? { main: "./source.js", coalesced: "./coalesced-source.js" }
6776
: { main: `./${name}.js` },
68-
experiments: { nativeWatcher: true },
77+
experiments: { nativeWatcher },
6978
lazyCompilation: { entries: true, imports: false },
7079
mode: "development",
7180
name,
@@ -78,6 +87,7 @@ describe("lazy MultiCompiler artifact provenance", () => {
7887
{
7988
apply(compiler) {
8089
compiler.hooks.invalid.tap("lazy-artifact-chain", file => {
90+
invalidations.push({ name, file });
8191
if (name === "source" && file === sourceFile) {
8292
resolveFileInvalidation?.();
8393
}
@@ -278,19 +288,32 @@ describe("lazy MultiCompiler artifact provenance", () => {
278288
const fileInvalidated = new Promise(resolve => {
279289
resolveFileInvalidation = resolve;
280290
});
281-
delayedSource = { started: signalSourceStarted, release: sourceRelease };
291+
delayedSource = {
292+
started: signalSourceStarted,
293+
release: sourceRelease
294+
};
282295
const beforeCoalesced = events.length;
283296
const beforeGenerations = { ...generations };
284297
assert.equal(await activate("source", JSON.parse(coalescedId)), 200);
285298
await withTimeout(sourceStarted, "the coalesced source build to start");
299+
const beforeFileInvalidations = invalidations.length;
286300
await fs.writeFile(
287301
sourceFile,
288302
'export const source = "source";\nexport const edited = "file-edit";\n'
289303
);
290-
await withTimeout(fileInvalidated, "the coalesced source file event");
304+
if (nativeWatcher) {
305+
await withTimeout(fileInvalidated, "the coalesced source file event");
306+
}
291307
releaseSource();
292308
assertBuild(await nextBuild(), ["source", "dependent", "tail"], "normal");
293309
const coalesced = events.slice(beforeCoalesced);
310+
assert.equal(
311+
invalidations
312+
.slice(beforeFileInvalidations)
313+
.filter(event => event.name === "source").length,
314+
1,
315+
"a coalesced file edit must notify the source compiler exactly once"
316+
);
294317
assert.equal(
295318
coalesced.every(event => event.kind === "normal"),
296319
true,
@@ -319,6 +342,9 @@ describe("lazy MultiCompiler artifact provenance", () => {
319342
assert.equal(generations.tail, beforeGenerations.tail + 1);
320343
assert.equal(generations.unrelated, beforeGenerations.unrelated);
321344
await assertTail();
345+
const deliveredGenerations = { ...generations };
346+
await new Promise(resolve => setTimeout(resolve, 80));
347+
assert.deepEqual(generations, deliveredGenerations);
322348
} finally {
323349
if (watching) {
324350
await new Promise((resolve, reject) =>
@@ -328,5 +354,13 @@ describe("lazy MultiCompiler artifact provenance", () => {
328354
if (server) await new Promise(resolve => server.close(resolve));
329355
await fs.rm(root, { force: true, recursive: true });
330356
}
331-
});
357+
}
358+
359+
it.each([
360+
["native", true],
361+
["watchpack", false]
362+
])(
363+
"keeps dependent and coalesced file-backed generations normal with %s watching",
364+
runArtifactChain
365+
);
332366
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const fs = require("node:fs/promises");
2+
const os = require("node:os");
3+
const path = require("node:path");
4+
const { rspack } = require("@rspack/core");
5+
6+
describe("NodeWatchFileSystem getInfo", () => {
7+
it("keeps an active aggregate callback and consumes paused changes once", async () => {
8+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-watchpack-"));
9+
const entry = path.join(root, "entry.js");
10+
await fs.writeFile(entry, "export default 1;\n");
11+
const beforeWatch = new Date(Date.now() - 3000);
12+
await fs.utimes(entry, beforeWatch, beforeWatch);
13+
14+
const compiler = rspack({
15+
context: root,
16+
entry,
17+
experiments: { nativeWatcher: false }
18+
});
19+
const watchFileSystem = compiler.watchFileSystem;
20+
const callbackChanges = [];
21+
const watcher = watchFileSystem.watch(
22+
[entry],
23+
[],
24+
[],
25+
Date.now(),
26+
{ aggregateTimeout: 1000 },
27+
(_error, _fileTimes, _contextTimes, changes) =>
28+
callbackChanges.push(changes),
29+
() => {}
30+
);
31+
32+
try {
33+
const watchpack = watchFileSystem.watcher;
34+
watchpack._onChange(entry, Date.now(), entry, "change");
35+
expect(watcher.hasPendingEvents()).toBe(true);
36+
expect(watcher.getInfo().changes).toEqual(new Set([entry]));
37+
expect(watchpack.aggregateTimer).toBeDefined();
38+
watchpack._onTimeout();
39+
expect(callbackChanges).toEqual([new Set([entry])]);
40+
41+
watchpack._onChange(entry, Date.now(), entry, "change");
42+
expect(watcher.hasPendingEvents()).toBe(true);
43+
expect(watcher.getInfo().changes).toEqual(new Set([entry]));
44+
expect(watcher.getInfo().changes).toEqual(new Set());
45+
expect(watcher.hasPendingEvents()).toBe(false);
46+
} finally {
47+
watcher.close();
48+
await new Promise(resolve => compiler.close(resolve));
49+
await fs.rm(root, { force: true, recursive: true });
50+
}
51+
});
52+
});

tests/rspack-test/multiCompilerCases/invalidate.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ module.exports = [
8080
a done,
8181
]
8282
`);
83-
expect(lazyCompilationCycles).toEqual([undefined, "lazy"]);
83+
// `a` consumes `b`'s lazy artifact, so its dependent
84+
// generation must remain normal and emit fresh output.
85+
expect(lazyCompilationCycles).toEqual([undefined, "normal"]);
8486
events.length = 0;
8587
expect(state).toBe(1);
8688
state = 2;
@@ -110,8 +112,8 @@ module.exports = [
110112
`);
111113
expect(lazyCompilationCycles).toEqual([
112114
undefined,
113-
"lazy",
114115
"normal",
116+
"normal"
115117
]);
116118
state = 3;
117119
compiler.close(error => (error ? reject(error) : resolve()));

0 commit comments

Comments
 (0)