import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { createRequire, registerHooks } from 'node:module';
import { pathToFileURL } from 'node:url';
// With Node 24, run after a local install of @rspack/core@2.2.4.
// Optional existing package resolution location is never written to results.
const require = createRequire(process.env.RSPACK_PACKAGE_JSON || import.meta.url);
const corePath = require.resolve('@rspack/core');
const version = require('@rspack/core/package.json').version;
assert.equal(version, '2.2.4');
const original = await readFile(corePath, 'utf8');
const digest = value => createHash('sha256').update(value).digest('hex');
const before = ` }), compiler.hooks.invalid.tap('MultiCompiler', ()=>{
compilerDone && (compilerDone = !1, doneCompilers--);
});`;
const after = ` });
const markNotDone = ()=>{
compilerDone && (compilerDone = !1, doneCompilers--);
};
compiler.hooks.invalid.tap('MultiCompiler', markNotDone);
compiler.hooks.watchRun.tap('MultiCompiler', markNotDone);`;
assert.equal(original.split(before).length - 1, 1, 'Exact baseline block must match once');
const baseline = await import(pathToFileURL(corePath).href);
const candidateURL = `${pathToFileURL(corePath).href}?done-reset-candidate`;
let transformed = 0;
const loader = registerHooks({
load(url, context, nextLoad) {
const result = nextLoad(url, context);
if (url !== candidateURL) return result;
const source = typeof result.source === 'string' ? result.source : Buffer.from(result.source).toString();
assert.equal(source, original, 'Only the exact installed module may be transformed');
transformed++;
return { ...result, source: source.replace(before, after) };
},
});
let candidate;
try {
candidate = await import(candidateURL);
} finally {
loader.deregister();
}
assert.equal(transformed, 1);
assert.notEqual(baseline.MultiCompiler, candidate.MultiCompiler);
// Controlled fake child hooks. The actual installed MultiCompiler and MultiStats
// classes execute; no Watching, watch graph, or native compilation is invoked.
class ControlledHook {
taps = [];
tap(name, callback) { this.taps.push({ name, callback }); }
call(...args) { for (const { callback } of this.taps) callback(...args); }
}
function runCase(MultiCompiler, name, actions) {
const states = Object.fromEntries(['web', 'node'].map(name => [name, {
name, building: false, generation: 0,
hooks: Object.fromEntries(['done', 'invalid', 'run', 'watchRun', 'beforeCompile', 'shutdown', 'infrastructureLog'].map(name => [name, new ControlledHook()])),
}]));
const multi = new MultiCompiler(Object.values(states));
const events = [];
const publications = [];
multi.hooks.done.tap('Capture', stats => {
publications.push({
at: events.length,
activeChildren: Object.values(states).filter(child => child.building).map(child => child.name),
generations: Object.fromEntries(stats.stats.map(stat => [stat.name, stat.generation])),
});
});
for (const [childName, type] of actions) {
const child = states[childName];
events.push({ child: childName, type });
if (type === 'watchRun') {
assert.equal(child.building, false);
child.building = true;
child.generation++;
child.hooks.watchRun.call(child);
} else if (type === 'done') {
assert.equal(child.building, true);
child.building = false;
child.hooks.done.call({ name: childName, generation: child.generation });
} else {
assert.equal(type, 'invalid');
child.hooks.invalid.call('component.js', 1);
}
}
assert(Object.values(states).every(child => !child.building));
return { name, events, publications };
}
const initial = [['web', 'watchRun'], ['node', 'watchRun'], ['web', 'done'], ['node', 'done']];
const normal = [...initial, ['web', 'invalid'], ['web', 'watchRun'], ['web', 'done']];
const duplicateInvalid = [...initial, ['web', 'invalid'], ['web', 'invalid'], ['web', 'watchRun'], ['web', 'done'], ['node', 'invalid'], ['node', 'watchRun'], ['node', 'done']];
const silentRestart = [...initial,
['web', 'invalid'], ['node', 'invalid'], ['web', 'watchRun'], ['node', 'watchRun'],
['web', 'invalid'], ['web', 'done'], ['web', 'watchRun'], ['node', 'done'], ['web', 'done'],
];
const cases = { initial, normal, duplicateInvalid, silentRestart };
const report = {
rspack: version, node: process.version,
implementation: 'Actual installed MultiCompiler and MultiStats; controlled fake child hooks and Stats payloads',
candidate: 'Exact one-block in-memory module transformation; only watchRun done-flag reset added',
installedSourceSha256: digest(original),
nativeCompilationInvoked: false, watchGraphInvoked: false,
arms: Object.fromEntries([['baseline', baseline], ['candidate', candidate]].map(([name, module]) => [name,
Object.fromEntries(Object.entries(cases).map(([caseName, actions]) => [caseName, runCase(module.MultiCompiler, caseName, actions)])),
])),
};
for (const [name, count] of [['initial', 1], ['normal', 2], ['duplicateInvalid', 3]]) {
assert.deepEqual(report.arms.baseline[name], report.arms.candidate[name]);
assert.equal(report.arms.baseline[name].publications.length, count);
assert(report.arms.baseline[name].publications.every(item => item.activeChildren.length === 0));
}
assert.deepEqual(report.arms.baseline.silentRestart.publications, [
{ at: 4, activeChildren: [], generations: { web: 1, node: 1 } },
{ at: 12, activeChildren: ['web'], generations: { web: 2, node: 2 } },
{ at: 13, activeChildren: [], generations: { web: 3, node: 2 } },
]);
assert.deepEqual(report.arms.candidate.silentRestart.publications, [
{ at: 4, activeChildren: [], generations: { web: 1, node: 1 } },
{ at: 13, activeChildren: [], generations: { web: 3, node: 2 } },
]);
assert.equal(await readFile(corePath, 'utf8'), original, 'Installed source must remain identical');
report.assertionsPassed = true;
report.installedSourceUnchanged = true;
await writeFile(new URL('./results.json', import.meta.url), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify({ assertionsPassed: true, controlsIdentical: 3,
baselinePrematurePublications: 1, candidatePrematurePublications: 0,
installedSourceUnchanged: true }));
MultiCompiler can publish aggregate done while a child has restarted without another invalid hook
Environment and scope
MultiCompilerandMultiStatsclasses.This reports a constructor accounting gap. It is not a minimized native watcher race or proof of a particular application's reload cause.
Reproduction
Save the self-contained reproduction below as
repro.mjs. Runnpm install --no-save @rspack/core@2.2.4, thennode repro.mjswith Node 24.The script loads the installed module normally for the baseline. It also loads a candidate through a temporary Node module loader that replaces one exact constructor block in memory. Installed package files are never changed, and the source is verified byte-identical after execution.
Starting after an initial successful build of both children, emit:
At step 5, baseline emits aggregate
donewith the previous web Stats even though web is running again. At step 6 it emits another aggregate result. The candidate emits only at step 6.The complete test records numbered events, active child names, and generation numbers at every aggregate publication. Initial-build, normal single-child update, and duplicate-invalid controls produce identical successful results before and after the candidate change.
Source and expected behavior
The MultiCompiler constructor resets each child's done flag only on
invalid. A child that starts another watch compilation should cease to count as completed even when a fresh public invalid notification did not occur.Minimal candidate:
Resetting on both hooks is idempotent. The test applies only this accounting change and never emits a synthetic invalid as a workaround.
Limitations and follow-up validation
The controlled schedule is motivated by the graph's running-outdated requeue and Watching's deduplicated invalid notifications. This fixture does not execute those mechanisms, so their real-world reachability should be covered separately. It also does not test native Stats serialization, entrypoint disappearance, or browser reloads.
The change does not stop redundant compilations and does not make aggregate done a global quiescence barrier: a pending rerun that has not reached watchRun can still be outside its scope. Before merging, cover dependent children, limited parallelism, actual second edits during compilation, watchRun errors, recovery, close/re-watch, and initial/normal behavior with real hooks and compilers.
Related but distinct: #15019 changes invalidation provenance/coalescing; #15454 documents stale compilation handles and leaves silent entrypoint/hash aliasing unresolved. Neither establishes that this exact accounting gap is already fixed.
Measured result
{ "assertionsPassed": true, "controlsIdentical": 3, "baselinePrematurePublications": 1, "candidatePrematurePublications": 0, "installedSourceUnchanged": true }Self-contained Node 24 reproduction