Skip to content

Commit ca82056

Browse files
committed
fixup! module: add clearCache for CJS and ESM
1 parent 3021478 commit ca82056

File tree

5 files changed

+328
-5
lines changed

5 files changed

+328
-5
lines changed

β€Ždoc/api/module.mdβ€Ž

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,36 @@ same target are not cleared β€” only entries for the exact `(specifier, parentUR
111111
are removed. The module cache itself is cleared by resolved file path, so all specifiers
112112
pointing to the same file will see a fresh execution on next import.
113113
114+
#### Memory retention and static imports
115+
116+
`clearCache` only removes references from the Node.js **JavaScript-level** caches
117+
(the ESM load cache, resolve cache, CJS `require.cache`, and related structures).
118+
It does **not** affect V8-internal module graph references.
119+
120+
When a module M is **statically imported** by a live parent module P
121+
(i.e., via a top-level `import … from '…'` statement that has already been
122+
evaluated), V8's module instantiation creates a permanent internal strong
123+
reference from P's compiled module record to M's module record. Calling
124+
`clearCache(M)` cannot sever that link. Consequences:
125+
126+
* The old instance of M **stays alive in memory** for as long as P is alive,
127+
regardless of how many times M is cleared and re-imported.
128+
* A fresh `import(M)` after clearing will create a **separate** module instance
129+
that new importers see. P, however, continues to use the original instance β€”
130+
the two coexist simultaneously (sometimes called a "split-brain" state).
131+
* This is a **bounded** retention: one stale module instance per cleared module
132+
per live static parent. It does not grow unboundedly across clear/re-import
133+
cycles.
134+
135+
For **dynamically imported** modules (`await import('./M.mjs')` with no live
136+
static parent holding the result), the old `ModuleWrap` becomes eligible for
137+
garbage collection once `clearCache` removes it from Node.js caches and all
138+
JS-land references (e.g., stored namespace objects) are dropped.
139+
140+
The safest pattern for hot-reload of ES modules is to use cache-busting search
141+
parameters (so each version is a distinct module URL) and to avoid statically
142+
importing modules that need to be reloaded:
143+
114144
#### ECMA-262 spec considerations
115145
116146
Re-importing the exact same `(specifier, parentURL, importAttributes)` tuple after clearing the module cache
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Flags: --expose-internals
2+
// Evaluates the hash_to_module_map memory behaviour across clearCache cycles.
3+
//
4+
// hash_to_module_map is a C++ unordered_multimap<int, ModuleWrap*> on the
5+
// Environment. Every new ModuleWrap adds an entry; the destructor removes it.
6+
// Clearing the Node-side loadCache does not directly touch hash_to_module_map β€”
7+
// entries are removed only when ModuleWrap objects are garbage-collected.
8+
//
9+
// We verify two invariants:
10+
//
11+
// 1. DYNAMIC imports: after clearCache + GC, the old ModuleWrap is collected
12+
// and therefore its hash_to_module_map entry is removed. The map does NOT
13+
// grow without bound for purely-dynamic import/clear cycles.
14+
// (Verified via checkIfCollectableByCounting.)
15+
//
16+
// 2. STATIC imports: when a parent P statically imports M, clearing M from
17+
// the load cache does not free M's ModuleWrap (the static link keeps it).
18+
// Each re-import adds one new entry while the old entry stays for the
19+
// lifetime of P. This is a bounded, expected retention (not an unbounded
20+
// leak): it is capped at one stale entry per module per live static parent.
21+
22+
import '../common/index.mjs';
23+
24+
import assert from 'node:assert';
25+
import { clearCache, createRequire } from 'node:module';
26+
import { queryObjects } from 'v8';
27+
28+
const require = createRequire(import.meta.url);
29+
const { checkIfCollectableByCounting } = require('../common/gc');
30+
const { internalBinding } = require('internal/test/binding');
31+
const { ModuleWrap } = internalBinding('module_wrap');
32+
33+
const counterBase = new URL(
34+
'../fixtures/module-cache/esm-counter.mjs',
35+
import.meta.url,
36+
).href;
37+
38+
const parentURL = new URL(
39+
'../fixtures/module-cache/esm-static-parent.mjs',
40+
import.meta.url,
41+
).href;
42+
43+
// ── Invariant 1: dynamic-only cycles do NOT leak ModuleWraps ────────────────
44+
// Use cache-busting query params so each import gets a distinct URL.
45+
46+
const outer = 8;
47+
const inner = 4;
48+
49+
await checkIfCollectableByCounting(async (i) => {
50+
for (let j = 0; j < inner; j++) {
51+
const url = `${counterBase}?hm=${i}-${j}`;
52+
await import(url);
53+
clearCache(url, {
54+
parentURL: import.meta.url,
55+
resolver: 'import',
56+
caches: 'all',
57+
});
58+
}
59+
return inner;
60+
}, ModuleWrap, outer, 50);
61+
62+
// ── Invariant 2: static-parent cycles cause bounded retention ───────────────
63+
// After loading the static parent (which pins one counter instance), each
64+
// clear+re-import of the base counter URL creates exactly one new ModuleWrap
65+
// while the old one stays alive (pinned by the parent).
66+
// The net growth per cycle is +1. After N cycles the live count is
67+
// baseline + 1(parent) + 1(pinned original counter) + 1(current counter)
68+
// β€” a constant overhead, not growing with N.
69+
70+
// Load the static parent; this also loads the counter (count starts at 1 for
71+
// the global, but we seed it fresh by clearing any earlier runs' state).
72+
delete globalThis.__module_cache_esm_counter;
73+
74+
const parent = await import(parentURL);
75+
assert.strictEqual(parent.count, 1);
76+
77+
const wrapCount0 = queryObjects(ModuleWrap, { format: 'count' });
78+
79+
// Cycle 1: clear counter + re-import β†’ new instance created, old pinned.
80+
clearCache(counterBase, {
81+
parentURL: import.meta.url,
82+
resolver: 'import',
83+
caches: 'all',
84+
});
85+
const v2 = await import(counterBase);
86+
assert.strictEqual(v2.count, 2);
87+
88+
const wrapCount1 = queryObjects(ModuleWrap, { format: 'count' });
89+
// +1 new ModuleWrap (v2); old one kept alive by parent's static link.
90+
assert.strictEqual(wrapCount1, wrapCount0 + 1,
91+
'Each clear+reimport cycle adds exactly one new ModuleWrap ' +
92+
'when a static parent holds the old instance');
93+
94+
// Cycle 2: clear counter again + re-import.
95+
clearCache(counterBase, {
96+
parentURL: import.meta.url,
97+
resolver: 'import',
98+
caches: 'all',
99+
});
100+
const v3 = await import(counterBase);
101+
assert.strictEqual(v3.count, 3);
102+
103+
const wrapCount2 = queryObjects(ModuleWrap, { format: 'count' });
104+
// Another +1 (v3); v2 is no longer in loadCache and has no other strong
105+
// holder, so it MAY have been collected already. v1 (pinned by parent) is
106+
// still alive. Net growth is bounded by the number of active versions in
107+
// any live strong reference β€” typically just the current one plus the
108+
// parent-pinned original.
109+
assert.ok(
110+
wrapCount2 <= wrapCount1 + 1,
111+
`After a second cycle, live ModuleWrap count should grow by at most 1 ` +
112+
`(got ${wrapCount2}, was ${wrapCount1})`,
113+
);
114+
115+
delete globalThis.__module_cache_esm_counter;
Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,94 @@
1+
// Tests race conditions between clearCache and concurrent dynamic imports
2+
// of a CJS module loaded via import().
3+
//
4+
// Scenarios covered:
5+
// A) clearCache fires BEFORE the in-flight import promise settles:
6+
// p = import(url); clearCache(url); result = await p
7+
// The original import must still succeed with the first module instance.
8+
//
9+
// B) Two concurrent imports (sharing the same in-flight job) with clearCache
10+
// between them, then a third import after clearing:
11+
// p1 = import(url) β†’ job1 created, cached
12+
// p2 = import(url) β†’ reuses job1 (same in-flight promise)
13+
// clearCache(url) β†’ removes job1 from cache
14+
// p3 = import(url) β†’ job3 created (fresh execution)
15+
// [await all three]
16+
// p1 and p2 must resolve to the SAME module instance (they shared job1).
17+
// p3 must resolve to a DIFFERENT, freshly-executed module instance.
18+
//
19+
// C) clearCache fires AFTER the import has fully settled and another clear +
20+
// import is done serially β€” basic sanity check that repeated cycles work.
21+
122
import '../common/index.mjs';
223
import assert from 'node:assert';
324
import { clearCache } from 'node:module';
425

526
const url = new URL('../fixtures/module-cache/cjs-counter.js', import.meta.url);
627

7-
const importPromise = import(url.href);
28+
// ── Scenario A: clearCache before in-flight import settles ──────────────────
29+
30+
const p_a = import(url.href); // in-flight; module not yet resolved
831
clearCache(url, {
932
parentURL: import.meta.url,
1033
resolver: 'import',
1134
caches: 'module',
1235
});
1336

14-
const first = await importPromise;
15-
assert.strictEqual(first.default.count, 1);
37+
const result_a = await p_a;
38+
// Scenario A: in-flight import must still resolve to the first instance.
39+
assert.strictEqual(result_a.default.count, 1);
40+
41+
// ── Scenario B: two concurrent imports share a job; clearCache between ──────
42+
43+
// Re-seed for a clean counter baseline.
44+
clearCache(url, {
45+
parentURL: import.meta.url,
46+
resolver: 'import',
47+
caches: 'module',
48+
});
49+
50+
delete globalThis.__module_cache_cjs_counter;
51+
52+
// Both p_b1 and p_b2 start before clearCache β†’ they share the same in-flight job.
53+
const p_b1 = import(url.href);
54+
const p_b2 = import(url.href);
55+
56+
clearCache(url, {
57+
parentURL: import.meta.url,
58+
resolver: 'import',
59+
caches: 'module',
60+
});
61+
62+
// p_b3 starts after clearCache β†’ gets a fresh independent job.
63+
const p_b3 = import(url.href);
64+
65+
const [r_b1, r_b2, r_b3] = await Promise.all([p_b1, p_b2, p_b3]);
66+
67+
// p_b1 and p_b2 shared the same in-flight job β†’ identical module namespace.
68+
assert.strictEqual(r_b1, r_b2);
69+
// Scenario B: shared job resolves to the first (re-seeded) instance.
70+
assert.strictEqual(r_b1.default.count, 1);
71+
72+
// p_b3 was created after clearCache β†’ fresh execution, different instance.
73+
assert.notStrictEqual(r_b3, r_b1);
74+
assert.strictEqual(r_b3.default.count, 2);
75+
76+
// ── Scenario C: serial repeated cycles ─────────────────────────────────────
77+
78+
clearCache(url, {
79+
parentURL: import.meta.url,
80+
resolver: 'import',
81+
caches: 'module',
82+
});
83+
const r_c1 = await import(url.href);
84+
assert.strictEqual(r_c1.default.count, 3);
1685

1786
clearCache(url, {
1887
parentURL: import.meta.url,
1988
resolver: 'import',
2089
caches: 'module',
2190
});
22-
const second = await import(url.href);
23-
assert.strictEqual(second.default.count, 2);
91+
const r_c2 = await import(url.href);
92+
assert.strictEqual(r_c2.default.count, 4);
2493

2594
delete globalThis.__module_cache_cjs_counter;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Flags: --expose-internals
2+
// Verifies the V8-level memory-retention behaviour of clearCache when a module
3+
// is statically imported by a still-live parent.
4+
//
5+
// BACKGROUND:
6+
// When a parent module P statically imports M (via `import … from './M'`),
7+
// V8's module instantiation creates an internal strong reference from P's
8+
// compiled module record to M's module record. This link is permanent for
9+
// the lifetime of P. Clearing M from Node.js's JS-level caches (loadCache /
10+
// resolveCache) does NOT sever the V8-internal link; the old ModuleWrap for
11+
// M stays alive as long as P is alive.
12+
//
13+
// Consequence: after `clearCache(M)` + `await import(M)`, TWO module
14+
// instances coexist:
15+
// - M_old : retained by P's V8-internal link, never re-executed by P
16+
// - M_new : created by the fresh import(), seen by all NEW importers
17+
//
18+
// This is a *bounded* leak (one stale instance per cleared module per live
19+
// static parent), not an unbounded one. It is unavoidable given the
20+
// ECMA-262 HostLoadImportedModule idempotency requirement.
21+
//
22+
// For purely dynamic imports (no static parent holding them) the old
23+
// ModuleWrap IS collectible after clearCache β€” see the second half of this
24+
// test which uses checkIfCollectableByCounting to confirm that.
25+
26+
import '../common/index.mjs';
27+
28+
import assert from 'node:assert';
29+
import { clearCache, createRequire } from 'node:module';
30+
import { queryObjects } from 'v8';
31+
32+
// Use createRequire to access CJS-only internals from this ESM file.
33+
const require = createRequire(import.meta.url);
34+
const { checkIfCollectableByCounting } = require('../common/gc');
35+
const { internalBinding } = require('internal/test/binding');
36+
const { ModuleWrap } = internalBinding('module_wrap');
37+
38+
const counterURL = new URL(
39+
'../fixtures/module-cache/esm-counter.mjs',
40+
import.meta.url,
41+
).href;
42+
43+
const parentURL = new URL(
44+
'../fixtures/module-cache/esm-static-parent.mjs',
45+
import.meta.url,
46+
).href;
47+
48+
// ── Part 1 : static-parent split-brain ──────────────────────────────────────
49+
// Load the static parent, which in turn statically imports esm-counter.mjs.
50+
51+
const parent = await import(parentURL);
52+
assert.strictEqual(parent.count, 1); // counter runs once
53+
54+
// Snapshot the number of live ModuleWraps before clearing.
55+
const wrapsBefore = queryObjects(ModuleWrap, { format: 'count' });
56+
57+
// Clear the counter's Node-side caches (does NOT sever V8 static links).
58+
clearCache(counterURL, {
59+
parentURL: import.meta.url,
60+
resolver: 'import',
61+
caches: 'all',
62+
});
63+
64+
// Re-import counter: a fresh instance is created and executed.
65+
const fresh = await import(counterURL);
66+
assert.strictEqual(fresh.count, 2); // New execution.
67+
// Parent still sees the OLD instance via the V8-internal static link β€”
68+
// the "split-brain" behaviour.
69+
assert.strictEqual(parent.count, 1);
70+
71+
// After the fresh import there should be MORE live ModuleWraps than before,
72+
// because the old instance (held by the parent) was NOT collected.
73+
const wrapsAfter = queryObjects(ModuleWrap, { format: 'count' });
74+
assert.ok(
75+
wrapsAfter > wrapsBefore,
76+
`Expected more live ModuleWraps after re-import (old instance retained ` +
77+
`by static parent). before=${wrapsBefore}, after=${wrapsAfter}`,
78+
);
79+
80+
// ── Part 2 : dynamic-only modules ARE collectible ───────────────────────────
81+
// Prove that for purely-dynamic imports (no static parent), cleared modules
82+
// can be garbage-collected. This confirms that the static-parent case is the
83+
// source of the memory retention, not clearCache itself.
84+
85+
const baseURL = new URL(
86+
'../fixtures/module-cache/esm-counter.mjs',
87+
import.meta.url,
88+
).href;
89+
90+
const outer = 8;
91+
const inner = 4;
92+
93+
await checkIfCollectableByCounting(async (i) => {
94+
for (let j = 0; j < inner; j++) {
95+
const url = `${baseURL}?leak-test=${i}-${j}`;
96+
await import(url);
97+
clearCache(url, {
98+
parentURL: import.meta.url,
99+
resolver: 'import',
100+
caches: 'all',
101+
});
102+
}
103+
return inner;
104+
}, ModuleWrap, outer, 50);
105+
106+
delete globalThis.__module_cache_esm_counter;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// A parent module that statically imports esm-counter.mjs.
2+
// Used by tests that verify memory retention of statically-linked modules.
3+
export { count } from './esm-counter.mjs';

0 commit comments

Comments
Β (0)