Skip to content

node:vm: stop a SourceTextModule from running another module's importModuleDynamically hook - #42496

Draft
robobun wants to merge 2 commits into
mainfrom
robobun/e246f767/vm-module-own-import-hook
Draft

robobun wants to merge 2 commits into
mainfrom
robobun/e246f767/vm-module-own-import-hook

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until oven-sh/WebKit#639 lands on main. Then WEBKIT_VERSION moves to that sha.

Problem

Fix

Background

Downsides

  • Identical vm.SourceTextModules no longer share linked code: 6.5 KB more per live duplicate of a 41-function module (8187 against 1641 bytes). No release shipped the sharing.
  • Bun's own loader still shares. It pays four more comparisons when a second record of one file links.
Notes

Repro (bun repro.mjs, or node --experimental-vm-modules repro.mjs):

import vm from "node:vm";
const mk = async tag => {
  const m = new vm.SourceTextModule(`export const who = ${JSON.stringify(tag)};`, { identifier: "dep-" + tag });
  await m.link(() => {});
  await m.evaluate();
  return m;
};
async function once(identifier, tag) {
  const d = await mk(tag);
  let calls = 0;
  const m = new vm.SourceTextModule("export const p = import('z');", {
    identifier,
    importModuleDynamically() {
      calls++;
      return d;
    },
  });
  await m.link(() => {});
  await m.evaluate();
  const ns = await m.namespace.p;
  return `hook-calls=${calls} got=${ns.who} want=${tag}`;
}
console.log(await once("same", "first"));
console.log(await once("same", "second"));
  • Fail-before: an ASAN debug build of main at 6d504dd (WebKit 299c5323) fails the two vm.test.ts tests and the alive-sameSourceModules scenario, with and without BUN_JSC_collectContinuously=1. It prints hook-calls=0 got=first want=second for the second line of the repro. Node 26.3.0 and a build with the patched WebKit print hook-calls=1 got=second want=second. The release canary 1.4.3-canary.1+6a92015fc (the Upgrade WebKit to cf1b36ec8703 #42319 commit) shows the same failure.
  • Hook lifetime (node:vm: importModuleDynamically lives as long as the code that can import() #43724): the fetcher holds the hook in a Weak that lives while an executable of that source is alive ([JSC] A live ScriptExecutable makes its source's ScriptFetcher an opaque root WebKit#712 makes the fetcher an opaque root of the executable). The second module's functions ran the first module's executable, so nothing rooted the second module's fetcher. The alive-sameSourceModules scenario keeps only each module's exported function, collects, and expects each function to reach its own hook with its own module as referrer. main answers {"result":"hooked by first"} for the second function. The fixture also runs under Node, which gives the expected output.
  • Probe on the patched build: while the second function is alive its hook is not finalized, and after the function is dropped a FinalizationRegistry on the hook fires. The hook does not leak.
  • Cost numbers in Downsides: 2000 live modules of one 41-function source on the release canary 1.4.3-canary.1+367d939d9, which still shares. With one identifier: 1 ModuleProgramExecutable, 1541 heap bytes plus 100 bytes of extra memory per module, 0.062 ms per module. With one identifier each (never shared, which is what every module gets after the bump): 2000 executables, 5419 plus 2768 bytes, 0.071 ms. The byte counts were identical in two runs. On the patched debug build both modes report one executable per module.
  • The branch was rebased onto main on 2026-09-23. The pin moved from the preview build of the first version of [JSC] Module records share an executable only when their sources have the same SourceOrigin and start position WebKit#639 to the preview build of its rebase onto WebKit main (299c5323, which main pins since node:vm: importModuleDynamically lives as long as the code that can import() #43724).
  • Extent: only the same identifier with the same source text in one context. A different identifier, a different text, no identifier, one context per module, vm.Script and vm.compileFunction are correct, because the map of executables is per global object and compares the key and the text.
  • The map (JSGlobalObject::m_moduleProgramExecutables) is a WeakGCMap. After a full collection the first executable can be dead, and then the second module links its own. That is why the wrong hook shows in most iterations of a loop and not in all of them. The tests keep every module reachable and export a function from every source, because a module's functions are what keeps its executable alive after evaluation.
  • import() inside an exported function of the second module also reached the first module's hook. The first test covers a top-level import() and one inside a function.
  • Position leak: with lineOffset: 100 on the first module and lineOffset: 0 on the second, a stack trace in the second module reported line 100. The second test compares against a module with an identifier that was never seen, so it does not depend on the absolute line numbers (node:vm: apply SourceTextModule lineOffset/columnOffset like Node and name frames after the identifier #38235 changes those).
  • Also correct again, not tested here: a tagged template in the two modules yields two template objects, as in Node. With the shared executable it was one object.
  • Sharing in Bun's own loader is unchanged. Probe: import a module, delete require.cache[path], import it again. The namespaces are distinct and a tagged template yields the same template object for both, before and after the bump.
  • Verification used the preview prebuilt autobuild-preview-pr-639-411bf2c6 (debug + ASAN, Linux x64). The first version of the patch was also built from source through the debug-local profile.
  • Suites on that build: test/js/node/vm/vm.test.ts (304 pass, 0 fail), vm-script-fetcher-leak.test.ts (24 pass), sourcetextmodule-leak, sourcetextmodule-link-gc, vm-sourceUrl, happy-dom-vm-16277, and the 23 test-vm-module-* / test-vm-source-map-url files in test/js/node/test/parallel. script-leak.test.ts (a vm.Script test) exceeds its 5 s timeout on debug builds with and without the bump.

no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/vm/vm.test.ts, test/js/node/vm/vm-script-fetcher-leak.test.ts

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:01 AM PT - Sep 12th, 2026

❌ @robobun, your commit 5e97450 has 2 failures in Build #114782 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42496

That installs a local version of the PR into your bun-42496 executable, so you can run:

bun-42496 --bun

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

…hook

Bump WebKit to the preview build of oven-sh/WebKit#639. Module records with
the same key and source text no longer share a ModuleProgramExecutable when
their sources differ in SourceOrigin or start position. A second
vm.SourceTextModule with the same identifier and source text as an earlier one
called the earlier module's hook on import() and reported its lineOffset.
…ep their own hook alive

The importModuleDynamically hook lives as long as code from its source is
alive. When two SourceTextModules ran one shared executable, only the first
module's fetcher was rooted, so the second module's function reached the first
module's hook. The new lifetime scenario keeps only each module's exported
function and checks that each one reaches its own hook and referrer.
@robobun
robobun force-pushed the robobun/e246f767/vm-module-own-import-hook branch from 5e97450 to 5f4e995 Compare September 23, 2026 06:49

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants