fix(module-runner): prevent partial-exports race on concurrent imports of in-flight invalidated re-export chains#22369
Open
schiller-manuel wants to merge 1 commit intovitejs:mainfrom
Open
Conversation
…s of in-flight invalidated re-export chains
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes an SSR/HMR module runner race where a concurrent import can observe an incomplete namespace from an in-flight module evaluation.
The same shape reproduces in any project where:
export *from a deeper moduleThe runner mis-detects the second request as "circular" and hands it the partial in-flight
mod.exportsobject beforeexport *has populated it, exposing an empty namespace to user code.Root Cause
ModuleRunner.cachedRequestdecides between returning partialmod.exports(cycle break) vs.await mod.promise(wait for full evaluation). The pre-fix logic used three signals; two of them consultedmod.importers:mod.importersis a monotonic set, never cleared byinvalidateModule. After HMR it carries every historical caller. A new concurrent caller is therefore frequently mis-classified as "in a cycle" with a stale importer, and receives an empty exports object.Walkthrough
Module graph
entry-a --\ +--> shared --> core (core gated on async work; wildcard-reexported by shared) entry-b --/ ^ | +-- import './entry-a.js' (real cycle: shared <-> entry-a)Old Behavior
shared.importers = { entry-a, entry-b },shared.imports = { entry-a, core }.evaluated/promise/exportsreset;importerspreserved.entry-are-imports -> reachesshared.directRequest-> reachescore-> suspends on async work.shared.promiseset,shared.evaluated = false,shared.exports = {}(empty placeholder).entry-bconcurrently re-imports -> reachescachedRequest(shared)withcallstack = [entry-b].callstack.includes(shared)-> falseisCircularModule(shared):shared.imports = { entry-a, core }intersectsshared.importers = { entry-a, entry-b }-> true (entry-ais in both)shared.exports, which is empty.entry-bbody executescreateThing(...)against an empty namespace ->TypeError: createThing is not a function.New Behavior
Replace the importer-based heuristics with a callstack-driven forward-graph check:
isCircularRequest(mod, callstack)walksmod.importstransitively (bounded byvisited, descent halted at already-evaluated modules) and asks the only question that matters for deadlock: does anythingmodis waiting on appear in my current callstack?Replaying the same scenario:
callstack = [entry-b]. Walkshared.imports = { entry-a, core }:entry-anot in[entry-b]; recurse:entry-a.imports = { shared };sharedalready visited -> falsecorenot in[entry-b];core.imports = {}-> falseawait mod.promise.entry-bwaits untilsharedfinishes evaluating, then sees the fully populated namespace.Real cycles still break correctly: when
sharedre-entersentry-aduringentry-a's own evaluation,callstack = [entry-a, shared]->callstack.includes(entry-a)-> returns partial exports (ESM spec-correct cycle break).Why Previous Heuristics Fail
isCircularModule: cannot distinguish "I am in a cycle with my caller" from "I have ever been in a cycle with anyone".isCircularImport:mod.importersis a historical reverse-edge set, not a runtime callstack. Across HMR it grows unboundedly. The check is alsoO(importers^2)in the worst case.The new check is local to the live request,
O(V+E)over the forward subgraph (visited-bounded), and matches the actual deadlock condition.Performance
isCircularRequestonly runs whenmod.promiseis set andmod.evaluatedis false, i.e. the rare in-flight overlap case.await mod.promisebranch that was inside thetryblock.Test
Adds
does not expose partial exports during concurrent updatesinserver-hmr.spec.tswith fixtures under__tests__/fixtures/hmr-reexport-race/.The test:
entry-aandentry-b(both importshared, whichexport *fromcoreand has a self-cycle toentry-a).core's top-level await.entry-are-import; awaits "gate hit".entry-bre-import; flushes a macrotask; releases the gate.The test fails on
mainand passes with the fix.Refs
createStartHandler is not a functionon every re-evaluation (re-occurrence of #5673, 1.167.50) TanStack/router#7285