From f3cfad5b6869193267aeb315ea13d1b7f46d8daa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:49:34 +0000 Subject: [PATCH 1/2] Add -Z flags to make .rmeta byte-stable under non-interface edits Content-addressed build systems (nix-cargo-unit) get early cutoff for free: if a rebuilt artifact is byte-identical, dependents are not re-derived. Today that cutoff almost never fires for Rust crates because crate metadata is oversensitive: a comment edit, a whitespace shift, or a private function body edit all change the .rmeta even though nothing a dependent crate consumes has changed. This adds three opt-in unstable flags, each addressing one churn source located by byte-diffing rmeta files across edit classes: * -Zrmeta-content-svh derives the SVH embedded in the crate root (and in the metadata stub) from the encoded metadata bytes themselves, plus the session dep-tracking hash and the stable crate id, instead of from the HIR. The SVH becomes stable exactly when the rest of the metadata is. The crate loader's link-time consistency check is unaffected: it compares encoded SVHs for equality, and the .rmeta and .rlib from one invocation embed the same bytes. The local crate_hash query is unchanged; the flag is rejected together with -Cincremental, whose green-reuse path never re-encodes metadata. * -Zrmeta-strip-spans=non-exported|all replaces spans with DUMMY_SP at the single span encoding choke point. Spans in metadata are byte offsets into local source files, so any edit that shifts text perturbs them. "non-exported" preserves spans in the regions whose contents dependents compile into their own output (MIR bodies, hygiene expansion data); "all" strips those too and substitutes expansion-index-derived ExpnHashes for the span-dependent real ones. Costs are documented per mode in the unstable book and at each site where fidelity is given up. Proc-macro crates are exempt. * -Zrmeta-normalize-src-hash zeroes the per-file source content hashes (and optional cargo checksums) recorded in the source file table. These cover a file's whole raw text, so any edit to a referenced file perturbs them. Dependent crates lose cross-crate diagnostic source snippets and debuginfo file checksums for this crate; the zero hash never matches real source, so consumers see "source unavailable" rather than stale text. With all three flags, recompiling a library crate after an identical rebuild, a comment edit, a line-shifting whitespace edit, or a private non-generic non-inlinable function body edit produces a byte-identical .rmeta, while generic/inlinable body edits and signature changes still change it (their MIR and types are legitimately part of what dependents consume). -Zrmeta-normalize-src-hash alone stabilizes length-and-line-preserving comment edits. The new run-make test rmeta-stability pins all of these properties, including the controls. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + compiler/rustc_interface/src/tests.rs | 8 +- compiler/rustc_metadata/Cargo.toml | 1 + compiler/rustc_metadata/src/rmeta/encoder.rs | 269 ++++++++++++++++-- compiler/rustc_session/src/config.rs | 64 ++++- compiler/rustc_session/src/options.rs | 26 ++ compiler/rustc_span/src/hygiene.rs | 14 + compiler/rustc_span/src/lib.rs | 12 + .../src/compiler-flags/rmeta-content-svh.md | 23 ++ .../rmeta-normalize-src-hash.md | 26 ++ .../src/compiler-flags/rmeta-strip-spans.md | 38 +++ tests/run-make/rmeta-stability/rmake.rs | 137 +++++++++ 12 files changed, 589 insertions(+), 30 deletions(-) create mode 100644 src/doc/unstable-book/src/compiler-flags/rmeta-content-svh.md create mode 100644 src/doc/unstable-book/src/compiler-flags/rmeta-normalize-src-hash.md create mode 100644 src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md create mode 100644 tests/run-make/rmeta-stability/rmake.rs diff --git a/Cargo.lock b/Cargo.lock index 1b57dbf60195..8ebae98d6278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,6 +4380,7 @@ dependencies = [ "rustc_expand", "rustc_feature", "rustc_fs_util", + "rustc_hashes", "rustc_hir", "rustc_hir_pretty", "rustc_index", diff --git a/compiler/rustc_interface/src/tests.rs b/compiler/rustc_interface/src/tests.rs index bb22bca27bf3..265098a4476d 100644 --- a/compiler/rustc_interface/src/tests.rs +++ b/compiler/rustc_interface/src/tests.rs @@ -16,8 +16,9 @@ use rustc_session::config::{ InstrumentCoverage, InstrumentMcount, InstrumentMcountOpts, InstrumentXRay, LinkSelfContained, LinkerPluginLto, LocationDetail, LtoCli, MirIncludeSpans, NextSolverConfig, Offload, Options, OutFileName, OutputType, OutputTypes, PAuthKey, PacRet, Passes, PatchableFunctionEntry, - Polonius, ProcMacroExecutionStrategy, Strip, SwitchWithOptPath, SymbolManglingVersion, - WasiExecModel, build_configuration, build_session_options, rustc_optgroups, + Polonius, ProcMacroExecutionStrategy, RmetaStripSpans, Strip, SwitchWithOptPath, + SymbolManglingVersion, WasiExecModel, build_configuration, build_session_options, + rustc_optgroups, }; use rustc_session::lint::Level; use rustc_session::search_paths::SearchPath; @@ -877,6 +878,9 @@ fn test_unstable_options_tracking_hash() { tracked!(regparm, Some(3)); tracked!(relax_elf_relocations, Some(true)); tracked!(remap_cwd_prefix, Some(PathBuf::from("abc"))); + tracked!(rmeta_content_svh, true); + tracked!(rmeta_normalize_src_hash, true); + tracked!(rmeta_strip_spans, RmetaStripSpans::All); tracked!(sanitizer, SanitizerSet::ADDRESS); tracked!(sanitizer_cfi_canonical_jump_tables, None); tracked!(sanitizer_cfi_generalize_pointers, Some(true)); diff --git a/compiler/rustc_metadata/Cargo.toml b/compiler/rustc_metadata/Cargo.toml index 2fce131e08b6..a5ae2de8832e 100644 --- a/compiler/rustc_metadata/Cargo.toml +++ b/compiler/rustc_metadata/Cargo.toml @@ -16,6 +16,7 @@ rustc_errors = { path = "../rustc_errors" } rustc_expand = { path = "../rustc_expand" } rustc_feature = { path = "../rustc_feature" } rustc_fs_util = { path = "../rustc_fs_util" } +rustc_hashes = { path = "../rustc_hashes" } rustc_hir = { path = "../rustc_hir" } rustc_hir_pretty = { path = "../rustc_hir_pretty" } rustc_index = { path = "../rustc_index" } diff --git a/compiler/rustc_metadata/src/rmeta/encoder.rs b/compiler/rustc_metadata/src/rmeta/encoder.rs index b32a23f53f8c..09e2eadc1d9e 100644 --- a/compiler/rustc_metadata/src/rmeta/encoder.rs +++ b/compiler/rustc_metadata/src/rmeta/encoder.rs @@ -7,9 +7,12 @@ use std::sync::Arc; use rustc_data_structures::fx::{FxIndexMap, FxIndexSet}; use rustc_data_structures::memmap::{Mmap, MmapMut}; +use rustc_data_structures::stable_hash::StableHasher; +use rustc_data_structures::svh::Svh; use rustc_data_structures::sync::{par_for_each_in, par_join}; use rustc_data_structures::temp_dir::MaybeTempDir; use rustc_data_structures::thousands::usize_with_underscores; +use rustc_hashes::Hash64; use rustc_hir as hir; use rustc_hir::attrs::{AttributeKind, EncodeCrossCrate}; use rustc_hir::def_id::{CRATE_DEF_ID, LOCAL_CRATE, LocalDefId, LocalDefIdSet}; @@ -27,12 +30,12 @@ use rustc_middle::ty::fast_reject::{self, TreatParams}; use rustc_middle::{bug, span_bug}; use rustc_serialize::{Decodable, Decoder, Encodable, Encoder, opaque}; use rustc_session::config::mitigation_coverage::DeniedPartialMitigation; -use rustc_session::config::{CrateType, OptLevel, TargetModifier}; +use rustc_session::config::{CrateType, OptLevel, RmetaStripSpans, TargetModifier}; use rustc_span::def_id::CRATE_MOD_ID; -use rustc_span::hygiene::HygieneEncodeContext; +use rustc_span::hygiene::{ExpnHash, HygieneEncodeContext}; use rustc_span::{ - ByteSymbol, ExternalSource, FileName, SourceFile, SpanData, SpanEncoder, StableSourceFileId, - Symbol, SyntaxContext, sym, + ByteSymbol, DUMMY_SP, ExternalSource, FileName, SourceFile, SpanData, SpanEncoder, + StableSourceFileId, Symbol, SyntaxContext, sym, }; use tracing::{debug, instrument, trace}; @@ -68,6 +71,24 @@ pub(super) struct EncodeContext<'a, 'tcx> { hygiene_ctxt: &'a HygieneEncodeContext, // Used for both `Symbol`s and `ByteSymbol`s. symbol_index_table: FxHashMap, + + // The effective `-Zrmeta-strip-spans` mode. Forced to + // `RmetaStripSpans::None` for proc-macro crates: their metadata consists + // almost entirely of span, hygiene and quoted-span data that dependent + // crates rely on for expansion, so stripping it would gut the crate + // rather than stabilize it. + strip_spans: RmetaStripSpans, + // While `true`, `encode_span` does not strip spans in + // `RmetaStripSpans::NonExported` mode. Set around the metadata regions + // whose contents are compiled into dependent crates (MIR bodies and + // hygiene expansion data), where spans feed debuginfo and macro expansion + // rather than just diagnostics. + span_stripping_paused: bool, + // The content-derived SVH computed by `encode_crate_root` under + // `-Zrmeta-content-svh`, so `encode_metadata` can reuse it for the + // metadata stub. `None` when the flag is off or the root has not been + // encoded yet. + content_svh: Option, } /// If the current crate is a proc-macro, returns early with `LazyArray::default()`. @@ -171,6 +192,16 @@ impl<'a, 'tcx> SpanEncoder for EncodeContext<'a, 'tcx> { } fn encode_span(&mut self, span: Span) { + // `-Zrmeta-strip-spans`: encode a dummy span instead of the real one, + // so that the encoded bytes do not depend on source positions. What + // this gives up: any consumer of this span in a dependent crate (e.g. + // a "function defined here" diagnostic note, or, in `all` mode, + // debuginfo line info for inlined MIR) sees `DUMMY_SP` instead of a + // location in this crate's source. Spans of source files that are + // never referenced are not encoded at all, which also keeps the + // source file table (and its per-file content hashes) from being + // encoded for stripped files. + let span = if self.should_strip_span() { DUMMY_SP } else { span }; match self.span_shorthands.entry(span) { Entry::Occupied(o) => { // If an offset is smaller than the absolute position, we encode with the offset. @@ -426,6 +457,26 @@ macro_rules! record_defaulted_array { } impl<'a, 'tcx> EncodeContext<'a, 'tcx> { + /// Whether `encode_span` should replace the span it is about to encode + /// with `DUMMY_SP`. See `RmetaStripSpans` for the semantics of each mode. + fn should_strip_span(&self) -> bool { + match self.strip_spans { + RmetaStripSpans::None => false, + RmetaStripSpans::NonExported => !self.span_stripping_paused, + RmetaStripSpans::All => true, + } + } + + /// Runs `f` with span stripping paused, for metadata regions whose spans + /// are exported for downstream compilation (MIR bodies, hygiene data) and + /// must stay faithful in `RmetaStripSpans::NonExported` mode. + fn with_span_stripping_paused(&mut self, f: impl FnOnce(&mut Self) -> R) -> R { + let old = std::mem::replace(&mut self.span_stripping_paused, true); + let res = f(self); + self.span_stripping_paused = old; + res + } + fn emit_lazy_distance(&mut self, position: NonZero) { let pos = position.get(); let distance = match self.lazy_state { @@ -596,6 +647,31 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { local_crate_stable_id, ); + // `-Zrmeta-normalize-src-hash`: the recorded hash covers the whole + // file's raw text, so *any* edit to the file (even a comment that + // moves no token) perturbs it and thereby the metadata bytes. + // Replace it with an all-zero hash of the same kind. What is given + // up, per consumer of this field: + // - Cross-crate diagnostic snippet rendering: a dependent crate + // verifies the on-disk source against this hash before quoting + // it in diagnostics. The zero hash never matches, so such + // diagnostics degrade to file:line:col without a quoted snippet + // (instead of risking quoting stale source). + // - Debuginfo: file checksums recorded for this crate's files in + // dependent crates' debug info become zero, so debuggers cannot + // detect source-file staleness for them. + // Proc-macro crates are exempt (see the `strip_spans` field docs). + if !self.is_proc_macro && self.tcx.sess.opts.unstable_opts.rmeta_normalize_src_hash { + adapted_source_file.src_hash = + rustc_span::SourceFileHash::zeroed(adapted_source_file.src_hash.kind); + // Same reasoning for the optional cargo-freshness checksum + // (`-Zchecksum-hash-algorithm`), which also covers the whole + // file's text. + adapted_source_file.checksum_hash = adapted_source_file + .checksum_hash + .map(|hash| rustc_span::SourceFileHash::zeroed(hash.kind)); + } + let on_disk_index: u32 = on_disk_index.try_into().expect("cannot export more than U32_MAX files"); adapted.set_some(on_disk_index, self.lazy(adapted_source_file)); @@ -655,7 +731,11 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { let incoherent_impls = stat!("incoherent-impls", || self.encode_incoherent_impls()); - _ = stat!("mir", || self.encode_mir()); + // MIR bodies are compiled into dependent crates (cross-crate inlining, + // generic instantiation, const eval), so their spans reach dependent + // debuginfo and const-eval backtraces. In `-Zrmeta-strip-spans=non-exported` + // mode they are preserved; only `all` mode strips them. + _ = stat!("mir", || self.with_span_stripping_paused(|this| this.encode_mir())); _ = stat!("def-ids", || self.encode_def_ids()); @@ -712,7 +792,12 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { // the incremental cache. If this causes us to deserialize a `Span`, then we may load // additional `SyntaxContext`s into the global `HygieneData`. Therefore, we need to encode // the hygiene data last to ensure that we encode any `SyntaxContext`s that might be used. - let (syntax_contexts, expn_data, expn_hashes) = stat!("hygiene", || self.encode_hygiene()); + // Hygiene expansion data feeds macro expansion and hygiene resolution + // in dependent crates, so like MIR it is preserved in + // `-Zrmeta-strip-spans=non-exported` mode and only stripped (with + // span-independent expansion hashes substituted) in `all` mode. + let (syntax_contexts, expn_data, expn_hashes) = + stat!("hygiene", || self.with_span_stripping_paused(|this| this.encode_hygiene())); let def_path_hash_map = stat!("def-path-hash-map", || self.encode_def_path_hash_map()); @@ -725,11 +810,12 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { let root = stat!("final", || { let attrs = tcx.hir_krate_attrs(); + let hash = self.metadata_svh(); self.lazy(CrateRoot { header: CrateHeader { name: tcx.crate_name(LOCAL_CRATE), triple: tcx.sess.opts.target_triple.clone(), - hash: tcx.crate_hash(LOCAL_CRATE), + hash, is_proc_macro_crate: proc_macro_data.is_some(), is_stub: false, }, @@ -849,6 +935,78 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { root } + + /// Returns the SVH to embed in the crate root header (and, mirrored, in + /// the metadata stub header). + /// + /// By default this is the HIR-based `crate_hash` query, which covers + /// essentially the whole crate (all bodies, source file names, spans when + /// incremental is enabled, ...), so *any* edit to the crate changes it, + /// and with it the metadata bytes, even when nothing a dependent crate + /// consumes has changed. + /// + /// Under `-Zrmeta-content-svh` the SVH is instead derived from the + /// encoded metadata bytes themselves: everything written before the + /// `CrateRoot` (this method must be called exactly when the root is about + /// to be encoded), which is all tables, MIR, hygiene and source map data. + /// That makes the SVH stable exactly when the rest of the metadata is. + /// Two extra inputs are mixed in for fields that appear only in the root + /// and thus after the hashed region: the session's dep-tracking hash + /// (covering every `[TRACKED]` option, e.g. `-Cextra-filename`, panic + /// strategy, edition, symbol mangling version) and the `StableCrateId`. + /// + /// Soundness of the link-time consistency check: a dependent compiled + /// against this crate's `.rmeta` records the SVH it read from that + /// metadata (`CrateDep.hash`), and the crate loader accepts an `.rlib` at + /// link time only if the metadata embedded in it carries an equal SVH. + /// The `.rmeta` and `.rlib` produced by one compiler invocation embed the + /// same encoded metadata, so they always agree. If the crate is + /// recompiled after a non-interface edit and the metadata bytes come out + /// identical, the new `.rlib` carries the same SVH as the cached + /// `.rmeta`, so a dependent compiled against the cached `.rmeta` links + /// the fresh `.rlib` -- which is precisely the early-cutoff behavior this + /// flag exists for. If any encoded byte differs, the SVH differs and the + /// loader rejects the mismatch, exactly as with the HIR-based SVH. + /// + /// Mixed toolchains: the SVH is an opaque 128-bit value compared only for + /// equality, and it never leaves the encoded metadata (the local + /// `crate_hash` query is unaffected by this flag). A dependent compiled + /// by an older compiler against old-scheme metadata simply recorded that + /// metadata's (old-scheme) SVH; artifacts from different compilers or + /// different flag settings have different metadata bytes anyway and are + /// never interchangeable in the first place. + fn metadata_svh(&mut self) -> Svh { + let tcx = self.tcx; + if !tcx.sess.opts.unstable_opts.rmeta_content_svh { + return tcx.crate_hash(LOCAL_CRATE); + } + + // Hash the bytes encoded so far, i.e. the whole metadata file except + // the `CrateRoot` that is about to be written. This mirrors the + // re-read that `-Zmeta-stats` performs. + self.opaque.flush(); + let mut file = self.opaque.file(); + let pos_before_rewind = file.stream_position().unwrap(); + file.rewind().unwrap(); + + let mut hasher = StableHasher::new(); + let mut buf = [0u8; 16 * 1024]; + loop { + let n = file.read(&mut buf).unwrap(); + if n == 0 { + break; + } + std::hash::Hasher::write(&mut hasher, &buf[..n]); + } + assert_eq!(file.stream_position().unwrap(), pos_before_rewind); + + std::hash::Hash::hash(&tcx.sess.opts.dep_tracking_hash(true), &mut hasher); + std::hash::Hash::hash(&tcx.stable_crate_id(LOCAL_CRATE), &mut hasher); + + let svh = Svh::new(hasher.finish()); + self.content_svh = Some(svh); + svh + } } struct AnalyzeAttrState { @@ -1961,6 +2119,35 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { }, |(this, _, expn_data_table, expn_hash_table), index, expn_data, hash| { if let Some(index) = index.as_local() { + // `-Zrmeta-strip-spans=all`: the real `ExpnHash` is + // computed over the `ExpnData`, whose inputs include + // source positions (call site and definition site spans), + // so it churns whenever source positions shift. Since the + // `ExpnData` we encode alongside it has had its spans + // stripped anyway, substitute a hash derived from the + // crate-local expansion index. Dependent crates use this + // hash only as an opaque, globally unique identity for + // deduplicating decoded expansions (its upper half must + // remain this crate's `StableCrateId`, which + // `ExpnHash::from_parts` preserves); the index is unique + // within the crate, and `index + 1` keeps it non-zero so + // it cannot collide with the root expansion hash. What is + // given up: the hash no longer reflects the expansion's + // contents, so a dependent's incremental cache keyed on it + // sees changes only when this crate's metadata changes at + // all (which is exactly the point). The root expansion + // (index 0) keeps its real hash, which is already + // span-independent (`Fingerprint::ZERO`). + let hash = if this.strip_spans == RmetaStripSpans::All + && index.as_raw().as_u32() != 0 + { + ExpnHash::from_parts( + this.tcx.stable_crate_id(LOCAL_CRATE), + Hash64::new(u64::from(index.as_raw().as_u32()) + 1), + ) + } else { + hash + }; expn_data_table.set_some(index.as_raw(), this.lazy(expn_data)); expn_hash_table.set_some(index.as_raw(), this.lazy(hash)); } @@ -2454,19 +2641,13 @@ pub fn encode_metadata(tcx: TyCtxt<'_>, path: &Path, ref_path: Option<&Path>) { tcx.dep_graph.assert_ignored(); // Generate the metadata stub manually, as that is a small file compared to full metadata. - if let Some(ref_path) = ref_path { - let _prof_timer = tcx.prof.verbose_generic_activity("generate_crate_metadata_stub"); - - with_encode_metadata_header(tcx, ref_path, |ecx| { - let header: LazyValue = ecx.lazy(CrateHeader { - name: tcx.crate_name(LOCAL_CRATE), - triple: tcx.sess.opts.target_triple.clone(), - hash: tcx.crate_hash(LOCAL_CRATE), - is_proc_macro_crate: false, - is_stub: true, - }); - header.position.get() - }) + // + // With `-Zrmeta-content-svh` the SVH is only known once the full metadata + // has been encoded, so the stub (which must carry the same SVH) is + // generated after the full metadata instead, at the end of this function. + let rmeta_content_svh = tcx.sess.opts.unstable_opts.rmeta_content_svh; + if !rmeta_content_svh && let Some(ref_path) = ref_path { + encode_stub(tcx, ref_path, tcx.crate_hash(LOCAL_CRATE)); } let _prof_timer = tcx.prof.verbose_generic_activity("generate_crate_metadata"); @@ -2505,7 +2686,7 @@ pub fn encode_metadata(tcx: TyCtxt<'_>, path: &Path, ref_path: Option<&Path>) { // Perform metadata encoding inside a task, so the dep-graph can check if any encoded // information changes, and maybe reuse the work product. - tcx.dep_graph.with_task( + let (content_svh, _) = tcx.dep_graph.with_task( dep_node, tcx, || { @@ -2523,18 +2704,44 @@ pub fn encode_metadata(tcx: TyCtxt<'_>, path: &Path, ref_path: Option<&Path>) { ecx.opaque.file().metadata().unwrap().len(), ); - root.position.get() + (root.position.get(), ecx.content_svh) }) }, None, ); + + // See above: with `-Zrmeta-content-svh` the stub is generated after the + // full metadata so it can embed the content-derived SVH. The incremental + // green-reuse path above cannot be taken here because the flag is + // rejected in combination with `-Cincremental` at session setup. + if rmeta_content_svh && let Some(ref_path) = ref_path { + let content_svh = + content_svh.expect("-Zrmeta-content-svh: no SVH computed during metadata encoding"); + encode_stub(tcx, ref_path, content_svh); + } } -fn with_encode_metadata_header( +/// Writes a metadata stub (a file containing only the crate header) to `path`. +fn encode_stub(tcx: TyCtxt<'_>, path: &Path, hash: Svh) { + let _prof_timer = tcx.prof.verbose_generic_activity("generate_crate_metadata_stub"); + + with_encode_metadata_header(tcx, path, |ecx| { + let header: LazyValue = ecx.lazy(CrateHeader { + name: tcx.crate_name(LOCAL_CRATE), + triple: tcx.sess.opts.target_triple.clone(), + hash, + is_proc_macro_crate: false, + is_stub: true, + }); + (header.position.get(), ()) + }) +} + +fn with_encode_metadata_header( tcx: TyCtxt<'_>, path: &Path, - f: impl FnOnce(&mut EncodeContext<'_, '_>) -> usize, -) { + f: impl FnOnce(&mut EncodeContext<'_, '_>) -> (usize, R), +) -> R { let mut encoder = opaque::FileEncoder::new(path) .unwrap_or_else(|err| tcx.dcx().emit_fatal(FailCreateFileEncoder { err })); encoder.emit_raw_bytes(METADATA_HEADER); @@ -2564,12 +2771,20 @@ fn with_encode_metadata_header( is_proc_macro: tcx.crate_types().contains(&CrateType::ProcMacro), hygiene_ctxt: &hygiene_ctxt, symbol_index_table: Default::default(), + // See the field docs for why proc-macro crates are exempt. + strip_spans: if tcx.crate_types().contains(&CrateType::ProcMacro) { + RmetaStripSpans::None + } else { + tcx.sess.opts.unstable_opts.rmeta_strip_spans + }, + span_stripping_paused: false, + content_svh: None, }; // Encode the rustc version string in a predictable location. rustc_version(tcx.sess.cfg_version).encode(&mut ecx); - let root_position = f(&mut ecx); + let (root_position, res) = f(&mut ecx); // Make sure we report any errors from writing to the file. // If we forget this, compilation can succeed with an incomplete rmeta file, @@ -2582,6 +2797,8 @@ fn with_encode_metadata_header( if let Err(err) = encode_root_position(file, root_position) { tcx.dcx().emit_fatal(FailWriteFile { path: ecx.opaque.path(), err }); } + + res } fn encode_root_position(mut file: &File, pos: usize) -> Result<(), std::io::Error> { diff --git a/compiler/rustc_session/src/config.rs b/compiler/rustc_session/src/config.rs index d73601aea318..5246a38cd2b1 100644 --- a/compiler/rustc_session/src/config.rs +++ b/compiler/rustc_session/src/config.rs @@ -637,6 +637,55 @@ impl FromStr for SplitDwarfKind { } } +/// The value of `-Zrmeta-strip-spans`, controlling which spans are replaced by +/// `DUMMY_SP` when encoding crate metadata. +/// +/// Spans in crate metadata are encoded as byte offsets into this crate's +/// source files, so any edit that shifts source positions (adding a comment, a +/// blank line, ...) changes the encoded bytes of the `.rmeta` even when the +/// crate's interface is unchanged. Build systems that content-address the +/// `.rmeta` (to skip rebuilding dependents when it is unchanged) can opt into +/// stripping those spans, trading diagnostic and debuginfo quality in +/// *dependent* crates for byte-stability of the metadata. See the variants for +/// exactly what each level gives up. Has no effect on proc-macro crates, whose +/// metadata is dominated by span and hygiene data that consumers rely on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RmetaStripSpans { + /// Encode all spans faithfully (default). + None, + /// Strip spans everywhere except in the parts of metadata that dependent + /// crates compile into their own output: encoded MIR bodies (used for + /// cross-crate inlining, generic instantiation, and const evaluation) and + /// hygiene expansion data. What is given up: diagnostics reported in + /// dependent crates lose the ability to point into this crate's source for + /// item-level spans (e.g. "function defined here" notes fall back to + /// dummy spans). Debuginfo for inlined/generic code is unaffected. + NonExported, + /// Additionally strip spans inside encoded MIR bodies and hygiene + /// expansion data, and replace expansion hashes with span-independent + /// ones. What is given up on top of `NonExported`: debuginfo line + /// information in dependent crates for code inlined or instantiated from + /// this crate, const-eval error backtraces pointing into this crate, and + /// macro-backtrace notes for this crate's macros. In exchange, the + /// metadata bytes no longer depend on source positions at all, so + /// non-interface edits (comments, whitespace, private non-inlined + /// function bodies) leave the `.rmeta` byte-identical. + All, +} + +impl FromStr for RmetaStripSpans { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(match s { + "none" => RmetaStripSpans::None, + "non-exported" => RmetaStripSpans::NonExported, + "all" => RmetaStripSpans::All, + _ => return Err(()), + }) + } +} + macro_rules! define_output_types { ( $( @@ -2736,6 +2785,16 @@ pub fn build_session_options(early_dcx: &mut EarlyDiagCtxt, matches: &getopts::M let incremental = cg.incremental.as_ref().map(PathBuf::from); + // `-Zrmeta-content-svh` derives the metadata SVH from the freshly encoded + // metadata bytes. With incremental compilation the metadata work product + // can be reused from a previous session without re-encoding, in which case + // there are no fresh bytes to hash (and the incremental system separately + // uses the HIR-based SVH to name session directories). Rather than + // silently mixing the two schemes, reject the combination. + if unstable_opts.rmeta_content_svh && incremental.is_some() { + early_dcx.early_fatal("option `-Z rmeta-content-svh` cannot be used with `-C incremental`"); + } + if cg.profile_generate.enabled() && cg.profile_use.is_some() { early_dcx.early_fatal("options `-C profile-generate` and `-C profile-use` are exclusive"); } @@ -3316,8 +3375,8 @@ pub(crate) mod dep_tracking { InstrumentMcountOpts, InstrumentXRay, LinkerPluginLto, LocationDetail, LtoCli, MirStripDebugInfo, NextSolverConfig, Offload, OptLevel, OutFileName, OutputType, OutputTypes, PatchableFunctionEntry, PointerAuthOption, Polonius, ResolveDocLinks, - SourceFileHashAlgorithm, SplitDwarfKind, SwitchWithOptPath, SymbolManglingVersion, - WasiExecModel, + RmetaStripSpans, SourceFileHashAlgorithm, SplitDwarfKind, SwitchWithOptPath, + SymbolManglingVersion, WasiExecModel, }; use crate::lint; use crate::utils::NativeLib; @@ -3402,6 +3461,7 @@ pub(crate) mod dep_tracking { Edition, LinkerPluginLto, ResolveDocLinks, + RmetaStripSpans, SplitDebuginfo, SplitDwarfKind, StackProtector, diff --git a/compiler/rustc_session/src/options.rs b/compiler/rustc_session/src/options.rs index 54288346f61b..1053beb0fb48 100644 --- a/compiler/rustc_session/src/options.rs +++ b/compiler/rustc_session/src/options.rs @@ -887,6 +887,7 @@ mod desc { "one of supported split-debuginfo modes (`off`, `packed`, or `unpacked`)"; pub(crate) const parse_split_dwarf_kind: &str = "one of supported split dwarf modes (`split` or `single`)"; + pub(crate) const parse_rmeta_strip_spans: &str = "one of `none`, `non-exported` or `all`"; pub(crate) const parse_link_self_contained: &str = "one of: `y`, `yes`, `on`, `n`, `no`, `off`, or a list of enabled (`+` prefix) and disabled (`-` prefix) \ components: `crto`, `libc`, `unwind`, `linker`, `sanitizers`, `mingw`"; pub(crate) const parse_linker_features: &str = @@ -1997,6 +1998,14 @@ pub mod parse { true } + pub(crate) fn parse_rmeta_strip_spans(slot: &mut RmetaStripSpans, v: Option<&str>) -> bool { + match v.and_then(|s| RmetaStripSpans::from_str(s).ok()) { + Some(e) => *slot = e, + _ => return false, + } + true + } + pub(crate) fn parse_stack_protector(slot: &mut StackProtector, v: Option<&str>) -> bool { match v.and_then(|s| StackProtector::from_str(s).ok()) { Some(ssp) => *slot = ssp, @@ -2792,6 +2801,23 @@ written to standard error output)"), "do not skip rigid aliases in normalization for internal debugging"), retpoline: bool = (false, parse_bool, [TRACKED] { TARGET_MODIFIER: Retpoline }, "enables retpoline-indirect-branches and retpoline-indirect-calls target features (default: no)"), + rmeta_content_svh: bool = (false, parse_bool, [TRACKED], + "derive the SVH stored in crate metadata from the encoded metadata bytes themselves \ + instead of from the HIR, so that it is stable exactly when the rest of the metadata is; \ + incompatible with incremental compilation (default: no)"), + rmeta_normalize_src_hash: bool = (false, parse_bool, [TRACKED], + "replace per-file source content hashes in crate metadata with zeroes, so that the \ + metadata does not vary with source file contents that no encoded item depends on; \ + dependent crates lose cross-crate diagnostic source snippets and debuginfo file \ + checksums for this crate (default: no)"), + rmeta_strip_spans: RmetaStripSpans = (RmetaStripSpans::None, parse_rmeta_strip_spans, [TRACKED], + "replace spans with dummy spans when encoding crate metadata, so that the encoded bytes \ + do not depend on source positions; degrades diagnostics and (with `all`) debuginfo \ + reported in dependent crates against this crate + + `none`: encode all spans faithfully (default) + `non-exported`: strip spans except in encoded MIR bodies and hygiene expansion data + `all`: strip every span, including exported MIR and hygiene expansion data"), retpoline_external_thunk: bool = (false, parse_bool, [TRACKED] { TARGET_MODIFIER: RetpolineExternalThunk }, "enables retpoline-external-thunk, retpoline-indirect-branches and retpoline-indirect-calls \ target features (default: no)"), diff --git a/compiler/rustc_span/src/hygiene.rs b/compiler/rustc_span/src/hygiene.rs index d57f21fc4222..823750e52c0a 100644 --- a/compiler/rustc_span/src/hygiene.rs +++ b/compiler/rustc_span/src/hygiene.rs @@ -158,6 +158,20 @@ impl ExpnHash { fn new(stable_crate_id: StableCrateId, local_hash: Hash64) -> ExpnHash { ExpnHash(Fingerprint::new(stable_crate_id.0, local_hash)) } + + /// Builds an [ExpnHash] from its parts, bypassing the usual computation + /// over the expansion's [ExpnData]. + /// + /// This is only meant for `-Zrmeta-strip-spans=all` metadata encoding, + /// which replaces the real expansion hashes (whose inputs include source + /// positions) with hashes derived from the crate-local expansion index, so + /// that the encoded metadata does not vary with source positions. + /// `local_hash` must be unique within the crate identified by + /// `stable_crate_id`, and must be non-zero for non-root expansions so the + /// result cannot be confused with the root [ExpnHash]. + pub fn from_parts(stable_crate_id: StableCrateId, local_hash: Hash64) -> ExpnHash { + ExpnHash::new(stable_crate_id, local_hash) + } } /// A property of a macro expansion that determines how identifiers diff --git a/compiler/rustc_span/src/lib.rs b/compiler/rustc_span/src/lib.rs index b8cf472309ba..e8eb91181ae6 100644 --- a/compiler/rustc_span/src/lib.rs +++ b/compiler/rustc_span/src/lib.rs @@ -1761,6 +1761,18 @@ impl Display for SourceFileHash { } impl SourceFileHash { + /// Returns a `SourceFileHash` of the given `kind` with an all-zero value. + /// + /// This is only meant for `-Zrmeta-normalize-src-hash`, which replaces + /// the per-file content hashes recorded in crate metadata so that they do + /// not vary with file contents. An all-zero value does not match the hash + /// of any actual source file (up to hash collisions), so consumers that + /// verify on-disk source against the recorded hash treat the source as + /// unavailable rather than silently using stale source. + pub fn zeroed(kind: SourceFileHashAlgorithm) -> SourceFileHash { + SourceFileHash { kind, value: [0; 32] } + } + pub fn new_in_memory(kind: SourceFileHashAlgorithm, src: impl AsRef<[u8]>) -> SourceFileHash { let mut hash = SourceFileHash { kind, value: Default::default() }; let len = hash.hash_len(); diff --git a/src/doc/unstable-book/src/compiler-flags/rmeta-content-svh.md b/src/doc/unstable-book/src/compiler-flags/rmeta-content-svh.md new file mode 100644 index 000000000000..6e917b86d89e --- /dev/null +++ b/src/doc/unstable-book/src/compiler-flags/rmeta-content-svh.md @@ -0,0 +1,23 @@ +# `rmeta-content-svh` + +--- + +This flag derives the strict version hash (SVH) stored in crate metadata from +the encoded metadata bytes themselves, instead of from the crate's HIR. + +The default HIR-based SVH covers essentially the whole crate, including every +function body and (indirectly) source positions, so any edit to the crate +changes the SVH and with it the metadata, even when nothing a dependent crate +consumes has changed. With this flag, the SVH is a hash of everything else in +the metadata file (plus the session's dependency-tracking hash and the stable +crate id), so it is stable exactly when the rest of the metadata is. Combined +with `-Zrmeta-strip-spans` and `-Zrmeta-normalize-src-hash`, this makes the +`.rmeta` byte-identical across rebuilds after non-interface edits, which lets +content-addressed build systems skip rebuilding dependents. + +The link-time consistency check is unaffected: the `.rmeta` and `.rlib` +produced by one compiler invocation embed the same metadata and therefore the +same SVH, and the crate loader continues to compare SVHs for equality. + +This flag is rejected in combination with `-Cincremental`, and it does not +change the `crate_hash` query used within the compiling session. diff --git a/src/doc/unstable-book/src/compiler-flags/rmeta-normalize-src-hash.md b/src/doc/unstable-book/src/compiler-flags/rmeta-normalize-src-hash.md new file mode 100644 index 000000000000..ae0594825b58 --- /dev/null +++ b/src/doc/unstable-book/src/compiler-flags/rmeta-normalize-src-hash.md @@ -0,0 +1,26 @@ +# `rmeta-normalize-src-hash` + +--- + +This flag replaces the per-file source content hashes recorded in crate +metadata with zeroes. The recorded hash covers a source file's entire raw +text, so by default any edit to a file referenced from metadata, even a +comment edit that moves no token, changes the crate's metadata bytes. + +Costs, per consumer of the recorded hash: + +* Cross-crate diagnostic snippets: a dependent crate verifies this crate's + on-disk source against the recorded hash before quoting it in diagnostics. + The zero hash never matches, so such diagnostics degrade to plain + `file:line:col` references without a quoted snippet (rather than risking + quoting stale source). +* Debuginfo: file checksums recorded for this crate's files in dependent + crates' debug info become zero, so debuggers cannot detect source + staleness for them. + +On its own, this flag stabilizes the metadata against comment edits that +preserve byte length and line positions. For general comment, whitespace, +and private function body edits, combine it with `-Zrmeta-strip-spans=all` +and `-Zrmeta-content-svh`. + +The flag has no effect on proc-macro crates. diff --git a/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md b/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md new file mode 100644 index 000000000000..13da0427250a --- /dev/null +++ b/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md @@ -0,0 +1,38 @@ +# `rmeta-strip-spans` + +--- + +This flag replaces spans with dummy spans when encoding crate metadata, so +that the encoded bytes do not depend on source positions. Spans in metadata +are byte offsets into this crate's source files, so by default any edit that +shifts source text (adding a comment, a blank line, editing a function body) +changes the metadata of the crate even when its interface is unchanged. + +* `-Zrmeta-strip-spans=none` (default): encode all spans faithfully. +* `-Zrmeta-strip-spans=non-exported`: strip spans except in the metadata that + dependent crates compile into their own output: encoded MIR bodies (used + for cross-crate inlining, generic instantiation, and const evaluation) and + hygiene expansion data. Cost: diagnostics reported in dependent crates can + no longer point into this crate's source for item-level spans ("function + defined here" style notes degrade to dummy spans). Debuginfo for + inlined/generic code is unaffected. +* `-Zrmeta-strip-spans=all`: additionally strip spans inside exported MIR and + hygiene data, and replace expansion hashes with span-independent ones. + Additional cost: debuginfo line information in dependent crates for code + inlined or instantiated from this crate, const-eval error backtraces + pointing into this crate, and macro-backtrace notes for this crate's + macros. Concretely, the declaration debuginfo a dependent crate emits for + a function inlined from this crate carries the dummy span, which resolves + to the dependent's own first source file at line 1 instead of this + crate's source; call-site line information in the dependent is + unaffected. In exchange the metadata does not depend on source positions at + all: with `-Zrmeta-content-svh` and `-Zrmeta-normalize-src-hash` the + `.rmeta` is byte-identical after comment, whitespace, and private + non-inlinable function body edits. + +Interface-relevant changes (signatures, visibility, adding or removing items, +and bodies of generic or inlinable functions, whose MIR is exported) still +change the metadata under every mode. + +The flag has no effect on proc-macro crates: their metadata consists largely +of span and hygiene data that dependent crates rely on for expansion. diff --git a/tests/run-make/rmeta-stability/rmake.rs b/tests/run-make/rmeta-stability/rmake.rs new file mode 100644 index 000000000000..5194ebcecb5e --- /dev/null +++ b/tests/run-make/rmeta-stability/rmake.rs @@ -0,0 +1,137 @@ +// Test that `-Zrmeta-content-svh`, `-Zrmeta-strip-spans=all` and +// `-Zrmeta-normalize-src-hash` together make a library crate's `.rmeta` +// byte-identical across non-interface edits: +// +// 1. an identical rebuild (this must also hold with the flags off), +// 2. a comment-only edit, +// 3. a whitespace edit that shifts every subsequent line, +// 4. a private non-generic non-inlinable function body edit, +// +// while interface-relevant edits must still change the metadata: +// +// 5. a generic function body edit (its MIR is encoded for downstream +// instantiation), +// 6. a signature change. +// +// `-Zrmeta-normalize-src-hash` alone is also checked to stabilize the +// weakest edit class: a comment edit that preserves byte length and line +// positions, which perturbs nothing but the recorded source file hash. + +//@ ignore-cross-compile + +use run_make_support::{rfs, rust_lib_name, rustc}; + +// The private function carries `#[inline(never)]` so that it is not +// auto-selected for cross-crate inlining at -Copt-level=3; a small private +// function without it would have its (legitimately body-dependent) MIR +// encoded into the metadata. +const BASE: &str = r#"//! Demo crate for rmeta stability. + +// helper does the arithmetic heavy lifting + +pub struct Config { + pub retries: u32, +} + +pub fn make_config() -> Config { + Config { retries: 3 } +} + +pub fn double(x: u64) -> u64 { + helper(x) * 2 +} + +#[inline(never)] +fn helper(x: u64) -> u64 { + x + 1 +} + +pub fn generic_max(a: T, b: T) -> T { + if a > b { a } else { b } +} + +#[inline] +pub fn inlined_add(a: u32, b: u32) -> u32 { + a + b +} +"#; + +const STABILITY_FLAGS: &[&str] = + &["-Zrmeta-content-svh", "-Zrmeta-strip-spans=all", "-Zrmeta-normalize-src-hash"]; + +fn compile(source: &str, flags: &[&str]) -> Vec { + rfs::write("lib.rs", source); + rustc() + .input("lib.rs") + .crate_name("demo") + .crate_type("lib") + .edition("2024") + .opt_level("3") + .emit("metadata,link") + .args(flags) + .run(); + rfs::read(rust_lib_name("demo").replace(".rlib", ".rmeta")) +} + +fn main() { + // Edit classes 2 to 4: must be byte-identical with the stability flags. + let comment_edit = BASE.replace("x + 1", "x + 1 // tweaked comment"); + let whitespace_edit = BASE.replacen( + "//! Demo crate for rmeta stability.\n", + "//! Demo crate for rmeta stability.\n\n", + 1, + ); + let private_body_edit = BASE.replace("x + 1", "x + 2"); + // Controls 5 and 6: must still differ with the stability flags. + let generic_body_edit = BASE.replace("if a > b", "if a >= b"); + let signature_edit = + BASE.replace("pub fn double(x: u64)", "pub fn double(x: u64, _unused: u8)"); + // Class 2c: same byte length, same line structure, different comment text. + let comment_edit_same_len = + BASE.replace("arithmetic heavy lifting", "arithmetic heavy WORKING"); + assert_eq!(BASE.len(), comment_edit_same_len.len()); + + // Class 1: identical rebuilds must be byte-identical even without flags. + let base_no_flags = compile(BASE, &[]); + let rebuild_no_flags = compile(BASE, &[]); + assert!(base_no_flags == rebuild_no_flags, "identical rebuild changed the rmeta (flags off)"); + + let base = compile(BASE, STABILITY_FLAGS); + let rebuild = compile(BASE, STABILITY_FLAGS); + assert!(base == rebuild, "identical rebuild changed the rmeta (flags on)"); + + let comment = compile(&comment_edit, STABILITY_FLAGS); + assert!(base == comment, "comment-only edit changed the rmeta despite stability flags"); + + let whitespace = compile(&whitespace_edit, STABILITY_FLAGS); + assert!(base == whitespace, "whitespace-only edit changed the rmeta despite stability flags"); + + let private_body = compile(&private_body_edit, STABILITY_FLAGS); + assert!( + base == private_body, + "private non-inlinable body edit changed the rmeta despite stability flags" + ); + + // The controls must keep churning: a stability scheme that hides interface + // changes would let dependents link against incompatible artifacts. + let generic_body = compile(&generic_body_edit, STABILITY_FLAGS); + assert!( + base != generic_body, + "generic function body edit did NOT change the rmeta; exported MIR must stay visible" + ); + + let signature = compile(&signature_edit, STABILITY_FLAGS); + assert!( + base != signature, + "signature change did NOT change the rmeta; interface changes must stay visible" + ); + + // `-Zrmeta-normalize-src-hash` alone must stabilize a length-and-line + // preserving comment edit (the only churn there is the source file hash). + let base_src_hash_only = compile(BASE, &["-Zrmeta-normalize-src-hash"]); + let comment_2c = compile(&comment_edit_same_len, &["-Zrmeta-normalize-src-hash"]); + assert!( + base_src_hash_only == comment_2c, + "length-preserving comment edit changed the rmeta despite -Zrmeta-normalize-src-hash" + ); +} From 19bc52996e86ce185df9b44ee34315c7b853e099 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:10:01 +0000 Subject: [PATCH 2/2] rmeta-strip-spans=non-exported: preserve def spans of MIR-exported items DWARF inspection of a dependent crate falsified the mode's original debuginfo claim: a dependent deriving debuginfo for a cross-crate inlined function takes its declaration file/line from the item's definition span, and binds the inlined instructions' line rows to that file. With definition spans stripped but body spans kept, the dependent emitted line rows carrying real line numbers bound to its own first source file, i.e. confidently wrong debuginfo, arguably worse than the honest no-line-info of =all. Preserve def_span and def_ident_span for exactly the defs whose MIR is exported (the should_encode_mir set). Re-verification shows the consumer's DW_AT_decl_file/decl_line and .debug_line rows for the inlined function now match a no-flags baseline exactly, with =all unchanged. Also document the measured stability boundary of this mode honestly: since preserved spans are byte offsets resolved through the encoded source file length and line tables, only byte-position-preserving edits keep the rmeta identical under =non-exported; any length change, even a trailing comment, perturbs the source file record (source_len and line table, verified by byte diff). Full stability under general non-interface edits remains the province of =all, which the extended run-make test now also pins for trailing comments, and the non-exported boundary (same-length edits stable, length-changing edits churn) is pinned as well. Co-Authored-By: Claude Fable 5 --- compiler/rustc_metadata/src/rmeta/encoder.rs | 58 ++++++++++++++++++- compiler/rustc_session/src/config.rs | 24 ++++++-- .../src/compiler-flags/rmeta-strip-spans.md | 24 ++++++-- tests/run-make/rmeta-stability/rmake.rs | 27 +++++++++ 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/compiler/rustc_metadata/src/rmeta/encoder.rs b/compiler/rustc_metadata/src/rmeta/encoder.rs index 09e2eadc1d9e..7b45eabbece9 100644 --- a/compiler/rustc_metadata/src/rmeta/encoder.rs +++ b/compiler/rustc_metadata/src/rmeta/encoder.rs @@ -89,6 +89,11 @@ pub(super) struct EncodeContext<'a, 'tcx> { // metadata stub. `None` when the flag is off or the root has not been // encoded yet. content_svh: Option, + // In `RmetaStripSpans::NonExported` mode, the local defs whose MIR is + // exported (the `should_encode_mir` set); their item-level spans are + // preserved together with their body spans. Empty in other modes. + // Populated by `encode_def_ids`. + mir_exported_defs: LocalDefIdSet, } /// If the current crate is a proc-macro, returns early with `LazyArray::default()`. @@ -477,6 +482,15 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { res } + /// Whether `def_id`'s item-level spans (`def_span`, `def_ident_span`) + /// must be preserved in `RmetaStripSpans::NonExported` mode because the + /// item's MIR is exported. See the comment at the use site in + /// `encode_def_ids`. + fn preserve_item_spans(&self, def_id: LocalDefId) -> bool { + self.strip_spans == RmetaStripSpans::NonExported + && self.mir_exported_defs.contains(&def_id) + } + fn emit_lazy_distance(&mut self, position: NonZero) { let pos = position.get(); let distance = match self.lazy_state { @@ -1575,6 +1589,23 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { let tcx = self.tcx; + if self.strip_spans == RmetaStripSpans::NonExported { + // Compute the set of defs whose MIR will be exported (the same + // filter `encode_mir` uses), so their item-level spans can be + // preserved below. + let reachable_set = tcx.reachable_set(()); + self.mir_exported_defs = tcx + .mir_keys(()) + .iter() + .copied() + .filter(|&def_id| { + let (encode_const, encode_opt) = + should_encode_mir(tcx, reachable_set, def_id); + encode_const || encode_opt + }) + .collect(); + } + for local_id in tcx.iter_local_def_id() { let def_id = local_id.to_def_id(); let def_kind = tcx.def_kind(local_id); @@ -1598,9 +1629,25 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { record!(self.tables.default_fields[def_id] <- anon.def_id.to_def_id()); } + // In `RmetaStripSpans::NonExported` mode, the definition spans of + // items whose MIR is exported must survive alongside the MIR body + // spans: a dependent crate that inlines such an item derives the + // function's debuginfo declaration file/line from its `def_span`, + // and binds the inlined instructions' line rows to that file. With + // the definition span stripped but body spans kept, the dependent + // would emit line rows carrying this crate's real line numbers + // bound to one of its own files, i.e. confidently wrong debuginfo + // (empirically verified via DWARF inspection). + let preserve_item_spans = self.preserve_item_spans(local_id); if should_encode_span(def_kind) { let def_span = tcx.def_span(local_id); - record!(self.tables.def_span[def_id] <- def_span); + if preserve_item_spans { + self.with_span_stripping_paused( + |this| record!(this.tables.def_span[def_id] <- def_span), + ); + } else { + record!(self.tables.def_span[def_id] <- def_span); + } } if should_encode_attrs(def_kind) { self.encode_attrs(local_id); @@ -1611,7 +1658,13 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> { if should_encode_span(def_kind) && let Some(ident_span) = tcx.def_ident_span(def_id) { - record!(self.tables.def_ident_span[def_id] <- ident_span); + if preserve_item_spans { + self.with_span_stripping_paused( + |this| record!(this.tables.def_ident_span[def_id] <- ident_span), + ); + } else { + record!(self.tables.def_ident_span[def_id] <- ident_span); + } } if def_kind.has_codegen_attrs() { record!(self.tables.codegen_fn_attrs[def_id] <- self.tcx.codegen_fn_attrs(def_id)); @@ -2779,6 +2832,7 @@ fn with_encode_metadata_header( }, span_stripping_paused: false, content_svh: None, + mir_exported_defs: LocalDefIdSet::default(), }; // Encode the rustc version string in a predictable location. diff --git a/compiler/rustc_session/src/config.rs b/compiler/rustc_session/src/config.rs index 5246a38cd2b1..bccb43d98be8 100644 --- a/compiler/rustc_session/src/config.rs +++ b/compiler/rustc_session/src/config.rs @@ -655,11 +655,25 @@ pub enum RmetaStripSpans { None, /// Strip spans everywhere except in the parts of metadata that dependent /// crates compile into their own output: encoded MIR bodies (used for - /// cross-crate inlining, generic instantiation, and const evaluation) and - /// hygiene expansion data. What is given up: diagnostics reported in - /// dependent crates lose the ability to point into this crate's source for - /// item-level spans (e.g. "function defined here" notes fall back to - /// dummy spans). Debuginfo for inlined/generic code is unaffected. + /// cross-crate inlining, generic instantiation, and const evaluation), + /// hygiene expansion data, and the definition spans of items whose MIR is + /// exported (a dependent deriving debuginfo for an inlined function takes + /// its declaration file/line from the definition span; stripping it while + /// keeping body spans was verified to produce line rows bound to the + /// wrong file). What is given up: diagnostics reported in dependent + /// crates lose the ability to point into this crate's source for + /// item-level spans of items without exported MIR (e.g. "function defined + /// here" notes fall back to dummy spans). + /// + /// Stability boundary (measured, deliberate): because the preserved spans + /// are byte offsets and the referenced source files' length and line + /// tables must stay real for those spans to resolve correctly in + /// dependents, only edits that preserve byte positions (same-length + /// comment rewrites, same-length body edits) leave the metadata + /// byte-identical in this mode. Any length-changing edit, even a comment + /// appended at end of file, perturbs the source file record and thus the + /// metadata. Use `All` when byte-stability under general non-interface + /// edits is the goal. NonExported, /// Additionally strip spans inside encoded MIR bodies and hygiene /// expansion data, and replace expansion hashes with span-independent diff --git a/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md b/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md index 13da0427250a..9e1c0f273544 100644 --- a/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md +++ b/src/doc/unstable-book/src/compiler-flags/rmeta-strip-spans.md @@ -11,11 +11,25 @@ changes the metadata of the crate even when its interface is unchanged. * `-Zrmeta-strip-spans=none` (default): encode all spans faithfully. * `-Zrmeta-strip-spans=non-exported`: strip spans except in the metadata that dependent crates compile into their own output: encoded MIR bodies (used - for cross-crate inlining, generic instantiation, and const evaluation) and - hygiene expansion data. Cost: diagnostics reported in dependent crates can - no longer point into this crate's source for item-level spans ("function - defined here" style notes degrade to dummy spans). Debuginfo for - inlined/generic code is unaffected. + for cross-crate inlining, generic instantiation, and const evaluation), + hygiene expansion data, and the definition spans of items whose MIR is + exported. The definition spans must accompany the body spans: a dependent + crate derives an inlined function's debuginfo declaration file/line from + the definition span and binds the inlined line rows to that file, so + stripping it while keeping body spans yields line rows bound to the wrong + file. Cost: diagnostics reported in dependent crates can no longer point + into this crate's source for item-level spans of items without exported + MIR ("function defined here" style notes degrade to dummy spans). + Stability boundary: because the preserved spans are byte offsets and the + referenced files' length and line tables must stay real for those spans + to resolve correctly in dependents, only byte-position-preserving edits + (same-length comment rewrites, same-length body edits of non-exported + functions) leave the metadata byte-identical in this mode; any + length-changing edit, even a comment appended at end of file, perturbs + the encoded source file record. This mode is for toolchains that want + the SVH and source-hash normalization plus reduced item-span surface + with zero debuginfo cost; use `all` when byte-stability under general + non-interface edits is the goal. * `-Zrmeta-strip-spans=all`: additionally strip spans inside exported MIR and hygiene data, and replace expansion hashes with span-independent ones. Additional cost: debuginfo line information in dependent crates for code diff --git a/tests/run-make/rmeta-stability/rmake.rs b/tests/run-make/rmeta-stability/rmake.rs index 5194ebcecb5e..bf50ba0f635f 100644 --- a/tests/run-make/rmeta-stability/rmake.rs +++ b/tests/run-make/rmeta-stability/rmake.rs @@ -126,6 +126,11 @@ fn main() { "signature change did NOT change the rmeta; interface changes must stay visible" ); + // A trailing comment (nothing after it changes) must also be stable. + let trailing_edit = format!("{BASE}// trailing note\n"); + let trailing = compile(&trailing_edit, STABILITY_FLAGS); + assert!(base == trailing, "trailing comment changed the rmeta despite stability flags"); + // `-Zrmeta-normalize-src-hash` alone must stabilize a length-and-line // preserving comment edit (the only churn there is the source file hash). let base_src_hash_only = compile(BASE, &["-Zrmeta-normalize-src-hash"]); @@ -134,4 +139,26 @@ fn main() { base_src_hash_only == comment_2c, "length-preserving comment edit changed the rmeta despite -Zrmeta-normalize-src-hash" ); + + // `-Zrmeta-strip-spans=non-exported` preserves exported-MIR spans and the + // source file shape data they resolve through, so its documented + // stability boundary is byte-position-preserving edits: the same-length + // comment rewrite and the same-length private body edit stay identical, + // while the length-changing comment edit legitimately differs. + const NON_EXPORTED_FLAGS: &[&str] = + &["-Zrmeta-content-svh", "-Zrmeta-strip-spans=non-exported", "-Zrmeta-normalize-src-hash"]; + let ne_base = compile(BASE, NON_EXPORTED_FLAGS); + let ne_2c = compile(&comment_edit_same_len, NON_EXPORTED_FLAGS); + assert!(ne_base == ne_2c, "same-length comment edit changed the rmeta under non-exported"); + let ne_priv = compile(&private_body_edit, NON_EXPORTED_FLAGS); + assert!( + ne_base == ne_priv, + "same-length private body edit changed the rmeta under non-exported" + ); + let ne_comment = compile(&comment_edit, NON_EXPORTED_FLAGS); + assert!( + ne_base != ne_comment, + "length-changing comment edit did NOT change the rmeta under non-exported; \ + preserved MIR spans should have shifted (documented stability boundary)" + ); }