Authors: russell@unturf.com · brackishbert@gmail.com · foxhop.net · TimeHexOn.com Patches, unit tests, benchmarks, whitepaper, and outreach briefs. Public domain — no copyright claimed. Use freely.
374 lines
14 KiB
Java
374 lines
14 KiB
Java
package unit;
|
||
|
||
import java.util.*;
|
||
|
||
/**
|
||
* Unit test for webpack-0001/0002/0003: CWE-407 in webpack HMR runtime.
|
||
*
|
||
* webpack-0001 (MEDIUM-HIGH):
|
||
* File: lib/hmr/JavascriptHotModuleReplacement.runtime.js:74
|
||
* Symbol: getAffectedModuleEffects — `outdatedModules.indexOf(parentId)`
|
||
* Defect: BFS traversal over a module dependency graph uses an Array for the
|
||
* outdatedModules visited set. For each queued module, for each parent,
|
||
* indexOf scans the entire array: O(M) per check, O(M²) total.
|
||
* Fix: Shadow with a Set; check becomes Set.has(), O(1).
|
||
*
|
||
* webpack-0002 (MEDIUM):
|
||
* File: lib/hmr/JavascriptHotModuleReplacement.runtime.js:101
|
||
* Symbol: addAllToSet — `a.indexOf(item)`
|
||
* Defect: Result-merging helper deduplicates by scanning the accumulator
|
||
* array on every insertion: O(N) per item, O(N²) total for N items.
|
||
* Fix: Maintain a companion Set on the accumulator array; check is O(1).
|
||
*
|
||
* webpack-0003 (MEDIUM):
|
||
* File: lib/hmr/HotModuleReplacement.runtime.js:60,67
|
||
* Symbol: createRequire — `parents.indexOf(moduleId)`, `me.children.indexOf(request)`
|
||
* Defect: Every require() call in the HMR hot path deduplicates parents and
|
||
* children using indexOf on plain arrays: O(P) and O(C) per call.
|
||
* Fix: Attach Set companions (_parentsSet, _childrenSet); O(1) per check.
|
||
*
|
||
* Modeled here in Java:
|
||
* JS Array + indexOf ≡ List<Integer> + contains/indexOf (defective)
|
||
* JS Set + has ≡ java.util.Set + contains (fixed)
|
||
* Comparison counts tracked at the membership-test site.
|
||
*
|
||
* Expected at M=200:
|
||
* defective BFS comparisons ≈ M² = 40 000
|
||
* fixed BFS comparisons ≈ M = 200
|
||
* ratio > 10×
|
||
*/
|
||
public class WebpackHmrTest {
|
||
|
||
// =========================================================================
|
||
// webpack-0001 model: BFS with outdatedModules as Array vs Set
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Defective BFS: outdatedModules is a plain List.
|
||
* For each (module, parent) pair we call list.contains(), which is O(size).
|
||
* comparisons counts every element examined during indexOf scans.
|
||
*/
|
||
static class DefectiveBfs {
|
||
long comparisons = 0;
|
||
|
||
/**
|
||
* Simulate getAffectedModuleEffects: process a chain of M modules where
|
||
* each module has one parent, and the parent is always "not yet in
|
||
* outdatedModules" (worst-case: no early-exit, full scan every time).
|
||
*
|
||
* Graph: 0 → 1 → 2 → ... → M-1 (child → parent direction).
|
||
* BFS starts from module 0, propagates up.
|
||
*/
|
||
List<Integer> run(int M) {
|
||
List<Integer> outdatedModules = new ArrayList<>();
|
||
outdatedModules.add(0);
|
||
|
||
// Queue: (moduleId). We process each and check its single parent.
|
||
Queue<Integer> queue = new ArrayDeque<>();
|
||
queue.add(0);
|
||
|
||
while (!queue.isEmpty()) {
|
||
int moduleId = queue.poll();
|
||
int parentId = moduleId + 1; // parent in linear chain
|
||
if (parentId >= M) continue; // no parent beyond chain end
|
||
|
||
// outdatedModules.indexOf(parentId) — O(current size) scan
|
||
boolean found = false;
|
||
for (int x : outdatedModules) {
|
||
comparisons++;
|
||
if (x == parentId) { found = true; break; }
|
||
}
|
||
if (found) continue;
|
||
|
||
outdatedModules.add(parentId);
|
||
queue.add(parentId);
|
||
}
|
||
return outdatedModules;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fixed BFS: shadow outdatedModules with a Set for O(1) membership.
|
||
* comparisons counts one hash-probe per Set.contains() call.
|
||
*/
|
||
static class FixedBfs {
|
||
long comparisons = 0;
|
||
|
||
List<Integer> run(int M) {
|
||
List<Integer> outdatedModules = new ArrayList<>();
|
||
Set<Integer> outdatedSet = new HashSet<>();
|
||
outdatedModules.add(0);
|
||
outdatedSet.add(0);
|
||
|
||
Queue<Integer> queue = new ArrayDeque<>();
|
||
queue.add(0);
|
||
|
||
while (!queue.isEmpty()) {
|
||
int moduleId = queue.poll();
|
||
int parentId = moduleId + 1;
|
||
if (parentId >= M) continue;
|
||
|
||
// Set.contains(parentId) — O(1)
|
||
comparisons++;
|
||
if (outdatedSet.contains(parentId)) continue;
|
||
|
||
outdatedModules.add(parentId);
|
||
outdatedSet.add(parentId);
|
||
queue.add(parentId);
|
||
}
|
||
return outdatedModules;
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// webpack-0002 model: addAllToSet with Array indexOf vs Set companion
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Defective addAllToSet: accumulator is a plain List; every insertion
|
||
* scans the list to deduplicate.
|
||
* comparisons counts every element examined during indexOf scans.
|
||
*/
|
||
static long addAllToSetDefective(List<Integer> a, List<Integer> b,
|
||
long[] comparisons) {
|
||
for (int item : b) {
|
||
// a.indexOf(item) — O(a.size()) scan
|
||
boolean found = false;
|
||
for (int x : a) {
|
||
comparisons[0]++;
|
||
if (x == item) { found = true; break; }
|
||
}
|
||
if (!found) a.add(item);
|
||
}
|
||
return comparisons[0];
|
||
}
|
||
|
||
/**
|
||
* Fixed addAllToSet: companion Set maintained alongside the List.
|
||
* comparisons counts one hash-probe per Set.contains() call.
|
||
*/
|
||
static long addAllToSetFixed(List<Integer> a, Set<Integer> aSet,
|
||
List<Integer> b, long[] comparisons) {
|
||
for (int item : b) {
|
||
comparisons[0]++; // O(1) Set.contains
|
||
if (!aSet.contains(item)) {
|
||
a.add(item);
|
||
aSet.add(item);
|
||
}
|
||
}
|
||
return comparisons[0];
|
||
}
|
||
|
||
// =========================================================================
|
||
// webpack-0003 model: parents/children dedup in require() hot path
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Defective require(): parents and children deduped via indexOf on plain arrays.
|
||
* Simulates N require() calls where each call checks both parents and children.
|
||
* comparisons counts every element examined during indexOf scans.
|
||
*/
|
||
static long simulateRequireDefective(int N) {
|
||
long comparisons = 0;
|
||
List<Integer> parents = new ArrayList<>();
|
||
List<Integer> children = new ArrayList<>();
|
||
|
||
for (int moduleId = 0; moduleId < N; moduleId++) {
|
||
int request = moduleId + 1000; // distinct child module IDs
|
||
|
||
// parents.indexOf(moduleId) — O(parents.size())
|
||
boolean foundParent = false;
|
||
for (int x : parents) {
|
||
comparisons++;
|
||
if (x == moduleId) { foundParent = true; break; }
|
||
}
|
||
if (!foundParent) parents.add(moduleId);
|
||
|
||
// me.children.indexOf(request) — O(children.size())
|
||
boolean foundChild = false;
|
||
for (int x : children) {
|
||
comparisons++;
|
||
if (x == request) { foundChild = true; break; }
|
||
}
|
||
if (!foundChild) children.add(request);
|
||
}
|
||
return comparisons;
|
||
}
|
||
|
||
/**
|
||
* Fixed require(): Set companions for O(1) dedup.
|
||
* comparisons counts one hash-probe per check.
|
||
*/
|
||
static long simulateRequireFixed(int N) {
|
||
long comparisons = 0;
|
||
List<Integer> parents = new ArrayList<>();
|
||
Set<Integer> parentsSet = new HashSet<>();
|
||
List<Integer> children = new ArrayList<>();
|
||
Set<Integer> childrenSet = new HashSet<>();
|
||
|
||
for (int moduleId = 0; moduleId < N; moduleId++) {
|
||
int request = moduleId + 1000;
|
||
|
||
// parentsSet.contains(moduleId) — O(1)
|
||
comparisons++;
|
||
if (!parentsSet.contains(moduleId)) {
|
||
parents.add(moduleId);
|
||
parentsSet.add(moduleId);
|
||
}
|
||
|
||
// childrenSet.contains(request) — O(1)
|
||
comparisons++;
|
||
if (!childrenSet.contains(request)) {
|
||
children.add(request);
|
||
childrenSet.add(request);
|
||
}
|
||
}
|
||
return comparisons;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Tests
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Test 1 — Correctness: defective and fixed BFS produce identical module sets.
|
||
*/
|
||
static void testBfsCorrectnessMatch() {
|
||
int M = 50;
|
||
DefectiveBfs def = new DefectiveBfs();
|
||
FixedBfs fix = new FixedBfs();
|
||
|
||
List<Integer> defResult = def.run(M);
|
||
List<Integer> fixResult = fix.run(M);
|
||
|
||
Set<Integer> defSet = new HashSet<>(defResult);
|
||
Set<Integer> fixSet = new HashSet<>(fixResult);
|
||
|
||
assert defSet.equals(fixSet)
|
||
: "BFS output mismatch: defective=" + defSet + " fixed=" + fixSet;
|
||
assert defResult.size() == M
|
||
: "expected " + M + " outdated modules; got " + defResult.size();
|
||
|
||
System.out.println("PASS testBfsCorrectnessMatch");
|
||
}
|
||
|
||
/**
|
||
* Test 2 — webpack-0001: ratio of defective vs fixed BFS comparisons > 10x at M=200.
|
||
*
|
||
* Defective: each of M modules triggers a scan of the growing outdatedModules
|
||
* list (size 0..M-1), summing to M*(M-1)/2 comparisons ~ O(M²).
|
||
* Fixed: each module triggers exactly 1 Set probe, summing to M ~ O(M).
|
||
*/
|
||
static void testBfsRatioAtScale() {
|
||
int M = 200;
|
||
|
||
DefectiveBfs def = new DefectiveBfs();
|
||
FixedBfs fix = new FixedBfs();
|
||
def.run(M);
|
||
fix.run(M);
|
||
|
||
long defComp = def.comparisons;
|
||
long fixComp = fix.comparisons;
|
||
double ratio = (double) defComp / fixComp;
|
||
|
||
// Defective: sum 0+1+...+(M-1) = M*(M-1)/2
|
||
long expectedDef = (long) M * (M - 1) / 2;
|
||
assert defComp == expectedDef
|
||
: "defective BFS comparisons should be M*(M-1)/2=" + expectedDef
|
||
+ "; got " + defComp;
|
||
|
||
// Fixed: M-1 probes (one per module that has a parent to check;
|
||
// the last module in the chain has no parent, so no probe is issued)
|
||
assert fixComp == M - 1
|
||
: "fixed BFS comparisons should be M-1=" + (M - 1) + "; got " + fixComp;
|
||
|
||
assert ratio > 10.0
|
||
: "ratio should be >10x at M=200; got " + ratio;
|
||
|
||
System.out.printf(
|
||
"PASS testBfsRatioAtScale (defective=%d, fixed=%d, ratio=%.1fx)%n",
|
||
defComp, fixComp, ratio);
|
||
}
|
||
|
||
/**
|
||
* Test 3 — webpack-0002: addAllToSet defective is O(N²), fixed is O(N).
|
||
*
|
||
* Merge N unique items into an accumulator. Defective scans the accumulator
|
||
* before each insert (size 0..N-1), costing N*(N-1)/2. Fixed costs N probes.
|
||
*/
|
||
static void testAddAllToSetRatio() {
|
||
int N = 200;
|
||
|
||
// Build source list of N unique items
|
||
List<Integer> source = new ArrayList<>();
|
||
for (int i = 0; i < N; i++) source.add(i);
|
||
|
||
// Defective
|
||
long[] defComp = {0};
|
||
List<Integer> defAcc = new ArrayList<>();
|
||
addAllToSetDefective(defAcc, source, defComp);
|
||
|
||
// Fixed
|
||
long[] fixComp = {0};
|
||
List<Integer> fixAcc = new ArrayList<>();
|
||
Set<Integer> fixSet = new HashSet<>();
|
||
addAllToSetFixed(fixAcc, fixSet, source, fixComp);
|
||
|
||
assert defAcc.size() == N && fixAcc.size() == N
|
||
: "both accumulators should have " + N + " items";
|
||
assert new HashSet<>(defAcc).equals(new HashSet<>(fixAcc))
|
||
: "accumulator contents differ";
|
||
|
||
double ratio = (double) defComp[0] / fixComp[0];
|
||
assert ratio > 10.0
|
||
: "addAllToSet ratio should be >10x at N=200; got " + ratio;
|
||
|
||
System.out.printf(
|
||
"PASS testAddAllToSetRatio (defective=%d, fixed=%d, ratio=%.1fx)%n",
|
||
defComp[0], fixComp[0], ratio);
|
||
}
|
||
|
||
/**
|
||
* Test 4 — webpack-0003: require() hot path defective is O(N²), fixed is O(N).
|
||
*
|
||
* N require() calls, each adding a unique moduleId to parents and a unique
|
||
* request to children. Defective: scans grow 0..N-1 for each; fixed: O(1) probes.
|
||
*/
|
||
static void testRequireHotPathRatio() {
|
||
int N = 200;
|
||
|
||
long defComp = simulateRequireDefective(N);
|
||
long fixComp = simulateRequireFixed(N);
|
||
double ratio = (double) defComp / fixComp;
|
||
|
||
// Defective: two indexOf scans per call, sizes grow 0..N-1
|
||
// Sum for parents: 0+1+...+(N-1) = N*(N-1)/2; same for children.
|
||
long expectedDef = (long) N * (N - 1); // two arrays: 2 × N*(N-1)/2
|
||
assert defComp == expectedDef
|
||
: "defective require comparisons should be N*(N-1)=" + expectedDef
|
||
+ "; got " + defComp;
|
||
|
||
// Fixed: exactly 2 probes per call (one per Set)
|
||
long expectedFix = 2L * N;
|
||
assert fixComp == expectedFix
|
||
: "fixed require comparisons should be 2*N=" + expectedFix
|
||
+ "; got " + fixComp;
|
||
|
||
assert ratio > 10.0
|
||
: "require() ratio should be >10x at N=200; got " + ratio;
|
||
|
||
System.out.printf(
|
||
"PASS testRequireHotPathRatio (defective=%d, fixed=%d, ratio=%.1fx)%n",
|
||
defComp, fixComp, ratio);
|
||
}
|
||
|
||
// =========================================================================
|
||
|
||
public static void main(String[] args) {
|
||
testBfsCorrectnessMatch();
|
||
testBfsRatioAtScale();
|
||
testAddAllToSetRatio();
|
||
testRequireHotPathRatio();
|
||
System.out.println("All webpack HMR tests passed.");
|
||
}
|
||
}
|