SyntheticModuleRecord: support a live exports source for namespace reads - #380
SyntheticModuleRecord: support a live exports source for namespace reads#380robobun wants to merge 5 commits into
Conversation
Adds an optional backing object to SyntheticModuleRecord so Bun's
mock.module / loader:"object" path can keep accessor exports live when
accessed via the module namespace object. The module environment slots
still hold the initial snapshot (so static `import { x }` bindings, which
read slots directly in every tier, are unaffected), but
JSModuleNamespaceObject::getOwnPropertySlotCommon forwards reads through
the backing object and returns an uncacheable value so the JIT's
module-namespace IC cannot inline the raw slot.
The backing object is passed as a trailing exportValue with no matching
exportName; tryCreateWithExportNamesAndValues already iterates by
exportNames.size(), so existing callers are unchanged.
Needed for oven-sh/bun#9874.
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
| if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(exportEntry.moduleRecord.get())) { | ||
| if (JSObject* source = synthetic->liveExportsSource()) [[unlikely]] { | ||
| slot.disableCaching(); | ||
| JSValue liveValue = source->get(globalObject, propertyName); | ||
| RETURN_IF_EXCEPTION(scope, false); | ||
| slot.setValue(this, static_cast<unsigned>(PropertyAttribute::DontDelete), liveValue); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🔴 The live-source lookup uses propertyName (the export name on this namespace) instead of exportEntry.localName (the binding name in the target synthetic module). When another module does export { foo as bar } from './mocked', propertyName is bar but the source object only has foo, so the read returns undefined — a regression vs. the environment-slot fallback. Additionally, this branch must be skipped when exportEntry.localName == vm.propertyNames->starNamespacePrivateName: for export * as X from './mocked' the resolved local name is the private star-namespace symbol, which the user's factory object cannot have, so nsB.X also becomes undefined instead of the mocked module's namespace object.
Extended reasoning...
What the bug is
m_exports on a JSModuleNamespaceObject maps this namespace's export names to ExportEntry { localName, moduleRecord }, where moduleRecord / localName are the resolved binding after following re-export chains (see the constructor, which stores resolution.localName and resolution.moduleRecord). The new live-source branch reads the source object with propertyName — the key on this namespace — instead of exportEntry.localName — the key in the target synthetic module's environment (and thus on its backing object).
For direct access on the synthetic module's own namespace these happen to coincide, because tryCreateWithExportNamesAndValues calls addExportEntry(ExportEntry::createLocal(exportName, exportName)). They diverge as soon as another module re-exports from the mocked module.
Step-by-step: renamed re-export
./mockedis aSyntheticModuleRecordcreated viamock.module(id, () => ({ get foo() { ... } }))with aliveExportsSourcethat has afooaccessor.- Module B contains
export { foo as bar } from './mocked'. resolveExportImplwalks theIndirectentry on B, enqueues(mockedRecord, 'foo'), and resolves at the synthetic module'sLocalentry toResolution{ Resolved, moduleRecord: mockedSyntheticRecord, localName: 'foo' }.- B's namespace stores
m_exports['bar'] = { localName: 'foo', moduleRecord: mockedSyntheticRecord }. - Reading
nsB.barentersgetOwnPropertySlotCommonwithpropertyName == 'bar'andexportEntry.localName == 'foo'. dynamicDowncast<SyntheticModuleRecord>(exportEntry.moduleRecord.get())succeeds,liveExportsSource()is non-null, and the code executessource->get(globalObject, propertyName)→source.bar→undefined.
Before this PR, step 6 fell through to setValueModuleNamespace with the environment slot value (the snapshot of foo), so this is a regression, not merely an incomplete feature. Note that the immediately preceding line already uses the correct key: getValue(environment, exportEntry.localName, scopeOffset).
Step-by-step: export * as X
- Module B contains
export * as X from './mocked'. resolveExport/getModuleNamespaceproduceResolution{ Resolved, moduleRecord: mockedSyntheticRecord, localName: starNamespacePrivateName }(AbstractModuleRecord.cpp handling for star-namespace bindings).- B's namespace stores
m_exports['X'] = { localName: starNamespacePrivateName, moduleRecord: mockedSyntheticRecord }. - Reading
nsB.X: the earlierif (exportEntry.localName == starNamespacePrivateName)block materializes the namespace, andgetValue(environment, starNamespacePrivateName, ...)correctly fetches the mocked module'sJSModuleNamespaceObjectfrom the slot. - But then the live-source branch matches (
exportEntry.moduleRecordis the synthetic record with a live source) and callssource->get(globalObject, 'X')on the user's plain factory object →undefined.
Fixing case 1 by switching to exportEntry.localName does not fix case 2: it would call source->get(starNamespacePrivateName), a private symbol the user object cannot have, still yielding undefined.
Why nothing prevents it
The branch is guarded only by dynamicDowncast<SyntheticModuleRecord>(exportEntry.moduleRecord) and liveExportsSource(). It does not check whether this namespace is the synthetic module's own namespace, nor whether the resolved local name is the star-namespace sentinel. Because exportEntry.moduleRecord is the resolved target record (not m_moduleRecord), any namespace whose export chain terminates at the mocked module hits this path.
Impact
Both cases return undefined where the pre-PR code returned the correct value (snapshot for the rename case, the namespace object for the star case). This is user-visible incorrect behavior for anyone who re-exports from a mock.module'd module — a common pattern when mocking a package that is itself re-exported through a barrel file.
Suggested fix
if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(exportEntry.moduleRecord.get())) {
if (JSObject* source = synthetic->liveExportsSource();
source && exportEntry.localName != vm.propertyNames->starNamespacePrivateName) [[unlikely]] {
slot.disableCaching();
JSValue liveValue = source->get(globalObject, exportEntry.localName);
RETURN_IF_EXCEPTION(scope, false);
slot.setValue(this, static_cast<unsigned>(PropertyAttribute::DontDelete), liveValue);
return true;
}
}i.e. (a) look up by exportEntry.localName, and (b) skip the branch entirely for the star-namespace sentinel so it falls through to setValueModuleNamespace with the already-correct slot value.
There was a problem hiding this comment.
The hasOwn fallback in 3cf0ca7 removes the undefined regression for both the renamed-re-export and export * as X cases (the source lacks bar/X, so we drop through to the env slot), but the lookup key is still propertyName rather than exportEntry.localName, so the underlying wrong-key issue remains:
- Live behavior is lost through a rename.
export { foo as bar } from './mocked'→nsB.barmisses onsource.barand returns the env-slot snapshot, whilensMocked.fooreturns the live value. The feature silently stops working across a barrel/re-export. - Wrong value when the outer name collides. If the mocked module also exports
bar, the source does ownbar, sohasOwnis true andnsB.barreturnssource.bar— but the resolved binding isfoo. That's a correctness bug, not just a stale snapshot.
Switching both getOwnPropertySlot and getValue calls to exportEntry.localName fixes both. With that change the hasOwn fallback also happens to cover the star-namespace case (the source can't own starNamespacePrivateName), so the explicit sentinel guard from the original suggestion becomes optional — though keeping it saves a pointless method-table dispatch on every export * as X read.
Preview Builds
|
When a SyntheticModuleRecord is backed by a live-exports source object, overrideExportValue now keeps that object consistent with the env slot. Without this, Bun's spyOn(ns, key) and mock.module re-mock of an accessor-backed namespace wrote only to the env slot while reads kept forwarding through the (stale) source object.
| if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(record)) { | ||
| if (JSObject* source = synthetic->liveExportsSource()) { | ||
| source->putDirect(vm, name, value, 0); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 The write-through uses name (the export name on this namespace) instead of resolution.localName (the binding name in the target synthetic module — the same key the env-slot write on line 470 already uses). With export { foo as bar } from './mocked', an override on B's namespace with name='bar' writes a stray source.bar while source.foo stays stale, so once the read side is fixed to use localName the override is silently lost for namespace reads. Separately, putDirect is a fragile primitive for a user-supplied backing object: it asserts !parseIndex(propertyName) (numeric export names like "0" trip a debug ASSERT and desync named/indexed storage in release), it overwrites an accessor's GetterSetter in place and transitions it to a plain data property (destroying the live getter this feature exists to preserve), and it is non-virtual so a Proxy source's traps are bypassed and the write is invisible to the read side's source->get() — consider a method-table put (or skip the write-through when the existing own descriptor is an accessor) and handle the index case.
Extended reasoning...
What the bug is
overrideExportValue follows re-export chains via resolveExport, so record is resolution.moduleRecord — potentially a different module than the one whose namespace was passed in — and the binding key inside that target module is resolution.localName. The env-slot write on line 470 gets this right:
symbolTablePutTouchWatchpointSet(moduleEnvironment, globalObject, resolution.localName, value, ...);but the new live-source write-through on line 483 uses name — the export name on this namespace — instead:
source->putDirect(vm, name, value, 0);The source object's keys are the synthetic module's own export names (its export entries are createLocal(exportName, exportName), so localName == exportName == the property key on the factory object). When name != resolution.localName, the write lands on a key the read side never looks at.
Step-by-step: renamed re-export
./mockedis aSyntheticModuleRecordcreated viamock.module(id, () => ({ get foo() { ... } }))with aliveExportsSourcethat hasfoo.- Module B contains
export { foo as bar } from './mocked'. spyOn(nsB, 'bar')(or any Bun-side caller) invokesoverrideExportValue(globalObject, 'bar', spy)on B's namespace.resolveExportwalks B'sIndirectentry and returns{ moduleRecord: mockedSyntheticRecord, localName: 'foo' }.- Line 470 correctly writes the env slot
fooin the mocked module's environment. - Line 483 executes
source->putDirect(vm, 'bar', spy, 0)— a straybarproperty is added to the factory object;source.foois untouched. - Once the read-side bug at line 216 is fixed to look up
source[exportEntry.localName], readingnsB.barforwards tosource.foo— which still holds the original getter — and the spy is never observed via the namespace.
Even without the line-216 fix this is observable today: after step 6, reading nsMocked.foo on the mocked module's own namespace (where propertyName == 'foo') forwards to source.foo and returns the stale value, while the env slot already holds the spy — the two paths this hunk was added to keep consistent have diverged.
This is the write-side analogue of the read-side issue already flagged at line 216, but at a distinct code location and needing its own fix: use resolution.localName instead of name.
Secondary: putDirect is the wrong primitive here
While touching this line, note that putDirect(VM&, PropertyName, JSValue, unsigned) (JSObject.h:1231 → putDirectInternal<PutModeDefineOwnProperty>) makes assumptions the user-supplied backing object need not satisfy:
-
Numeric export names.
putDirectInternalhasASSERT(!parseIndex(propertyName))(JSObjectInlines.h:501) and the header documents "the property name is assumed to not be an index". A factory likemock.module(id, () => ({ '0': v }))or() => ['a','b']yields export'0';overrideExportValueon that key trips the debug ASSERT and, in release, adds'0'to named storage while the live value sits in indexed storage — the read side'ssource->get(globalObject, '0')parses the index and misses the write. Switching toresolution.localNamedoes not help (it's still'0').putDirectMayBeIndex(JSObject.cpp:3951) or aparseIndexbranch handles this. -
Accessor destruction. If
sourcehasget foo() { ... }— the exact shape this PR exists to support —putDirectInternalfinds the existing offset withPropertyAttribute::Accessor, overwrites theGetterSettercell viaputDirectOffset, and (sincenewAttributes(0) != currentAttributes) takesStructure::attributeChangeTransitionto a plain data property (JSObjectInlines.h:588–603). The live accessor is gone permanently; a later restore that writes the original snapshot back through the same path leavesns.foofrozen — the pre-PR behavior this feature was meant to eliminate. If clobbering is the intended spyOn semantic that's fine, but if not, skip the write-through when the existing own descriptor is an accessor. -
Proxy sources.
liveExportsSourceaccepts anyJSObject, somock.module(id, () => new Proxy(target, { get(t,k){...} }))reaches here.putDirectis non-virtual and writes to theProxyObjectcell's own structure/butterfly, never invoking[[Set]]/[[DefineOwnProperty]]traps or touching the target. The read side'ssource->get()dispatches throughProxyObject::getOwnPropertySlot→ thegettrap → the target, so the value just written is invisible and the write-through is silently lost. A method-table-dispatched put (e.g.source->methodTable()->put(...)with a strictPutPropertySlot, ordefineOwnProperty) would respect traps.
These three are edge cases and arguably nit-level on their own, but they all flow from the same choice of low-level define-own-property on an object whose shape the engine does not control, and can be addressed in the same one-line hunk as the localName fix.
(Minor: putDirect(VM&, ...) never throws a JS exception, so the RETURN_IF_EXCEPTION on line 484 is dead.)
Partial re-mocks (and spyOn on a single key) leave some exports on the env slot only. Forward through the live source only for properties it actually owns so un-overridden keys keep their previous value.
| if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(record)) { | ||
| if (JSObject* source = synthetic->liveExportsSource()) { | ||
| source->putDirect(vm, name, value, 0); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 The sibling batch API overrideExports() (lines 426-448) performs the same env-slot write via symbolTablePutTouchWatchpointSet but was not given the matching write-through to liveExportsSource() added here — so a bulk override on a live-source synthetic module updates only the env slot while namespace reads (lines 211-224) keep returning the source object's stale value. Before this PR both read and write agreed on the env slot, so this is a regression for that path; mirror this block inside the overrideExports() loop after line 441, keyed on pair.value.localName and dynamicDowncast<SyntheticModuleRecord>(pair.value.moduleRecord.get()). (Distinct from the wrong-key issue already noted on this hunk — here the write-through is absent from a separate function.)
Extended reasoning...
What the bug is
Commit 1ee717b added a write-through to the live-exports backing object in overrideExportValue() (lines 488-493) precisely because this PR redirected namespace reads on a SyntheticModuleRecord with a liveExportsSource away from the env slot and onto the source object (lines 211-224). But the sibling batch API overrideExports() at lines 426-448 — which performs the identical env-slot write via symbolTablePutTouchWatchpointSet at line 441 — was not given the same write-through. Its writes are now invisible to namespace reads whenever the target export resolves to a live-source synthetic module.
Step-by-step
mock.module('m', () => ({ get foo() { return orig } }))creates aSyntheticModuleRecordwhosem_liveExportsSourceis the factory object with an ownfooaccessor, and whose env slotfooholds the first-read snapshot.- Bun-side code calls
ns->overrideExports(...)to bulk-replace exports (this Bun-specific API exists for re-mock / HMR paths). For thefooentry,pair.value.moduleRecordis the mockedSyntheticModuleRecordandpair.value.localName == 'foo'. - Line 441 writes
valueinto the env slot forfoo. The source object's ownfooaccessor is untouched. - A subsequent
ns.fooread entersgetOwnPropertySlotCommon, reaches line 211,dynamicDowncast<SyntheticModuleRecord>succeeds,liveExportsSource()is non-null, andsource->getOwnPropertySlot(..., 'foo', ...)finds the old own accessor →hasOwn == true. - Line 218 returns
sourceSlot.getValue(...)— the original getter's result — and never consults the env slot. The override is silently lost.
Because pair.value.moduleRecord is the resolved target record (stored from resolution.moduleRecord in the constructor), this applies whether overrideExports() is called on the mocked module's own namespace or on a namespace that re-exports from it.
Why this is a regression introduced by this PR
Before this PR, both the write path (symbolTablePutTouchWatchpointSet → env slot) and the read path (getValue(environment, exportEntry.localName, scopeOffset) → env slot) agreed, so overrideExports() worked correctly on synthetic modules. This PR moved the read path to the source object but only taught overrideExportValue() to write there. The author's own commit message for 1ee717b ("overrideExportValue: write through to the live-exports source") states exactly the invariant being broken here for the batch variant.
Not a duplicate
The two existing comments on this PR are (a) line 223 — read side uses propertyName instead of exportEntry.localName, and (b) line 493 — overrideExportValue's write-through uses the wrong key / putDirect primitive. This finding is about a separate function where the write-through is absent entirely, and needs its own fix regardless of how (b) is resolved.
Fix
Mirror the block at 488-493 inside the overrideExports() loop, after line 441:
if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(pair.value.moduleRecord.get())) {
if (JSObject* source = synthetic->liveExportsSource())
source->putDirect(vm, pair.value.localName, value, 0); // subject to the same putDirect caveats noted at line 493
}(keyed on pair.value.localName, since that is the binding name in the target synthetic module — the same key line 441 already uses for the env-slot write).
Severity note
Marked nit rather than blocking because overrideExports() has no in-tree callers — it is invoked from the Bun side, and whether the companion change (oven-sh/bun#36677) exercises it against a live-source synthetic module cannot be confirmed from this repo. If it does, this is a user-visible regression and should be treated as blocking; either way the JSC-side inconsistency is real and cheap to fix alongside the line-493 change.
…alled Before a SyntheticModuleRecord has a live-exports source, namespace reads return setValueModuleNamespace, so baseline installs a ModuleNamespaceAccessCase (pointer-compare guard + raw env-slot load) and DFG lowers it to CheckIsConstant + GetClosureVar. Installing a live source later left those compiled sites reading the stale env slot while the interpreter forwarded through the source. SyntheticModuleRecord now carries an InlineWatchpointSet that both the access case (via additionalSetImpl) and the DFG lowering watch; firing it on the first setLiveExportsSource resets those stubs / jettisons that code. getOwnPropertySlotCommon returns an uncacheable value for the env-slot fallthrough once the watchpoint has fired so the IC is never re-installed on a record that has ever had a live source.
The re-mock path in Bun now clears the live source before the overrideExportValue loop (so the write-through does not mutate the previous factory object, which may be a user-captured require() result) and installs the new source after. setMayBeNull lets that clear go through the same entry point; the watchpoint fires on the first-ever install only.
Adds an optional backing object to
SyntheticModuleRecordso Bun'smock.module/loader:"object"path can keep accessor exports live when accessed via the module namespace object.Problem
Bun's
mock.module(id, () => ({ get foo() { ... } }))materializes the factory's properties into aSyntheticModuleRecord'sJSModuleEnvironmentslots at creation time. Since those slots hold plainJSValues andJSModuleNamespaceObject::getOwnPropertySlotCommonreads them viaenvironment->variableAt(scopeOffset), the accessor is evaluated once and frozen. See oven-sh/bun#9874.Change
SyntheticModuleRecordgainsWriteBarrier<JSObject> m_liveExportsSource(visited invisitChildrenImpl).tryCreateWithExportNamesAndValuesaccepts a trailingexportValuewith no matchingexportNameas the backing object. The slot-population loop already iterates byexportNames.size(), so existing callers are unchanged.JSModuleNamespaceObject::getOwnPropertySlotCommonchecks for aSyntheticModuleRecordwithliveExportsSource(); when present, the read is forwarded tosource->get(propertyName)and returned as a plain uncacheable value (notsetValueModuleNamespace), so the JIT'sModuleNamespaceAccessCaseIC (and the DFG/FTL lowering toGetClosureVarthat hangs off it) is never installed for that property.The module environment slots still hold the first-read snapshot, so static
import { x }bindings (which all tiers read as a rawvariableAt(offset)load) continue to see a plain value.All under
USE(BUN_JSC_ADDITIONS).Companion bun change: oven-sh/bun#36677.