Skip to content

[Bug]: MultiCompiler can publish done while a child has restarted without another invalid hook #15682

Description

@matthewdavis-oai

MultiCompiler can publish aggregate done while a child has restarted without another invalid hook

Environment and scope

  • Rspack 2.2.4, Node 24.19.0.
  • Deterministic JavaScript hook-level reproduction using the installed MultiCompiler and MultiStats classes.
  • Child hook emitters and Stats payloads are controlled test doubles. No native compilation, watch graph, dev server, or browser is involved.

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. Run npm install --no-save @rspack/core@2.2.4, then node repro.mjs with 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:

  1. Web invalid; node invalid.
  2. Web watchRun; node watchRun.
  3. Web invalid while running; web done.
  4. Web watchRun again, without another invalid hook.
  5. Node done.
  6. Web done.

At step 5, baseline emits aggregate done with 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:

-      compiler.hooks.invalid.tap('MultiCompiler', () => {
+      const markNotDone = () => {
         if (compilerDone) {
           compilerDone = false;
           doneCompilers--;
         }
-      });
+      };
+      compiler.hooks.invalid.tap('MultiCompiler', markNotDone);
+      compiler.hooks.watchRun.tap('MultiCompiler', markNotDone);

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
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 }));

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions