Skip to content

Commit c8d97e2

Browse files
fix: trampoline getSideEffectsConnectionState to avoid stack overflow on deep import chains (#20993)
1 parent e9930f1 commit c8d97e2

6 files changed

Lines changed: 237 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Fix `RangeError: Maximum call stack size exceeded` thrown from `HarmonyImportSideEffectDependency.getModuleEvaluationSideEffectsState` on long linear chains of side-effect-free imports. `NormalModule.getSideEffectsConnectionState` previously descended through `HarmonyImportSideEffectDependency.getModuleEvaluationSideEffectsState` recursively, adding two stack frames per module, which overflowed V8's stack at a few thousand modules deep. The traversal is now iterative.

lib/NormalModule.js

Lines changed: 96 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const parseJson = require("./util/parseJson");
6161
/** @typedef {import("../declarations/WebpackOptions").ResolveOptions} ResolveOptions */
6262
/** @typedef {import("../declarations/WebpackOptions").NoParse} NoParse */
6363
/** @typedef {import("./config/defaults").WebpackOptionsNormalizedWithDefaults} WebpackOptions */
64+
/** @typedef {import("./Dependency")} Dependency */
6465
/** @typedef {import("./Dependency").UpdateHashContext} UpdateHashContext */
6566
/** @typedef {import("./Generator")} Generator */
6667
/** @typedef {import("./Generator").GenerateErrorFn} GenerateErrorFn */
@@ -85,6 +86,7 @@ const parseJson = require("./util/parseJson");
8586
/** @typedef {import("./Module").UnsafeCacheData} UnsafeCacheData */
8687
/** @typedef {import("./ModuleGraph")} ModuleGraph */
8788
/** @typedef {import("./ModuleGraphConnection").ConnectionState} ConnectionState */
89+
/** @typedef {Iterator<SideEffectsWalk, ConnectionState, ConnectionState>} SideEffectsWalk */
8890
/** @typedef {import("./NormalModuleFactory")} NormalModuleFactory */
8991
/** @typedef {import("./NormalModuleFactory").NormalModuleTypes} NormalModuleTypes */
9092
/** @typedef {import("./NormalModuleFactory").ResourceSchemeData} ResourceSchemeData */
@@ -126,6 +128,87 @@ const getExtractSourceMap = memoize(() => require("./util/extractSourceMap"));
126128

127129
const getValidate = memoize(() => require("schema-utils").validate);
128130

131+
const getHarmonyImportSideEffectDependency = memoize(() =>
132+
require("./dependencies/HarmonyImportSideEffectDependency")
133+
);
134+
135+
/**
136+
* @param {NormalModule} mod the module
137+
* @param {ModuleGraph} moduleGraph the module graph
138+
* @param {Dependency} dep the dep that triggered the bailout
139+
*/
140+
const recordSideEffectsBailout = (mod, moduleGraph, dep) => {
141+
if (mod._addedSideEffectsBailout === undefined) {
142+
mod._addedSideEffectsBailout = new WeakSet();
143+
} else if (mod._addedSideEffectsBailout.has(moduleGraph)) {
144+
return;
145+
}
146+
mod._addedSideEffectsBailout.add(moduleGraph);
147+
moduleGraph
148+
.getOptimizationBailout(mod)
149+
.push(
150+
() =>
151+
`Dependency (${dep.type}) with side effects at ${formatLocation(dep.loc)}`
152+
);
153+
};
154+
155+
/**
156+
* Generator form of `getSideEffectsConnectionState` — descends through
157+
* `HarmonyImportSideEffectDependency` via `yield` so the trampoline in
158+
* `getSideEffectsConnectionState` can drive the walk iteratively (#20986).
159+
* @param {NormalModule} mod the module being evaluated
160+
* @param {ModuleGraph} moduleGraph the module graph
161+
* @returns {SideEffectsWalk} the generator
162+
*/
163+
function* walkSideEffects(mod, moduleGraph) {
164+
if (mod.factoryMeta !== undefined) {
165+
if (mod.factoryMeta.sideEffectFree) return false;
166+
if (mod.factoryMeta.sideEffectFree === false) return true;
167+
}
168+
if (!(mod.buildMeta !== undefined && mod.buildMeta.sideEffectFree)) {
169+
return true;
170+
}
171+
if (mod._isEvaluatingSideEffects) {
172+
return ModuleGraphConnection.CIRCULAR_CONNECTION;
173+
}
174+
175+
const SideEffectDep = getHarmonyImportSideEffectDependency();
176+
mod._isEvaluatingSideEffects = true;
177+
/** @type {ConnectionState} */
178+
let current = false;
179+
180+
for (const dep of mod.dependencies) {
181+
/** @type {ConnectionState} */
182+
let state;
183+
if (dep instanceof SideEffectDep) {
184+
const refModule = moduleGraph.getModule(dep);
185+
if (!refModule) {
186+
state = true;
187+
} else if (refModule instanceof NormalModule) {
188+
state = yield walkSideEffects(refModule, moduleGraph);
189+
} else {
190+
state = refModule.getSideEffectsConnectionState(moduleGraph);
191+
}
192+
} else {
193+
state = dep.getModuleEvaluationSideEffectsState(moduleGraph);
194+
}
195+
196+
if (state === true) {
197+
recordSideEffectsBailout(mod, moduleGraph, dep);
198+
mod._isEvaluatingSideEffects = false;
199+
return true;
200+
}
201+
if (state !== ModuleGraphConnection.CIRCULAR_CONNECTION) {
202+
current = ModuleGraphConnection.addConnectionStates(current, state);
203+
}
204+
}
205+
206+
mod._isEvaluatingSideEffects = false;
207+
// When caching is implemented here, make sure to not cache when
208+
// at least one circular connection was folded into `current`.
209+
return current;
210+
}
211+
129212
const ABSOLUTE_PATH_REGEX = /^(?:[a-z]:\\|\\\\|\/)/i;
130213

131214
/**
@@ -405,12 +488,10 @@ class NormalModule extends Module {
405488
*/
406489
this._forceBuild = true;
407490
/**
408-
* @private
409491
* @type {boolean}
410492
*/
411493
this._isEvaluatingSideEffects = false;
412494
/**
413-
* @private
414495
* @type {WeakSet<ModuleGraph> | undefined}
415496
*/
416497
this._addedSideEffectsBailout = undefined;
@@ -1453,47 +1534,21 @@ class NormalModule extends Module {
14531534
* @returns {ConnectionState} how this module should be connected to referencing modules when consumed for side-effects only
14541535
*/
14551536
getSideEffectsConnectionState(moduleGraph) {
1456-
if (this.factoryMeta !== undefined) {
1457-
if (this.factoryMeta.sideEffectFree) return false;
1458-
if (this.factoryMeta.sideEffectFree === false) return true;
1459-
}
1460-
if (this.buildMeta !== undefined && this.buildMeta.sideEffectFree) {
1461-
if (this._isEvaluatingSideEffects) {
1462-
return ModuleGraphConnection.CIRCULAR_CONNECTION;
1463-
}
1464-
this._isEvaluatingSideEffects = true;
1465-
/** @type {ConnectionState} */
1466-
let current = false;
1467-
for (const dep of this.dependencies) {
1468-
const state = dep.getModuleEvaluationSideEffectsState(moduleGraph);
1469-
if (state === true) {
1470-
if (
1471-
this._addedSideEffectsBailout === undefined
1472-
? ((this._addedSideEffectsBailout = new WeakSet()), true)
1473-
: !this._addedSideEffectsBailout.has(moduleGraph)
1474-
) {
1475-
this._addedSideEffectsBailout.add(moduleGraph);
1476-
moduleGraph
1477-
.getOptimizationBailout(this)
1478-
.push(
1479-
() =>
1480-
`Dependency (${
1481-
dep.type
1482-
}) with side effects at ${formatLocation(dep.loc)}`
1483-
);
1484-
}
1485-
this._isEvaluatingSideEffects = false;
1486-
return true;
1487-
} else if (state !== ModuleGraphConnection.CIRCULAR_CONNECTION) {
1488-
current = ModuleGraphConnection.addConnectionStates(current, state);
1489-
}
1537+
// Trampoline `walkSideEffects` so the descent doesn't consume the
1538+
// call stack (#20986).
1539+
const stack = [walkSideEffects(this, moduleGraph)];
1540+
/** @type {ConnectionState} */
1541+
let r = false;
1542+
while (stack.length > 0) {
1543+
const step = stack[stack.length - 1].next(r);
1544+
if (step.done) {
1545+
stack.pop();
1546+
r = step.value;
1547+
} else {
1548+
stack.push(step.value);
14901549
}
1491-
this._isEvaluatingSideEffects = false;
1492-
// When caching is implemented here, make sure to not cache when
1493-
// at least one circular connection was in the loop above
1494-
return current;
14951550
}
1496-
return true;
1551+
return r;
14971552
}
14981553

14991554
/**

test/NormalModule.unittest.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const SourceMapSource = require("webpack-sources").SourceMapSource;
44
const OriginalSource = require("webpack-sources").OriginalSource;
55
const RawSource = require("webpack-sources").RawSource;
66
const NormalModule = require("../lib/NormalModule");
7+
const HarmonyImportSideEffectDependency = require("../lib/dependencies/HarmonyImportSideEffectDependency");
78

89
describe("NormalModule", () => {
910
let normalModule;
@@ -382,4 +383,90 @@ describe("NormalModule", () => {
382383
});
383384
});
384385
});
386+
387+
describe("#getSideEffectsConnectionState", () => {
388+
// Builds a synthetic linear chain of `count` side-effect-free modules
389+
// linked by HarmonyImportSideEffectDependency. Walking the chain via
390+
// the recursive form used 2 stack frames per module and overflowed on
391+
// long chains (issue #20986).
392+
const buildChain = (count) => {
393+
const modules = [];
394+
for (let i = 0; i < count; i++) {
395+
const mod = new NormalModule({
396+
type: "javascript/auto",
397+
request: `/m${i}`,
398+
userRequest: `/m${i}`,
399+
rawRequest: `m${i}`,
400+
loaders: [],
401+
resource: `/m${i}`,
402+
parser: { parse() {} },
403+
generator: null,
404+
resolveOptions: {}
405+
});
406+
mod.buildMeta = { sideEffectFree: true };
407+
modules.push(mod);
408+
}
409+
const depToModule = new Map();
410+
for (let i = 0; i < count - 1; i++) {
411+
const dep = new HarmonyImportSideEffectDependency(
412+
`m${i + 1}`,
413+
0,
414+
"evaluation"
415+
);
416+
modules[i].dependencies = [dep];
417+
depToModule.set(dep, modules[i + 1]);
418+
}
419+
modules[count - 1].dependencies = [];
420+
const moduleGraph = {
421+
getModule: (dep) => depToModule.get(dep),
422+
getOptimizationBailout: () => []
423+
};
424+
return { modules, moduleGraph };
425+
};
426+
427+
it("handles deep linear chains without overflowing the stack", () => {
428+
const { modules, moduleGraph } = buildChain(20000);
429+
expect(modules[0].getSideEffectsConnectionState(moduleGraph)).toBe(false);
430+
});
431+
432+
it("detects cycles in the side-effect graph", () => {
433+
const { modules, moduleGraph } = buildChain(50);
434+
// close the loop: last module's dep points to modules[0]
435+
const lastDep = new HarmonyImportSideEffectDependency(
436+
"m0",
437+
0,
438+
"evaluation"
439+
);
440+
modules[49].dependencies = [lastDep];
441+
const originalGetModule = moduleGraph.getModule;
442+
moduleGraph.getModule = (dep) =>
443+
dep === lastDep ? modules[0] : originalGetModule(dep);
444+
// Cycles fold to `false` (the accumulator's identity) when all
445+
// participating modules are side-effect free — same as the original
446+
// recursive behavior.
447+
expect(modules[0].getSideEffectsConnectionState(moduleGraph)).toBe(false);
448+
expect(modules[0]._isEvaluatingSideEffects).toBe(false);
449+
});
450+
451+
it("reports the bailout dep when a chain member has side effects", () => {
452+
const { modules, moduleGraph } = buildChain(10);
453+
// Make module 5 have side effects.
454+
modules[5].buildMeta = { sideEffectFree: false };
455+
const bailouts = new Map();
456+
moduleGraph.getOptimizationBailout = (mod) => {
457+
if (!bailouts.has(mod)) bailouts.set(mod, []);
458+
return bailouts.get(mod);
459+
};
460+
expect(modules[0].getSideEffectsConnectionState(moduleGraph)).toBe(true);
461+
// Each ancestor in the chain (modules 0..4) records the bailout once
462+
// for the dep that triggered descent, matching the recursive baseline.
463+
for (let i = 0; i < 5; i++) {
464+
const list = bailouts.get(modules[i]);
465+
expect(list).toHaveLength(1);
466+
expect(list[0]()).toMatch(
467+
/Dependency \(harmony side effect evaluation\)/
468+
);
469+
}
470+
});
471+
});
385472
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src/
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { value, config } from "./src/chain/mod-0.js";
2+
3+
it("should compile a deep linear chain of side-effect-free imports without overflowing the stack", () => {
4+
expect(config.id).toBe(0);
5+
// value is a chain of nested arrays — innermost element is from the terminal module
6+
expect(Array.isArray(value)).toBe(true);
7+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"use strict";
2+
3+
const fs = require("fs");
4+
const path = require("path");
5+
6+
// Mirrors the reproduction from
7+
// https://github.com/abecirovic-mo/webpack-5.107.0-repro: a long chain of
8+
// side-effect-free modules where each imports the next and uses the
9+
// imported binding in its own export. The SideEffectsFlagPlugin walks the
10+
// chain via `HarmonyImportSideEffectDependency.getModuleEvaluationSideEffectsState`,
11+
// which used to recurse and overflow V8's stack on 5.107.0 (issue #20986).
12+
// The chain here is linear (mod-N is terminal) — that's enough to exercise
13+
// the same recursive path. The unit test in `test/NormalModule.unittest.js`
14+
// covers a 20000-deep chain to assert the algorithm is stack-safe
15+
// regardless of size.
16+
const N = 500;
17+
const chainDir = path.join(__dirname, "src", "chain");
18+
if (!fs.existsSync(chainDir)) {
19+
fs.mkdirSync(chainDir, { recursive: true });
20+
fs.writeFileSync(
21+
path.join(chainDir, `mod-${N}.js`),
22+
`export const value = [${N}];\nexport const config = { id: ${N} };\n`
23+
);
24+
for (let i = N - 1; i >= 0; i--) {
25+
fs.writeFileSync(
26+
path.join(chainDir, `mod-${i}.js`),
27+
`import { value as imported } from "./mod-${i + 1}.js";
28+
export const value = [imported, ${i}];
29+
export const config = { id: ${i} };
30+
`
31+
);
32+
}
33+
}
34+
35+
/** @type {import("../../../../").Configuration} */
36+
module.exports = {
37+
mode: "production",
38+
optimization: {
39+
minimize: false
40+
}
41+
};

0 commit comments

Comments
 (0)