Skip to content

[JSC] A JSSourceCode traces the value its synthetic module exports - #758

Open
robobun wants to merge 2 commits into
mainfrom
robobun/9dffe51b/source-code-payload
Open

robobun wants to merge 2 commits into
mainfrom
robobun/9dffe51b/source-code-payload

Conversation

@robobun

@robobun robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Needed by oven-sh/bun#44466.

Problem

  • A SyntheticSourceProvider generator is a closure, which the collector cannot trace. A generator that exports a JS value holds a raw pointer and a root, and Bun drops the root in the first run.
  • makeModule() runs the generator each time a module is made from the source: never, once, or more than once. In Bun a second run reads a freed object (ASSERTION FAILED: decontaminate(), StructureID.h(76)), and a root that stays keeps its realm alive.

Fix

  • JSSourceCode::createWithPayload() makes a synthetic source with the value it exports. The cell traces the value, and makeModule() passes it to the generator, a plain function.
  • It is the only way to make such a provider, and there is no deferred form. The other forms and the loader's control flow do not change, so Bun at its current pin still compiles.
  • Verified by the tests in Trace the value an object or data module exports from its JSSourceCode bun#44466: the jsc shell cannot make a synthetic source.

Background

  • The registry entry's fetch promise holds the JSSourceCode.
  • A second run needs removeEntry(), clearAll() or the synchronous replay in hostLoadImportedModule(), which this fork added.
  • Considered a JSC::Strong in the closure. The value reaches that root through its global, so its realm is never collected.

Downsides

  • The collector visits every live JSSourceCode: +6 instructions per module source per full collection.
  • A JSSourceCode costs one more store when it is made. It grows from 24 to 32 bytes inside the same 32-byte cell.
Notes
  • Measured on Bun built against the preview of this change (details in Trace the value an object or data module exports from its JSSourceCode bun#44466): one visitChildren call per live JSSourceCode per full collection, fast path 35 -> 41 instructions for a source with no payload (objdump, release). sizeof(SyntheticSourceProvider) stays 208 bytes on debug and 200 on release, because the three generator forms share one slot. Per object or data module fetch: Heap::protect 1 -> 0, Heap::unprotect 1 -> 0, malloc calls 3 -> 2.
  • The cycle that rules out a Strong: value, Structure, global object, loader, registry entry, fetch promise, JSSourceCode, provider, root. With the protect root that Bun has today, 20 ShadowRealms that each start two import() of one .json file leave 21 live global objects after a full collection. Without a root, 2.
  • Not changed: moduleLoadTopSettled still gives the source of a removed entry to a new entry by key (the thread at [JSC] removeEntry() and clearAll() keep the realm's [[LoadedModules]] in step with the registry #748 (comment)). After this change that second module is made from a live value.
  • The jsc shell has no way to make a SyntheticSourceProvider, so there is no test in JSTests. CI here builds every lane. Bun's current sources and the Bun change both build and pass against the preview.

A SyntheticSourceProvider generator is a closure. A generator that exports
a JS value captured that value, and a closure cannot be traced, so the
embedder had to root the value and decide when to release the root. The
loader runs a generator every time it makes a module from the source:
never, once, or more than once. No release point is right for all three.

JSSourceCode::createWithPayload() makes the source of a synthetic module
together with the value it exports. The cell traces the value, and
JSModuleLoader::makeModule() passes it to the generator, which is a plain
function (PayloadSyntheticSourceGenerator) and captures nothing.

The three generator forms share one slot in SyntheticSourceProvider, and
the payload takes the bytes that rounding up to the cell size left unused
in JSSourceCode, so neither object grows.
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Preview build of 47ef27f: autobuild-preview-pr-758-47ef27fd

robobun added a commit to oven-sh/bun that referenced this pull request Oct 2, 2026
A module that exports a JS value (a plugin's loader "object", a
mock.module() result, a JSON, TOML or YAML file, a text file) had a
generator that captured the value as a raw pointer. The value was
protected when the generator was made and unprotected inside the
generator's first run. The loader runs a generator each time it makes a
module from the source, which is never, once, or more than once:

- A second run read a freed value: a crash, or a namespace with the
  properties of another object.
- A source that never became a module, or whose generator threw, kept
  the value protected forever, and the value kept its realm alive.

The value is now the payload of the JSSourceCode
(JSSourceCode::createWithPayload, oven-sh/WebKit#758). The cell traces
it and the loader passes it to a generator that captures nothing.

WEBKIT_VERSION is the preview build of oven-sh/WebKit#758.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 2, 2026
A module that exports a JS value (a plugin's loader "object", a
mock.module() result, a JSON, TOML or YAML file, a text file) had a
generator that captured the value as a raw pointer. The value was
protected when the generator was made and unprotected inside the
generator's first run. The loader runs a generator each time it makes a
module from the source, which is never, once, or more than once:

- A second run read a freed value: a crash, or a namespace with the
  properties of another object.
- A source that never became a module, or whose generator threw, kept
  the value protected forever, and the value kept its realm alive.

The value is now the payload of the JSSourceCode
(JSSourceCode::createWithPayload, oven-sh/WebKit#758). The cell traces
it and the loader passes it to a generator that captures nothing.

WEBKIT_VERSION is the preview build of oven-sh/WebKit#758.
@robobun
robobun marked this pull request as ready for review October 2, 2026 18:05

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it adds a new GC-traced field and visitChildren to a core JSC cell and can only be exercised from Bun (no JSTests coverage), a human look would still be worthwhile.

What was reviewed:

  • Every generate() caller and every SyntheticSourceProvider constructor site was updated; makeModule()'s payload() call sits inside the existing USE(BUN_JSC_ADDITIONS) block, so the non-Bun build is unaffected.
  • m_payload is default-empty on the create() paths and appended unconditionally in visitChildrenImpl (empty barrier is fine); createWithPayload sets it via the write barrier after finishCreation, and JSCellInlines.h pulls in SlotVisitorInlines.h for DEFINE_VISIT_CHILDREN.
  • The sizeof(JSSourceCode) <= 32 assert checks out: 8 (JSCell) + 16 (SourceCode: RefPtr + two ints) + 8 (payload).
  • Candidate issues ruled out: a generator that throws keeps the payload alive for the registry entry's life (by design, the cell owns it); the replay path reads payload() only after the result is already treated as a JSSourceCode; a null function pointer crashes at make time rather than creation (embedder contract, same as the existing forms).
Extended reasoning...

The change touches five JavaScriptCore files: SourceProvider.h collapses the two synthetic generator members into a Variant and adds a captureless function-pointer form that receives a JSValue payload; JSSourceCode gains a WriteBarrier m_payload, a visitChildren, and a createWithPayload factory under USE(BUN_JSC_ADDITIONS); JSModuleLoader::makeModule and SyntheticModuleRecord::runDeferredGenerator pass the payload (or an empty value with an assert) through. It touches no injection, auth, or data-exposure surface, but it does touch GC tracing of a core cell, which is memory-safety-sensitive. Deferring rather than approving because Source/JavaScriptCore is covered by a CODEOWNERS entry, the new tracing path has no in-repo test (only downstream Bun tests), and the new ASSERTs are not exercised by any CI lane.

@coderabbitai

coderabbitai Bot commented Oct 2, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (2)
Source/JavaScriptCore/CLAUDE.md — auto-discovered
CLAUDE.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 437c81ba-da4c-4273-b8b6-421e6066225d

📥 Commits

Reviewing files that changed from the base of the PR and between 4fde158 and 47ef27f.

📒 Files selected for processing (5)
  • Source/JavaScriptCore/parser/SourceProvider.h
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp
  • Source/JavaScriptCore/runtime/JSSourceCode.cpp
  • Source/JavaScriptCore/runtime/JSSourceCode.h
  • Source/JavaScriptCore/runtime/SyntheticModuleRecord.cpp

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 1 remain after this review.


Walkthrough

Synthetic source providers now support eager, lazy, and payload-aware generators. With BUN_JSC_ADDITIONS enabled, JSSourceCode stores and traces a payload. JSModuleLoader passes that payload to synthetic module generation, while deferred generation passes an empty value.

Changes

Synthetic module payload flow

Layer / File(s) Summary
Synthetic source generator dispatch
Source/JavaScriptCore/parser/SourceProvider.h
SyntheticSourceProvider stores eager, lazy, or payload-aware generators in a Variant. Its generate method dispatches to the selected generator and accepts a payload.
JSSourceCode payload storage
Source/JavaScriptCore/runtime/JSSourceCode.h, Source/JavaScriptCore/runtime/JSSourceCode.cpp
With BUN_JSC_ADDITIONS enabled, JSSourceCode adds a payload factory and accessor. It stores the payload in a write barrier and traces it during child visitation.
Synthetic module generation wiring
Source/JavaScriptCore/runtime/JSModuleLoader.cpp, Source/JavaScriptCore/runtime/SyntheticModuleRecord.cpp
JSModuleLoader passes the JSSourceCode payload to synthetic source generation. Deferred generation asserts that its generator does not take a payload and passes an empty value.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 47ef2

No actionable merge-blocking issue is established. The payload reuse path remains unverified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: JSSourceCode now traces the value exported by a synthetic module.
Description check ✅ Passed The description directly explains the synthetic module lifetime problem, the JSSourceCode payload fix, implementation details, testing, and trade-offs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

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