Repository navigation
bun test: require() returns the ES module mock.module() patched - #47819
dylan-conway wants to merge 3 commits into
Conversation
…s that module again mock.module() of an ES module that is already loaded patches it in place, so the exports the factory leaves out keep their values. Since #44833, require() of such a module returned the factory's object, which lacks those exports and is not what import() returns. require() now takes the module from the ES module registry, as it did in 1.4.4. A module that is loaded as the mock, jest.mock(), jest.doMock() and vi.mock() still give require() the factory's object.
|
No actionable comments were generated in the recent review. WalkthroughThe loader detects when ChangesMocked ESM require handling
Possibly related PRs
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the new early return jsNumber(-1) in handleVirtualModuleResult against the exception-scope rules: isESModulePatchedByMockModule only does downcasts and a registry lookup (no JS entry), the preceding moduleLoaderOf has its RETURN_IF_EXCEPTION, and leaving res->success unset matches the existing hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice -1 path. The { __esModule: true, default } interop shape and the per-call Identifier::fromString (same as the neighboring helper) were also examined and ruled out.
Extended reasoning...
The change adds a mock-state predicate in src/jsc/bindings/BunPlugin.cpp and a single early-return branch in handleVirtualModuleResult in src/jsc/bindings/ModuleLoader.cpp so require() of a mock.module()-patched ES module returns the registry namespace, plus new fixture tests; it touches no security-sensitive surface. The inline findings already flag behavior gaps (re-mock in beforeEach, node: builtins, --aot divergence), so this note only records what else was examined and ruled out.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/jsc/bindings/ModuleLoader.cpp— pre-existing: tests that partially mock anode:builtin still get the factory's object from require(), missing every export the factory left out, whilebun:builtins and files now get the patched namespace.require("node:fs")goes through$requireNativeModule, which reaches the mock branch of fetchBuiltinModuleWithoutResolution at src/jsc/bindings/ModuleLoader.cpp:674, and that branch never consults isESModulePatchedByMockModule. Fix: apply the same patched-namespace rule on every require() route to a mocked ES module, including thenode:builtin route, so require() of a module mock.module() patched in place yields the namespace there too. The PR text names this route as not touched. [also at: src/jsc/bindings/ModuleLoader.cpp:532 - pre-existing: after merging,require("bun:jsc")of a modulemock.module()patched returns its namespace, butrequire("node:fs")patched the same way still returns the factory's object, so exports a partial mock leaves out stayundefinedandrequire!==import.]Why this was flagged
A test file does
import * as fs from "node:fs", thenmock.module("node:fs", () => ({ readFileSync: fake })), thenrequire("node:fs"). src/js/builtins/CommonJS.ts:30-44 sends any id starting withnode:to$requireNativeModule, never to fetchCommonJSModule, so requireVirtualModule and the new check in handleVirtualModuleResult (src/jsc/bindings/ModuleLoader.cpp:527-533) are not reached. jsFunctionRequireNativeModule (src/jsc/bindings/JSCommonJSModule.cpp:1566) calls fetchBuiltinModuleWithoutResolution, whose mock branch at src/jsc/bindings/ModuleLoader.cpp:666-676 returns commonJSExportsOfObjectModule(result, true), i.e. the factory's object. Sorequire("node:fs").existsSyncis undefined andrequire("node:fs") !== fs, exactly as on the base branch, whereas after this PRrequire("bun:jsc")andrequire("./dep")return the patched namespace. The patched namespace exists for node: builtins too: patchedNamespace is set at src/jsc/bindings/BunPlugin.cpp:2128 whenever the loader holds a namespace object for the key. The PR description lists node: builtins as not touched.Verification: pre-existing; acknowledged in diff: the PR description lists
node:builtins as not touched. Aftermock.module("node:fs", () => ({ readFileSync: fake })),require("node:fs")goes via src/js/builtins/CommonJS.ts:30-44 to$requireNativeModule, never reaching the new check at src/jsc/bindings/ModuleLoader.cpp:527-533; the mock branch at ModuleLoader.cpp:666-676 returns the factory's object, as on the base.
…xport A factory's object marked __esModule whose default is the object itself was taken for one with no default. So require() of an ES module that mock.module() patched returned the namespace object, not that default as on main and in 1.4.4. commonJSExportsOfObjectModule() now returns empty when the object names no module.exports, and its two callers choose what require() gets then.
|
Edited: an earlier version said #47605 fixes cause 1 and that no PR existed for cause 2. Both were wrong, see below. 1. 40 test files that also fail on mainThey are 39 files under
2.
|
| Outcome | Builds |
|---|---|
| 64 MB in all 4 attempts, failed | #137139, #137138, #137132, #137128 |
| 64 MB, then 63 MB on a retry | #137137, #137129, #137127, #137126, #137121 |
| 63 MB on the first attempt | #137135 |
- main doesn't show it because its builds have no x64-asan test jobs.
- test(request): run the Request leak loops in a fixture process and bound the growth per request #47579 is the open fix. It shows that nothing leaks: ASAN's quarantine and the first FTL compile account for the reading.
- I have not ported it. It rewrites an unrelated test, and this build would stay red for cause 1 anyway.
- I can't re-run BuildKite jobs from here.
Generated by Claude Code
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the second commit's change to commonJSExportsOfObjectModule returning empty: both callers handle it (fetchBuiltinModuleWithoutResolution restores the raw-object fallback, handleVirtualModuleResult assigns exportsObject), a mock whose result is a JSCommonJSModule never reaches the new !exports branch because exportsObject() yields undefined rather than an empty value, and the new isESModulePatchedByMockModule helper makes no JS-entering calls, so the only throw point in the new block (moduleLoaderOf) is the one that is checked.
Extended reasoning...
The second push changed the shared helper's contract for its two callers and added a non-throwing registry lookup; those paths were traced and found consistent, and the open inline findings plus the unaddressed concerns from the earlier review still warrant a human look.
…e patched ES module A second mock.module() of a module starts with the namespace object that the first one patched. While the patch of its async factory was pending, require() took that for its own patch and returned the first mock's values. It now returns what the factory returned until the patch lands, as on main.
|
Tested this PR's build ( The regression is real on canary, and this PR fixes it. With the example from the description:
Same result whether No regressions on a real suite. Our backend unit suite is 1,410 files / ~21.7k tests, with 32 files using We came here from #31316: #44833's per-file @dylan-conway @Jarred-Sumner: this and #47605 both change the same two |
What does this PR do?
require()of an ES module thatmock.module()patched in place returns that module again, likeimport()and like 1.4.4. Since #44833 it returned the factory's object, so exports that a partial mock leaves out wereundefined. No release is affected.commonJSExportsOfObjectModule()return the factory's object for every mock. That fixes [bun:test] mocking mutable exports (let) withmock.moduleis broken #9874 for a module loaded as the mock, but a module patched in place takes the same path.handleVirtualModuleResult()answers-1(take it from the ES module registry) when the factory's object names nomodule.exportsandBun::isESModulePatchedByMockModule()holds. That is:mock.module()made the mock, its own patch has landed, the requirer's loader holds the patched record, and that record evaluated without an error.commonJSExportsOfObjectModule()returns empty when the object names nomodule.exports. Its two callers fall back to the mock's object.{ __esModule: true, default }(also whendefaultis the object itself), and a module thatrequire.cacheheld when it was mocked.node:builtins, which take another path. Also aBun.ModuleGraphthat loaded its own instance before the mock: since bun test: Vitest compatibility #44833require()in it gets the factory's object too, whileimport()keeps the instance.jest.mock(),jest.doMock()andvi.mock()still giverequire()the factory's object, as in Jest. Dropping thefunction != MockModulecheck undoes that.bun test --aotfollows #44833's mocks #47605: twotest/aotfiles from AOT: bun test --aot on large suites, module loading fixes, fuzzer fixes #47316 pin this rule for plainbun testand fail on main. AOT: hangs, wrong answers, failed builds;bun test --aotfollows #44833's mocks #47605 rewrites them to what main does, so whichever lands second has to follow the other.How did you verify your code works?
mock-module.test.tsfail on a debug build of main (6cdb383) and pass here, with no flag,--isolateand--isolate=modules. Whole file: 319 pass, 1 todo.USE_SYSTEM_BUN=1is no baseline for them. The system Bun is 1.4.4, which predates the regression, so 2 of the 12 pass there.test/aotfiles have more: 28 to 34 of 54, and 1 to 4 of 6.--aotitself answers the same on both builds. Forbun:jscthat is the factory's object, so 12 of 81 orders now differ from plainbun test(0 on main).Generated by Claude Code