Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
target/
rustc/__pycache__/
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,20 @@ Seven real bugs from rustc's history were then replayed by reverting their
fixes. mirth catches five; three of those needed a fixture addition or a
new step, made knowing the bug.

Pointed at the unmodified compiler with a wider fixture, it found two
incremental bugs that look new, where a rebuild after an edit encodes
different metadata from a clean build, and one known parallel-front-end
bug.
Pointed at the unmodified compiler, it found three incremental bugs that
look new, where a rebuild after an edit publishes different metadata from a
clean build, and one known parallel-front-end bug. Two came from a wider
fixture, the third from fuzzing edits and replaying ten crates' git histories.

- [`docs/report.md`](docs/report.md): the experiment, for readers new to it
- [`docs/results.md`](docs/results.md): each edit and the output that caught it
- [`docs/regressions.md`](docs/regressions.md): the replayed bugs, caught and missed
- [`docs/hunt.md`](docs/hunt.md): bugs found in the unmodified compiler
- [`docs/scale.md`](docs/scale.md): replaying crates' histories and fuzzing edits at scale
- [`docs/properties.md`](docs/properties.md): checkable properties surveyed from 1,000 rustc bugs
- [`docs/motivating.md`](docs/motivating.md): the real rustc bugs behind each property, each reproduced before and after its fix
- [`docs/ur-queries.md`](docs/ur-queries.md): the bugs' patterns, and closed bugs' patterns, as Ur queries over rustc's source
- [`docs/shadow-mode.md`](docs/shadow-mode.md): checking reuse inside rustc, and what exists today
- [`docs/plan.md`](docs/plan.md): the plan the work followed, with the properties

## An instrumented compiler
Expand All @@ -46,6 +51,9 @@ rustc/check.sh chain # check fixtures/chain
rustc/edits.sh chain # apply, check and revert each edit in rustc/edits
EDITS=regressions rustc/edits.sh chain # the same for the past bugs in rustc/regressions
rustc/hunt.sh wide # repeated threaded builds, and P6 for each of fixtures/wide/edits
rustc/fuzz.py --rustc <rustc> --fixture fixtures/sink --work <dir> # random edits, P6 on each
rustc/replay.py --rustc <rustc> --repo <git checkout> --work <dir> # a crate's history, P6 per commit
rustc/audit-options.py --rustc <rustc> --source <rust checkout> --crate fixtures/audit/lib.rs # untracked options
```

The build takes about an hour on 16 cores. `rustc/rmeta.toml` says what is
Expand Down
11 changes: 8 additions & 3 deletions docs/hunt.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks.
| 1 | incremental rebuilds encode `Generics::param_def_id_to_index` in a different order from clean builds | **looks new**; root cause found; fix and regression test written |
| 2 | incremental rebuilds encode a string literal twice where clean builds encode it once | **looks new**; root cause found, regression from #116707 (1.90); fix and regression test written |
| 3 | with `-Zthreads=8`, two traits with `-> impl Trait` methods give different metadata from run to run | known: [#162202](https://github.com/rust-lang/rust/issues/162202) |
| 4 | incremental rebuilds republish the previous session's metadata when an edit moves no span, so its source map describes old files | **looks new**; found later by the fuzzer and the history replay ([`scale.md`](scale.md)); root cause found, regression from #114669 (1.90); fix and regression test written |
| 5 | `-Zemit-stack-sizes`, `-Zcodegen-source-order` and `-Zbuild-sdylib-interface` are untracked but change output that incremental compilation reuses | **looks new**; found by a query written from a closed bug and an option audit ([`ur-queries.md`](ur-queries.md)); report drafted |

Findings 1 and 2 are single-threaded: an ordinary `cargo build`, an edit, another
`cargo build`, and the metadata differs from a clean build of the edited source. Both come
Expand All @@ -35,9 +37,12 @@ incremental cache unchanged. All three reproduce with the official `nightly-2026
without mirth: `docs/hunt/repro.sh` runs them. None was searched for: P5 and P6 reported
them on the first run of the new fixture.

Draft bug reports for 1 and 2, written to be filed upstream, are
[`hunt/issue-generics-order.md`](hunt/issue-generics-order.md) and
[`hunt/issue-literal-dedup.md`](hunt/issue-literal-dedup.md). Each has a candidate fix
Draft bug reports for 1, 2, 4 and 5, written to be filed upstream, are
[`hunt/issue-generics-order.md`](hunt/issue-generics-order.md),
[`hunt/issue-literal-dedup.md`](hunt/issue-literal-dedup.md) and
[`hunt/issue-stale-metadata-reuse.md`](hunt/issue-stale-metadata-reuse.md) and
[`hunt/issue-untracked-options.md`](hunt/issue-untracked-options.md), the last a comment for
rust-lang/rust#84232. Each has a candidate fix
(`hunt/*.patch`) and a regression test in the style of rustc's `tests/run-make`
(`hunt/tests/`), which fails on the pinned compiler and passes with the fix.

Expand Down
48 changes: 35 additions & 13 deletions docs/hunt/alloc-dedup-on-decode.patch
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
--- a/compiler/rustc_middle/src/mir/interpret/mod.rs
+++ b/compiler/rustc_middle/src/mir/interpret/mod.rs
@@ -214,7 +214,15 @@
trace!("creating memory alloc ID");
let alloc = <ConstAllocation<'tcx> as Decodable<_>>::decode(decoder);
@@ -99,6 +99,10 @@
VTable,
Static,
Type,
+ /// Memory that was deduplicated when it was created (string literals, for instance), and
+ /// must be deduplicated again when decoded: otherwise the same allocation decoded from the
+ /// incremental cache and created afresh would get two `AllocId`s.
+ DedupAlloc,
}

pub fn specialized_encode_alloc_id<'tcx, E: TyEncoder<'tcx>>(
@@ -109,7 +113,13 @@
match tcx.global_alloc(alloc_id) {
GlobalAlloc::Memory(alloc) => {
trace!("encoding {:?} with {:#?}", alloc_id, alloc);
- AllocDiscriminant::Alloc.encode(encoder);
+ let deduplicated = tcx.alloc_map.dedup.lock().get(&(GlobalAlloc::Memory(alloc), CTFE_ALLOC_SALT))
+ == Some(&alloc_id);
+ if deduplicated {
+ AllocDiscriminant::DedupAlloc.encode(encoder);
+ } else {
+ AllocDiscriminant::Alloc.encode(encoder);
+ }
alloc.encode(encoder);
}
GlobalAlloc::Function { instance } => {
@@ -216,6 +226,12 @@
trace!("decoded alloc {:?}", alloc);
- decoder.interner().reserve_and_set_memory_alloc(alloc)
+ // Immutable memory is deduplicated when it is created (string literals, for
+ // instance), so deduplicate it here too. Otherwise one allocation decoded
+ // from the incremental cache and the same one created afresh get two
+ // `AllocId`s, and metadata depends on which results came from the cache.
+ if alloc.inner().mutability.is_not() {
+ decoder.interner().reserve_and_set_memory_dedup(alloc, CTFE_ALLOC_SALT)
+ } else {
+ decoder.interner().reserve_and_set_memory_alloc(alloc)
+ }
decoder.interner().reserve_and_set_memory_alloc(alloc)
}
+ AllocDiscriminant::DedupAlloc => {
+ trace!("creating deduplicated memory alloc ID");
+ let alloc = <ConstAllocation<'tcx> as Decodable<_>>::decode(decoder);
+ trace!("decoded alloc {:?}", alloc);
+ decoder.interner().reserve_and_set_memory_dedup(alloc, CTFE_ALLOC_SALT)
+ }
AllocDiscriminant::Fn => {
trace!("creating fn alloc ID");
let instance = ty::Instance::decode(decoder);
9 changes: 9 additions & 0 deletions docs/hunt/generics-index-map.patch
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@

pub has_self: bool,
pub has_late_bound_regions: Option<Span>,
@@ -132,8 +132,6 @@

impl std::fmt::Debug for Generics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
- // ironically, we get this warning because of what we're trying to fix.
- #[expect(rustc::potential_query_instability)]
let mut stabilized_hashmap = self.param_def_id_to_index.iter().collect::<Vec<_>>();
stabilized_hashmap.sort_by_key(|(_, v)| **v);
f.debug_struct("Generics")
22 changes: 16 additions & 6 deletions docs/hunt/issue-generics-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,31 @@ every round trip; keys that don't collide are stable.
- It hides other incremental bugs from anyone comparing incremental and clean outputs. I
found it while doing that, and it masked a second issue (#…).

The instability was known in one place: `impl Debug for Generics` collects and sorts the map
before printing it, under `#[expect(rustc::potential_query_instability)]` and the comment
"ironically, we get this warning because of what we're trying to fix". The encoding was
not given the same treatment.

### Suggested fix

Keep the map's order deterministic across encoding and decoding. Making the field an
`FxIndexMap<DefId, u32>` does that: an `IndexMap` iterates in insertion order, and decoding
inserts in the encoded order, so a round trip is the identity. It is a two-line change
(`compiler/rustc_middle/src/ty/generics.rs`), and every construction site uses `.collect()`
inserts in the encoded order, so a round trip is the identity. It is a small change
(`compiler/rustc_middle/src/ty/generics.rs`; the `#[expect]` in the `Debug` impl then has
nothing to expect and goes too), and every construction site uses `.collect()`
and every use is `get` or indexing. Another option is not to encode the map at all and
rebuild it from `own_params` when decoding.

With the `FxIndexMap` change, the reproduction, the four crates above, and nine of ten edits
to a larger test fixture give identical metadata after an incremental rebuild. (The tenth is
the other issue, #….) With this change and the one suggested there applied together,
`tests/incremental` (180), the metadata-related UI
(532) and run-make (46) tests, `tests/ui/{consts,statics,const-generics}` (1844) and
`tests/codegen-llvm` (1122) still pass; the full test suite was not run. The `Generics`
the issue about string literals, #….)

With all three changes proposed in this series applied (this one and the two in #… and
#…), each attached regression test passes, and each fails when only its own change is
removed. These rustc tests still pass: `tests/incremental` (180), the UI tests in
`tests/ui/{deprecation,crate-loading,rmeta,extern,cross-crate}` (532), 46 metadata-related
`tests/run-make` tests, `tests/ui/{consts,statics,const-generics}` (1844) and
`tests/codegen-llvm` (1122). The full test suite was not run. A fuzzer making random edits to a 1,200-line test workspace ran 10,717 edits on the patched compiler, 8,936 of which built and were compared with a clean build, and a replay of ten crates' git histories compared 4,862 commits; neither found a difference. The `Generics`
struct is the only one I found that derives `TyEncodable`/`Encodable`, reaches metadata and
has a `HashMap` field; the others with such fields (`TypeckResults::used_trait_imports`,
`CrateInfo`, the on-disk cache footer) do not reach `.rmeta`.
Expand Down
60 changes: 38 additions & 22 deletions docs/hunt/issue-literal-dedup.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,32 +79,48 @@ two and the clean build one.

### Suggested fix

Decode immutable memory allocations the way they were created, deduplicated:
Decode an allocation the way it was created. When encoding a memory allocation, check
whether the deduplication map holds exactly this `AllocId` for it, under `CTFE_ALLOC_SALT`.
If it does, encode it with a new discriminant, and decode that one through
`reserve_and_set_memory_dedup` (attached, `alloc-dedup-on-decode.patch`):

```diff
AllocDiscriminant::Alloc => {
let alloc = <ConstAllocation<'tcx> as Decodable<_>>::decode(decoder);
- decoder.interner().reserve_and_set_memory_alloc(alloc)
+ if alloc.inner().mutability.is_not() {
+ decoder.interner().reserve_and_set_memory_dedup(alloc, CTFE_ALLOC_SALT)
+ } else {
+ decoder.interner().reserve_and_set_memory_alloc(alloc)
+ }
}
GlobalAlloc::Memory(alloc) => {
- AllocDiscriminant::Alloc.encode(encoder);
+ let deduplicated = tcx.alloc_map.dedup.lock().get(&(GlobalAlloc::Memory(alloc), CTFE_ALLOC_SALT))
+ == Some(&alloc_id);
+ if deduplicated {
+ AllocDiscriminant::DedupAlloc.encode(encoder);
+ } else {
+ AllocDiscriminant::Alloc.encode(encoder);
+ }
alloc.encode(encoder);
}
...
+ AllocDiscriminant::DedupAlloc => {
+ let alloc = <ConstAllocation<'tcx> as Decodable<_>>::decode(decoder);
+ decoder.interner().reserve_and_set_memory_dedup(alloc, CTFE_ALLOC_SALT)
+ }
```

With it, together with the fix for #… (the other issue), the reproduction and all ten
single-threaded incremental edits to a larger test fixture give identical metadata. These all
still pass: `tests/incremental` (180), `tests/ui/{consts,statics,const-generics}` (1844),
`tests/codegen-llvm` (1122), and the 532 UI and 46 run-make tests about metadata and crate
loading that I run. The full test suite was not run.

It does more than strictly needed: it would also merge an immutable allocation decoded from a
dependency's metadata with an identical local one, and two immutable allocations that were
distinct when created (results of different constants, say). As far as I know, neither has
a guaranteed unique address, but someone who knows the const-eval memory model should
confirm. A narrower fix records, when encoding, whether an allocation was created
through deduplication and with which salt, and repeats exactly that when decoding.
A simpler change, deduplicating every immutable allocation on decode, is wrong. I tried it
first, and it fixes this reproduction but breaks the same property elsewhere. It merges
allocations that a clean build keeps apart, because they were never deduplicated when
created. On serde at commit `2f58a20` ("Inline is_human_readable", 2017), an incremental
rebuild then encoded 59 bytes fewer of `interpret-alloc-index` than a clean build
(`-Zmeta-stats`). With the narrower change above, that commit and its neighbours match.

Two things for a reviewer. Only `CTFE_ALLOC_SALT` is handled: allocations deduplicated
under other salts (Miri's) are encoded as before, which only matters if those reach an
incremental cache. And the change adds a discriminant to the encoding of allocations in
metadata and in the incremental cache, so it may want a metadata version bump.

With all three changes proposed in this series applied (this one and the two in #… and
#…), each attached regression test passes, and each fails when only its own change is
removed. These rustc tests still pass: `tests/incremental` (180), the UI tests in
`tests/ui/{deprecation,crate-loading,rmeta,extern,cross-crate}` (532), 46 metadata-related
`tests/run-make` tests, `tests/ui/{consts,statics,const-generics}` (1844) and
`tests/codegen-llvm` (1122). The full test suite was not run. A fuzzer making random edits to a 1,200-line test workspace ran 10,717 edits on the patched compiler, 8,936 of which built and were compared with a clean build, and a replay of ten crates' git histories compared 4,862 commits; neither found a difference.

A regression test in the style of `tests/run-make`, which fails before the change and passes
after, is attached (`incr-metadata-literal-dedup/rmake.rs`).
Expand Down
Loading
Loading