-
Notifications
You must be signed in to change notification settings - Fork 51
SyntheticModuleRecord: support a live exports source for namespace reads #380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
a59646c
1ee717b
3cf0ca7
abed2db
1b4afe5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,9 @@ | |
| #include "JSCInlines.h" | ||
| #include "JSModuleEnvironment.h" | ||
| #include "JSModuleRecord.h" | ||
| #if USE(BUN_JSC_ADDITIONS) | ||
| #include "SyntheticModuleRecord.h" | ||
| #endif | ||
|
|
||
| namespace JSC { | ||
|
|
||
|
|
@@ -195,6 +198,25 @@ | |
| return false; | ||
| } | ||
|
|
||
| #if USE(BUN_JSC_ADDITIONS) | ||
| // Bun's mock.module / loader:"object" may back a synthetic module with the | ||
| // factory-returned object so that accessor exports stay live. The module | ||
| // environment slots still hold the first-read snapshot for static imports | ||
| // (which read slots directly), but dynamic import namespace access | ||
| // re-evaluates through the source object on every read. Returning a plain | ||
| // uncacheable value here keeps the JIT's module-namespace IC (which would | ||
| // inline the raw slot) from being installed. | ||
| 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; | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| slot.setValueModuleNamespace(this, static_cast<unsigned>(PropertyAttribute::DontDelete), value, environment, scopeOffset); | ||
| return true; | ||
| } | ||
|
|
@@ -452,6 +474,17 @@ | |
| putResult = moduleNamespaceObject->put(moduleNamespaceObject, globalObject, name, value, putter); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| moduleNamespaceObject->m_isOverridingValue = false; | ||
|
|
||
| // Keep the live-exports backing object (if any) consistent with the env | ||
| // slot so spyOn / re-mock writes are observed by namespace reads that | ||
| // forward through it. | ||
| if (auto* synthetic = dynamicDowncast<SyntheticModuleRecord>(record)) { | ||
| if (JSObject* source = synthetic->liveExportsSource()) { | ||
| source->putDirect(vm, name, value, 0); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| } | ||
| } | ||
|
Check failure on line 486 in Source/JavaScriptCore/runtime/JSModuleNamespaceObject.cpp
|
||
|
Comment on lines
+495
to
+500
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The write-through uses Extended reasoning...What the bug is
symbolTablePutTouchWatchpointSet(moduleEnvironment, globalObject, resolution.localName, value, ...);but the new live-source write-through on line 483 uses source->putDirect(vm, name, value, 0);The source object's keys are the synthetic module's own export names (its export entries are Step-by-step: renamed re-export
Even without the line-216 fix this is observable today: after step 6, reading 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 Secondary:
|
||
|
|
||
| return putResult; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The live-source lookup uses
propertyName(the export name on this namespace) instead ofexportEntry.localName(the binding name in the target synthetic module). When another module doesexport { foo as bar } from './mocked',propertyNameisbarbut the source object only hasfoo, so the read returnsundefined— a regression vs. the environment-slot fallback. Additionally, this branch must be skipped whenexportEntry.localName == vm.propertyNames->starNamespacePrivateName: forexport * as X from './mocked'the resolved local name is the private star-namespace symbol, which the user's factory object cannot have, sonsB.Xalso becomesundefinedinstead of the mocked module's namespace object.Extended reasoning...
What the bug is
m_exportson aJSModuleNamespaceObjectmaps this namespace's export names toExportEntry { localName, moduleRecord }, wheremoduleRecord/localNameare the resolved binding after following re-export chains (see the constructor, which storesresolution.localNameandresolution.moduleRecord). The new live-source branch reads the source object withpropertyName— the key on this namespace — instead ofexportEntry.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
tryCreateWithExportNamesAndValuescallsaddExportEntry(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.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' }.m_exports['bar'] = { localName: 'foo', moduleRecord: mockedSyntheticRecord }.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
setValueModuleNamespacewith the environment slot value (the snapshot offoo), 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 Xexport * as X from './mocked'.resolveExport/getModuleNamespaceproduceResolution{ Resolved, moduleRecord: mockedSyntheticRecord, localName: starNamespacePrivateName }(AbstractModuleRecord.cpp handling for star-namespace bindings).m_exports['X'] = { localName: starNamespacePrivateName, moduleRecord: mockedSyntheticRecord }.nsB.X: the earlierif (exportEntry.localName == starNamespacePrivateName)block materializes the namespace, andgetValue(environment, starNamespacePrivateName, ...)correctly fetches the mocked module'sJSModuleNamespaceObjectfrom the slot.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.localNamedoes not fix case 2: it would callsource->get(starNamespacePrivateName), a private symbol the user object cannot have, still yieldingundefined.Why nothing prevents it
The branch is guarded only by
dynamicDowncast<SyntheticModuleRecord>(exportEntry.moduleRecord)andliveExportsSource(). 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. BecauseexportEntry.moduleRecordis the resolved target record (notm_moduleRecord), any namespace whose export chain terminates at the mocked module hits this path.Impact
Both cases return
undefinedwhere 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 amock.module'd module — a common pattern when mocking a package that is itself re-exported through a barrel file.Suggested fix
i.e. (a) look up by
exportEntry.localName, and (b) skip the branch entirely for the star-namespace sentinel so it falls through tosetValueModuleNamespacewith the already-correct slot value.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
hasOwnfallback in 3cf0ca7 removes theundefinedregression for both the renamed-re-export andexport * as Xcases (the source lacksbar/X, so we drop through to the env slot), but the lookup key is stillpropertyNamerather thanexportEntry.localName, so the underlying wrong-key issue remains: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.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
getOwnPropertySlotandgetValuecalls toexportEntry.localNamefixes both. With that change thehasOwnfallback also happens to cover the star-namespace case (the source can't ownstarNamespacePrivateName), so the explicit sentinel guard from the original suggestion becomes optional — though keeping it saves a pointless method-table dispatch on everyexport * as Xread.