Skip to content

test: each vm.SourceTextModule runs its own importModuleDynamically hook - #42496

Open
robobun wants to merge 1 commit into
mainfrom
robobun/e246f767/vm-module-own-import-hook
Open

robobun wants to merge 1 commit into
mainfrom
robobun/e246f767/vm-module-own-import-hook

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Behaviour change: none

Problem

Fix

  • This PR now only adds tests. The WEBKIT_VERSION change is gone, because main's pin contains [JSC] Module records share an executable only when their sources have the same SourceOrigin and start position WebKit#639.
  • test/js/node/vm/vm.test.ts: each module calls its own hook with itself as referrer, and reports positions with its own lineOffset.
  • vm-script-fetcher-leak.test.ts: the alive-sameSourceModules scenario. Each module's hook stays alive while that module's function is alive.
  • Verified: a debug build of main at f8bc171 passes both files (335 pass, 0 fail). The release canary 1.4.3-canary.1+367d939d9, from before the WebKit change, fails both vm.test.ts tests.

Background

  • JSC hands an import() to the embedder with the running code's SourceOrigin: a URL plus an optional ScriptFetcher, an embedder object.
  • node:vm creates one NodeVMScriptFetcher per module. It holds the hook weakly, alive while code from that source is alive (node:vm: importModuleDynamically lives as long as the code that can import() #43724).
  • JSC finds an executable to share through a weak map, and a module's functions keep the executable alive. So every test source exports a function and the tests keep every module reachable.
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"));

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 6:43 PM PT - Oct 16th, 2026

❌ @robobun, your commit 0836b83 has 1 failures in Build #126899 (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

Two SourceTextModules with the same identifier and source text ran one shared
ModuleProgramExecutable. The second module's import() called the first
module's hook, its stack traces reported the first module's lineOffset, and
only the first module's hook stayed alive with the code. The WebKit bump in
#44500 (oven-sh/WebKit#639) gives each module its own executable again.

Add tests for the hook, the referrer, the line offset and the hook lifetime.
@robobun robobun changed the title node:vm: stop a SourceTextModule from running another module's importModuleDynamically hook test: each vm.SourceTextModule runs its own importModuleDynamically hook Oct 16, 2026
@robobun
robobun force-pushed the robobun/e246f767/vm-module-own-import-hook branch from 5f4e995 to 0836b83 Compare October 16, 2026 21:57
@robobun
robobun marked this pull request as ready for review October 16, 2026 21:57
@coderabbitai

coderabbitai Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8ddbefaa-17d1-4606-8637-fb68815d8441
📥 Commits

Reviewing files that changed from the base of the PR and between f8bc171 and 0836b83.

📒 Files selected for processing: 3
  • test/js/node/vm/vm-import-callback-lifetime-fixture.js
  • test/js/node/vm/vm-script-fetcher-leak.test.ts
  • test/js/node/vm/vm.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

}
const shifted = await lineOfThrow("same-identifier-other-offset.mjs", 100);
const unshifted = await lineOfThrow("same-identifier-other-offset.mjs", 0);
expect(unshifted).toBe(await lineOfThrow("never-seen-identifier.mjs", 0));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): a wrong lineOffset applied to the second same-identifier module (off by one, doubled, partially shifted) still passes this test. The assertion at test/js/node/vm/vm.test.ts:1144 only checks shifted > unshifted, although lineOffset 100 must shift the reported line by exactly 100. Fix: assert the exact invariant, expect(shifted).toBe(unshifted + 100), which stays independent of absolute line numbers and matches the exact-line assertions the existing lineOffset test at vm.test.ts:2544 already makes. [also at: test/js/node/vm/vm.test.ts:1146 - nit: the lineOffset test only checks shifted is greater than unshifted, so a module that applied a wrong non-zero offset would still pass.]

Why this was flagged

The test at test/js/node/vm/vm.test.ts:1130-1145 compiles two SourceTextModules with identifier "same-identifier-other-offset.mjs", lineOffset 100 then 0, and reads the line from new Error("here").stack. At vm.test.ts:1144 it asserts only expect(shifted).toBeGreaterThan(unshifted). Any regression where the offset of a shared or re-used executable is applied wrongly but still positive (for example line 2, 50 or 201 instead of 101) passes. The repository review rules ask for the strongest invariant; the sibling test at vm.test.ts:2544 asserts the exact line for lineOffset 5. The base branch has no test here, so this is a weaker-than-necessary new assertion rather than a regression.

Verification: nit. Triggering condition: any regression that applies a SourceTextModule lineOffset of 100 as some other positive shift (off by one, doubled, partial) at function-call time. Mechanism verified: /home/claude/bun/test/js/node/vm/vm.test.ts:1144 reads expect(shifted).toBeGreaterThan(unshifted);. Line 1144 accepts 2, 50 or 201 just as readily as 101.

Comment on lines +1117 to +1128
test("each call their own importModuleDynamically", async () => {
const first = await load("first");
const second = await load("second");
const calls = [
{ specifier: "dep", referrerIsThisModule: true },
{ specifier: "dep", referrerIsThisModule: true },
];
expect({ first, second }).toEqual({
first: { calls, imported: ["first", "first"] },
second: { calls, imported: ["second", "second"] },
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get coverage for same-identifier executable sharing only in the main context, while the sibling test directly above runs both contexts. The new describe block at test/js/node/vm/vm.test.ts:1079 never passes a context option, so both tests only exercise the Zig global object. Fix: run the new hook and lineOffset tests over the same ["the main context", false], ["a new context", true] matrix the sibling at vm.test.ts:1033 uses, passing context to every SourceTextModule and SyntheticModule it creates. The executable map that enabled the old sharing is per JSGlobalObject, so a vm context is a separate instance of the hazard. [also at: test/js/node/vm/vm.test.ts:1079 - nit: two same-identifier modules sharing one vm.createContext() are not covered, so a regression limited to non-main contexts would pass this suite.]

Why this was flagged

The existing test at test/js/node/vm/vm.test.ts:1033-1077 covers the same-identifier-and-source hazard with test.each over the main context and a createContext({}) context, because the module executable map lives on each JSGlobalObject. The new describe at test/js/node/vm/vm.test.ts:1079 adds the hook and lineOffset variants but load() at vm.test.ts:1091 and lineOfThrow at vm.test.ts:1131 never set context, so every module lands on the main Zig::GlobalObject. A vm context is a NodeVMGlobalObject with its own executable map and its own import() entry at src/jsc/bindings/NodeVM.cpp:1700. If executable sharing came back for records on a NodeVMGlobalObject only, the second module in a context would again run the first module's importModuleDynamically hook and report the first module's lineOffset, and neither new test would fail. On the base branch there is no coverage for either context; after merging there is coverage for one of the two the sibling test treats as distinct.

Verification: A future regression of same-identifier executable sharing that is specific to vm contexts would go uncaught, because the new tests only run on the main global. The new describe block (test/js/node/vm/vm.test.ts:1079-1145) constructs every SourceTextModule and SyntheticModule without a context option. The sibling test directly above (vm.test.ts:1033-1077) runs a test.each matrix over both contexts.

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