Skip to content

[JSC] Overrides of a module export have owners and end in any order - #1033

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/929034ed/export-override-owners
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/929034ed/export-override-owners

Conversation

@robobun

@robobun robobun commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: this does not fit main yet. #1036 landed after this branch was made, and this PR conflicts with it in five files. Two things in #1036 need new handling here. overrideExportValue now forwards a store for a compiled original to the compiled runtime. The compiled runtime now also saves what a mock replaces in a field of a module instance. The branch stays on 7f020d2, the commit that Bun's main pins, until oven-sh/bun#48617 is on Bun's main.

Problem

  • Two overrides of one module binding leave a wrong value when the older one ends first. Each override (a Bun module mock, a namespace spyOn) stores back its own copy of what it replaced, and export * from lets several namespace objects share one binding.
  • bun test then reads "mock of inner" in the next test file. The compiled runtime has the same fault (AOT::Modules::endMockLaidOverFile).

Fix

  • JSModuleNamespaceObject::layExportOverride and removeExportOverride take an owner cell. The declaring record keeps the owners of a binding in order, and what each one found (ExportOverrideLayers).
  • The owner on top stores back what it found. Any other owner hands that to the owner above it.
  • AOT::Modules does the same for a program variable.
  • Verified: JSTests/modules/bun-owners-of-an-export-override-end-in-any-order.js. overrideExportValue is unchanged.

Background

  • A binding is a variable of a module environment. The owners live in a table under a private name of its record, made at the first override.
  • Considered a field on AbstractModuleRecord: 8 bytes per record in every program. Considered a hand-over in Bun: spies and compiled modules stay wrong.

Downsides

  • No override, no cost: sizeof of AbstractModuleRecord, JSModuleRecord, JSModuleNamespaceObject and VM does not change. AOT::Modules grows by 16 bytes, .text of release bun by 14,080 with the Bun side.
  • spyOn plus mockRestore of a namespace export: 5,683 instructions, from 4,813 (no JIT).
  • An end that is not the newest walks the owners of its binding. 1,000 mocks of one binding, removed oldest first: 1,344M instructions, from 1,152M.
Notes

The rules. Each overridden binding has its owners in the order in which they stored. Each owner has what it found in the binding when it stored.

The owner Store (layExportOverride) End (removeExportOverride)
is new finds the value of the binding, stores nothing to end
shows its value keeps what it found, stores stores back what it found
lies under a later owner hands what it found to the next owner above, moves to the top, finds the value of the binding, stores hands what it found to the next owner above, stores nothing
is set aside (setExportOverrideAside) stands again where it stood. Under a later owner it stores nothing: that owner has found the new value keeps its place for its next value

A binding that had no value yet when its owner stored cannot go back to having none (DFG and FTL drop the TDZ check). The end returns HadNoValue and the host decides what to store. didInitializeExportUnderOverride records the value that the module body stores later. exportFoundByOverride reads what an owner found.

Storage. The record that declares the binding gets one object under the private name exportOverrides at its first override. The object has two elements per binding, at twice the scope offset of the binding. One owner takes the two elements (the owner, what it found) and no cell. A second owner, or an owner that is set aside, makes an array of three elements per owner. The table stays with the record after the last override ends.

export * as ns. The *namespace* binding of a record is empty until getModuleNamespace() runs. layExportOverride runs it first, as overrideExportValue does, so the owner finds the namespace object.

Compiled runtime. AOT::Modules::tryReplaceExport kept the first value that a mock replaced in LoadedModule::variablesBeforeMock, and endMockLaidOverFile stored it back. That table now names the variables only, and is the owner of what the mock laid. A host owner (a spy) comes through AOT::layExportOverride. takeOwnersHandedByMocks tells the host which owners the end of a mock handed a value to. Exports that the runtime keeps outside a variable of the program (captured exports of a builtin module, a namespace handed on, an export behind a star) keep their storage: AOT::layExportOverride returns false for them.

Bun side. oven-sh/bun#48621.

Removed. JSModuleNamespaceObject::overrideExports had no caller in Bun.

Base. The branch starts at 7f020d2, the commit that Bun's main pins, so that the preview build works with Bun's main. WebKit main is 9 commits past it. The last of them is #1036: see the note at the top.

What the test covers. Three namespace objects that export one binding (export *, export { a as renamed }). The owner that stored last ends first, and ends last. An owner that stores again. An owner that is set aside, alone and under another owner. A value that the module assigns meanwhile. Two names of one binding. A binding that has no value yet. export * as before the namespace object exists. An export that has no binding.

Checked against a model. The rules ran against an enumeration of mock, spy and unmock sequences (about 1M sequences, 0 wrong answers). The table form with one owner ran against the array form in a random walk of 4.2M steps, with no difference.

Numbers. Release builds of bun with the same flags, 873ab3a against this change on 873ab3a, each with its side of Bun. The branch was rebased twice since (onto 264cf6d, then 7f020d2). The numbers were not measured again. Instructions are user-mode counts (perf_event_open), the minimum of 3 runs.

before after
sizeof AbstractModuleRecord / JSModuleRecord / JSModuleNamespaceObject / VM 320 / 416 / 72 / 110,872 the same
sizeof(AOT::Modules) 4,888 4,904
.text of bun 109,836,112 109,850,192
overrideExportValue 594 bytes, 161 instructions the same
vm.SyntheticModule setExport, per call 898 898
read of an overridden export, per read 6.5 6.5
mock and unmock of a loaded module, per export 2,497 2,756
spyOn plus mockRestore of a namespace export (no JIT) 4,813 5,683
cells per overridden export while a mock stands (200 exports) 0 0, and 1 table per module
stores for two mocks of one binding, ended oldest first 4 3
resolveExport calls for one mock of 100 exports and its end 309 209
1,000 mocks of one binding, removed oldest first 1,152M 1,344M

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Preview build of a3a3515: autobuild-preview-pr-1033-a3a3515a

JSModuleNamespaceObject::layExportOverride() stores a value in the binding that an
export resolves to, for an owner. The record that declares the binding keeps the owners
of each of its bindings in the order in which they stored, and what each of them found
in the binding. removeExportOverride() ends one owner: the owner whose value shows gives
back what it found, and any other hands that to the owner that stored next.

One owner of a binding takes two elements of the record's table and no cell. An array is
made for a binding when a second owner stores in it, or when its owner is set aside.

AOT::Modules keeps the owners of a variable of the program in the same way. The mock of a
file owns what it lays, and AOT::layExportOverride() takes the owner of a host.

overrideExportValue() is unchanged. overrideExports() had no caller and goes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant