Skip to content

Optional crate identity metadata for rules_rs to not duplicate rlibs - #57

Draft
genevieve-me wants to merge 100 commits into
hermeticbuild:mainfrom
genevieve-me:feat/optional-crate-identity
Draft

genevieve-me wants to merge 100 commits into
hermeticbuild:mainfrom
genevieve-me:feat/optional-crate-identity

Conversation

@genevieve-me

Copy link
Copy Markdown

This is preliminary work to solve issue hermeticbuild/rules_rs#144. It doesn't do anything unless combined with a PR on rules_rs (which I will open shortly).

It adds support for optional crate identity metadata as initial prep work to let rules_rs not duplicate rlibs from different crate hubs, as described in the above issue. Ie, if my app A depends on library B (internal Bazel dependency) and library C (crates.io dependency) and both depend on tracing, right now the tracing library will end up duplicated in the built app A. This adds some metadata that can be used by rules_rs when driving rules_rust in order to 'coalesce' these dependencies into one linked library wherever possible.

When targets opt into this extra logical identity, an aspect validates Rust link units to make sure we don't end up with incompatible duplicate instances of the same Cargo package.

Note: this is a contribution as part of work done at my employer Xona Space and has been approved to open source, the CLA will fail but should be fixed very shortly.

Disclaimer: Generative AI was used heavily in the creation of this branch, but it has been tested and used on a real Rust codebase and all responsibility for the code and design rests with me, the author.

Overview

This adds:

  • crate_identity attribute and RustCrateIdentityInfo, associating a source-qualified logical identity with the configured output artifact.
  • rust_link_validation_aspect and intrinsic link-unit validation in Rust terminal rules (also through native deps/implementation_deps via an internal aspect).
  • Public command-line aspect and checked cc_binary/cc_shared_library wrappers for native C/C++ links aggregating static Rust closures.
  • Intrinsic recovery of Rust identity closures through native dependency edges.
  • Alias/extern-name checks and rustc crate-metadata disambiguation for transitive same-name crates; aliases required for multiple direct same-named dependencies.
  • Correct RustCrateIdentityInfo propagation for generated Cargo libraries, build-script host isolation, proc-macro isolation, cdylib, and static-link units.

Performance

The new internal aspect is attached to every Rust rule’s deps and link_deps in rust/private/rust.bzl.
I think this is justified but it does affect build performance. this does not apply to any local path dependencies/libraries, which are not deduped at all.

When tested with my rules_rs branch implementing this:

  • aspect traversal is effectively free
  • there is a performance cost from dependency depth.
  • on a real app (zoxide): 2.9-3.1s vs 3.2-3.5s for a cold build, so a sub-second build time increase.

Synthetic analys time, from my rules_rs branch using these rules_rust changes vs pre-MR latest commit to rules_rs (and its pinned rules_rust),

  • 200: 1.05s vs 0.95s (+0.10)
  • 400: 1.20s vs 0.96s (+0.24)
  • 800: 1.91s vs 1.24s (+0.67)
  • 1600: 4.39s vs 2.27s (+2.12)
  • 800 wide/shallow (only 1 nested dependency): 0.37s vs 0.39s (no measurable difference)

So performance is technically eventually polynomial in dependency depth driven by rustc.bzl _link_closure_identities() calling dep_info.transitive_crates.to_list() at every rustc_compile and the aspect then aggregating that. By dependency depth, I mean A depends on B depends on C depends on...

Real apps have bounded depth and would never have transitive dependencies >1000 deep, so the cost stays <1 second in real apps I tried testing on likezoxide. only unrealistically deep synthetic stacks blow up.

dzbarsky and others added 30 commits September 1, 2026 13:25
Add support for tier 3 targets bpfeb-unknown-none and bpfel-unknown-none
(see

https://github.com/rust-lang/rust/blob/f5e2df7/src/doc/rustc/src/platform-support.md?plain=1#L311-L312).

This is modeled after bazelbuild#3507
and
should probably be updated if/when
bazelbuild/platforms#131 is merged.

(please use rebase merge when landing this as the proper commit message
is in the commit, rather than the PR description)

/cc @avrabe
…hollow rlibs: the RustcMetadata action runs rustc to completion with -Zno-codegen, emitting a .rlib archive. This approach mirrors the one used by buck2 and avoids needing to kill rustc mid-output in order to produce metadata.

While not fixing problems with SVH mismatches when non-determinism, this does simplify the codepath and uses a production tested technique that doesn't have any of the dangers associated with killing the rustc process while it's still active.
Port the sharding wrapper feature from bazelbuild#3774 into the hermeticbuild fork. The implementation wraps rust_test executables when experimental_enable_sharding is set while keeping rustc_compile_action's existing provider-list API for internal and extension callers.

rust_test now scans the returned providers to replace DefaultInfo for the wrapper, so extensions such as prost and wasm-bindgen continue to consume rustc_compile_action without API churn.

Co-authored-by: Brian Duff <bduff@linkedin.com>

Co-authored-by: Codex <noreply@openai.com>
Rustc emits GNU-like Windows staticlibs as lib<crate>.a, but rules_rust was stripping the lib prefix for all Windows non-rlib library outputs. Keep the prefix for staticlib outputs when the target ABI is gnu or gnullvm so declared outputs match rustc.
dzbarsky and others added 28 commits September 1, 2026 13:33
Add documentation extraction targets for the public cargo and rust Starlark packages. Declare the bazel_features, selects, and cc_debug_helper_bzl dependencies required by those targets.
)

Scan code-generating rustc outputs for the resolved ${pwd} value after successful compilation. Reject embedded CARGO_MANIFEST_DIR, OUT_DIR, and other sandbox paths while preserving compile-time include_str! and relative paths.

Inspect cargo_build_script OUT_DIR through test runfiles instead of retaining compile-action paths.

Assisted-by: OpenAI Codex
* rustc: stage cc dynamic libraries in test runfiles

The cc dynamic libraries a target loads at runtime were collected into its
runfiles only when the crate type was bin, cdylib or staticlib. A rust_test
reports the type of the crate under test -- "lib" for rust_test(crate = ":foo")
-- so tests were skipped entirely. The collection also walked only `deps`, never
`crate`, so even a bin built that way would have missed them.

A rust_test depending on a cc_import shared library therefore produced runfiles
containing just the executable and the repo mapping. Its RUNPATH is
$ORIGIN/../../_solib_<cpu>/..., so it runs only where that execroot directory
happens to sit beside the binary. Locally it does, and the test passes. Under
remote execution the input tree is exactly the declared runfiles, and the test
dies at load time with `cannot open shared object file`.

Gate on crate_info.is_test as well, and scan `crate` alongside `deps`.

* test: cover imported dylibs in rust_test runfiles

A cc_binary(linkshared = True) in `deps` already reached a consumer's runfiles
through the generic deps traversal, so the existing check_runfiles cases passed
regardless of the dynamic library collection. A cc_import contributes no
runfiles of its own -- the dylib is only in its CcInfo linking context -- so it
isolates that collection.

Adds two cases over an imported dylib: a rust_binary (which reaches it through
`deps`) and a rust_test (through `crate`). The rust_test case fails without the
preceding commit and passes with it.

* ci: bump llvm to 0.8.4 for web.archive.org id_ URL fix

llvm 0.7.7 pins the macOS SDK archive at a web.archive.org URL without the
id_ modifier (web/20260430051604/...), which now serves the Wayback HTML
wrapper page instead of the raw pkg. The download checksum-mismatches and
the build aborts during analysis of any target that transitively fetches
@macos_sdk (e.g. //test/genquery:bar_deps), skipping every test.

llvm 0.8.4+ uses the id_ modifier (web/20260430051604id_/...) which returns
the raw file with the correct sha256. Bump the bazel_dep to unblock CI.
Allow disabling the default hermetic macOS SDKROOT globally.

Preserve explicit SDKROOT values and C/C++ toolchain SDKROOT.
Use source_file_label to preserve filenames relative to the owning Bazel
package when query_label_for builds the srcs query. Files such as
src/adb.rs now match the target's source label instead of :adb.rs.

Add a regression test for root packages, nested directories, and direct
source files. The test fails with the old basename behavior.

Based on bazelbuild#4238; addresses hermeticbuild/rules_rs#232.

Validation: 10 Flycheck tests and 44 rust-analyzer library tests pass
through rules_rs. --saved-file command checks pass for four package
layouts.
…eticbuild#48)

```
 On Windows, consolidate_dependency_search_paths (util/process_wrapper/main.rs:156) collapses
 every -Ldependency=<dir> rustc flag into one unified directory, to avoid Windows command-line size
 overruns (commit 3f613e4). It does so by read_dir-ing each search path and hardlinking (falling
 back to copying) every regular file it finds into that unified dir.

 However, builds failed with:
 Error: ProcessWrapperError("unable to copy
   .../ijent_util-subscriber_contract-test.exe.tmp8ceb5a4 into unified
   dependency dir ...: Access is denied. (os error 5)")

 .exe.tmpXXXXXXX file is an LLVM's atomic-output temp file. Every LLVM tool that writes output this
 way does it: lld-link / rust-lld (the PE image and the PDB), llvm-lib (import libs),
 llvm-objcopy.
 So this file is rust-lld writing the sibling target ijent_util-subscriber_contract-test.exe.
 It only occurs with toolchain.linker_preference == "rust" (rustc.bzl:445-448);
 MSVC's link.exe writes in place.

 Lifecycle of that temp file

 ┌────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
 │     Phase      │                                                       What happens                                                        │
 ├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
 │ Created        │ At the start of the sibling's link step. TempFile::create passes OF_Delete, then immediately calls                        │
 │                │ setDeleteDisposition(H, true) → DELETE_PENDING from birth.                                                                │
 ├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
 │ Alive          │ For the entire duration of that link, while lld mmaps and writes the image into it.                                       │
 ├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
 │ Deleted        │ FileOutputBuffer::commit() → TempFile::keep(FinalPath): setDeleteDisposition(H, false), then rename_handle(H,             │
 │ (success)      │ "…-test.exe"). The temp name vanishes by rename.                                                                          │
 ├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
 │ Deleted        │ TempFile::discard() — the already-set delete disposition does it on close; RemoveFileOnSignal covers crashes.             │
 │ (failure)      │                                                                                                                           │
 └────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

 Why os error 5 and not os error 2

 Because of DELETE_PENDING. On Windows a delete-pending file:

 - still appears in directory enumeration — fs::read_dir yields it and entry.file_type()
   succeeds (metadata comes from the dirent);
 - but every CreateFile on it fails with STATUS_DELETE_PENDING → ERROR_ACCESS_DENIED (5).

 So fs::hard_link (CreateHardLinkW) fails with 5, the fs::copy (CopyFileExW) fallback fails
 with 5, and the wrapper escalates to a fatal ProcessWrapperError. LLVM's own createUniqueEntity
 retries on errc::permission_denied for exactly this reason.

 This is not a narrow race. The window is the whole link of the sibling target, and the failure is
 deterministic once read_dir observes the temp file.

 Why the temp file is in a -L dependency= directory at all

 rust/private/rustc.bzl:2994-2998 derives the search paths from the dirnames of
 dep_info.transitive_crate_outputs (_get_dirname, rustc.bzl:3433). A dirname is an entire Bazel
 package bin dir (bazel-out/<cfg>/bin/fleet/native/ijent/ijent_util/) containing every output of
 that package — sibling .exes, .pdbs, .os, .rustc-outputs, tree artifacts, in-flight linker
 temps — not a curated set of crate artifacts.

 Bazel has no sandboxing on Windows, so those scanned directories are the live bazel-out tree
 with every concurrent action's activity visible.
```
Remove cargo_toml_env_vars inputs from the determinism BUILD files and match RustdocTest in the C++ runtime analysis test. Restore the dynamic standard-library default for construct_arguments callers and include C++ linker files in Windows GNU library compile inputs for dlltool.

Add dynamic-standard-library assertions for Clippy and rustdoc tests, and cover dlltool inputs for GNU, GNU LLVM, MSVC, and Linux targets.
Rust generates import libraries in-process for Windows GNU LLVM and MSVC. Restrict the dlltool argument and C++ linker-file inputs to Windows GNU, where raw-dylib compilation invokes dlltool. Update the rlib input test so GNU LLVM retains no C++ linker files.
Expand C++ linker paths and flags in Args.map_each callbacks, so Bazel can
apply the Rustc action's path mapper. Preserve the eager get_linker_and_args
interface for Cargo build scripts and bindgen, and retain Rust linker
selection and flag filtering. Defer Windows GNU dlltool paths as well.

Add an analysis regression with a generated C++ linker, linker script, and
LIB-derived search directory. It passes with mapping off on Bazel 9.1.0 and
9.3.0-dzbarsky18, and with strip on a 9.3.0-dzbarsky18 build containing
bazelbuild/bazel#30789. It fails with strip on unpatched Bazel.

All 21 selected linker/native-dependency/SDKROOT analysis tests pass with
mapping off and on. Codex at 52e73e3a builds //codex-rs/cli:codex with strip
after backporting this change to its rules_rust pin, preserving its patches.
Unmodified rules_rust fails to find Scrt1.o and libc with strip, including
with the Bazel backport alone.
Default experimental_use_allocator_libraries_with_mangled_symbols to true
so cc_common.link and C++ consumers of Rust libraries receive allocator
symbols generated by the selected rustc. Preserve the explicit opt-out for
custom and legacy C++ allocator libraries and document the nightly
requirement for the global allocator implementation.

Remove redundant allocator flags from integration fixtures and ThinLTO
tests. Cover heap allocation through C++ and per-target cc_common.link,
default allocator CcInfo, and explicit opt-out. Use the C++ bootstrap
wrapper in the mock codegen-units toolchain to avoid a dependency cycle.

Validated with Bazel 9.1.0 on macOS: 20 cc_common.link integration tests,
32 CcInfo/allocator/ThinLTO unit tests, and the pinned nightly global
allocator test. Per-target and C++ allocation tests also pass with the
global cc_common.link setting disabled; the C++ test passes with dynamic
libstd. Dynamic-libstd Rust tests encounter a getopts link failure also
reproduced on unchanged main. Buildifier and git diff --check pass.

Assisted-by: OpenAI Codex
Bootstrap actions pass rustc flags through @params, but the C++ bootstrap
process wrapper substitutes only direct arguments. The literal remap
placeholders leave execution directories in tinyjson crate metadata.

Use -Zremap-cwd-prefix for bootstrap compilation so rustc resolves its own
working directory. Default RUSTC_BOOTSTRAP to the bootstrap crate's name,
preserving explicit environment settings. Keep the existing response files
and SDKROOT expansion without changing the C++ wrapper.

Add a regression test that reads the actual bootstrap tinyjson archive.
The test fails before the fix. Sandboxed and local builds in different
output bases now produce identical archives without execution directories.
Validation: Bazel 9.1.0, Rust 1.98.0, macOS arm64; bazel test //... passed
621 tests and skipped 37 platform-incompatible tests.

Refs hermeticbuild/rules_rs#233 and hermeticbuild#46.

Co-authored-by: Codex <noreply@openai.com>
…alidation

Prep work to fix the upstream hermeticbuild/rules_rs issue 144.

Motivation
A crate can be pulled from two different registries or resolved differently across Bzlmod hubs,
producing two identical-name rlibs in one binary. For crates with global/static state
(e.g., log, tracing), that breaks shared-state assumptions. There should be
at most one configured instance per source-qualified identity per link unit.

Adds an opt-in crate_identity attribute to library-producing Rust rules and a
RustCrateIdentityInfo/RustLinkClosureInfo provider pair so a configured Rust
library can carry a source-qualified logical identity.

This commit does NOT merge or deduplicate crate instances; it only carries the
identity and enforces the per-link-unit invariant (at most one configured
instance of each source-qualified identity per native link unit) at analysis
time, intrinsically for Rust terminal link units and via an opt-in aspect or
the rust_link_checked_* wrappers for native C/C++ links. Deduplication of
compatible same-source packages across independent hubs is performed upstream
by the consuming crate generator (rules_rs interner).
Align crate_identity docs with reality:
- The crate generator emits a source-qualified identity (source, name, version)
  as 'cargo:' + canonical record, not the non-existent
  fully_qualified_cargo_package_id helper; the generator merges compatible
  same-source packages, this feature only carries/validates the identity.
- State explicitly that cross-registry same-name is a distinct identity accepted
  by design (private-registry shadowing), and that the enforced invariant is at
  most one configured instance per source-qualified identity per link unit.
- Make the opt-in nature of native link validation explicit.
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.