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

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 stays for AbstractModuleRecord (320), JSModuleRecord (416), JSModuleNamespaceObject (72) and VM (110,872). AOT::Modules grows by 16 bytes, .text of release bun by 13,568 with the Bun side.
  • spyOn plus mockRestore of a namespace export: 5,679 instructions, from 4,814 (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,347M instructions, from 1,150M.
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 264cf6d, the commit that Bun's main pins, so that the preview build works with Bun's main. It merges into main without conflict.

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. They are from before the rebase onto 264cf6d. 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,150M 1,344M

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Preview build of 13643a4: autobuild-preview-pr-1033-13643a4d

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