Skip to content

build: pin -C metadata so bun's crates keep stable symbol names - #33339

Open
robobun wants to merge 5 commits into
mainfrom
farm/a27e62b9/pin-rustc-metadata
Open

build: pin -C metadata so bun's crates keep stable symbol names#33339
robobun wants to merge 5 commits into
mainfrom
farm/a27e62b9/pin-rustc-metadata

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

bun_runtime's Rust symbol disambiguator changes on nearly every build, so a symbol like

_RNvNtNtCs4EG9u9StnXu_11bun_runtime3cli7command15boot_standalone

does not exist under that name in the next build. Nothing is wrong with the shipped binary, but anything keyed on symbol names across two builds silently loses the crate: reusing an lld --symbol-ordering-file from an earlier build, sccache hits, and symbolicating an old profile against a new binary.

Cause

Cargo's -C metadata for a unit mixes in the sorted -C metadata of every one of its dependencies (compute_metadata in compilation_files.rs), and rustc hashes -C metadata into the StableCrateId that every v0 symbol carries. So a dependency-edge edit anywhere below a crate renames every symbol that crate defines.

bun_runtime has direct dependencies on ~100 workspace crates, so it is renamed by almost every commit. bun_core, bun_bunfig and bun_js_parser sit near the bottom of the graph and so never move. It is not build.rs, the git sha, or the workspace version: RUSTFLAGS and the profile are deliberately excluded from -C metadata (cargo hashes them into -C extra-filename only), and -C metadata is computed before any build script runs.

Concretely, between two adjacent canary builds bun_sql_jsc dropped its bun_wyhash dependency. That rotated bun_sql_jsc's -C metadata, which rotated bun_runtime's and bun_rust's.

Fix

A RUSTC_WORKSPACE_WRAPPER (scripts/build/rustc-metadata-shim.rs) replaces cargo's value with one derived only from the unit's own identity:

bun/bun_runtime@0.0.0/x86_64-unknown-linux-gnu/lib/default

That is everything in cargo's hash that identifies the compilation unit (package, version, target, crate types, features), minus the dependency hash that churns. Symbols stay unique because rustc mixes the crate name into StableCrateId independently of -C metadata, and no two workspace packages share a name.

Notes on the shape of the fix:

  • _WORKSPACE_, not plain RUSTC_WRAPPER. Cargo applies it to workspace members only, which is exactly the set whose names we care about; registry crates (where two versions of one crate name can coexist) keep cargo's collision-proof hash. cargo clippy sets this variable itself, so it keeps working.
  • A wrapper is the only lever. Cargo has no knob for this, and rustc sorts/dedups/hashes every -C metadata it is handed, so an extra one from RUSTFLAGS does not shadow cargo's — verified, it produces a third value that still rotates.
  • -C extra-filename is left alone, so cargo's on-disk artifact names stay unique across dependency-graph states.
  • It is built by a bare rustc rather than cargo: the wrapper has to exist before cargo runs, so it can't be a workspace member, and one host .rs is cheaper than a second cargo invocation. rustc sits next to cargo, the same assumption findRustup() already makes.

One-time cost: the wrapper's path is part of cargo's fingerprint for workspace members, so the first build after this lands recompiles bun's own crates.

Verification

Re-applying the exact bun_sql_jsc dependency edit on a debug linux-x64 build:

crate unpinned (before → after) pinned
bun_runtime CsduDwerHUqXvCscY7r8IXUlgZ unchanged
bun_sql_jsc Cs9HsfIadT9o5Cs1w60FSEQOrU unchanged
bun_core CsbzcMvzFcEb5CsbzcMvzFcEb5 unchanged
Commands
# with and without the bun_wyhash edge on bun_sql_jsc
ninja -C build/debug bun-rust
llvm-nm --defined-only build/debug/rust-target/x86_64-unknown-linux-gnu/debug/libbun_rust.a \
  | grep -oE 'Cs[A-Za-z0-9]+_11bun_runtime\b' | sort -u

bun bd links and runs, and the binary carries exactly one bun_runtime disambiguator.

test/internal/rustc-metadata-shim.test.ts covers the ninja wiring (everywhere) and the wrapper's rewriting (where rustc is available): two different cargo metadata hashes collapse to the same pinned value, distinct units keep distinct values, -C extra-filename and every other argument pass through, and an invocation with no -C metadata (cargo's -vV / --print probes) is untouched.

Cargo folds the -C metadata of every dependency into a unit's own, and
rustc hashes that value into the StableCrateId that v0 symbol names
carry. A dependency-edge edit anywhere below a crate therefore renames
every symbol that crate defines. bun_runtime has direct deps on ~100
workspace crates, so it was renamed by nearly every commit, while leaves
like bun_core never moved.

Add a RUSTC_WORKSPACE_WRAPPER that replaces cargo's value with one
derived only from the unit's identity (package, version, target, crate
types, features). Registry crates keep cargo's hash; only workspace
members are wrapped. Symbols stay unique because rustc mixes the crate
name into StableCrateId independently of -C metadata.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:22 PM PT - Jul 4th, 2026

@robobun, your commit b2f2d8db7f37fb2bcf11824589916fdb00c33948 passed in Build #68428! 🎉


🧪   To try this PR locally:

bunx bun-pr 33339

That installs a local version of the PR into your bun-33339 executable, so you can run:

bun-33339 --bun

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced by re-applying the exact dependency edit that rotated it between the two canary builds (bun_sql_jsc dropping bun_wyhash) on a debug linux-x64 build:

  • unpinned: bun_runtime CsduDwerHUqXvCscY7r8IXUlgZ, bun_sql_jsc Cs9HsfIadT9o5Cs1w60FSEQOrU, bun_core unchanged
  • pinned: all three unchanged

bun bd links and runs; the binary carries exactly one bun_runtime disambiguator. test/internal/rustc-metadata-shim.test.ts: 6 pass with the change, 0 pass with it stashed.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff8cd0f2-9e29-4f81-a71f-1b7dabc0f599

📥 Commits

Reviewing files that changed from the base of the PR and between cc95357 and b2f2d8d.

📒 Files selected for processing (1)
  • test/internal/rustc-metadata-shim.test.ts

Walkthrough

This PR adds a Rust host shim that rewrites -C metadata for workspace builds, wires it into the Rust build graph as RUSTC_WORKSPACE_WRAPPER, and adds tests plus documentation for the new build behavior.

Changes

Metadata shim and build wiring

Layer / File(s) Summary
Shim source
scripts/build/rustc-metadata-shim.rs
Parses the rustc path from argv, rewrites any metadata= argument to a deterministic value based on CARGO_PKG_NAME, and runs rustc via exec() on Unix or status() elsewhere.
Build rule wiring
scripts/build/rust.ts, scripts/build/CLAUDE.md
Adds the shim source path and output helper, registers the rustc_metadata_shim ninja rule, builds the shim before cargo, sets RUSTC_WORKSPACE_WRAPPER, updates implicit inputs for cargo and the Windows shim, and documents the new module entry.
Tests
test/internal/rustc-metadata-shim.test.ts
Verifies the build graph wiring, compiles and runs the shim, and checks metadata pinning, package-name separation, and pass-through when no metadata flag is present.

Possibly related PRs

  • oven-sh/bun#31300: Also changes scripts/build/rust.ts around Rust shim execution and emitRust() wiring, so it overlaps with the same build orchestration area.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: pinning Rust metadata to keep symbol names stable.
Description check ✅ Passed The description is detailed and covers the change and verification, even though it uses different headings than the template.
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.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/build/rust.ts`:
- Around line 214-234: The `rustc_metadata_shim` rule is running before the
cross-toolchain repair path, so a partially installed pinned toolchain can fail
during the shim build and block `rust_build_cross`. Update the flow so the
`rustup toolchain install --force ...` repair happens before any
`metadataShim`-triggered build, or apply the same toolchain-availability guard
around the `rustc_metadata_shim` step. Use the existing `rustc_metadata_shim`
rule and the `rust_build_cross` / `metadataShim` dependency path to place the
fix without changing the build intent.

In `@test/internal/rustc-metadata-shim.test.ts`:
- Around line 51-73: Add test coverage for the compact `-Cmetadata=<hash>`
spelling handled by `rewrite_metadata` so the shim’s one-argument rewrite path
is exercised, not just the existing `-C metadata=<hash>` form. Update the tests
in `rustc-metadata-shim.test.ts`—especially the `unit`, `argvFor`, and
`metadataOf` cases—to include both accepted spellings and assert they normalize
the same way through `rewrite_metadata`/`argvFor`.
- Around line 94-198: The behavioral tests in describe.skipIf(rustc === null)
are recompiling the same shim for every case, which is redundant. Move the
compileShim call out of each test into a shared setup such as beforeAll, keep
the per-test tempDir fixture isolation, and reuse the compiled shim path in
argvFor/metadataOf-driven assertions. Ensure the shared shim is only built once
from compileShim and then passed into each test that currently invokes it.
- Around line 96-106: The subprocess helpers are leaving pipes unread, which can
deadlock child processes when buffers fill. Update both `compileShim` and
`argvFor` to drain all piped streams concurrently by awaiting stdout, stderr,
and exit together, following the repo convention used in subprocess tests. Make
sure the fix is applied at the helper level so every test using `compileShim` or
`argvFor` benefits from drained `proc.stdout` and `proc.stderr`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: eca9dd4e-7f8d-410a-9487-e4043418a1d8

📥 Commits

Reviewing files that changed from the base of the PR and between d9b2811 and 7e98ff5.

📒 Files selected for processing (4)
  • scripts/build/CLAUDE.md
  • scripts/build/rust.ts
  • scripts/build/rustc-metadata-shim.rs
  • test/internal/rustc-metadata-shim.test.ts

Comment thread scripts/build/rust.ts
Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
The pin only has to identify the compilation unit; rustc mixes the crate
name into StableCrateId on top of it, no two workspace packages share a
name, and cargo wraps workspace members only. So CARGO_PKG_NAME is
enough and the wrapper needs no argument parsing at all.

Also run the shim's bare rustc through the same rustup toolchain repair
rust_build_cross does: it is the build's first rustup-proxy call, and a
partially installed toolchain has no host rust-std for it to link
against. Share that prefix between the two rules instead of duplicating it.

Test: fix the ninja output name assertion on Windows hosts (exeSuffix),
drain both subprocess pipes, and compile the wrapper once per suite.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Cut the diff down in 8a21b11 after feedback that the first pass was over-engineered: 412 added lines → 285, and the wrapper itself 152 → 72.

The pin is now just the package name. metadata=bun.<CARGO_PKG_NAME>, read from the env cargo already sets. The wrapper no longer parses --target / --crate-type / --cfg feature= out of the command line, because none of that is needed for uniqueness: rustc mixes the crate name into StableCrateId on top of -C metadata, no two workspace packages share a name, and cargo applies a RUSTC_WORKSPACE_WRAPPER to workspace members only. The one case the dropped components guarded — the same package compiled twice in one build — can't arise here (no workspace crate is a build-dependency, and the bun_shim_impl build writes to a different profile directory).

Re-verified the same way, on a debug linux-x64 build, by adding bun_wyhash back as a dependency of bun_sql_jsc:

crate unpinned (before → after) pinned
bun_runtime CsduDwerHUqXvCscY7r8IXUlgZ Csh9R7kasNhmX both
bun_sql_jsc Cs9HsfIadT9o5Cs1w60FSEQOrU Cs3SGzG24WXXr both
bun_core CsbzcMvzFcEb5 (stable) CsejrBCtb4PWi both

All four workspace crates sampled still get distinct ids, bun bd links and runs, and the test file is 4 pass / 0 pass with the change reverted.


On the review comments:

rustc_metadata_shim can block the cross-toolchain repair — correct, and fixed. The shim edge is the build's first rustup-proxy call, so a partially installed toolchain would have no host rust-std for it to link against and it would fail before rust_build_cross ever got to repair anything. The rustup toolchain install --force prefix is now a shared repair string used by both rules, so rust_build_cross lost its duplicate copy.

Windows .exe suffix in the graph assertion — correct, and fixed. metadataShimPath() appends cfg.host.exeSuffix, and the host is the machine running the test, not the linux target the test asks for. The regex now accepts rustc-metadata-shim(.exe)? so the assertion keeps running on Windows rather than being skipped.

Unread stdio pipes — fixed; both helpers now drain stdout and stderr alongside exited and assert on the combined object.

Shim recompiled per test — fixed; it's compiled once in beforeAll.

Untested -Cmetadata=<hash> one-argument spelling — the branch is gone rather than tested. Cargo emits -C and metadata=… as two arguments (.arg("-C").arg(format!("metadata={}", …))), and nothing else it passes starts with metadata=, so the wrapper just rewrites any argument with that prefix. Handling a spelling cargo never produces was part of what made the first version too big.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/internal/rustc-metadata-shim.test.ts`:
- Around line 80-141: The three behavioral tests in the rustc metadata shim
suite are independent and currently run sequentially even though they only call
argvFor after beforeAll finishes setting up shim and fakeRustc. Update the test
declarations under describe.skipIf(rustc === null) to run concurrently (prefer
test.concurrent or describe.concurrent where appropriate) so the
subprocess-spawning cases can execute in parallel without shared mutable state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8ee042f5-8af8-4137-975e-b5ced4d8b4c7

📥 Commits

Reviewing files that changed from the base of the PR and between 7e98ff5 and 8a21b11.

📒 Files selected for processing (3)
  • scripts/build/rust.ts
  • scripts/build/rustc-metadata-shim.rs
  • test/internal/rustc-metadata-shim.test.ts

Comment thread test/internal/rustc-metadata-shim.test.ts Outdated
They only read `shim`/`fakeRustc`, which beforeAll finishes writing before
any of them start. test/CLAUDE.md asks for this whenever tests in a file
spawn processes.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

test.concurrent applied in cc95357 — the three subprocess cases only read shim/fakeRustc, which beforeAll finishes writing before any of them start. File drops from 5.19s to 3.90s.

CI is green on the pin: all 16 build-rust lanes pass, so the wrapper builds and cargo runs through it on every host/target combination (linux gnu/musl × x64/aarch64, baseline, asan, android, freebsd, darwin, windows).


Worth noting that #33302 landed on main while this was open, which makes the payoff concrete rather than hypothetical. It links with --symbol-ordering-file, and inheritOrderFile() in scripts/build/ci.ts downloads linker.order from a previous build on the branch for every canary that doesn't carry [generate symbol order] in its commit message:

export function shouldGenerateOrderFile(cfg: Config): boolean {
  if (!orderFileEligible(cfg)) return false;
  if (!cfg.canary) return true;
  return /\[generate symbol order\]/i.test(process.env.BUILDKITE_MESSAGE ?? "");
}

So the common path links against an order file produced by an earlier build. Every bun_runtime entry in that file names a disambiguator the current build no longer uses, and lld silently skips symbols it can't resolve — verifyOrderFileApplied() only hard-fails on the regenerate path, where the binary was traced from itself. Without the pin, the inherited order file quietly loses the whole crate and nothing reports it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Earlier feedback is addressed and I found no new issues, but this inserts a wrapper into every workspace rustc invocation and reshapes the toolchain-repair path in rust.ts — build-infrastructure with cross-platform reach that's worth a human sign-off.

Extended reasoning...

Overview

This PR adds a RUSTC_WORKSPACE_WRAPPER (scripts/build/rustc-metadata-shim.rs, 72 lines of Rust compiled by a bare rustc at build time) that rewrites cargo's -C metadata=<hash> to metadata=bun.<CARGO_PKG_NAME> for every workspace crate, so v0 symbol names stay stable across dependency-graph edits. scripts/build/rust.ts gains a new ninja rule for the shim, hoists the rustup toolchain install --force repair into a shared prefix used by both the shim rule and rust_build_cross, sets RUSTC_WORKSPACE_WRAPPER in cargo's env, and threads the compiled shim as an implicit input of every cargo edge. A one-line entry is added to scripts/build/CLAUDE.md, and test/internal/rustc-metadata-shim.test.ts covers both the ninja-graph wiring and the wrapper's argv rewriting.

Security risks

None apparent. The wrapper only runs at build time, only reads CARGO_PKG_NAME from the environment cargo sets, and execs the real rustc with a rewritten argv. No network, no untrusted input, no change to shipped-binary behavior.

Level of scrutiny

Moderate-to-high. This is not runtime code, but it sits in the path of every workspace rustc invocation across all 16 build lanes and changes how symbol disambiguators are derived. The correctness argument rests on cargo/rustc internals (that RUSTC_WORKSPACE_WRAPPER applies only to workspace members, that cargo always emits -C and metadata=… as two args, that no two workspace packages share a name, that rustc mixes the crate name into StableCrateId independently). The refactor also moves the toolchain-repair prefix and changes how the rustc_metadata_shim rule is shell-wrapped on Windows. All of this is well-reasoned in the PR description and comments, and CI is reported green on all lanes — but it's the kind of build-system design decision a maintainer should confirm.

Other factors

  • All prior review feedback (mine on the Windows .exe regex and undrained stderr; CodeRabbit's on the toolchain-repair ordering, pipe draining, single-compile beforeAll, and test.concurrent) is resolved in commits 8a21b11 and cc95357, and the current diff reflects those fixes.
  • The bug-hunting pass on the latest revision found nothing.
  • The one-time cost (first build after merge recompiles all workspace crates because the wrapper path enters cargo's fingerprint) is called out in the description.
  • The change is not simple/mechanical: it introduces a new build-time executable, a new ninja rule, and a behavioral dependency on cargo's argv shape — outside the "approve without human review" bar.

CI's test lanes run a downloaded binary, and the rustc they find is a
rustup proxy: the first call downloads a channel (40s, progress on
stderr) and on Windows writes shim.exe where the test looked for shim.
Neither belongs in a test lane — the build-rust lanes already prove the
wrapper compiles and cargo runs through it on every platform.

Point the behavioural tests at the wrapper emitRust() drops in the build
directory, next to the bun under test, and skip when it isn't there.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI caught a real problem in the test, not the pin — fixed in 85704c8.

The behavioural tests compiled the wrapper with whatever rustc they found. On CI's test lanes that rustc is a rustup proxy pointing at an uninstalled channel, so the first call downloaded 19 components (40s, progress written to stderr, which the test asserted was empty), and on Windows it wrote shim.exe where the test looked for shim. A test lane has no business invoking a rust toolchain.

They now drive the wrapper emitRust() already dropped in the build directory, next to the bun under test, and skip when it isn't there (a downloaded binary has no build directory). Under bun bd test that's 4 pass; on a binary with no build directory it's 1 pass / 4 skip in 0.3s, touching nothing.

No coverage lost that matters: the 16 build-rust lanes are what prove the wrapper compiles and cargo runs the whole workspace through it on every host and target.

The other red lane in that build, test/cli/update_interactive_install.test.ts on aarch64, isn't marked (new) in the annotation — it's failing on main too.

A debug or ASAN bun writes benign noise there (CLAUDE.md says so). The
pipe is still drained so it can't fill; stderr now only surfaces in the
message when the wrapper exits non-zero.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant