From ce72c4f17d319b1f6ff7136014e6321a9e6f6bcf Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:06:03 +0000 Subject: [PATCH 01/14] Oracles: optimization, solver, Miri, equivalent-rewrite and ABI differentials; findings 19 and 20 (RISC-V/LoongArch ABI) rustc/opt-diff.py runs each runnable UI test under 13 configurations (opt levels, MIR opt levels, LTO, target CPU, Cranelift) and compares behavior with the unoptimized baseline. solver-diff.py compiles every UI test with the old and new trait solvers and with Polonius, and runs one-sided acceptances under Miri. miri-diff.py interprets runnable tests at MIR opt levels 0, 2 and 4 and compares with the compiled program. rewrite-diff.py applies mirth-rewrite's meaning-preserving rewrites (generic-wrap, alias, reorder, unused) and compares verdicts and error codes. abi-diff.py compares rustc's extern "C" lowering with clang's for random C signatures on 21 main targets. uitest.py holds the shared header reading, building, running, Miri and job driver. Findings 19 (stack-passed narrow integers not sign-extended) and 20 (float+pointer structs in FP registers) on riscv64 and loongarch64, from abi-diff.py. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- Cargo.lock | 22 +- Cargo.toml | 1 + crates/mirth-rewrite/Cargo.toml | 12 + crates/mirth-rewrite/src/main.rs | 263 +++++++++++++ docs/hunt.md | 2 + docs/hunt/riscv-float-pointer-struct.md | 72 ++++ docs/hunt/riscv-stack-arg-extension.md | 94 +++++ rustc/abi-diff.py | 468 ++++++++++++++++++++++++ rustc/miri-diff.py | 138 +++++++ rustc/opt-diff.py | 162 ++++++++ rustc/rewrite-diff.py | 139 +++++++ rustc/solver-diff.py | 125 +++++++ rustc/uitest.py | 161 ++++++++ 13 files changed, 1658 insertions(+), 1 deletion(-) create mode 100644 crates/mirth-rewrite/Cargo.toml create mode 100644 crates/mirth-rewrite/src/main.rs create mode 100644 docs/hunt/riscv-float-pointer-struct.md create mode 100644 docs/hunt/riscv-stack-arg-extension.md create mode 100644 rustc/abi-diff.py create mode 100644 rustc/miri-diff.py create mode 100644 rustc/opt-diff.py create mode 100644 rustc/rewrite-diff.py create mode 100644 rustc/solver-diff.py create mode 100644 rustc/uitest.py diff --git a/Cargo.lock b/Cargo.lock index 98e076b..e285699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,15 @@ dependencies = [ "regex", ] +[[package]] +name = "mirth-rewrite" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "mirth-runtime" version = "0.0.0" @@ -153,7 +162,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -165,6 +174,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" diff --git a/Cargo.toml b/Cargo.toml index 4e8513d..ca464a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/mirth-cli", "crates/mirth-runtime", "crates/mirth-watch", + "crates/mirth-rewrite", "examples/count-calls", ] exclude = ["fixtures"] diff --git a/crates/mirth-rewrite/Cargo.toml b/crates/mirth-rewrite/Cargo.toml new file mode 100644 index 0000000..1777f43 --- /dev/null +++ b/crates/mirth-rewrite/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mirth-rewrite" +description = "Meaning-preserving rewrites of a Rust file, for metamorphic testing of rustc" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +syn = { version = "2.0.119", features = ["full", "visit-mut", "extra-traits"] } +quote = "1" +proc-macro2 = "1" diff --git a/crates/mirth-rewrite/src/main.rs b/crates/mirth-rewrite/src/main.rs new file mode 100644 index 0000000..1192cb6 --- /dev/null +++ b/crates/mirth-rewrite/src/main.rs @@ -0,0 +1,263 @@ +//! Meaning-preserving rewrites of a Rust file, for metamorphic testing of rustc: a rewritten +//! program must get the same verdict (and the same error codes) as the original. +//! +//! mirth-rewrite +//! +//! prints the rewritten file to stdout; exits 2 when the file does not parse (as `syn` sees +//! Rust) and 3 when the rewrite does not apply to it. Comments are not kept (the tokens are +//! printed back), so line numbers change: compare verdicts and error codes, not spans. +//! +//! Rewrites: +//! +//! - `identity`: the file printed back unchanged: the baseline for the others, since printing +//! tokens back drops comments and moves every line. +//! - `generic-wrap`: the body of each free function with plain parameters moves into a generic +//! inner function, called with `()` for its unused type parameter. What the body does is the +//! same; it is now checked in a generic context and instantiated. +//! - `alias`: each struct, enum and union gets a type alias with the same generic parameters, +//! and every type mentioning it by its bare name mentions the alias instead. +//! - `reorder`: the top-level items in reverse order (item order does not matter in Rust; +//! files with `macro_rules!` or item-position macro calls are left out, where it does). +//! - `unused`: an unused function, struct and trait added at the end. + +use std::collections::{BTreeMap, BTreeSet}; +use std::process::exit; + +use proc_macro2::Span; +use quote::{ToTokens, format_ident, quote}; +use syn::visit_mut::VisitMut; +use syn::{FnArg, GenericParam, Ident, Item, ItemFn, Pat, TypePath}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() != 3 { + eprintln!("usage: mirth-rewrite "); + exit(64); + } + let text = std::fs::read_to_string(&args[2]).unwrap_or_else(|error| { + eprintln!("{}: {error}", args[2]); + exit(64) + }); + let Ok(mut file) = syn::parse_file(&text) else { exit(2) }; + let applied = match args[1].as_str() { + "generic-wrap" => generic_wrap(&mut file), + "alias" => alias(&mut file), + "reorder" => reorder(&mut file), + "unused" => unused(&mut file), + "identity" => true, + other => { + eprintln!("unknown rewrite {other}"); + exit(64) + } + }; + if !applied { + exit(3); + } + println!("{}", file.into_token_stream()); +} + +/// Moves the body of `fn f(a: A, b: B) -> R { body }` into +/// `fn __mirth_inner<__MirthT>(a: A, b: B) -> R { body }`, called as `__mirth_inner::<()>(a, b)`. +fn generic_wrap(file: &mut syn::File) -> bool { + let mut applied = false; + for item in &mut file.items { + if let Item::Fn(f) = item + && wrappable(f) + { + wrap(f); + applied = true; + } + } + applied +} + +fn wrappable(f: &ItemFn) -> bool { + let sig = &f.sig; + // Plain functions only: no generics (an inner fn cannot use the outer's parameters), no + // `impl Trait`, no qualifiers that change how the body runs, no `self`, no attributes that + // name the function (`#[test]`, `#[no_mangle]`, `#[track_caller]` would change meaning). + sig.generics.params.is_empty() + && sig.generics.where_clause.is_none() + && sig.constness.is_none() + && sig.asyncness.is_none() + && sig.unsafety.is_none() + && sig.abi.is_none() + && sig.variadic.is_none() + && f.attrs.iter().all(|a| a.path().is_ident("allow") || a.path().is_ident("inline")) + && !sig.to_token_stream().to_string().contains("impl ") + && !sig.to_token_stream().to_string().contains('\'') + && sig.inputs.iter().all(|arg| matches!(arg, FnArg::Typed(t) if matches!(&*t.pat, Pat::Ident(p) if p.by_ref.is_none() && p.subpat.is_none()))) +} + +fn wrap(f: &mut ItemFn) { + let sig = &f.sig; + let inputs = &sig.inputs; + let output = &sig.output; + let names: Vec<&Ident> = sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Typed(t) => match &*t.pat { + Pat::Ident(p) => &p.ident, + _ => unreachable!("checked in wrappable"), + }, + FnArg::Receiver(_) => unreachable!("checked in wrappable"), + }) + .collect(); + let body = &f.block; + let new: syn::Block = syn::parse_quote!({ + #[allow(non_camel_case_types, unused_mut)] + fn __mirth_inner<__MirthT>(#inputs) #output #body + __mirth_inner::<()>(#(#names),*) + }); + // Parameters declared `mut` are mutated in the body, now the inner function's. + for arg in f.sig.inputs.iter_mut() { + if let FnArg::Typed(t) = arg + && let Pat::Ident(p) = &mut *t.pat + { + p.mutability = None; + } + } + *f.block = new; +} + +/// `type __MirthAlias_S = S;` for each struct, enum and union `S`, and every +/// type that names `S` by its bare name names the alias instead. +fn alias(file: &mut syn::File) -> bool { + // Names also used for generic parameters somewhere: a bare path may mean the parameter. + struct Params(BTreeSet); + impl VisitMut for Params { + fn visit_generic_param_mut(&mut self, p: &mut GenericParam) { + match p { + GenericParam::Type(t) => self.0.insert(t.ident.to_string()), + GenericParam::Const(c) => self.0.insert(c.ident.to_string()), + GenericParam::Lifetime(_) => false, + }; + syn::visit_mut::visit_generic_param_mut(self, p); + } + } + let mut params_seen = Params(BTreeSet::new()); + params_seen.visit_file_mut(&mut file.clone()); + let mut aliases = BTreeMap::new(); + let mut new_items = Vec::new(); + for item in &file.items { + let (ident, generics) = match item { + Item::Struct(s) => (&s.ident, &s.generics), + Item::Enum(e) => (&e.ident, &e.generics), + Item::Union(u) => (&u.ident, &u.generics), + _ => continue, + }; + if params_seen.0.contains(&ident.to_string()) { + continue; + } + let alias = format_ident!("__MirthAlias_{}", ident); + // The alias's parameters: the type's, without bounds (aliases ignore them), with defaults. + let mut params = generics.clone(); + params.where_clause = None; + for p in params.params.iter_mut() { + match p { + GenericParam::Type(t) => { + // `?Sized` must stay: without it the alias requires `Sized`. + let maybe: Vec = t + .bounds + .iter() + .filter(|b| matches!(b, syn::TypeParamBound::Trait(tb) if matches!(tb.modifier, syn::TraitBoundModifier::Maybe(_)))) + .cloned() + .collect(); + t.bounds = maybe.into_iter().collect(); + if t.bounds.is_empty() { + t.colon_token = None; + } + } + GenericParam::Lifetime(l) => { + l.bounds.clear(); + l.colon_token = None; + } + GenericParam::Const(_) => {} + } + } + let args: Vec = generics + .params + .iter() + .map(|p| match p { + GenericParam::Type(t) => t.ident.to_token_stream(), + GenericParam::Lifetime(l) => l.lifetime.to_token_stream(), + GenericParam::Const(c) => c.ident.to_token_stream(), + }) + .collect(); + let target = if args.is_empty() { quote!(#ident) } else { quote!(#ident<#(#args),*>) }; + new_items.push(syn::parse_quote!( + #[allow(non_camel_case_types, type_alias_bounds, dead_code)] + type #alias #params = #target; + )); + aliases.insert(ident.to_string(), alias); + } + if aliases.is_empty() { + return false; + } + struct Rename<'a> { + aliases: &'a BTreeMap, + count: usize, + } + impl VisitMut for Rename<'_> { + fn visit_type_path_mut(&mut self, ty: &mut TypePath) { + if ty.qself.is_none() + && ty.path.leading_colon.is_none() + && ty.path.segments.len() == 1 + && let Some(alias) = self.aliases.get(&ty.path.segments[0].ident.to_string()) + { + ty.path.segments[0].ident = Ident::new(&alias.to_string(), Span::call_site()); + self.count += 1; + } + syn::visit_mut::visit_type_path_mut(self, ty); + } + // Inside a type's own definition, `Self`-like uses must stay (a recursive type through + // its alias would be a cycle in the alias), so definitions are not visited. + fn visit_item_struct_mut(&mut self, _: &mut syn::ItemStruct) {} + fn visit_item_enum_mut(&mut self, _: &mut syn::ItemEnum) {} + fn visit_item_union_mut(&mut self, _: &mut syn::ItemUnion) {} + // Macros' tokens are not types to `syn`; derive input stays as it is. + fn visit_macro_mut(&mut self, _: &mut syn::Macro) {} + } + let mut rename = Rename { aliases: &aliases, count: 0 }; + for item in &mut file.items { + rename.visit_item_mut(item); + } + if rename.count == 0 { + return false; + } + file.items.extend(new_items); + true +} + +fn reorder(file: &mut syn::File) -> bool { + let has_macros = file.items.iter().any(|item| matches!(item, Item::Macro(_))); + if has_macros || file.items.len() < 2 { + return false; + } + // `use` and `extern crate` first, as written, so that preludes and `#[macro_use]` stay put. + let (mut head, mut rest): (Vec, Vec) = + file.items.drain(..).partition(|item| matches!(item, Item::Use(_) | Item::ExternCrate(_))); + rest.reverse(); + head.extend(rest); + file.items = head; + true +} + +fn unused(file: &mut syn::File) -> bool { + let names: BTreeSet = file + .items + .iter() + .filter_map(|item| match item { + Item::Fn(f) => Some(f.sig.ident.to_string()), + _ => None, + }) + .collect(); + if names.contains("__mirth_unused") { + return false; + } + file.items.push(syn::parse_quote!(#[allow(dead_code)] fn __mirth_unused() {})); + file.items.push(syn::parse_quote!(#[allow(dead_code)] struct __MirthUnused;)); + file.items.push(syn::parse_quote!(#[allow(dead_code)] trait __MirthUnusedTrait {})); + true +} diff --git a/docs/hunt.md b/docs/hunt.md index 21e9beb..9da3af4 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -42,6 +42,8 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 16 | with `-Zcache-proc-macros=yes -Zmetadata-crate-hash=no`, an incremental rebuild of a crate using derives gets a different crate hash (SVH) from a clean build after an edit upstream | unstable options (one "potentially unsound"); found by the fuzzer under walk configurations; not root-caused; [facts](hunt/cached-proc-macros-crate-hash.md); excluded from the models | | 17 | with `-Zunleash-the-miri-inside-of-you`, the "skipping const checks" warning is not shown again on an incremental rebuild | testing-only option; found by the UI-test fuzzer ([`coverage.md`](coverage.md)); since at least 1.60; [facts](hunt/unleash-warning-lost.md); tests using the option skipped | | 18 | after a fatal error (a missing lang item), an incremental rebuild reports fewer errors than a clean build: the fatal error is reached in a different query order | diagnostics only; found by the UI-test fuzzer; stock nightly; [facts](hunt/fatal-error-order.md); labelled known in `ui-fuzz.py` | +| 19 | on riscv64 and loongarch64, an `extern "C"` call passes an `i32` (or narrower integer) that lands on the stack without sign-extending it; a clang-compiled callee reads the slot as already extended | **looks new**; ABI, stable code; found by the ABI differential against clang ([`checks.md`](checks.md)); since at least 1.80; cause found (extension only `if *avail_gprs >= 1` in `callconv/riscv.rs`, same in `loongarch.rs`); [facts](hunt/riscv-stack-arg-extension.md) | +| 20 | on RISC-V and LoongArch hard-float targets, a `repr(C)` struct of one float and one pointer is passed in a floating-point and an integer register; clang passes it by the integer convention, so C and Rust disagree on where it is | **looks new**; ABI, stable code; found by the ABI differential; since at least 1.80; cause found (`Primitive::Pointer` counted as an integer in `should_use_fp_conv_helper`, `callconv/riscv.rs` and `loongarch.rs`); [facts](hunt/riscv-float-pointer-struct.md) | 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 diff --git a/docs/hunt/riscv-float-pointer-struct.md b/docs/hunt/riscv-float-pointer-struct.md new file mode 100644 index 0000000..ff76089 --- /dev/null +++ b/docs/hunt/riscv-float-pointer-struct.md @@ -0,0 +1,72 @@ +# RISC-V and LoongArch: a struct of a float and a pointer goes in the wrong registers + +Facts for finding 20. Found by the ABI differential ([`checks.md`](../checks.md), check 14: +`rustc/abi-diff.py`). + +## What happens + +Under the hardware floating-point calling conventions (riscv64 lp64d, riscv32 ilp32d, +loongarch64 lp64d), a struct with one floating-point member and one *integer* member is passed +in one floating-point register and one integer register. rustc applies that rule when the +second member is a raw pointer. clang does not: it passes the struct by the integer convention +(two integer registers, or by reference if it is too large). A C function and a Rust function +declared with the same struct therefore disagree about where the arguments are. + +## Reproduction + +```c +struct FP { float f; void *p; }; +void take_fp(struct FP a); +``` + +```rust +#[repr(C)] pub struct FP { f: f32, p: *mut u8 } +#[no_mangle] pub extern "C" fn take_fp(a: FP) { /* ... */ } +``` + +LLVM IR of the definition, `--target riscv64gc-unknown-linux-gnu` / `riscv64-unknown-linux-gnu -mabi=lp64d`: + +| signature | clang 21 | rustc (pinned nightly) | +|---|---|---| +| `void take_fp(struct { float; void*; })` | `[2 x i64]` (a0, a1) | `{ float, i64 }` (fa0, a0) | +| `void take_dp(struct { double; void*; })` | `[2 x i64]` | `{ double, i64 }` | +| `void take_pf(struct { void*; float; })` | `[2 x i64]` | `{ i64, float }` | +| `struct { float; void*; } ret_fp(void)` | `[2 x i64]` | `{ float, i64 }` | +| `void take_fi(struct { float; long; })` (control) | `float, i64` | `{ float, i64 }`: same registers | + +loongarch64 gives the same table. On riscv32 (ilp32d), `struct { double; void*; }` is passed by +reference by clang (larger than two XLEN words, and not eligible for the FP convention) and as +`{ double, i32 }` by rustc. + +## Expected + +The RISC-V psABI (hardware floating-point calling convention): "A struct containing one +floating-point real and one integer (or bitfield), in either order, is passed in a +floating-point register and an integer register…". A pointer is not an integer type. clang's +`detectFPCCEligibleStruct` takes only integral and enumeration types. GCC's +`riscv_flatten_aggregate_field` also tests for an integral type; that is from reading its +source, and its output was not checked here (no riscv GCC on this host). The LoongArch psABI +uses the same rule. + +## Where + +`compiler/rustc_target/src/callconv/riscv.rs`, `should_use_fp_conv_helper`: + +```rust + BackendRepr::Scalar(scalar) => match scalar.primitive() { + Primitive::Int(..) | Primitive::Pointer(_) => { +``` + +`callconv/loongarch.rs` line 47 has the same arm. + +## Versions + +Same IR (`{ float, i64 }`) with 1.80.0, 1.90.0, 1.98.0 and nightly-2026-10-06. + +## Scope + +Any `extern "C"` function, in either direction, whose signature has a `repr(C)` struct (by +value, argument or return) made of one float or double and one pointer (or a struct nesting +them), on riscv64gc-unknown-linux-gnu (tier 2 with host tools), the other hard-float RISC-V +targets, and loongarch64 hard-float targets. Soft-float targets (`*-softfloat`, `riscv*imac`) +are not affected: the FP convention does not apply there. diff --git a/docs/hunt/riscv-stack-arg-extension.md b/docs/hunt/riscv-stack-arg-extension.md new file mode 100644 index 0000000..5bf73ad --- /dev/null +++ b/docs/hunt/riscv-stack-arg-extension.md @@ -0,0 +1,94 @@ +# riscv64 and loongarch64: integer arguments passed on the stack are not sign-extended + +Facts for finding 19. Found by the ABI differential ([`checks.md`](../checks.md), check 14: +`rustc/abi-diff.py`, rustc's `extern "C"` lowering against clang's for random C signatures). + +## What happens + +On riscv64 and loongarch64, when an `extern "C"` call has more integer arguments than argument +registers (eight), the rest go on the stack. rustc sign-extends an `i32` (or narrower integer) +to 64 bits only when it is passed in a register. On the stack it stores the whole 64-bit +register, upper bits included. clang marks every such parameter `signext`, and a C callee +compiled by clang reads the stack slot as an already-extended 64-bit value. A Rust caller can +therefore hand C a value outside the `int` range. + +## Reproduction + +```c +// callee.c +long callee(long a, long b, long c, long d, long e, long f, long g, long h, int i9) { return i9; } +``` + +```rust +// caller.rs (#![no_core] with minicore, or any crate for the target) +extern "C" { fn callee(a: i64, b: i64, c: i64, d: i64, e: i64, f: i64, g: i64, h: i64, i9: i32) -> i64; } +#[no_mangle] pub unsafe extern "C" fn caller(x: i64) -> i64 { callee(0, 0, 0, 0, 0, 0, 0, 0, x as i32) } +``` + +`rustc --target riscv64gc-unknown-linux-gnu -Copt-level=2 --emit=asm` (pinned nightly): + +``` +caller: + mv t0, a0 # x, all 64 bits + li a0, 0 # ... a1-a7 = 0 + sd t0, 0(sp) # the ninth argument: stored without sign extension + call callee +``` + +`clang --target=riscv64-unknown-linux-gnu -O2 -S callee.c`: + +``` +callee: + ld a0, 0(sp) # read as a sign-extended 64-bit value and returned as is + ret +``` + +`caller(0x1_0000_0005)` returns `0x1_0000_0005`. The C function's `int` parameter holds a value +an `int` cannot have, and C code is entitled to rely on the extension. + +The LLVM IR shows the cause. clang declares `i32 noundef signext` for both `int` parameters of +`long callee(long ×8, int, int)`. rustc declares `i32 noundef` with no `signext` for the +parameters that do not fit in registers. loongarch64 is the same (`st.d $a0, $sp, 0` in the +caller, `ld.d $a0, $sp, 0` in the callee). + +## Expected + +The RISC-V psABI (riscv-cc.adoc, integer calling convention) says: "When passed in registers +or on the stack, integer scalars narrower than XLEN bits are widened according to the sign of +their type up to 32 bits, then sign-extended to XLEN bits." clang does this for every argument. +The LoongArch psABI has the same rule for the stack. + +## Where + +`compiler/rustc_target/src/callconv/riscv.rs`, end of `classify_arg`: + +```rust + // "When passed in registers, scalars narrower than XLEN bits are widened + // according to the sign of their type up to 32 bits, then sign-extended to + // XLEN bits." + if *avail_gprs >= 1 { + extend_integer_width(arg, xlen); + *avail_gprs -= 1; + } +``` + +The quoted text is the older wording; the extension is skipped once the registers run out. +`callconv/loongarch.rs` has the same structure. + +## Versions + +The IR lacks `signext` on stack-passed `i32` parameters with 1.80.0, 1.90.0, 1.98.0 and +nightly-2026-10-06. Older toolchains did not build the `no_core` test file and were not checked. + +## Scope + +- Rust calling C (or any clang/GCC-compiled callee) with more than eight integer-class arguments, + where a narrow integer lands on the stack. riscv32 has the same rule for `i8`/`i16` widened to + 32 bits; not yet checked. +- C calling Rust is not affected the same way: a Rust callee without `signext` re-extends the + value itself. + +## Local stopgap + +None yet: it does not affect mirth's incremental checks. The ABI differential records it as +known. diff --git a/rustc/abi-diff.py b/rustc/abi-diff.py new file mode 100644 index 0000000..b49508d --- /dev/null +++ b/rustc/abi-diff.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +"""ABI differential: rustc's `extern "C"` must lower a signature the way clang lowers the same C +signature, on every target both support. + +Generates random C signatures (bool, integers of each width, float, double, pointers, __int128 +on 64-bit targets, and repr(C) structs, unions and arrays of them, nested, packed or +over-aligned), writes each as a C function (compiled by clang for the target's LLVM triple) and +as a Rust `#[no_mangle] extern "C" fn` (compiled by rustc for the target against minicore, so no +sysroot is needed), and compares the two LLVM IR signatures, parameter by parameter after +first-class aggregates are flattened (LLVM assigns their elements to registers one by one): + + finding a parameter or the return value differs in its register class (integer, floating + point, vector, memory), in an extension attribute (zeroext, signext), in inreg, + byval or sret, in the alignment of a byval or sret pointer, in the number of + parameters, or in the calling convention + note the same register classes with different IR types (`{double, double}` against + `float, double`), or a `noundef` difference + + rustc/abi-diff.py --rustc --rust --work [--targets t1,t2 | --all] + [--count 200] [--seed 1] [--jobs 8] [--clang clang] + +Known bugs are labelled, not reported: rust-lang/rust#163911 (x86_64 bool returns) and findings +19 and 20 in docs/hunt.md (RISC-V and LoongArch); so are two differences this host cannot decide +(i686 MSVC small-struct returns, a PowerPC64 `inreg` float). + +Writes //{a.c,a.rs,c.ll,r.ll} and /results.json (per target: functions +compared, findings, notes), and prints the findings grouped by kind. +""" + +import argparse +import ast +import json +import random +import re +import subprocess +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import os + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--rust", required=True) +p.add_argument("--work", required=True) +p.add_argument("--targets") +p.add_argument("--all", action="store_true", help="every target rustc knows; the default is MAIN, the tier 1 and " + "2 targets whose differences have been triaged (the others still show representation differences)") +p.add_argument("--count", type=int, default=200) +p.add_argument("--seed", type=int, default=1) +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--clang", default="clang") +args = p.parse_args() +WORK = Path(args.work).resolve() +WORK.mkdir(parents=True, exist_ok=True) +ENV = dict(os.environ, RUSTC_BOOTSTRAP="1") + +SCALARS = [ # (C, Rust) + ("_Bool", "bool"), ("signed char", "i8"), ("unsigned char", "u8"), ("short", "i16"), + ("unsigned short", "u16"), ("int", "i32"), ("unsigned int", "u32"), ("long long", "i64"), + ("unsigned long long", "u64"), ("float", "f32"), ("double", "f64"), ("void*", "*mut u8"), +] +WIDE = [("__int128", "i128"), ("unsigned __int128", "u128")] + + +class Gen: + def __init__(self, rng, wide): + self.rng, self.wide, self.structs, self.names = rng, wide, [], 0 + + def scalar(self): + pool = SCALARS + (WIDE if self.wide else []) + return self.rng.choice(pool) + + def field_type(self, depth): + r = self.rng.random() + if depth < 2 and r < 0.15: + return self.aggregate(depth + 1) + if r < 0.25: + c, rs = self.scalar() + n = self.rng.choice([1, 2, 3, 4, 8]) + return ("array", c, rs, n) + return self.scalar() + + def aggregate(self, depth=0): + name = f"S{self.names}" + self.names += 1 + union = self.rng.random() < 0.12 + packed = not union and self.rng.random() < 0.08 + # Rust rejects packed with align, and a packed type containing an over-aligned one: + # packed structs get scalars and arrays only. + align = None if packed else self.rng.choice([None] * 9 + [16, 32]) + fields = [self.field_type(2 if packed else depth) for _ in range(self.rng.randint(1, 5))] + self.structs.append((name, union, packed, align, fields)) + return (name, name) + + def ty(self): + return self.aggregate() if self.rng.random() < 0.4 else self.scalar() + + +def c_field(i, f): + if f[0] == "array": + return f"{f[1]} f{i}[{f[3]}];" + c = f[0] + return f"{c} f{i};" + + +def r_field(i, f): + if f[0] == "array": + return f"pub f{i}: [{f[2]}; {f[3]}]," + return f"pub f{i}: {f[1]}," + + +def c_ty(t): + return t[0] + + +def program(target_wide, seed): + rng = random.Random(seed) + g = Gen(rng, target_wide) + fns = [] + for k in range(args.count): + params = [g.ty() for _ in range(rng.randint(0, 8))] + ret = None if rng.random() < 0.15 else g.ty() + fns.append((f"f{k}", params, ret)) + c = ["#include "] + rs = ["#![feature(no_core)]", "#![no_core]", '#![crate_type = "lib"]', + "#![allow(improper_ctypes_definitions, unused, non_snake_case)]", + "extern crate minicore;", "use minicore::*;"] + for name, union, packed, align, fields in g.structs: + kw = "union" if union else "struct" + attrs = (" __attribute__((packed))" if packed else "") + (f" __attribute__((aligned({align})))" if align else "") + c.append(f"typedef {kw} {name} {{ {' '.join(c_field(i, f) for i, f in enumerate(fields))} }}{attrs} {name};") + repr_ = "C" + (", packed" if packed else "") + (f", align({align})" if align else "") + rs.append(f"#[repr({repr_})] pub {kw} {name} {{ {' '.join(r_field(i, f) for i, f in enumerate(fields))} }}") + # Union fields must be Copy; minicore has no derive. + rs.append(f"impl Copy for {name} {{}}") + for name, params, ret in fns: + cp = ", ".join(f"{c_ty(t)} a{i}" for i, t in enumerate(params)) or "void" + c.append(f"{c_ty(ret) if ret else 'void'} {name}({cp}) {{ for (;;); }}") + rp = ", ".join(f"a{i}: {t[1]}" for i, t in enumerate(params)) + rr = f" -> {ret[1]}" if ret else "" + rs.append(f"#[no_mangle] pub extern \"C\" fn {name}({rp}){rr} {{ loop {{}} }}") + return "\n".join(c) + "\n", "\n".join(rs) + "\n", fns + + +DEFINE = re.compile(r"^define\s+(.*?)@(\w+)\((.*)\)(.*)\{\s*$") +DROP = re.compile(r"\b(noundef|nonnull|noalias|nocapture|readonly|readnone|writeonly|writable|dead_on_unwind|" + r"captures\([^)]*\)|dereferenceable(_or_null)?\(\d+\)|initializes\([^)]*\)|range\([^)]*\)|" + r"nofpclass\([^)]*\)|immarg|returned|local_unnamed_addr|unnamed_addr|dso_local|" + r"dso_preemptable|hidden|protected|internal|private|nounwind|noinline|optnone|" + r"!\w+ !\d+|#\d+)\b") + + +def split_top(s): + out, depth, cur = [], 0, "" + for ch in s: + if ch in "({[<": + depth += 1 + elif ch in ")}]>": + depth -= 1 + if ch == "," and depth == 0: + out.append(cur.strip()) + cur = "" + else: + cur += ch + if cur.strip(): + out.append(cur.strip()) + return out + + +def param_type(p): + """The IR type of a parameter declaration, its attributes, and whether it has noundef.""" + p = re.sub(r"\s+%[\w.]+$", "", p.strip()) + noundef = "noundef" in p + m = re.match(r"^(\{[^}]*\}|\[[^\]]*\]|<[^>]*>|[\w.%*]+)(.*)$", p) + ty, attrs = (m.group(1), m.group(2)) if m else (p, "") + attrs = DROP.sub("", attrs) + kept = [] + for a in re.findall(r"(zeroext|signext|inreg|byval\([^)]*\)|sret\([^)]*\)|byref\([^)]*\)|align \d+|inalloca\([^)]*\))", attrs): + kept.append(re.sub(r"\(.*\)$", "", a) if a.startswith(("byval", "sret", "byref", "inalloca")) else a) + # An `align` on a plain pointer is a hint, not ABI; on byval and sret it is ABI. + if not any(k.startswith(("byval", "sret", "byref")) for k in kept): + kept = [k for k in kept if not k.startswith("align")] + return ty, tuple(sorted(kept)), noundef + + +def flatten(ty, named): + """A type's register-assignable parts; `named` maps the module's %struct names to bodies.""" + ty = ty.strip() + if ty in named: + ty = named[ty] + if ty.startswith("<{") and ty.endswith("}>"): # packed struct + ty = ty[1:-1] + if ty.startswith("{") and ty.endswith("}"): + return [x for part in split_top(ty[1:-1]) for x in flatten(part.strip(), named)] + m = re.fullmatch(r"\[1 x (.*)\]", ty) + if m: # a one-element array goes where its element goes + return flatten(m.group(1), named) + return [ty] + + +def units(types, arch=None, ret=False, width=64): + """Register classes, one per register-sized unit: an integer wider than a register takes + several. For a return value, an array is its elements (returned in consecutive registers).""" + out = [] + for t in types: + m = re.fullmatch(r"\[(\d+) x (i\d+|float|double)\]", t) + mi = re.fullmatch(r"i(\d+)", t) + if mi and int(mi.group(1)) > width: + out += ["int"] * (int(mi.group(1)) // width) + # A float array is a homogeneous aggregate: in a return, or an ARM VFP argument, it + # takes consecutive floating-point registers like a struct of its elements. + elif m and (ret or (arch == "arm" and m.group(2) in ("float", "double"))): + out += units([m.group(2)] * int(m.group(1)), arch, ret, width) + elif t == "agg": + out.append("agg") + else: + k = klass(t) + # x86's SSE registers hold floats, doubles and vectors alike. + if arch in ("x86_64", "x86") and k in ("fp", "vec"): + k = "sse" + out.append(k) + return out + + +def klass(ty): + if ty in ("float", "double", "half", "bfloat", "fp128", "x86_fp80", "ppc_fp128") or ty.startswith("<"): + return "fp" if not ty.startswith("<") else "vec" + if ty.startswith("["): + m = re.match(r"\[(\d+) x (.*)\]", ty) + return f"[{m.group(1)} x {klass(m.group(2))}]" if m else ty + if ty == "void": + return "void" + return "int" + + +def signature(line, named): + m = DEFINE.match(line) + if not m: + return None + head, name, params, tail = m.groups() + cc = re.findall(r"\b(\w+cc|cc \d+)\b", head) + head = DROP.sub("", head).strip() + tm = re.search(r"(\{[^{}]*\}|<\{[^{}]*\}>|\[[^\]]*\]|<[^>]*>|[\w.%*]+)\s*$", head) + ret_ty = tm.group(1) if tm else "void" + ret_attrs = tuple(sorted(a for a in head[:tm.start() if tm else 0].split() if a in ("zeroext", "signext", "inreg"))) + ps = [] + for p in split_top(params): + ty, attrs, noundef = param_type(p) + for t in flatten(ty, named): + ps.append((t, attrs, noundef)) + rets = flatten(ret_ty, named) + return {"name": name, "cc": tuple(cc), "ret": rets, "ret_attrs": ret_attrs, "params": ps, + "ret_noundef": "noundef" in m.group(1)} + + +def signatures(ll): + named = {m.group(1): m.group(2).strip() for m in re.finditer(r"^(%[\w.]+) = type (.*)$", ll, re.M)} + return {s["name"]: s for s in (signature(l, named) for l in ll.splitlines() if l.startswith("define")) if s} + + +def compare(c, r, arch, slot): + """`arch`: the target's arch; `slot`: its stack slot size in bytes (the pointer width).""" + findings, notes = [], [] + if c["cc"] != r["cc"]: + findings.append(f"calling convention: clang {c['cc']} rustc {r['cc']}") + if c["ret_attrs"] != r["ret_attrs"]: + what = f"return attributes: clang {c['ret_attrs']} rustc {r['ret_attrs']}" + # x86's psABI leaves the bits above a small integer return undefined (rustc stopped + # extending them in #142389); only bool's bits 1-7 must be zero (#163911). + if arch in ("x86_64", "x86") and c["ret"] != ["i1"]: + notes.append(what + " (x86 psABI: upper bits undefined)") + else: + findings.append(what) + if units(c["ret"], arch, True, slot * 8) != units(r["ret"], arch, True, slot * 8): + findings.append(f"return: clang {c['ret']} rustc {r['ret']}") + elif c["ret"] != r["ret"]: + notes.append(f"return types: clang {c['ret']} rustc {r['ret']}") + cp, rp = c["params"], r["params"] + # ARM and 64-bit PowerPC split a byval aggregate between registers and the stack as they do + # an array argument of the same size: both are "an aggregate". + if arch in ("arm", "powerpc64"): + agg = lambda t, a: ("agg", (), False) if ("byval" in a or re.match(r"\[\d+ x i\d+\]", t)) else (t, a, None) + cp = [agg(t, a) if agg(t, a)[0] == "agg" else (t, a, n) for t, a, n in cp] + rp = [agg(t, a) if agg(t, a)[0] == "agg" else (t, a, n) for t, a, n in rp] + if units([t for t, _, _ in cp], arch, False, slot * 8) != units([t for t, _, _ in rp], arch, False, slot * 8): + what = f"parameters: clang {[t for t, _, _ in cp]} rustc {[t for t, _, _ in rp]}" + # i386 passes every argument on the stack: a struct expanded into its scalars and a + # byval copy of it are the same bytes. + if arch == "x86" and any("byval" in a for _, a, _ in cp + rp): + notes.append(what + " (x86: same stack bytes)") + else: + findings.append(what) + elif [t for t, _, _ in cp] != [t for t, _, _ in rp]: + notes.append(f"parameter types: clang {[t for t, _, _ in cp]} rustc {[t for t, _, _ in rp]}") + elif True: + for i, ((ct, ca, cn), (rt, ra, rn)) in enumerate(zip(cp, rp)): + if ca != ra: + what = f"parameter {i} attributes: clang {ca} rustc {ra} ({ct})" + aligns = [int(a.split()[1]) for a in ca + ra if a.startswith("align ")] + rest_c = [a for a in ca if not a.startswith("align ")] + rest_r = [a for a in ra if not a.startswith("align ")] + ext_only_rust = not rest_c and set(rest_r) <= {"zeroext", "signext"} + if rest_c == rest_r and aligns and max(aligns) <= slot: + # A byval copy goes in stack slots at least `slot` bytes aligned either way. + notes.append(what + " (both within a stack slot)") + elif arch in ("wasm32", "wasm64") and {*rest_c, *rest_r} <= {"byval"}: + # WebAssembly lowers byval to a pointer to a copy the caller makes. + notes.append(what + " (wasm: byval is a pointer to a copy)") + elif arch == "x86" and {*rest_c, *rest_r} <= {"byval"}: + # i386 passes everything on the stack: a byval copy and a struct expanded + # into its scalars occupy the same bytes. + notes.append(what + " (x86: same stack bytes)") + elif arch == "x86_64" and ext_only_rust: + # Win64: rustc extends small integers, clang does not; LLVM does not rely on + # it in the callee (checked: both re-extend), so nothing observable. + notes.append(what + " (win64: extension not relied on)") + else: + findings.append(what) + if ct != rt: + notes.append(f"parameter {i}: clang {ct} rustc {rt}") + if cn != rn: + notes.append(f"parameter {i} noundef: clang {cn} rustc {rn}") + return findings, notes + + +MAIN = ("x86_64-unknown-linux-gnu x86_64-unknown-linux-musl x86_64-pc-windows-msvc x86_64-pc-windows-gnu " + "x86_64-apple-darwin i686-unknown-linux-gnu i686-pc-windows-msvc aarch64-unknown-linux-gnu " + "aarch64-apple-darwin aarch64-pc-windows-msvc aarch64-unknown-linux-musl armv7-unknown-linux-gnueabihf " + "arm-unknown-linux-gnueabi thumbv7em-none-eabihf riscv64gc-unknown-linux-gnu riscv32imac-unknown-none-elf " + "loongarch64-unknown-linux-gnu powerpc64le-unknown-linux-gnu s390x-unknown-linux-gnu wasm32-unknown-unknown " + "wasm32-wasip1").split() + + +def known(arch, finding): + """A finding that is a bug already recorded: the label, or None.""" + if arch == "x86_64" and finding.startswith("return attributes: clang ('zeroext',)"): + return "rust-lang/rust#163911" + if arch in ("riscv64", "riscv32", "loongarch64"): + if re.match(r"parameter \d+ attributes: clang \('(signext|zeroext)',\) rustc \(\)", finding): + return "finding 19 (docs/hunt.md)" + if finding.startswith(("parameters:", "return:")): + c, _, r = finding.partition(" rustc ") + fp = lambda text: len(re.findall(r"'(float|double)'", text)) + if fp(r) > fp(c): + return "finding 20 (docs/hunt.md)" + return None + + +def unresolved(arch, target, finding): + """A difference whose correct side needs a reference this host lacks: the label, or None.""" + lists = re.fullmatch(r"parameters: clang (\[.*?\]) rustc (\[.*\])", finding) + sret_only = bool(lists) and (lambda c, r: c[:1] == ["ptr"] and c[1:] == r)(*map(ast.literal_eval, lists.groups())) + if target.endswith("windows-msvc") and arch == "x86" and ( + (finding.startswith("return:") and "'void'" in finding) or sret_only): + # clang returns an 8-byte struct with an array field of 3 bytes indirectly (its + # register-size rule recurses into fields); rustc and MSVC's documentation return any + # 8-byte struct in edx:eax. Needs MSVC to decide. + return "i686 msvc small-struct return (needs MSVC)" + if arch == "powerpc64" and re.match(r"parameter \d+ attributes: clang \('inreg',\) rustc \(\) \((float|double)\)", finding): + # clang marks a float from a single-member aggregate inreg; whether the PowerPC backend + # then places it differently needs a run on the target. + return "ppc64 inreg float (needs a run)" + return None + + +def run(argv, cwd): + try: + r = subprocess.run(argv, capture_output=True, text=True, timeout=600, cwd=cwd, env=ENV) + return r.returncode, r.stderr + except subprocess.TimeoutExpired: + return -1, "timeout" + + +def spec(target): + out = subprocess.run([args.rustc, "--print", "target-spec-json", "-Zunstable-options", "--target", target], + capture_output=True, text=True, env=ENV) + return json.loads(out.stdout) if out.returncode == 0 else None + + +def one_target(target): + s = spec(target) + if not s: + return target, {"skip": "no target spec"} + width = int(s.get("target-pointer-width", 64)) + if width < 32 or s.get("c-int-width", "32") != "32": + return target, {"skip": "16-bit int"} + d = WORK / target + d.mkdir(parents=True, exist_ok=True) + csrc, rsrc, fns = program(width == 64 and s.get("arch") not in ("sparc64",), args.seed) + (d / "a.c").write_text(csrc) + (d / "a.rs").write_text(rsrc) + base = [args.rustc, "--target", target, "-Zunstable-options", "--edition", "2021", "-Cpanic=abort", + "--out-dir", str(d)] + code, err = run(base + ["--crate-type", "rlib", "--crate-name", "minicore", "-Awarnings", + str(Path(args.rust) / "tests/auxiliary/minicore.rs")], d) + if code: + return target, {"skip": "minicore does not build", "err": err[-500:]} + code, err = run(base + ["--emit=llvm-ir", "-Copt-level=0", "--extern", f"minicore={d}/libminicore.rlib", + "-o", str(d / "r.ll"), str(d / "a.rs")], d) + if code: + return target, {"skip": "rust side does not build", "err": err[-1500:]} + cflags = [] + # The target's CPU and features decide parts of the ABI in clang too (soft-float, SSE). + if s.get("cpu") and s["cpu"] != "generic": + cflags += ["-Xclang", "-target-cpu", "-Xclang", s["cpu"]] + for feature in filter(None, s.get("features", "").split(",")): + cflags += ["-Xclang", "-target-feature", "-Xclang", feature] + if s.get("llvm-abiname"): + cflags.append(f"-mabi={s['llvm-abiname']}") + if s.get("llvm-floatabi") == "hard": + cflags.append("-mfloat-abi=hard") + code, err = run([args.clang, f"--target={s['llvm-target']}", "-ffreestanding", "-S", "-emit-llvm", "-O0", + "-Wno-everything", *cflags, "-o", str(d / "c.ll"), str(d / "a.c")], d) + if code: + return target, {"skip": "clang does not build", "err": err[-800:]} + cs, rs = signatures((d / "c.ll").read_text()), signatures((d / "r.ll").read_text()) + findings, notes, known_hits = {}, {}, {} + for name, _, _ in fns: + if name not in cs or name not in rs: + continue + f, n = compare(cs[name], rs[name], s.get("arch"), width // 8) + labelled = [(x, known(s.get("arch"), x) or unresolved(s.get("arch"), target, x)) for x in f] + f = [x for x, k in labelled if not k] + for x, k in labelled: + if k: + known_hits.setdefault(k, 0) + known_hits[k] += 1 + if f: + findings[name] = f + if n: + notes[name] = n + return target, {"compared": len(fns), "findings": findings, "notes": notes, "known": known_hits} + + +def main(): + if args.targets: + targets = args.targets.split(",") + elif not args.all: + targets = MAIN + else: + targets = subprocess.run([args.rustc, "--print", "target-list"], capture_output=True, text=True, + env=ENV).stdout.split() + with ThreadPoolExecutor(args.jobs) as ex: + results = dict(ex.map(one_target, targets)) + (WORK / "results.json").write_text(json.dumps(results, indent=1)) + kinds = defaultdict(lambda: defaultdict(int)) + skipped = defaultdict(list) + for t, r in results.items(): + if "skip" in r: + skipped[r["skip"]].append(t) + continue + for fs in r["findings"].values(): + for f in fs: + kinds[re.sub(r"\(.*|:.*", "", f).strip()][t] += 1 + compared = [t for t, r in results.items() if "skip" not in r] + knowns = defaultdict(int) + for r in results.values(): + for k, n in r.get("known", {}).items(): + knowns[k] += n + if knowns: + print("known: " + ", ".join(f"{k} {n}" for k, n in sorted(knowns.items()))) + print(f"{len(compared)} targets compared; skipped: " + ", ".join(f"{k} {len(v)}" for k, v in skipped.items())) + for kind, per in sorted(kinds.items(), key=lambda kv: -sum(kv[1].values())): + print(f"{kind}: {sum(per.values())} in {len(per)} targets: " + + ", ".join(f"{t} {n}" for t, n in sorted(per.items(), key=lambda kv: -kv[1])[:8])) + + +main() diff --git a/rustc/miri-diff.py b/rustc/miri-diff.py new file mode 100644 index 0000000..7fac7ce --- /dev/null +++ b/rustc/miri-diff.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Miri differential: an accepted program must be free of undefined behavior, MIR optimizations +must not introduce any, and the compiled program must do what Miri says it does. + +For each runnable UI test (run-pass, run-fail), interprets it with Miri three times: without MIR +optimizations (Miri's default), at `-Zmir-opt-level=2` (what `-O` runs) and at +`-Zmir-opt-level=4` (every MIR pass), and builds and runs it with the compiler under test, +unoptimized. Findings: + + ub Miri reports undefined behavior in an accepted program at mir-opt-level 0: the + compiler accepted something unsound, or the test's own unsafe code is wrong + ub-opt undefined behavior only after MIR optimization: a MIR pass broke the program + opt-differs Miri's exit status or stdout changes with the MIR optimization level + native the compiled program's exit status or stdout differs from Miri's + +Overflow checks and debug assertions are on everywhere. Tests Miri cannot run (foreign +functions, inline assembly, unsupported operations) or that time out are skipped. + + rustc/miri-diff.py --rustc --tests /tests/ui --work [--only ] + [--known ] [--jobs 8] [--timeout 120] [--pause-on-finding] [--recheck] + +Writes /results.jsonl and /findings//. +""" + +import argparse +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import uitest # noqa: E402 + +FIXED = ["-Coverflow-checks=on", "-Cdebug-assertions=on"] +LEVELS = {"miri0": [], "miri2": ["-Zmir-opt-level=2"], "miri4": ["-Zmir-opt-level=4"]} +OWN = re.compile(r"^-O$|opt-level|overflow-checks|debug-assertions|codegen-backend|mir-enable-passes|" + r"panic=|-Cpanic|prefer-dynamic|-Zbuild-std|-Clink|-Ctarget") +# Code Miri cannot interpret: skip without trying. +NOT_FOR_MIRI = re.compile(r"\basm!|global_asm!|naked_asm!|extern\s+\"C\"\s*\{|#\[link\(|std::process::Command|" + r"\bfork\b|libc::|dlopen|std::os::unix::process") + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--tests", required=True) +p.add_argument("--work", required=True) +p.add_argument("--only") +p.add_argument("--known") +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--timeout", type=int, default=120) +p.add_argument("--pause-on-finding", action="store_true") +p.add_argument("--recheck", action="store_true") +args = p.parse_args() +WORK = Path(args.work).resolve() +(WORK / "scratch").mkdir(parents=True, exist_ok=True) +known = set(Path(args.known).read_text().split()) if args.known else set() + + +def clean(text): + text = re.sub(r"(thread '[^']*') \(\d+\)", r"\1", text) + return re.sub(r"\S*/lib/rustlib/src/rust/library/", "library/", text) + + +def one(path, flags, edition, kind): + rel = str(path.relative_to(args.tests)) + record = {"test": rel, "kind": kind} + with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: + d = Path(d) + status, _, binary = uitest.compile(args.rustc, path.resolve(), d / "native", flags, edition, + FIXED + ["-Copt-level=0"]) + if binary is None: + record["skip"] = f"native build {status}" + return record, [] + code, out, _ = uitest.run(binary) + native = {"exit": code, "stdout": out.decode(errors="replace")} + again = uitest.run(binary) + if (again[0], again[1].decode(errors="replace")) != (native["exit"], native["stdout"]): + record["skip"] = "native run is nondeterministic" + return record, [] + miri = {} + for name, extra in LEVELS.items(): + m = uitest.miri(path.resolve(), flags, edition, FIXED + extra, timeout=args.timeout, cwd=d) + m["stdout"] = clean(m["stdout"]) + miri[name] = m + if name == "miri0" and m["status"] in ("unsupported", "error", "timeout"): + record["skip"] = f"miri {m['status']}" + record["why"] = m["stderr"][-300:] + return record, [] + record["miri"] = {k: v["status"] for k, v in miri.items()} + found = [] + if miri["miri0"]["status"] == "ub": + found.append({"what": "ub", "stderr": miri["miri0"]["stderr"][-3000:]}) + for name in ("miri2", "miri4"): + m = miri[name] + if m["status"] == "ub" and miri["miri0"]["status"] != "ub": + found.append({"what": f"ub-opt ({name})", "stderr": m["stderr"][-3000:]}) + elif m["status"] == "ice": + found.append({"what": f"ice ({name})", "stderr": m["stderr"][-3000:]}) + elif (m["status"] == "ok" and miri["miri0"]["status"] == "ok" + and (m["exit"], m["stdout"]) != (miri["miri0"]["exit"], miri["miri0"]["stdout"])): + found.append({"what": f"opt-differs ({name})", "miri0": miri["miri0"]["stdout"][-1500:], + "got": m["stdout"][-1500:], "exits": [miri["miri0"]["exit"], m["exit"]]}) + m0 = miri["miri0"] + if m0["status"] == "ok": + # Miri exits 1 on a panic that reaches main, native code 101. + exit_m = 101 if m0["exit"] == 1 and "panicked" in m0["stderr"] else m0["exit"] + if (exit_m, m0["stdout"]) != (native["exit"], native["stdout"]): + found.append({"what": "native", "miri": [m0["exit"], m0["stdout"][-1500:]], + "native": [native["exit"], native["stdout"][-1500:]]}) + if found: + outdir = WORK / "findings" / rel.replace("/", "__") + shutil.rmtree(outdir, ignore_errors=True) + outdir.mkdir(parents=True) + shutil.copy(path, outdir / path.name) + (outdir / "finding.json").write_text(json.dumps( + {"test": rel, "flags": flags, "edition": edition, "fixed": FIXED, "found": found}, indent=1)) + record["found"] = [f["what"] for f in found] + return record, found + + +def main(): + wanted = None + if args.recheck: + wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} + todo = [] + for path, flags, edition, kind in uitest.tests( + args.tests, ("run-pass", "run-fail"), + lambda text, flags: any(OWN.search(f) for f in flags) or NOT_FOR_MIRI.search(text)): + rel = str(path.relative_to(args.tests)) + if rel in known or (args.only and args.only not in rel) or (wanted is not None and rel not in wanted): + continue + todo.append((path, flags, edition, kind)) + print(f"{len(todo)} tests", flush=True) + sys.exit(uitest.drive(todo, one, WORK / "results.jsonl", args.jobs, args.pause_on_finding)) + + +main() diff --git a/rustc/opt-diff.py b/rustc/opt-diff.py new file mode 100644 index 0000000..a6cb8f5 --- /dev/null +++ b/rustc/opt-diff.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Optimization differential: a program's behavior must not depend on how it was optimized. + +Builds each runnable UI test (run-pass, run-fail) under a set of configurations (optimization +levels, MIR optimization levels, LTO, target CPU, the Cranelift backend) and runs it. Overflow +checks and debug assertions are fixed across configurations, so the program's semantics are the +same in all of them. Compared with the unoptimized baseline (`-Copt-level=0 -Zmir-opt-level=0`): + + run exit status and stdout (stderr too, with panic locations kept, backtrace hints dropped) + build a configuration fails to build, or crashes the compiler, where the baseline builds + +A baseline whose output varies between two runs is nondeterministic and skipped; a difference +is confirmed by running both binaries again before it counts. + + rustc/opt-diff.py --rustc --tests /tests/ui --work + [--cranelift ] [--configs O3,O3-lto] [--only ] + [--known ] [--jobs 8] [--pause-on-finding] [--recheck] + +Writes /results.jsonl, and /findings// with the source, the configurations' +argv and outputs. With --pause-on-finding, stops starting new tests at the first finding and +exits 3 (the frontier loop: docs/hunt.md). --recheck runs only the tests with findings. +""" + +import argparse +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import uitest # noqa: E402 + +FIXED = ["-Coverflow-checks=on", "-Cdebug-assertions=on", "-Cpanic=unwind", "-Cdebuginfo=0"] +CONFIGS = { + "base": ["-Copt-level=0", "-Zmir-opt-level=0"], + "O0": ["-Copt-level=0"], + "O0-mir4": ["-Copt-level=0", "-Zmir-opt-level=4"], + "O1": ["-Copt-level=1"], + "O2": ["-Copt-level=2"], + "O3": ["-Copt-level=3"], + "Os": ["-Copt-level=s"], + "Oz": ["-Copt-level=z"], + "O3-mir4": ["-Copt-level=3", "-Zmir-opt-level=4"], + "O3-lto": ["-Copt-level=3", "-Clto=fat", "-Ccodegen-units=1"], + "O2-cgu16": ["-Copt-level=2", "-Ccodegen-units=16"], + "O3-native": ["-Copt-level=3", "-Ctarget-cpu=native"], + "cranelift": ["-Copt-level=0", "-Zcodegen-backend=cranelift"], +} +# Tests that choose these themselves are left out: the configuration would contradict them. +OWN = re.compile(r"^-O$|opt-level|mir-opt-level|overflow-checks|debug-assertions|codegen-backend|" + r"mir-enable-passes|^-Clto|lto=|target-cpu|panic=|-Cpanic|prefer-dynamic|-Zbuild-std") + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--cranelift", help="a rustc with the cranelift backend (the pinned nightly)") +p.add_argument("--tests", required=True) +p.add_argument("--work", required=True) +p.add_argument("--configs", help="comma-separated subset (base is always built)") +p.add_argument("--only", help="tests whose path contains this") +p.add_argument("--known", help="file of test paths to leave out (known findings)") +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--pause-on-finding", action="store_true") +p.add_argument("--recheck", action="store_true") +args = p.parse_args() +WORK = Path(args.work).resolve() +WORK.mkdir(parents=True, exist_ok=True) +(WORK / "scratch").mkdir(exist_ok=True) +configs = {k: v for k, v in CONFIGS.items() + if k == "base" or not args.configs or k in args.configs.split(",")} +if not args.cranelift: + configs.pop("cranelift", None) +known = set(Path(args.known).read_text().split()) if args.known else set() + + +def normalized(stderr): + lines = [l for l in stderr.decode(errors="replace").splitlines() + if not l.startswith(("note: run with `RUST_BACKTRACE", "note: Some details are omitted"))] + text = "\n".join(lines) + # Panic messages name the thread with its OS id: `thread 'main' (909942) panicked`. + text = re.sub(r"(thread '[^']*') \(\d+\)", r"\1", text) + # A toolchain with rust-src prints std's own paths in full. + return re.sub(r"\S*/lib/rustlib/src/rust/library/", "library/", text) + + +def observe(binary): + code, out, err = uitest.run(binary) + return {"exit": code, "stdout": out.decode(errors="replace"), "stderr": normalized(err)} + + +def one(path, flags, edition, kind): + rel = str(path.relative_to(args.tests)) + with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: + d = Path(d) + built, runs = {}, {} + text = path.read_text(errors="replace") + for name, cfg in configs.items(): + # Cranelift does not unwind on this target yet (catch_unwind catches nothing). + if name == "cranelift" and ("catch_unwind" in text or "needs-unwind" in text): + continue + rustc = args.cranelift if name == "cranelift" else args.rustc + status, stderr, binary = uitest.compile(rustc, path.resolve(), d / name, flags, edition, FIXED + cfg) + built[name] = {"status": status, "stderr": stderr[-2000:]} + if binary: + runs[name] = observe(binary) + record = {"test": rel, "kind": kind, "build": {k: v["status"] for k, v in built.items()}} + if built["base"]["status"] != "ok": + record["skip"] = "baseline does not build" + return record, [] + again = observe(d / "base" / "prog") + if again != runs["base"]: + record["skip"] = "baseline is nondeterministic" + return record, [] + found = [] + for name in configs: + if name == "base" or name not in built: + continue + b = built[name]["status"] + if b != "ok": + # The Cranelift backend has documented gaps (unsupported intrinsics, inline asm). + if name == "cranelift" and b == "error": + continue + found.append({"config": name, "what": f"build {b}", "stderr": built[name]["stderr"]}) + continue + # Cranelift cannot unwind on this target yet: a panic aborts. + if name == "cranelift" and "failed to initiate panic" in runs[name]["stderr"]: + continue + if runs[name] != runs["base"]: + retry = observe(d / name / "prog") + if retry != runs["base"] and retry == runs[name]: + diff = [k for k in ("exit", "stdout", "stderr") if runs[name][k] != runs["base"][k]] + found.append({"config": name, "what": "run differs: " + ",".join(diff), + "base": runs["base"], "got": runs[name]}) + if found: + out = WORK / "findings" / rel.replace("/", "__") + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True) + shutil.copy(path, out / path.name) + (out / "finding.json").write_text(json.dumps({ + "test": rel, "flags": flags, "edition": edition, "fixed": FIXED, + "configs": {f["config"]: configs[f["config"]] for f in found}, "found": found, + "base": runs["base"]}, indent=1)) + record["found"] = [f"{f['config']}: {f['what']}" for f in found] + return record, found + + +def main(): + if args.recheck: + wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} + todo = [] + for path, flags, edition, kind in uitest.tests(args.tests, ("run-pass", "run-fail"), + lambda text, flags: any(OWN.search(f) for f in flags)): + rel = str(path.relative_to(args.tests)) + if rel in known or (args.only and args.only not in rel) or (args.recheck and rel not in wanted): + continue + todo.append((path, flags, edition, kind)) + print(f"{len(todo)} tests, configurations: {', '.join(configs)}", flush=True) + sys.exit(uitest.drive(todo, one, WORK / "results.jsonl", args.jobs, args.pause_on_finding)) + + +main() diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py new file mode 100644 index 0000000..463b451 --- /dev/null +++ b/rustc/rewrite-diff.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Equivalent rewrites: rewriting a program into an equivalent one must not change its verdict. + +Each standalone UI test is printed back unchanged (`mirth-rewrite identity`: the baseline, since +printing drops comments and moves lines) and rewritten by each of mirth-rewrite's rewrites +(generic-wrap, alias, reorder, unused). Each version is compiled the way the test's headers say +(metadata for check tests, a full build for build and run tests), and compared with the +baseline: + + verdict accepted against rejected, or a crash on one side only (a finding) + codes both rejected with different sets of error codes (a finding for reorder and unused, + which change nothing a diagnostic could depend on; noted for the others) + +A test whose baseline differs from the original file's verdict is left out (the printer cannot +represent it faithfully). + + rustc/rewrite-diff.py --rustc --tests /tests/ui --work + [--rewrites generic-wrap,alias] [--only ] [--known ] [--jobs 8] + [--pause-on-finding] [--recheck] +""" + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import uitest # noqa: E402 + +REWRITER = Path(__file__).resolve().parent.parent / "target/release/mirth-rewrite" +REWRITES = ["generic-wrap", "alias", "reorder", "unused"] +STRICT = {"reorder", "unused"} +KINDS = ("check-pass", "build-pass", "run-pass", "check-fail", "build-fail", "run-fail", None) + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--tests", required=True) +p.add_argument("--work", required=True) +p.add_argument("--rewrites", help="comma-separated subset") +p.add_argument("--only") +p.add_argument("--known") +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--pause-on-finding", action="store_true") +p.add_argument("--recheck", action="store_true") +args = p.parse_args() +WORK = Path(args.work).resolve() +(WORK / "scratch").mkdir(parents=True, exist_ok=True) +rewrites = args.rewrites.split(",") if args.rewrites else REWRITES +known = set(Path(args.known).read_text().split()) if args.known else set() + + +def codes(stderr): + return sorted(set(re.findall(r"error\[(E\d{4})\]", stderr))) + + +def verdict(source, flags, edition, kind, out): + emit = "metadata" if kind in ("check-pass", "check-fail", None) else "link" + status, stderr, _ = uitest.compile(args.rustc, source, out, flags, edition, timeout=120, emit=emit) + return {"status": status, "codes": codes(stderr), "stderr": stderr[-2500:]} + + +def rewrite(name, source, target): + r = subprocess.run([str(REWRITER), name, str(source)], capture_output=True, text=True, timeout=60) + if r.returncode != 0: + return False + target.write_text(r.stdout) + return True + + +def one(path, flags, edition, kind): + rel = str(path.relative_to(args.tests)) + record = {"test": rel, "kind": kind} + with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: + d = Path(d) + base_src = d / "identity.rs" + if not rewrite("identity", path, base_src): + record["skip"] = "does not parse" + return record, [] + original = verdict(path.resolve(), flags, edition, kind, d / "original") + base = verdict(base_src, flags, edition, kind, d / "identity") + if (original["status"], original["codes"]) != (base["status"], base["codes"]): + record["skip"] = "printing changes the verdict" + return record, [] + record["base"] = base["status"] + found, notes, applied = [], [], [] + for name in rewrites: + src = d / f"{name}.rs" + if not rewrite(name, path, src): + continue + applied.append(name) + v = verdict(src, flags, edition, kind, d / name) + if v["status"] != base["status"] and "timeout" not in (v["status"], base["status"]): + found.append({"rewrite": name, "what": f"verdict: {base['status']} -> {v['status']}", + "base_codes": base["codes"], "codes": v["codes"], "stderr": v["stderr"], + "base_stderr": base["stderr"]}) + elif v["status"] == base["status"] == "error" and v["codes"] != base["codes"]: + entry = {"rewrite": name, "what": f"codes: {base['codes']} -> {v['codes']}", + "stderr": v["stderr"], "base_stderr": base["stderr"]} + (found if name in STRICT else notes).append(entry) + if any(f["rewrite"] == name for f in found + notes): + shutil.copy(src, d / f"keep-{name}.rs") + record["applied"] = applied + record["found"] = [f"{f['rewrite']}: {f['what']}" for f in found] + record["notes"] = [f"{f['rewrite']}: {f['what']}" for f in notes] + if found or notes: + outdir = WORK / ("findings" if found else "notes") / rel.replace("/", "__") + shutil.rmtree(outdir, ignore_errors=True) + outdir.mkdir(parents=True) + shutil.copy(path, outdir / path.name) + shutil.copy(base_src, outdir / "identity.rs") + for f in found + notes: + shutil.copy(d / f"keep-{f['rewrite']}.rs", outdir / f"{f['rewrite']}.rs") + (outdir / "finding.json").write_text(json.dumps( + {"test": rel, "flags": flags, "edition": edition, "kind": kind, "found": found, "notes": notes}, + indent=1)) + return record, found + + +def main(): + if not REWRITER.exists(): + sys.exit("build mirth-rewrite first: cargo build --release -p mirth-rewrite") + wanted = None + if args.recheck: + wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} + todo = [] + for path, flags, edition, kind in uitest.tests(args.tests, KINDS): + rel = str(path.relative_to(args.tests)) + if rel in known or (args.only and args.only not in rel) or (wanted is not None and rel not in wanted): + continue + todo.append((path, flags, edition, kind)) + print(f"{len(todo)} tests, rewrites: {', '.join(rewrites)}", flush=True) + sys.exit(uitest.drive(todo, one, WORK / "results.jsonl", args.jobs, args.pause_on_finding)) + + +main() diff --git a/rustc/solver-diff.py b/rustc/solver-diff.py new file mode 100644 index 0000000..52a103c --- /dev/null +++ b/rustc/solver-diff.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Solver differential: the trait solvers and the borrow checkers must agree. + +Compiles each standalone UI test four ways: the old trait solver (`-Znext-solver=coherence`, +what compiletest pins), nightly's default (the new solver everywhere), the old solver with +Polonius (`-Zpolonius=next`), and the new solver with Polonius. Compared with the old solver +and NLL: + + ice a configuration crashes where the reference does not + verdict accepted by one and rejected by the other + codes both reject, with different sets of error codes (reported, not a finding: the + solvers word errors differently) + +A program accepted only by a non-reference configuration and runnable (it has `fn main`) is +interpreted with Miri under that configuration's flags: undefined behavior there means the +other configuration accepted something unsound (the Polonius soundness bugs had that shape). + +Tests that name a solver or Polonius in their headers (they test the difference on purpose) are +left out. + + rustc/solver-diff.py --rustc --tests /tests/ui --work [--only ] + [--known ] [--jobs 8] [--pause-on-finding] [--recheck] +""" + +import argparse +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import uitest # noqa: E402 + +CONFIGS = { + "old": ["-Znext-solver=coherence"], + "next": [], + "old-polonius": ["-Znext-solver=coherence", "-Zpolonius=next"], + "next-polonius": ["-Zpolonius=next"], +} +REFERENCE = "old" +KINDS = ("check-pass", "build-pass", "run-pass", "check-fail", "build-fail", "run-fail", None) + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--tests", required=True) +p.add_argument("--work", required=True) +p.add_argument("--only") +p.add_argument("--known") +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--pause-on-finding", action="store_true") +p.add_argument("--recheck", action="store_true") +args = p.parse_args() +WORK = Path(args.work).resolve() +(WORK / "scratch").mkdir(parents=True, exist_ok=True) +known = set(Path(args.known).read_text().split()) if args.known else set() + + +def codes(stderr): + return sorted(set(re.findall(r"error\[(E\d{4})\]", stderr))) + + +def one(path, flags, edition, kind): + rel = str(path.relative_to(args.tests)) + record = {"test": rel, "kind": kind} + results = {} + with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: + d = Path(d) + for name, cfg in CONFIGS.items(): + # Metadata is enough for the verdict: type checking and borrow checking run for it. + status, stderr, _ = uitest.compile(args.rustc, path.resolve(), d / name, flags, edition, cfg, + timeout=120, emit="metadata") + results[name] = {"status": status, "codes": codes(stderr), "stderr": stderr[-2500:]} + record["status"] = {k: v["status"] for k, v in results.items()} + ref = results[REFERENCE] + found, notes = [], [] + for name, r in results.items(): + if name == REFERENCE: + continue + if r["status"] == "ice" and ref["status"] != "ice": + found.append({"config": name, "what": "ice", "stderr": r["stderr"]}) + elif r["status"] == "timeout" and ref["status"] != "timeout": + found.append({"config": name, "what": "timeout"}) + elif {r["status"], ref["status"]} == {"ok", "error"}: + f = {"config": name, "what": f"verdict: {REFERENCE} {ref['status']}, {name} {r['status']}", + "ref_codes": ref["codes"], "codes": r["codes"], + "stderr": (r if r["status"] == "error" else ref)["stderr"]} + accepted_by = name if r["status"] == "ok" else REFERENCE + if "fn main" in path.read_text(errors="replace"): + m = uitest.miri(path.resolve(), flags, edition, CONFIGS[accepted_by], timeout=120, cwd=WORK / "scratch") + f["miri"] = {"config": accepted_by, "status": m["status"], "stderr": m["stderr"][-1500:]} + if m["status"] == "ub": + f["what"] += f"; Miri: UB under {accepted_by}" + found.append(f) + elif r["status"] == ref["status"] == "error" and r["codes"] != ref["codes"]: + notes.append(f"{name} codes {r['codes']} vs {ref['codes']}") + record["found"] = [f"{f['config']}: {f['what']}" for f in found] + record["notes"] = notes + if found: + outdir = WORK / "findings" / rel.replace("/", "__") + shutil.rmtree(outdir, ignore_errors=True) + outdir.mkdir(parents=True) + shutil.copy(path, outdir / path.name) + (outdir / "finding.json").write_text(json.dumps( + {"test": rel, "flags": flags, "edition": edition, "configs": CONFIGS, "found": found}, indent=1)) + return record, found + + +def main(): + wanted = None + if args.recheck: + wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} + skip = lambda text, flags: re.search(r"next-solver|polonius|^//@\s*revisions:.*\bnext\b", text, re.M) + todo = [] + for path, flags, edition, kind in uitest.tests(args.tests, KINDS, skip): + rel = str(path.relative_to(args.tests)) + if rel in known or (args.only and args.only not in rel) or (wanted is not None and rel not in wanted): + continue + todo.append((path, flags, edition, kind)) + print(f"{len(todo)} tests, configurations: {', '.join(CONFIGS)}", flush=True) + sys.exit(uitest.drive(todo, one, WORK / "results.jsonl", args.jobs, args.pause_on_finding)) + + +main() diff --git a/rustc/uitest.py b/rustc/uitest.py new file mode 100644 index 0000000..8ac1156 --- /dev/null +++ b/rustc/uitest.py @@ -0,0 +1,161 @@ +"""What the oracle scripts need from rustc's UI tests: their `//@` headers, which of them can be +compiled on their own on this host, and a way to build and run one. + +The header reading is the same as in ui-fuzz.py, ui-coverage.py and ui-solver-diff.py (the first +revision of a test with revisions). +""" + +import os +import re +import subprocess +from pathlib import Path + +# Tests that need more than one file, another target, or a tool this host may lack. +NOT_STANDALONE = re.compile( + r"^//@\s*(aux-build|aux-crate|aux-bin|aux-codegen-backend|proc-macro|add-minicore|" + r"needs-llvm-components|needs-sanitizer|needs-profiler|needs-rust-lld|needs-enzyme|" + r"only-(?!x86_64|linux|unix|64bit|elf|gnu)|ignore-x86_64|ignore-linux|ignore-unix|ignore-64bit|" + r"known-bug|rustc-env|unset-rustc-env)", re.M) + + +def headers(text): + """(flags, edition, kind, revision) of a test, for its first revision.""" + flags, edition, revision, kind = [], None, None, None + revs = re.search(r"^//@\s*revisions:\s*(.*)$", text, re.M) + if revs: + revision = revs.group(1).split()[0] + for m in re.finditer(r"^//@(?:\[([\w,-]+)\])?\s*([a-z-]+)(?::\s*(.*))?$", text, re.M): + only, key, value = m.group(1), m.group(2), (m.group(3) or "").strip() + if only and (revision is None or revision not in only.split(",")): + continue + if key == "compile-flags": + flags += value.split() + elif key == "edition": + edition = value.split()[0] + elif key in ("check-pass", "build-pass", "run-pass", "check-fail", "build-fail", "run-fail"): + kind = key + if revision: + flags += ["--cfg", revision] + return flags, edition, kind, revision + + +def tests(root, kinds, extra_skip=None): + """The standalone tests under `root` of the given kinds, as (path, flags, edition, kind).""" + root = Path(root) + for path in sorted(root.rglob("*.rs")): + if "auxiliary" in path.parts: + continue + text = path.read_text(errors="replace") + if NOT_STANDALONE.search(text): + continue + flags, edition, kind, _ = headers(text) + if kind not in kinds: + continue + if extra_skip and extra_skip(text, flags): + continue + yield path, flags, edition, kind + + +def is_ice(stderr): + return ("internal compiler error" in stderr or "the compiler unexpectedly panicked" in stderr + or "rustc interrupted by SIG" in stderr) + + +def compile(rustc, source, out, flags, edition, extra=(), timeout=300, emit="link"): + """Build `source` into the directory `out`; returns (status, stderr, binary). + status: ok, error, ice, timeout.""" + out.mkdir(parents=True, exist_ok=True) + binary = out / "prog" + argv = [str(rustc), str(source), "--edition", edition or "2015", f"--emit={emit}", "-o", str(binary), + "-Zunstable-options", "-Ainternal_features", "-Aincomplete_features", "--error-format=short", + *flags, *extra] + try: + r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, cwd=out, + env=dict(os.environ, RUSTC_BOOTSTRAP="1", RUST_BACKTRACE="0")) + except subprocess.TimeoutExpired: + return "timeout", "", None + if is_ice(r.stderr): + return "ice", r.stderr, None + return ("ok" if r.returncode == 0 else "error"), r.stderr, binary if r.returncode == 0 else None + + +def run(binary, timeout=20): + """Run a built program; (exit, stdout, stderr), exit a number, or 'signal N' or 'timeout'.""" + try: + r = subprocess.run([str(binary)], capture_output=True, timeout=timeout, cwd=binary.parent, + stdin=subprocess.DEVNULL, env=dict(os.environ, RUST_BACKTRACE="0")) + except subprocess.TimeoutExpired: + return "timeout", b"", b"" + code = r.returncode if r.returncode >= 0 else f"signal {-r.returncode}" + return code, r.stdout, r.stderr + + +MIRI_TOOLCHAIN = Path.home() / ".rustup/toolchains/nightly-2026-10-06-x86_64-unknown-linux-gnu" +MIRI_SYSROOT = Path.home() / ".cache/miri" + + +def miri(source, flags, edition, extra=(), timeout=120, cwd=None): + """Interpret `source` with Miri (the pinned nightly's, sysroot from `cargo miri setup`). + Returns {status, exit, stdout, stderr}; status: ok (ran to the end, any exit code), ub, + unsupported, error (did not compile), timeout.""" + argv = [str(MIRI_TOOLCHAIN / "bin/miri"), "--sysroot", str(MIRI_SYSROOT), str(source), + "--edition", edition or "2015", "-Zunstable-options", "-Ainternal_features", + "-Aincomplete_features", "-Zmiri-disable-isolation", "-Zmiri-deterministic-floats", *flags, *extra] + try: + r = subprocess.run(argv, capture_output=True, timeout=timeout, cwd=cwd, stdin=subprocess.DEVNULL, + env=dict(os.environ, RUSTC_BOOTSTRAP="1", RUST_BACKTRACE="0")) + except subprocess.TimeoutExpired: + return {"status": "timeout", "exit": None, "stdout": "", "stderr": ""} + err = r.stderr.decode(errors="replace") + if "Undefined Behavior:" in err: + status = "ub" + elif "unsupported operation" in err or "can't call foreign function" in err: + status = "unsupported" + elif is_ice(err): + status = "ice" + elif re.search(r"^error(\[E\d+\])?: ", err, re.M) and r.returncode == 1 and "panicked" not in err: + status = "error" + else: + status = "ok" + return {"status": status, "exit": r.returncode, "stdout": r.stdout.decode(errors="replace"), "stderr": err} + + +def drive(todo, one, results, jobs, pause_on_finding): + """Run `one(*item)` for each item on `jobs` threads; each returns (record, findings). Records + go to the `results` file as JSON lines. With `pause_on_finding`, stops starting new items at + the first finding (the frontier loop) and returns 3; otherwise 0.""" + import json + import sys + from concurrent.futures import ThreadPoolExecutor, FIRST_COMPLETED, wait + it = iter(todo) + findings = done = 0 + stop = False + with ThreadPoolExecutor(jobs) as ex, open(results, "a") as out: + pending = set() + + def submit(): + nxt = None if stop else next(it, None) + if nxt is not None: + pending.add(ex.submit(one, *nxt)) + for _ in range(jobs * 2): + submit() + while pending: + finished, _ = wait(pending, return_when=FIRST_COMPLETED) + for fut in finished: + pending.discard(fut) + try: + record, found = fut.result() + except Exception as error: # a harness bug must not stop the run + record, found = {"test": "?", "harness": repr(error)[:300]}, [] + out.write(json.dumps(record) + "\n") + out.flush() + done += 1 + if found: + findings += 1 + print(f"FINDING {record.get('test')}: {record.get('found')}", flush=True) + stop = stop or pause_on_finding + if done % 100 == 0: + print(f"{done}/{len(todo)} done, {findings} with findings", flush=True) + submit() + print(f"{done} tests, {findings} with findings", flush=True) + return 3 if findings and pause_on_finding else 0 From aa9f4ee973c282de1f4c2f1a536e2adc450133a0 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:13:02 +0000 Subject: [PATCH 02/14] release-diff.py (cargo check of real repositories under two toolchains), crash-diff.py (UI tests under a debug-assertions compiler with -Zvalidate-mir); harness fixes: full builds where monomorphization matters, argv[0] and test timings, rewrites without lint attributes, files that cannot move skipped Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- crates/mirth-rewrite/src/main.rs | 29 ++++++--- rustc/crash-diff.py | 93 +++++++++++++++++++++++++++ rustc/opt-diff.py | 21 ++++++- rustc/release-diff.py | 104 +++++++++++++++++++++++++++++++ rustc/rewrite-diff.py | 27 ++++++-- rustc/solver-diff.py | 6 +- 6 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 rustc/crash-diff.py create mode 100644 rustc/release-diff.py diff --git a/crates/mirth-rewrite/src/main.rs b/crates/mirth-rewrite/src/main.rs index 1192cb6..78ac2e4 100644 --- a/crates/mirth-rewrite/src/main.rs +++ b/crates/mirth-rewrite/src/main.rs @@ -105,10 +105,10 @@ fn wrap(f: &mut ItemFn) { }) .collect(); let body = &f.block; + // No attributes (a test may `forbid` the lint they would allow); names no lint objects to. let new: syn::Block = syn::parse_quote!({ - #[allow(non_camel_case_types, unused_mut)] - fn __mirth_inner<__MirthT>(#inputs) #output #body - __mirth_inner::<()>(#(#names),*) + fn _mirth_inner(#inputs) #output #body + _mirth_inner::<()>(#(#names),*) }); // Parameters declared `mut` are mutated in the body, now the inner function's. for arg in f.sig.inputs.iter_mut() { @@ -150,7 +150,17 @@ fn alias(file: &mut syn::File) -> bool { if params_seen.0.contains(&ident.to_string()) { continue; } - let alias = format_ident!("__MirthAlias_{}", ident); + let alias = format_ident!("_MirthAlias{}", ident); + // The type's own `cfg`s: an alias of a configured-out type would name nothing. + let cfgs: Vec<&syn::Attribute> = match item { + Item::Struct(x) => &x.attrs, + Item::Enum(x) => &x.attrs, + Item::Union(x) => &x.attrs, + _ => unreachable!(), + } + .iter() + .filter(|a| a.path().is_ident("cfg") || a.path().is_ident("cfg_attr")) + .collect(); // The alias's parameters: the type's, without bounds (aliases ignore them), with defaults. let mut params = generics.clone(); params.where_clause = None; @@ -187,7 +197,7 @@ fn alias(file: &mut syn::File) -> bool { .collect(); let target = if args.is_empty() { quote!(#ident) } else { quote!(#ident<#(#args),*>) }; new_items.push(syn::parse_quote!( - #[allow(non_camel_case_types, type_alias_bounds, dead_code)] + #(#cfgs)* type #alias #params = #target; )); aliases.insert(ident.to_string(), alias); @@ -253,11 +263,12 @@ fn unused(file: &mut syn::File) -> bool { _ => None, }) .collect(); - if names.contains("__mirth_unused") { + if names.contains("_mirth_unused") { return false; } - file.items.push(syn::parse_quote!(#[allow(dead_code)] fn __mirth_unused() {})); - file.items.push(syn::parse_quote!(#[allow(dead_code)] struct __MirthUnused;)); - file.items.push(syn::parse_quote!(#[allow(dead_code)] trait __MirthUnusedTrait {})); + // Leading underscores keep `dead_code` quiet without an attribute a test could `forbid`. + file.items.push(syn::parse_quote!(fn _mirth_unused() {})); + file.items.push(syn::parse_quote!(struct _MirthUnused;)); + file.items.push(syn::parse_quote!(trait _MirthUnusedTrait {})); true } diff --git a/rustc/crash-diff.py b/rustc/crash-diff.py new file mode 100644 index 0000000..c11f049 --- /dev/null +++ b/rustc/crash-diff.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Internal checks on: what the compiler's own invariants say about every UI test. + +Compiles each standalone UI test with the compiler under test and again with a second compiler +built from the same source with debug assertions (`rust.debug-assertions`), with +`-Zvalidate-mir` added. Findings are a crash, a failed assertion or a MIR validation error +under the second that the first does not have: rustc's invariants failing where release +builds go on silently (19 of the last 1,000 ICE reports needed such a build). + + rustc/crash-diff.py --rustc --checked + --tests /tests/ui --work [--extra "-Zvalidate-mir"] [--only ] + [--known ] [--jobs 8] [--pause-on-finding] [--recheck] +""" + +import argparse +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import uitest # noqa: E402 + +KINDS = ("check-pass", "build-pass", "run-pass", "check-fail", "build-fail", "run-fail", None) + +p = argparse.ArgumentParser() +p.add_argument("--rustc", required=True) +p.add_argument("--checked", required=True) +p.add_argument("--extra", default="-Zvalidate-mir") +p.add_argument("--tests", required=True) +p.add_argument("--work", required=True) +p.add_argument("--only") +p.add_argument("--known") +p.add_argument("--jobs", type=int, default=8) +p.add_argument("--pause-on-finding", action="store_true") +p.add_argument("--recheck", action="store_true") +args = p.parse_args() +WORK = Path(args.work).resolve() +(WORK / "scratch").mkdir(parents=True, exist_ok=True) +known = set(Path(args.known).read_text().split()) if args.known else set() + + +def message(stderr): + """The first line saying what went wrong inside the compiler.""" + for pattern in (r"panicked at [^\n]*\n[^\n]*", r"internal compiler error: [^\n]*", r"broken MIR[^\n]*"): + m = re.search(pattern, stderr) + if m: + return re.sub(r"/\S+/compiler/", "compiler/", m.group(0))[:400] + return "" + + +def one(path, flags, edition, kind): + rel = str(path.relative_to(args.tests)) + emit = "metadata" if kind in ("check-pass", "check-fail", None) else "link" + with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: + d = Path(d) + a, ea, _ = uitest.compile(args.rustc, path.resolve(), d / "release", flags, edition, timeout=300, emit=emit) + b, eb, _ = uitest.compile(args.checked, path.resolve(), d / "checked", flags, edition, args.extra.split(), + timeout=600, emit=emit) + record = {"test": rel, "release": a, "checked": b} + found = [] + if b == "ice" and a != "ice": + found.append({"what": "only with internal checks", "message": message(eb), "stderr": eb[-4000:]}) + elif b == "ice" and a == "ice" and message(ea) != message(eb): + record["note"] = "both crash, differently" + record["found"] = [f"{f['what']}: {f['message'][:160]}" for f in found] + if found: + out = WORK / "findings" / rel.replace("/", "__") + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True) + shutil.copy(path, out / path.name) + (out / "finding.json").write_text(json.dumps({"test": rel, "flags": flags, "edition": edition, + "extra": args.extra, "found": found}, indent=1)) + return record, found + + +def main(): + wanted = None + if args.recheck: + wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} + todo = [] + for path, flags, edition, kind in uitest.tests(args.tests, KINDS): + rel = str(path.relative_to(args.tests)) + if rel in known or (args.only and args.only not in rel) or (wanted is not None and rel not in wanted): + continue + todo.append((path, flags, edition, kind)) + print(f"{len(todo)} tests", flush=True) + sys.exit(uitest.drive(todo, one, WORK / "results.jsonl", args.jobs, args.pause_on_finding)) + + +main() diff --git a/rustc/opt-diff.py b/rustc/opt-diff.py index a6cb8f5..ac3b0bd 100644 --- a/rustc/opt-diff.py +++ b/rustc/opt-diff.py @@ -48,6 +48,14 @@ "O3-native": ["-Copt-level=3", "-Ctarget-cpu=native"], "cranelift": ["-Copt-level=0", "-Zcodegen-backend=cranelift"], } +# Tests whose outcome legitimately depends on optimization: unspecified behavior (whether two +# equal promoted constants share an address), stack usage, or a backend's documented gaps. +NOISE = { + "mir/mir_raw_fat_ptr.rs": {"cranelift"}, # compares the addresses of two `&0u8` + "codegen/StackColoring-not-blowup-stack-issue-40883.rs": {"O0-mir4", "O0"}, # stack usage + "attributes/fn-align-dyn.rs": {"cranelift"}, # Cranelift ignores #[align] on functions + "backtrace/backtrace.rs": {"cranelift"}, # Cranelift backtraces lack frames +} # Tests that choose these themselves are left out: the configuration would contradict them. OWN = re.compile(r"^-O$|opt-level|mir-opt-level|overflow-checks|debug-assertions|codegen-backend|" r"mir-enable-passes|^-Clto|lto=|target-cpu|panic=|-Cpanic|prefer-dynamic|-Zbuild-std") @@ -85,8 +93,15 @@ def normalized(stderr): def observe(binary): - code, out, err = uitest.run(binary) - return {"exit": code, "stdout": out.decode(errors="replace"), "stderr": normalized(err)} + # Every configuration's program runs from the same path: some tests print argv[0]. + fixed = binary.parent.parent / "run" / "prog" + fixed.parent.mkdir(exist_ok=True) + shutil.copy2(binary, fixed) + code, out, err = uitest.run(fixed) + stdout = out.decode(errors="replace") + # The test harness prints how long tests took. + stdout = re.sub(r"finished in \d+\.\d+s", "finished in …s", stdout) + return {"exit": code, "stdout": stdout, "stderr": normalized(err)} def one(path, flags, edition, kind): @@ -114,7 +129,7 @@ def one(path, flags, edition, kind): return record, [] found = [] for name in configs: - if name == "base" or name not in built: + if name == "base" or name not in built or name in NOISE.get(rel, ()): continue b = built[name]["status"] if b != "ok": diff --git a/rustc/release-diff.py b/rustc/release-diff.py new file mode 100644 index 0000000..6710a26 --- /dev/null +++ b/rustc/release-diff.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Release-to-release: code that one toolchain accepts the next must accept too, in comparable +time. + +Runs `cargo check --locked` on each repository of a corpus of real crates with two toolchains +(an older and a newer rustup toolchain, or a local rustc via a rustup-linked name), each in its +own target directory, deleted afterwards. Compared: + + regression the older toolchain checks the repository, the newer one does not (the new + error codes and the first error are recorded) + fixed the other way round (reported, not a finding) + slower the newer toolchain takes more than --slower times as long (both succeed) + ice the newer toolchain crashes + +Dependencies are fetched first (`cargo fetch --locked`), so the timed runs are offline. + + rustc/release-diff.py --corpus --old --new + --work [--only ] [--jobs 2] [--slower 1.5] [--timeout 1800] + +Writes /results.jsonl and prints regressions, crashes and slowdowns. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +p = argparse.ArgumentParser() +p.add_argument("--corpus", required=True) +p.add_argument("--old", required=True) +p.add_argument("--new", required=True) +p.add_argument("--work", required=True) +p.add_argument("--only") +p.add_argument("--jobs", type=int, default=2) +p.add_argument("--slower", type=float, default=1.5) +p.add_argument("--timeout", type=int, default=1800) +args = p.parse_args() +WORK = Path(args.work).resolve() +WORK.mkdir(parents=True, exist_ok=True) + + +def check(repo, toolchain): + target = WORK / "target" / f"{repo.name}-{toolchain}" + shutil.rmtree(target, ignore_errors=True) + env = dict(os.environ, CARGO_TARGET_DIR=str(target), CARGO_TERM_COLOR="never", + CARGO_INCREMENTAL="0", RUSTFLAGS="--cap-lints=warn") + env.pop("RUSTC_WRAPPER", None) + start = time.time() + try: + r = subprocess.run(["cargo", f"+{toolchain}", "check", "--locked", "--offline", "--workspace", + "--message-format=short"], cwd=repo, env=env, capture_output=True, text=True, + timeout=args.timeout) + code, err = r.returncode, r.stderr + except subprocess.TimeoutExpired: + code, err = "timeout", "" + seconds = round(time.time() - start, 1) + shutil.rmtree(target, ignore_errors=True) + ice = "internal compiler error" in err or "the compiler unexpectedly panicked" in err + codes = sorted(set(re.findall(r"error\[(E\d{4})\]", err))) + first = next((l for l in err.splitlines() if re.search(r"\berror(\[E\d+\])?:", l)), "") + return {"code": code, "seconds": seconds, "ice": ice, "codes": codes, "first": first[:300], + "tail": err[-3000:] if code != 0 else ""} + + +def one(repo): + fetch = subprocess.run(["cargo", f"+{args.new}", "fetch", "--locked"], cwd=repo, capture_output=True, + text=True, timeout=1800) + if fetch.returncode != 0: + return {"repo": repo.name, "skip": "fetch failed", "why": fetch.stderr[-400:]} + old = check(repo, args.old) + new = check(repo, args.new) + rec = {"repo": repo.name, "old": old, "new": new, "found": []} + if old["code"] == 0 and new["code"] != 0: + rec["found"].append("ice" if new["ice"] else "regression") + elif old["code"] != 0 and new["code"] == 0: + rec["notes"] = ["fixed"] + elif old["code"] == 0 and new["code"] == 0 and old["seconds"] > 5 and new["seconds"] > args.slower * old["seconds"]: + rec["found"].append(f"slower: {old['seconds']}s -> {new['seconds']}s") + if new["ice"] and "ice" not in rec["found"]: + rec["found"].append("ice") + return rec + + +def main(): + repos = sorted(p for p in Path(args.corpus).iterdir() if (p / "Cargo.toml").exists() + and (not args.only or args.only in p.name)) + print(f"{len(repos)} repositories, {args.old} -> {args.new}", flush=True) + with ThreadPoolExecutor(args.jobs) as ex, (WORK / "results.jsonl").open("a") as out: + for rec in ex.map(one, repos): + out.write(json.dumps(rec) + "\n") + out.flush() + tag = rec.get("skip") or ", ".join(rec.get("found", [])) or "same" + old, new = rec.get("old", {}), rec.get("new", {}) + print(f"{rec['repo']:45} {tag:30} old {old.get('code')} {old.get('seconds')}s " + f"new {new.get('code')} {new.get('seconds')}s {new.get('first', '')[:80]}", flush=True) + + +main() diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py index 463b451..99877a1 100644 --- a/rustc/rewrite-diff.py +++ b/rustc/rewrite-diff.py @@ -34,6 +34,9 @@ REWRITER = Path(__file__).resolve().parent.parent / "target/release/mirth-rewrite" REWRITES = ["generic-wrap", "alias", "reorder", "unused"] STRICT = {"reorder", "unused"} +NOT_MOVABLE = re.compile(r"^\s*(pub(\([^)]*\))?\s+)?mod\s+\w+\s*;|include(_str|_bytes)?!|#\[path|#!\[no_core\]", re.M) +# Item order matters to textual macro scoping: no reordering where macros are defined. +ORDER_MATTERS = re.compile(r"macro_rules!|#\[macro_use\]|macro\s+\w+") KINDS = ("check-pass", "build-pass", "run-pass", "check-fail", "build-fail", "run-fail", None) p = argparse.ArgumentParser() @@ -57,9 +60,12 @@ def codes(stderr): return sorted(set(re.findall(r"error\[(E\d{4})\]", stderr))) -def verdict(source, flags, edition, kind, out): - emit = "metadata" if kind in ("check-pass", "check-fail", None) else "link" - status, stderr, _ = uitest.compile(args.rustc, source, out, flags, edition, timeout=120, emit=emit) +def verdict(source, flags, edition, kind, out, has_main): + # A full build whenever there is a program: generic-wrap moves errors to monomorphization. + emit = "link" if has_main or kind not in ("check-pass", "check-fail", None) else "metadata" + # Lints capped: a rewrite may add or move a warning, and lint levels are not the subject. + status, stderr, _ = uitest.compile(args.rustc, source, out, flags, edition, ["--cap-lints=warn"], + timeout=120, emit=emit) return {"status": status, "codes": codes(stderr), "stderr": stderr[-2500:]} @@ -74,25 +80,34 @@ def rewrite(name, source, target): def one(path, flags, edition, kind): rel = str(path.relative_to(args.tests)) record = {"test": rel, "kind": kind} + text = path.read_text(errors="replace") + # The rewritten file is compiled elsewhere: files it names by relative path are not there. + # Without `core`, a new trait or generic parameter does not compile. + if NOT_MOVABLE.search(text): + record["skip"] = "uses files by path or has no core" + return record, [] with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: d = Path(d) base_src = d / "identity.rs" if not rewrite("identity", path, base_src): record["skip"] = "does not parse" return record, [] - original = verdict(path.resolve(), flags, edition, kind, d / "original") - base = verdict(base_src, flags, edition, kind, d / "identity") + has_main = "fn main" in text + original = verdict(path.resolve(), flags, edition, kind, d / "original", has_main) + base = verdict(base_src, flags, edition, kind, d / "identity", has_main) if (original["status"], original["codes"]) != (base["status"], base["codes"]): record["skip"] = "printing changes the verdict" return record, [] record["base"] = base["status"] found, notes, applied = [], [], [] for name in rewrites: + if name == "reorder" and ORDER_MATTERS.search(text): + continue src = d / f"{name}.rs" if not rewrite(name, path, src): continue applied.append(name) - v = verdict(src, flags, edition, kind, d / name) + v = verdict(src, flags, edition, kind, d / name, has_main) if v["status"] != base["status"] and "timeout" not in (v["status"], base["status"]): found.append({"rewrite": name, "what": f"verdict: {base['status']} -> {v['status']}", "base_codes": base["codes"], "codes": v["codes"], "stderr": v["stderr"], diff --git a/rustc/solver-diff.py b/rustc/solver-diff.py index 52a103c..0fec9da 100644 --- a/rustc/solver-diff.py +++ b/rustc/solver-diff.py @@ -68,9 +68,11 @@ def one(path, flags, edition, kind): with tempfile.TemporaryDirectory(dir=WORK / "scratch") as d: d = Path(d) for name, cfg in CONFIGS.items(): - # Metadata is enough for the verdict: type checking and borrow checking run for it. + # Metadata is enough for check tests (type checking and borrow checking run for it); + # build and run tests get a full build, which reaches monomorphization-time errors. + emit = "metadata" if kind in ("check-pass", "check-fail", None) else "link" status, stderr, _ = uitest.compile(args.rustc, path.resolve(), d / name, flags, edition, cfg, - timeout=120, emit="metadata") + timeout=120, emit=emit) results[name] = {"status": status, "codes": codes(stderr), "stderr": stderr[-2500:]} record["status"] = {k: v["status"] for k, v in results.items()} ref = results[REFERENCE] From 1a65834980709ed9a1d62ad38ba6ceeabe69c42b Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:18:59 +0000 Subject: [PATCH 03/14] opt-diff.py: remapped std and registry paths, test-harness result order, Cranelift's own panics as backend gaps; the sweep over 3,217 runnable UI tests and 13 configurations has no differences left Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- rustc/opt-diff.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/rustc/opt-diff.py b/rustc/opt-diff.py index ac3b0bd..e9910da 100644 --- a/rustc/opt-diff.py +++ b/rustc/opt-diff.py @@ -88,8 +88,10 @@ def normalized(stderr): text = "\n".join(lines) # Panic messages name the thread with its OS id: `thread 'main' (909942) panicked`. text = re.sub(r"(thread '[^']*') \(\d+\)", r"\1", text) - # A toolchain with rust-src prints std's own paths in full. - return re.sub(r"\S*/lib/rustlib/src/rust/library/", "library/", text) + # Toolchains print std's and dependencies' paths differently: in full (rust-src installed), + # remapped to /rustc//, or relative. + text = re.sub(r"\S*/lib/rustlib/src/rust/library/|/rustc/[0-9a-f]+/library/", "library/", text) + return re.sub(r"\S*/registry/(src/)?[^/\s]+/([^/\s]+-\d[^/\s]*)/", r"/\2/", text) def observe(binary): @@ -99,8 +101,13 @@ def observe(binary): shutil.copy2(binary, fixed) code, out, err = uitest.run(fixed) stdout = out.decode(errors="replace") - # The test harness prints how long tests took. + # The test harness prints how long tests took, and runs tests on several threads: their + # result lines come in any order. stdout = re.sub(r"finished in \d+\.\d+s", "finished in …s", stdout) + lines = stdout.split("\n") + results = sorted(l for l in lines if l.startswith("test ") and " ... " in l) + it = iter(results) + stdout = "\n".join(next(it) if (l.startswith("test ") and " ... " in l) else l for l in lines) return {"exit": code, "stdout": stdout, "stderr": normalized(err)} @@ -133,8 +140,9 @@ def one(path, flags, edition, kind): continue b = built[name]["status"] if b != "ok": - # The Cranelift backend has documented gaps (unsupported intrinsics, inline asm). - if name == "cranelift" and b == "error": + # The Cranelift backend has documented gaps (tail calls, some linkages and SIMD + # intrinsics), which it reports as errors or as panics inside itself. + if name == "cranelift" and (b == "error" or "rustc_codegen_cranelift" in built[name]["stderr"]): continue found.append({"config": name, "what": f"build {b}", "stderr": built[name]["stderr"]}) continue From 041f926d54bb3f510a1baa6f1db644b0bb580034 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:24:11 +0000 Subject: [PATCH 04/14] miri-diff.py: no preemption, test-harness and argv[0] normalization, threaded tests not compared natively, UB in tests with their own unsafe code noted, tests that assert unspecified behavior listed; the sweep over 3,094 runnable UI tests has no findings left Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- rustc/miri-diff.py | 58 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/rustc/miri-diff.py b/rustc/miri-diff.py index 7fac7ce..489d3ea 100644 --- a/rustc/miri-diff.py +++ b/rustc/miri-diff.py @@ -7,8 +7,9 @@ `-Zmir-opt-level=4` (every MIR pass), and builds and runs it with the compiler under test, unoptimized. Findings: - ub Miri reports undefined behavior in an accepted program at mir-opt-level 0: the - compiler accepted something unsound, or the test's own unsafe code is wrong + ub Miri reports undefined behavior at mir-opt-level 0 in an accepted program without + `unsafe` code: the compiler accepted something unsound (UB in a test with its own + unsafe code is noted, not reported) ub-opt undefined behavior only after MIR optimization: a MIR pass broke the program opt-differs Miri's exit status or stdout changes with the MIR optimization level native the compiled program's exit status or stdout differs from Miri's @@ -34,6 +35,17 @@ import uitest # noqa: E402 FIXED = ["-Coverflow-checks=on", "-Cdebug-assertions=on"] +# Miri without preemption: threads switch only where they block, the same at every MIR level. +MIRI_FLAGS = ["-Zmiri-preemption-rate=0"] +# Tests asserting what Rust leaves unspecified, which Miri varies on purpose: function pointer +# equality, the addresses of zero-sized values, stack addresses, function alignment. +UNSPECIFIED = {"consts/const-extern-function.rs", "consts/zst_no_llvm_alloc.rs", + "layout/null-pointer-optimization.rs", "mir/mir_misc_casts.rs", "mir/mir_coercions.rs", + "extern/extern-compare-with-return-type.rs", "fn/fn-ptr-trait-run.rs", "mir/mir_raw_fat_ptr.rs", + "codegen/StackColoring-not-blowup-stack-issue-40883.rs", "attributes/fn-align-dyn.rs", + # Miri calls .init_array functions without glibc's (argc, argv, envp). + "runtime/stdout-before-main.rs"} +THREADS = re.compile(r"thread::(spawn|scope)|std::sync::mpsc|\bspawn\(") LEVELS = {"miri0": [], "miri2": ["-Zmir-opt-level=2"], "miri4": ["-Zmir-opt-level=4"]} OWN = re.compile(r"^-O$|opt-level|overflow-checks|debug-assertions|codegen-backend|mir-enable-passes|" r"panic=|-Cpanic|prefer-dynamic|-Zbuild-std|-Clink|-Ctarget") @@ -57,9 +69,17 @@ known = set(Path(args.known).read_text().split()) if args.known else set() -def clean(text): +def clean(text, path): text = re.sub(r"(thread '[^']*') \(\d+\)", r"\1", text) - return re.sub(r"\S*/lib/rustlib/src/rust/library/", "library/", text) + text = re.sub(r"\S*/lib/rustlib/src/rust/library/", "library/", text) + # argv[0]: the source file under Miri, the binary natively. + text = text.replace(str(path.resolve()), "") + text = re.sub(r"\S*/native/prog\b", "", text) + # The test harness: timings, and result lines in completion order. + text = re.sub(r"finished in \d+\.\d+s", "finished in …s", text) + lines = text.split("\n") + results = iter(sorted(l for l in lines if l.startswith("test ") and " ... " in l)) + return "\n".join(next(results) if (l.startswith("test ") and " ... " in l) else l for l in lines) def one(path, flags, edition, kind): @@ -72,16 +92,17 @@ def one(path, flags, edition, kind): if binary is None: record["skip"] = f"native build {status}" return record, [] - code, out, _ = uitest.run(binary) - native = {"exit": code, "stdout": out.decode(errors="replace")} - again = uitest.run(binary) - if (again[0], again[1].decode(errors="replace")) != (native["exit"], native["stdout"]): + runs = [uitest.run(binary) for _ in range(3)] + outs = {(c, clean(o.decode(errors="replace"), path)) for c, o, _ in runs} + if len(outs) > 1: record["skip"] = "native run is nondeterministic" return record, [] + code, out = outs.pop() + native = {"exit": code, "stdout": out} miri = {} for name, extra in LEVELS.items(): - m = uitest.miri(path.resolve(), flags, edition, FIXED + extra, timeout=args.timeout, cwd=d) - m["stdout"] = clean(m["stdout"]) + m = uitest.miri(path.resolve(), flags, edition, FIXED + MIRI_FLAGS + extra, timeout=args.timeout, cwd=d) + m["stdout"] = clean(m["stdout"], path) miri[name] = m if name == "miri0" and m["status"] in ("unsupported", "error", "timeout"): record["skip"] = f"miri {m['status']}" @@ -89,8 +110,13 @@ def one(path, flags, edition, kind): return record, [] record["miri"] = {k: v["status"] for k, v in miri.items()} found = [] - if miri["miri0"]["status"] == "ub": - found.append({"what": "ub", "stderr": miri["miri0"]["stderr"][-3000:]}) + # UB in a program without `unsafe` code can only be the compiler's; in a test with its + # own unsafe code it is most likely the test's (noted, not a finding). + safe = "unsafe" not in path.read_text(errors="replace") + if miri["miri0"]["status"] == "ub" and safe and rel not in UNSPECIFIED: + found.append({"what": "ub (safe code)", "stderr": miri["miri0"]["stderr"][-3000:]}) + elif miri["miri0"]["status"] == "ub": + record["note"] = "ub in a test with unsafe code" for name in ("miri2", "miri4"): m = miri[name] if m["status"] == "ub" and miri["miri0"]["status"] != "ub": @@ -102,7 +128,9 @@ def one(path, flags, edition, kind): found.append({"what": f"opt-differs ({name})", "miri0": miri["miri0"]["stdout"][-1500:], "got": m["stdout"][-1500:], "exits": [miri["miri0"]["exit"], m["exit"]]}) m0 = miri["miri0"] - if m0["status"] == "ok": + threaded = THREADS.search(path.read_text(errors="replace")) + # Native threads race; Miri's do not without preemption: no comparison for threaded tests. + if m0["status"] == "ok" and not threaded and rel not in UNSPECIFIED: # Miri exits 1 on a panic that reaches main, native code 101. exit_m = 101 if m0["exit"] == 1 and "panicked" in m0["stderr"] else m0["exit"] if (exit_m, m0["stdout"]) != (native["exit"], native["stdout"]): @@ -126,7 +154,9 @@ def main(): todo = [] for path, flags, edition, kind in uitest.tests( args.tests, ("run-pass", "run-fail"), - lambda text, flags: any(OWN.search(f) for f in flags) or NOT_FOR_MIRI.search(text)): + lambda text, flags: any(OWN.search(f) for f in flags) or NOT_FOR_MIRI.search(text) + # Compile-time output (trace_macros, log_syntax) would land in Miri's stdout. + or "trace_macros" in text or "log_syntax" in text): rel = str(path.relative_to(args.tests)) if rel in known or (args.only and args.only not in rel) or (wanted is not None and rel not in wanted): continue From f6255d2a7f47261203d529d0cd0577451069750d Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:24:47 +0000 Subject: [PATCH 05/14] miri-diff.py: threaded tests not compared across MIR levels either Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- rustc/miri-diff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rustc/miri-diff.py b/rustc/miri-diff.py index 489d3ea..a0bf77c 100644 --- a/rustc/miri-diff.py +++ b/rustc/miri-diff.py @@ -117,18 +117,18 @@ def one(path, flags, edition, kind): found.append({"what": "ub (safe code)", "stderr": miri["miri0"]["stderr"][-3000:]}) elif miri["miri0"]["status"] == "ub": record["note"] = "ub in a test with unsafe code" + threaded = THREADS.search(path.read_text(errors="replace")) for name in ("miri2", "miri4"): m = miri[name] if m["status"] == "ub" and miri["miri0"]["status"] != "ub": found.append({"what": f"ub-opt ({name})", "stderr": m["stderr"][-3000:]}) elif m["status"] == "ice": found.append({"what": f"ice ({name})", "stderr": m["stderr"][-3000:]}) - elif (m["status"] == "ok" and miri["miri0"]["status"] == "ok" + elif (m["status"] == "ok" and miri["miri0"]["status"] == "ok" and not threaded and (m["exit"], m["stdout"]) != (miri["miri0"]["exit"], miri["miri0"]["stdout"])): found.append({"what": f"opt-differs ({name})", "miri0": miri["miri0"]["stdout"][-1500:], "got": m["stdout"][-1500:], "exits": [miri["miri0"]["exit"], m["exit"]]}) m0 = miri["miri0"] - threaded = THREADS.search(path.read_text(errors="replace")) # Native threads race; Miri's do not without preemption: no comparison for threaded tests. if m0["status"] == "ok" and not threaded and rel not in UNSPECIFIED: # Miri exits 1 on a panic that reaches main, native code 101. From 941a9abe2bd4928e8ab45f06bd87c586119e0135 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:26:55 +0000 Subject: [PATCH 06/14] mirth-rewrite alias: names defined once only, no lifetime or defaulted parameters, cfg (not cfg_attr) copied, inline modules untouched; rewrite-diff.py: a private copy of the rewriter per sweep, only verdicts are findings, generic-wrap skips generic_const_exprs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- crates/mirth-rewrite/src/main.rs | 40 ++++++++++++++++++++++++++++++-- rustc/rewrite-diff.py | 18 +++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/mirth-rewrite/src/main.rs b/crates/mirth-rewrite/src/main.rs index 78ac2e4..0586229 100644 --- a/crates/mirth-rewrite/src/main.rs +++ b/crates/mirth-rewrite/src/main.rs @@ -138,6 +138,29 @@ fn alias(file: &mut syn::File) -> bool { } let mut params_seen = Params(BTreeSet::new()); params_seen.visit_file_mut(&mut file.clone()); + // Types defined more than once (a nested item shadowing a top-level one): a bare name may + // mean either. + struct Defined(BTreeMap); + impl VisitMut for Defined { + fn visit_item_struct_mut(&mut self, i: &mut syn::ItemStruct) { + *self.0.entry(i.ident.to_string()).or_default() += 1; + syn::visit_mut::visit_item_struct_mut(self, i); + } + fn visit_item_enum_mut(&mut self, i: &mut syn::ItemEnum) { + *self.0.entry(i.ident.to_string()).or_default() += 1; + syn::visit_mut::visit_item_enum_mut(self, i); + } + fn visit_item_union_mut(&mut self, i: &mut syn::ItemUnion) { + *self.0.entry(i.ident.to_string()).or_default() += 1; + syn::visit_mut::visit_item_union_mut(self, i); + } + fn visit_item_type_mut(&mut self, i: &mut syn::ItemType) { + *self.0.entry(i.ident.to_string()).or_default() += 1; + syn::visit_mut::visit_item_type_mut(self, i); + } + } + let mut defined = Defined(BTreeMap::new()); + defined.visit_file_mut(&mut file.clone()); let mut aliases = BTreeMap::new(); let mut new_items = Vec::new(); for item in &file.items { @@ -147,7 +170,16 @@ fn alias(file: &mut syn::File) -> bool { Item::Union(u) => (&u.ident, &u.generics), _ => continue, }; - if params_seen.0.contains(&ident.to_string()) { + if params_seen.0.contains(&ident.to_string()) || defined.0.get(&ident.to_string()) != Some(&1) { + continue; + } + // Lifetime parameters elide differently through an alias; defaults may name other + // parameters: such types are left alone. + if generics.params.iter().any(|p| match p { + GenericParam::Lifetime(_) => true, + GenericParam::Type(t) => t.default.is_some(), + GenericParam::Const(c) => c.default.is_some(), + }) { continue; } let alias = format_ident!("_MirthAlias{}", ident); @@ -159,7 +191,8 @@ fn alias(file: &mut syn::File) -> bool { _ => unreachable!(), } .iter() - .filter(|a| a.path().is_ident("cfg") || a.path().is_ident("cfg_attr")) + // `cfg` only: a `cfg_attr` may expand to a `derive`, which an alias cannot have. + .filter(|a| a.path().is_ident("cfg")) .collect(); // The alias's parameters: the type's, without bounds (aliases ignore them), with defaults. let mut params = generics.clone(); @@ -228,6 +261,9 @@ fn alias(file: &mut syn::File) -> bool { fn visit_item_union_mut(&mut self, _: &mut syn::ItemUnion) {} // Macros' tokens are not types to `syn`; derive input stays as it is. fn visit_macro_mut(&mut self, _: &mut syn::Macro) {} + // In an inline module the bare name reaches the type through a `use`, the alias would + // need one too: modules are left as they are. + fn visit_item_mod_mut(&mut self, _: &mut syn::ItemMod) {} } let mut rename = Rename { aliases: &aliases, count: 0 }; for item in &mut file.items { diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py index 99877a1..81137ee 100644 --- a/rustc/rewrite-diff.py +++ b/rustc/rewrite-diff.py @@ -33,7 +33,13 @@ REWRITER = Path(__file__).resolve().parent.parent / "target/release/mirth-rewrite" REWRITES = ["generic-wrap", "alias", "reorder", "unused"] -STRICT = {"reorder", "unused"} +# Error codes may differ legitimately for every rewrite (which error suppresses which depends on +# order): only a changed verdict is a finding. +STRICT = set() +# Differences that are resource limits or legitimate requirements of a generic context. +NOISE = { + ("consts/chained-constants-stackoverflow.rs", "reorder"), # 10,000 chained consts: query depth +} NOT_MOVABLE = re.compile(r"^\s*(pub(\([^)]*\))?\s+)?mod\s+\w+\s*;|include(_str|_bytes)?!|#\[path|#!\[no_core\]", re.M) # Item order matters to textual macro scoping: no reordering where macros are defined. ORDER_MATTERS = re.compile(r"macro_rules!|#\[macro_use\]|macro\s+\w+") @@ -103,6 +109,12 @@ def one(path, flags, edition, kind): for name in rewrites: if name == "reorder" and ORDER_MATTERS.search(text): continue + if (rel, name) in NOISE: + continue + # generic_const_exprs requires `where` bounds in generic contexts that a concrete one + # does not. + if name == "generic-wrap" and "generic_const_exprs" in text: + continue src = d / f"{name}.rs" if not rewrite(name, path, src): continue @@ -136,8 +148,12 @@ def one(path, flags, edition, kind): def main(): + global REWRITER if not REWRITER.exists(): sys.exit("build mirth-rewrite first: cargo build --release -p mirth-rewrite") + # A private copy: rebuilding mirth-rewrite must not change a sweep halfway. + shutil.copy2(REWRITER, WORK / "mirth-rewrite") + REWRITER = WORK / "mirth-rewrite" wanted = None if args.recheck: wanted = {json.loads((f / "finding.json").read_text())["test"] for f in (WORK / "findings").glob("*")} From d3a5cb62671c33891c4efb5966f9f3fa984a35f0 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Fri, 9 Oct 2026 23:36:16 +0000 Subject: [PATCH 07/14] Findings 21-24: rustc's internal checks (MIR validation, debug assertions) failing on 17 UI tests that pass the release compiler; crash-known.txt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- docs/hunt.md | 4 ++ docs/hunt/internal-checks.md | 83 ++++++++++++++++++++++++++++++++++++ rustc/crash-known.txt | 17 ++++++++ 3 files changed, 104 insertions(+) create mode 100644 docs/hunt/internal-checks.md create mode 100644 rustc/crash-known.txt diff --git a/docs/hunt.md b/docs/hunt.md index 9da3af4..4d593ac 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -44,6 +44,10 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 18 | after a fatal error (a missing lang item), an incremental rebuild reports fewer errors than a clean build: the fatal error is reached in a different query order | diagnostics only; found by the UI-test fuzzer; stock nightly; [facts](hunt/fatal-error-order.md); labelled known in `ui-fuzz.py` | | 19 | on riscv64 and loongarch64, an `extern "C"` call passes an `i32` (or narrower integer) that lands on the stack without sign-extending it; a clang-compiled callee reads the slot as already extended | **looks new**; ABI, stable code; found by the ABI differential against clang ([`checks.md`](checks.md)); since at least 1.80; cause found (extension only `if *avail_gprs >= 1` in `callconv/riscv.rs`, same in `loongarch.rs`); [facts](hunt/riscv-stack-arg-extension.md) | | 20 | on RISC-V and LoongArch hard-float targets, a `repr(C)` struct of one float and one pointer is passed in a floating-point and an integer register; clang passes it by the integer convention, so C and Rust disagree on where it is | **looks new**; ABI, stable code; found by the ABI differential; since at least 1.80; cause found (`Primitive::Pointer` counted as an integer in `should_use_fp_conv_helper`, `callconv/riscv.rs` and `loongarch.rs`); [facts](hunt/riscv-float-pointer-struct.md) | +| 21 | `-Zvalidate-mir` rejects MIR the compiler builds from accepted code: projections into `#[repr(simd)]` types (banned by MCP#838) in 9 SIMD tests, and an unsize coercion to `Pin>` in `async-await/issue-86507.rs` | found by the internal-checks sweep (`crash-diff.py`); stock nightly with `-Zvalidate-mir`; compiletest does not validate UI tests; [facts](hunt/internal-checks.md) | +| 22 | the new trait solver trips a debug assertion in region outlives (`regions.rs:37`, `!type_outlives.has_non_rigid_aliases()`) on 5 UI tests | debug-assertion builds with nightly's default solver; hidden in CI by compiletest's solver pin; a sibling of closed #160206; [facts](hunt/internal-checks.md) | +| 23 | an `attempt to add with overflow` in `ty/instance.rs:421` compiling `recursion/issue-83150.rs` under the new solver | overflow-checked builds; hidden by the solver pin; [facts](hunt/internal-checks.md) | +| 24 | `-Zvalidate-mir` rejects a move of a dereferenced unsized place into a call (`unsized-locals/unsized-exprs2.rs`) | incomplete `unsized_fn_params`; [facts](hunt/internal-checks.md) | 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 diff --git a/docs/hunt/internal-checks.md b/docs/hunt/internal-checks.md new file mode 100644 index 0000000..1af986d --- /dev/null +++ b/docs/hunt/internal-checks.md @@ -0,0 +1,83 @@ +# rustc's internal checks on the UI tests + +Facts for findings 21 to 24. Found by `rustc/crash-diff.py` ([`checks.md`](../checks.md), check 19): +every standalone UI test (18,624) compiled with the release compiler under test and again with a +compiler built from the same tree with `rust.debug-assertions = true`, +`rust.debug-assertions-std = true` and `rust.overflow-checks = true`, plus `-Zvalidate-mir`. +17 tests pass the release compiler and fail the internal checks. They fall into four groups. + +Why rustc's CI does not see them: compiletest does not pass `-Zvalidate-mir` to UI tests (groups +21 and 24), and it pins `-Znext-solver=coherence`, while nightly's default is the new solver +everywhere ([`solver.md`](../solver.md)). Groups 22 and 23 pass under the pinned solver. + +| finding | tests | fires with | release + `-Zvalidate-mir` | debug assertions alone | debug assertions + old solver | +|---|---:|---|---|---|---| +| 21 | 10 | `-Zvalidate-mir` | ICE | passes | ICE | +| 22 | 5 | debug assertions and the new solver | passes | ICE | passes | +| 23 | 1 | debug assertions (overflow checks) and the new solver | passes | ICE | passes | +| 24 | 1 | `-Zvalidate-mir` | ICE | passes | ICE | + +## 21. MIR validation: SIMD field projections, and an unsize coercion + +`compiler/rustc_mir_transform/src/validate.rs:81`, after `LintAndRemoveUninhabited`. + +Nine tests: "Projecting into SIMD type … is banned by MCP#838". The projections come from field +access on `#[repr(simd)]` structs and from derived impls on them (`{impl#1}::clone` in +`simd/shuffle.rs`, `{impl#10}::eq` in `simd/intrinsic/generic-select.rs`, a const in +`consts/const-eval/simd/insert_extract.rs`). Type checking accepts the code, and the validator +then rejects the MIR built from it. + +- `simd/shuffle.rs`, `simd/monomorphize-shuffle-index.rs`, `simd/masked-load-store-build-fail.rs` +- `simd/intrinsic/generic-arithmetic-saturating-2.rs`, `generic-gather-scatter.rs`, + `generic-select.rs`, `generic-shuffle.rs`, `inlining-issue67557-ice.rs` +- `consts/const-eval/simd/insert_extract.rs` + +One test: `async-await/issue-86507.rs`, in `{impl#0}::bar`: "Unsize coercion, but +`Pin>` isn't coercible to `Pin + Send + '_>>`". +Type checking accepted the coercion. The validator, in its own typing environment, cannot prove +it. This test is the regression test for #86507 (an earlier ICE). + +## 22. New-solver assertion in region outlives + +`compiler/rustc_trait_selection/src/regions.rs:37`: +`assertion failed: !infcx.next_trait_solver() || !type_outlives.has_non_rigid_aliases()`. +Reached only with debug assertions and the new solver (nightly's default): + +- `pattern/usefulness/impl-trait.rs` +- `type-alias-impl-trait/implied_bounds2.rs`, `implied_lifetime_wf_check3.rs`, + `implied_lifetime_wf_check4_static.rs`, `unbounded_opaque_type.rs` + +Related: #160206 (closed), a sibling assertion in the same code path +(`!tcx.next_trait_solver_globally() || !(verify_if_eq.ty, test_ty).has_non_rigid_aliases()`). +This one was not found in the issue tracker. + +## 23. Integer overflow in `Instance` resolution under the new solver + +`compiler/rustc_middle/src/ty/instance.rs:421`: `attempt to add with overflow`, compiling +`recursion/issue-83150.rs` (which expects a recursion-limit error). Overflow checks on, new solver. +With overflow checks off (release builds) the addition wraps silently; what follows was not +investigated. + +## 24. MIR validation: moving a dereferenced unsized place into a call + +`validate.rs:444`: "encountered `Move` of a non-local, non-box place in `Call` terminator: +`_1 = udrop::<[u8]>(move (*_2))`", compiling `unsized-locals/unsized-exprs2.rs` (the incomplete +`unsized_fn_params` feature). + +## Reproduction + +``` +rustc + tests/ui/simd/shuffle.rs -Zvalidate-mir # 21 +rustc + tests/ui/type-alias-impl-trait/implied_bounds2.rs # 22 +rustc + tests/ui/recursion/issue-83150.rs # 23 +rustc + tests/ui/unsized-locals/unsized-exprs2.rs -Zvalidate-mir # 24 +``` + +Each with the test's own `//@ compile-flags` and edition. The debug-assertions build is +`./x.py build --stage 1 compiler/rustc library --set rust.debug-assertions=true --set +rust.debug-assertions-std=true --set rust.overflow-checks=true` (mirth: `~/mirth-work/build-da`). + +## Local stopgap + +None: these are checks failing, not wrong output, and they do not affect mirth's incremental +checks. `crash-diff.py` takes them as `--known` (`rustc/crash-known.txt`). diff --git a/rustc/crash-known.txt b/rustc/crash-known.txt new file mode 100644 index 0000000..e032f4c --- /dev/null +++ b/rustc/crash-known.txt @@ -0,0 +1,17 @@ +async-await/issue-86507.rs +consts/const-eval/simd/insert_extract.rs +pattern/usefulness/impl-trait.rs +recursion/issue-83150.rs +simd/intrinsic/generic-arithmetic-saturating-2.rs +simd/intrinsic/generic-gather-scatter.rs +simd/intrinsic/generic-select.rs +simd/intrinsic/generic-shuffle.rs +simd/intrinsic/inlining-issue67557-ice.rs +simd/masked-load-store-build-fail.rs +simd/monomorphize-shuffle-index.rs +simd/shuffle.rs +type-alias-impl-trait/implied_bounds2.rs +type-alias-impl-trait/implied_lifetime_wf_check3.rs +type-alias-impl-trait/implied_lifetime_wf_check4_static.rs +type-alias-impl-trait/unbounded_opaque_type.rs +unsized-locals/unsized-exprs2.rs From ca32538b47f285688d39eb6d7f05066a67ede8a5 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 00:14:26 +0000 Subject: [PATCH 08/14] Finding 25: an invalid constant's error depends on genericity and the MIR opt level; alias rewrite leaves receivers alone Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- crates/mirth-rewrite/src/main.rs | 3 + docs/hunt.md | 1 + docs/hunt/promoted-validation-generic.md | 71 ++++++++++++++++++++++++ rustc/rewrite-diff.py | 1 + 4 files changed, 76 insertions(+) create mode 100644 docs/hunt/promoted-validation-generic.md diff --git a/crates/mirth-rewrite/src/main.rs b/crates/mirth-rewrite/src/main.rs index 0586229..7aa2819 100644 --- a/crates/mirth-rewrite/src/main.rs +++ b/crates/mirth-rewrite/src/main.rs @@ -264,6 +264,9 @@ fn alias(file: &mut syn::File) -> bool { // In an inline module the bare name reaches the type through a `use`, the alias would // need one too: modules are left as they are. fn visit_item_mod_mut(&mut self, _: &mut syn::ItemMod) {} + // A receiver typed with the impl's own name (`self: &mut Test`) elides lifetimes like + // `&mut self`; through an alias it does not (resolution does not see through aliases). + fn visit_receiver_mut(&mut self, _: &mut syn::Receiver) {} } let mut rename = Rename { aliases: &aliases, count: 0 }; for item in &mut file.items { diff --git a/docs/hunt.md b/docs/hunt.md index 4d593ac..ec6adff 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -48,6 +48,7 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 22 | the new trait solver trips a debug assertion in region outlives (`regions.rs:37`, `!type_outlives.has_non_rigid_aliases()`) on 5 UI tests | debug-assertion builds with nightly's default solver; hidden in CI by compiletest's solver pin; a sibling of closed #160206; [facts](hunt/internal-checks.md) | | 23 | an `attempt to add with overflow` in `ty/instance.rs:421` compiling `recursion/issue-83150.rs` under the new solver | overflow-checked builds; hidden by the solver pin; [facts](hunt/internal-checks.md) | | 24 | `-Zvalidate-mir` rejects a move of a dereferenced unsized place into a call (`unsized-locals/unsized-exprs2.rs`) | incomplete `unsized_fn_params`; [facts](hunt/internal-checks.md) | +| 25 | an invalid constant (E0080, `UnsafeCell` in read-only memory) is rejected when an unused `let _ = &C` is in a non-generic function, and accepted when it is in a generic one, unless `-Zmir-opt-level=0`: a MIR pass removes the promoted's last use and nothing validates it at monomorphization | **looks new**; stable code; found by the equivalent-rewrite differential (`generic-wrap`); since at least 1.80; pass located (`SimplifyLocals-before-const-prop` removes the last use), cause not narrowed further; [facts](hunt/promoted-validation-generic.md) | 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 diff --git a/docs/hunt/promoted-validation-generic.md b/docs/hunt/promoted-validation-generic.md new file mode 100644 index 0000000..9a4dbaf --- /dev/null +++ b/docs/hunt/promoted-validation-generic.md @@ -0,0 +1,71 @@ +# An invalid constant is accepted when its unused reference sits in a generic function + +Facts for finding 25. Found by the equivalent-rewrite differential (`rustc/rewrite-diff.py`, the +`generic-wrap` rewrite: a function's body moved into a generic inner function called with +`()`) on `tests/ui/consts/interior-mut-const-via-union.rs`. + +## What happens + +The test builds a constant `C: S` whose enum tag is changed through a raw pointer, so that it +holds an `UnsafeCell` (through a union). Taking `&C` is rejected with E0080 ("constructing invalid +value of type &S: … encountered `UnsafeCell` in read-only memory"). Whether that error is reported +depends on where the reference is taken and on the MIR optimization level: + +| `C` as in the test; then | rustc (pinned nightly, also 1.80.0, 1.90.0, 1.98.0) | +|---|---| +| `fn main() { let _: &'static _ = &C; }` | E0080 | +| `fn inner() { let _: &'static _ = &C; } fn main() { inner() }` | E0080 | +| `fn inner() { let _: &'static _ = &C; } fn main() { inner::<()>() }` | **compiles** | +| the same, `-Zmir-opt-level=0` | E0080 | +| the same with `let _x: &'static S = &C;` | E0080 | +| `fn inner() -> &'static S { &C }` used by `main` | E0080 | + +`-Copt-level` makes no difference. The program is the same in every row: the reference is taken +in a function that is instantiated and called. + +## Where it goes + +`-Zdump-mir=inner` at `-Zmir-opt-level=1`. Up to `LowerIntrinsics` the body has +`_4 = const inner::::promoted[0]; _2 = &(*_4); _1 = &(*_2); PlaceMention(_1);`. +`RemovePlaceMention` drops the mention, `SimplifyCfg-pre-optimizations` and +`InstSimplify-before-inline` simplify the copies, and `SimplifyLocals-before-const-prop` removes +the last statement using `promoted[0]`. A non-generic body's promoteds are evaluated during +analysis, before any of this. A generic body's promoted is evaluated at monomorphization: when +code generation uses it (`_x`, the returned reference) it is validated and the error appears. When +the use has been optimized away, nothing reports the error, although the promoted should still be +evaluated as a required constant of the body. Not narrowed further: whether the required-constant +evaluation runs without validation, or the promoted is missing from `required_consts`. + +## Expected + +The same verdict in every row. Either the invalid value is an error wherever it is referenced +(as in the non-generic and `-Zmir-opt-level=0` cases), or nowhere. The MIR optimization level and +whether a function is generic should not decide whether a program compiles. + +## Reproduction + +```rust +use std::cell::Cell; +use std::mem::ManuallyDrop; + +#[repr(C)] struct S { x: u32, y: E } +#[repr(u32)] enum E { A, B(U) } +union U { cell: ManuallyDrop> } + +const C: S = { + let mut s = S { x: 0, y: E::A }; + let p = &mut s.x as *mut u32; + unsafe { *p.add(1) = 1 }; + s +}; + +fn inner() { let _: &'static _ = &C; } +fn main() { inner::<()>() } +``` + +`rustc generic.rs` compiles. `rustc -Zmir-opt-level=0 generic.rs` gives E0080. Without the type +parameter it gives E0080 at every level. + +## Local stopgap + +None; not an incremental difference. `rewrite-diff.py` lists the test as known for `generic-wrap`. diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py index 81137ee..ba34eb3 100644 --- a/rustc/rewrite-diff.py +++ b/rustc/rewrite-diff.py @@ -39,6 +39,7 @@ # Differences that are resource limits or legitimate requirements of a generic context. NOISE = { ("consts/chained-constants-stackoverflow.rs", "reorder"), # 10,000 chained consts: query depth + ("consts/interior-mut-const-via-union.rs", "generic-wrap"), # finding 25 (docs/hunt.md) } NOT_MOVABLE = re.compile(r"^\s*(pub(\([^)]*\))?\s+)?mod\s+\w+\s*;|include(_str|_bytes)?!|#\[path|#!\[no_core\]", re.M) # Item order matters to textual macro scoping: no reordering where macros are defined. From c271271907cf7ebc4e11e27e622d554d7e2cd382 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 00:15:05 +0000 Subject: [PATCH 09/14] release-diff.py: regressions in crates that enable unstable features are noted, not reported Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- rustc/release-diff.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/rustc/release-diff.py b/rustc/release-diff.py index 6710a26..87d2ebd 100644 --- a/rustc/release-diff.py +++ b/rustc/release-diff.py @@ -7,7 +7,8 @@ own target directory, deleted afterwards. Compared: regression the older toolchain checks the repository, the newer one does not (the new - error codes and the first error are recorded) + error codes and the first error are recorded); noted instead when the failing crate + enables unstable features (`#![feature]`, often only when it detects a nightly) fixed the other way round (reported, not a finding) slower the newer toolchain takes more than --slower times as long (both succeed) ice the newer toolchain crashes @@ -68,6 +69,19 @@ def check(repo, toolchain): "tail": err[-3000:] if code != 0 else ""} +def uses_unstable(first_error): + """The crate a first error points into, if its source enables `#![feature(...)]`.""" + m = re.search(r"(/\S*?/registry/src/[^/]+/[^/]+|/\S+?)/src/", first_error) + if not m: + return None + root = Path(m.group(1)) + for f in list((root / "src").glob("lib.rs")) + list((root / "src").glob("main.rs")): + text = f.read_text(errors="replace") + if re.search(r"#!\[(cfg_attr\([^]]*)?feature\(", text): + return root.name + return None + + def one(repo): fetch = subprocess.run(["cargo", f"+{args.new}", "fetch", "--locked"], cwd=repo, capture_output=True, text=True, timeout=1800) @@ -77,7 +91,13 @@ def one(repo): new = check(repo, args.new) rec = {"repo": repo.name, "old": old, "new": new, "found": []} if old["code"] == 0 and new["code"] != 0: - rec["found"].append("ice" if new["ice"] else "regression") + unstable = uses_unstable(new["first"]) + if unstable and not new["ice"]: + # A crate that turns on unstable features when it detects a nightly compiler breaks + # when they change: expected between nightlies, noted rather than reported. + rec["notes"] = [f"regression in a crate using unstable features ({unstable})"] + else: + rec["found"].append("ice" if new["ice"] else "regression") elif old["code"] != 0 and new["code"] == 0: rec["notes"] = ["fixed"] elif old["code"] == 0 and new["code"] == 0 and old["seconds"] > 5 and new["seconds"] > args.slower * old["seconds"]: From c4d2314f57aaa3f0c2b9dddfb9196ca367e3cc83 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 04:49:51 +0000 Subject: [PATCH 10/14] Findings 26 (never-type fallback change breaks meilisearch with no prior warning) and 27 (new solver: parameter reached only through a projection not inferred; surrealdb via diskann-wide); alias rewrite leaves impl self types and attributed parameters alone Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- crates/mirth-rewrite/src/main.rs | 11 ++- docs/hunt.md | 2 + docs/hunt/release-regressions.md | 77 +++++++++++++++++++ docs/hunt/tests/never-fallback-cast.rs | 11 +++ .../tests/next-solver-fn-ptr-projection.rs | 14 ++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 docs/hunt/release-regressions.md create mode 100644 docs/hunt/tests/never-fallback-cast.rs create mode 100644 docs/hunt/tests/next-solver-fn-ptr-projection.rs diff --git a/crates/mirth-rewrite/src/main.rs b/crates/mirth-rewrite/src/main.rs index 7aa2819..93b037e 100644 --- a/crates/mirth-rewrite/src/main.rs +++ b/crates/mirth-rewrite/src/main.rs @@ -26,7 +26,7 @@ use std::process::exit; use proc_macro2::Span; use quote::{ToTokens, format_ident, quote}; use syn::visit_mut::VisitMut; -use syn::{FnArg, GenericParam, Ident, Item, ItemFn, Pat, TypePath}; +use syn::{FnArg, GenericParam, Ident, Item, ItemFn, Pat, Type, TypePath}; fn main() { let args: Vec = std::env::args().collect(); @@ -86,7 +86,8 @@ fn wrappable(f: &ItemFn) -> bool { && f.attrs.iter().all(|a| a.path().is_ident("allow") || a.path().is_ident("inline")) && !sig.to_token_stream().to_string().contains("impl ") && !sig.to_token_stream().to_string().contains('\'') - && sig.inputs.iter().all(|arg| matches!(arg, FnArg::Typed(t) if matches!(&*t.pat, Pat::Ident(p) if p.by_ref.is_none() && p.subpat.is_none()))) + // Parameters without attributes: a `#[cfg]`-ed out one cannot be passed on by name. + && sig.inputs.iter().all(|arg| matches!(arg, FnArg::Typed(t) if t.attrs.is_empty() && matches!(&*t.pat, Pat::Ident(p) if p.by_ref.is_none() && p.subpat.is_none()))) } fn wrap(f: &mut ItemFn) { @@ -267,6 +268,12 @@ fn alias(file: &mut syn::File) -> bool { // A receiver typed with the impl's own name (`self: &mut Test`) elides lifetimes like // `&mut self`; through an alias it does not (resolution does not see through aliases). fn visit_receiver_mut(&mut self, _: &mut syn::Receiver) {} + // The same rule compares a receiver with the impl's self type: that stays as written. + fn visit_item_impl_mut(&mut self, imp: &mut syn::ItemImpl) { + let self_ty = std::mem::replace(&mut *imp.self_ty, Type::Verbatim(Default::default())); + syn::visit_mut::visit_item_impl_mut(self, imp); + *imp.self_ty = self_ty; + } } let mut rename = Rename { aliases: &aliases, count: 0 }; for item in &mut file.items { diff --git a/docs/hunt.md b/docs/hunt.md index ec6adff..aea6873 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -49,6 +49,8 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 23 | an `attempt to add with overflow` in `ty/instance.rs:421` compiling `recursion/issue-83150.rs` under the new solver | overflow-checked builds; hidden by the solver pin; [facts](hunt/internal-checks.md) | | 24 | `-Zvalidate-mir` rejects a move of a dereferenced unsized place into a call (`unsized-locals/unsized-exprs2.rs`) | incomplete `unsized_fn_params`; [facts](hunt/internal-checks.md) | | 25 | an invalid constant (E0080, `UnsafeCell` in read-only memory) is rejected when an unused `let _ = &C` is in a non-generic function, and accepted when it is in a generic one, unless `-Zmir-opt-level=0`: a MIR pass removes the promoted's last use and nothing validates it at monomorphization | **looks new**; stable code; found by the equivalent-rewrite differential (`generic-wrap`); since at least 1.80; pass located (`SimplifyLocals-before-const-prop` removes the last use), cause not narrowed further; [facts](hunt/promoted-validation-generic.md) | +| 26 | meilisearch (edition 2021) stops compiling on nightly-2026-10-06: `Ok(()) as Result<_>` now infers `!` (never-type fallback in edition 2021), and neither 1.98 nor the July nightly warned, also with the future-compatibility lints on | **looks new** as a lint false negative; found by release-to-release; reduced to 11 lines; [facts](hunt/release-regressions.md) | +| 27 | under the new trait solver (nightly's default), a type parameter that appears only in a projection (`T0::Of<'_>`) of a function-pointer coercion is not inferred (E0283); breaks surrealdb through `diskann-wide 0.54.0` | new-solver behavior since at least July, user-visible since the default changed; found by release-to-release; reduced to 15 lines; [facts](hunt/release-regressions.md) | 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 diff --git a/docs/hunt/release-regressions.md b/docs/hunt/release-regressions.md new file mode 100644 index 0000000..a2905f3 --- /dev/null +++ b/docs/hunt/release-regressions.md @@ -0,0 +1,77 @@ +# Regressions in real crates, nightly-2026-07-18 to nightly-2026-10-06 + +Facts for findings 26 and 27. Found by the release-to-release check (`rustc/release-diff.py`, +[`checks.md`](../checks.md) check 2): `cargo check --locked` of 87 popular repositories +(`~/proofhouse-repos/rust`) under both nightlies. 53 behave the same, 18 fail on both, 10 could not +fetch their locked dependencies. Of the 5 regressions, two are `allocative 0.3.4`, which enables +`#![feature(never_type)]` when it detects a nightly and then conflicts with `Infallible` becoming +`type Infallible = !` (expected for a crate using unstable features). One is a timeout under load. +The other two are below. + +## 26. Never-type fallback: a stable edition-2021 crate breaks with no warning beforehand + +meilisearch (`crates/milli/src/update/new/indexer/mod.rs:432`, edition 2021) fails on +nightly-2026-10-06 with E0605: "non-primitive cast: `Result<(), _>` as `Result`". +Reduced to [`tests/never-fallback-cast.rs`](tests/never-fallback-cast.rs): + +```rust +#[derive(Debug)] struct Error; +type Result = std::result::Result; +fn run R + Send, R: Send>(f: F) -> std::thread::Result { Ok(f()) } +fn work() -> Result<()> { + run(|| { + Ok(()) as Result<_> + }) + .unwrap()?; + Ok(()) +} +fn main() { work().unwrap(); } +``` + +| toolchain, edition 2021 | result | +|---|---| +| 1.98.0 | compiles; no warning, also with `-W rust-2024-compatibility -W dependency-on-unit-never-type-fallback` | +| nightly-2026-07-18 | compiles, no warning | +| nightly-2026-10-06 | E0605, `_` inferred as `!` | + +Between the two nightlies the never-type fallback became `!` in edition 2021 too: the textbook +case (`if c { return } else { foo() }` with `foo`) is the deny-by-default lint "this +function depends on never type fallback being `()`" on 1.98 and the July nightly, and E0277 +`!: Default` on the October nightly. That change is planned. The finding is that this program +depends on the fallback (the `_` in the cast target falls back through the `?` on the closure's +result) and the lint meant to announce the change says nothing about it. The code compiles warning-free +on stable and stops compiling. Not found in the issue tracker. + +## 27. New trait solver: a type parameter reached only through a projection is not inferred + +surrealdb fails through its dependency `diskann-wide 0.54.0` +(`src/arch/x86_64/v3/mod.rs:435`) with E0283 "type annotations needed". Reduced to +[`tests/next-solver-fn-ptr-projection.rs`](tests/next-solver-fn-ptr-projection.rs): + +```rust +pub trait AddLifetime: 'static { type Of<'a>; } +pub trait FTarget1 { fn run(a: A, t: T) -> R; } +#[derive(Clone, Copy)] pub struct V; +impl V { + pub unsafe fn run_function_with_1(self, x0: T0::Of<'_>) -> R + where T0: AddLifetime, F: for<'a> FTarget1> { F::run(self, x0) } + + pub fn dispatch1(self) -> unsafe fn(Self, T0::Of<'_>) -> R + where T0: AddLifetime, F: for<'a> FTarget1> { + let f: unsafe fn(Self, T0::Of<'_>) -> R = Self::run_function_with_1::; + f + } +} +``` + +| toolchain | result | +|---|---| +| 1.98.0, nightly-2026-07-18 | compiles | +| nightly-2026-07-18 `-Znext-solver=globally` | E0283 | +| nightly-2026-10-06 (new solver by default) | E0283 | +| nightly-2026-10-06 `-Znext-solver=coherence` | compiles | + +`T0` appears in the function pointer type only as `T0::Of<'_>`. The old solver infers it (the +caller's where-clauses name the same projection); the new solver reports ambiguity. Since +#160895 made the new solver nightly's default, real crates hit it. The UI-test differences of the +same kind are in [`solver.md`](../solver.md). `diskann-wide` was not found in the issue tracker. diff --git a/docs/hunt/tests/never-fallback-cast.rs b/docs/hunt/tests/never-fallback-cast.rs new file mode 100644 index 0000000..6a83af0 --- /dev/null +++ b/docs/hunt/tests/never-fallback-cast.rs @@ -0,0 +1,11 @@ +#[derive(Debug)] struct Error; +type Result = std::result::Result; +fn run R + Send, R: Send>(f: F) -> std::thread::Result { Ok(f()) } +fn work() -> Result<()> { + run(|| { + Ok(()) as Result<_> + }) + .unwrap()?; + Ok(()) +} +fn main() { work().unwrap(); } diff --git a/docs/hunt/tests/next-solver-fn-ptr-projection.rs b/docs/hunt/tests/next-solver-fn-ptr-projection.rs new file mode 100644 index 0000000..e3fe49a --- /dev/null +++ b/docs/hunt/tests/next-solver-fn-ptr-projection.rs @@ -0,0 +1,14 @@ +pub trait AddLifetime: 'static { type Of<'a>; } +pub trait FTarget1 { fn run(a: A, t: T) -> R; } +#[derive(Clone, Copy)] pub struct V; +impl V { + pub unsafe fn run_function_with_1(self, x0: T0::Of<'_>) -> R + where T0: AddLifetime, F: for<'a> FTarget1> { F::run(self, x0) } + + pub fn dispatch1(self) -> unsafe fn(Self, T0::Of<'_>) -> R + where T0: AddLifetime, F: for<'a> FTarget1> { + let f: unsafe fn(Self, T0::Of<'_>) -> R = Self::run_function_with_1::; + f + } +} +fn main() {} From 811cf4b785fa847cf00911fadfdff2767efaac04 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 04:50:55 +0000 Subject: [PATCH 11/14] Finding 28: glob-import ambiguity depends on item order (E0659 in one order, a different function called in the other) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- docs/hunt.md | 1 + docs/hunt/glob-ambiguity-order.md | 55 +++++++++++++++++++++++ docs/hunt/tests/glob-ambiguity-order-a.rs | 13 ++++++ docs/hunt/tests/glob-ambiguity-order-b.rs | 13 ++++++ rustc/rewrite-diff.py | 3 ++ 5 files changed, 85 insertions(+) create mode 100644 docs/hunt/glob-ambiguity-order.md create mode 100644 docs/hunt/tests/glob-ambiguity-order-a.rs create mode 100644 docs/hunt/tests/glob-ambiguity-order-b.rs diff --git a/docs/hunt.md b/docs/hunt.md index aea6873..b29e637 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -51,6 +51,7 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 25 | an invalid constant (E0080, `UnsafeCell` in read-only memory) is rejected when an unused `let _ = &C` is in a non-generic function, and accepted when it is in a generic one, unless `-Zmir-opt-level=0`: a MIR pass removes the promoted's last use and nothing validates it at monomorphization | **looks new**; stable code; found by the equivalent-rewrite differential (`generic-wrap`); since at least 1.80; pass located (`SimplifyLocals-before-const-prop` removes the last use), cause not narrowed further; [facts](hunt/promoted-validation-generic.md) | | 26 | meilisearch (edition 2021) stops compiling on nightly-2026-10-06: `Ok(()) as Result<_>` now infers `!` (never-type fallback in edition 2021), and neither 1.98 nor the July nightly warned, also with the future-compatibility lints on | **looks new** as a lint false negative; found by release-to-release; reduced to 11 lines; [facts](hunt/release-regressions.md) | | 27 | under the new trait solver (nightly's default), a type parameter that appears only in a projection (`T0::Of<'_>`) of a function-pointer coercion is not inferred (E0283); breaks surrealdb through `diskann-wide 0.54.0` | new-solver behavior since at least July, user-visible since the default changed; found by release-to-release; reduced to 15 lines; [facts](hunt/release-regressions.md) | +| 28 | glob-import ambiguity depends on item order: with two modules re-exporting each other's globs, one order is E0659 and the other compiles and calls a different function (1.98, nightly); the accepted order has swapped between releases | **looks new**; stable code; found by the equivalent-rewrite differential (`reorder`) on 3 UI tests; [facts](hunt/glob-ambiguity-order.md) | 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 diff --git a/docs/hunt/glob-ambiguity-order.md b/docs/hunt/glob-ambiguity-order.md new file mode 100644 index 0000000..8f2d388 --- /dev/null +++ b/docs/hunt/glob-ambiguity-order.md @@ -0,0 +1,55 @@ +# Glob-import ambiguity depends on the order of items + +Facts for finding 28. Found by the equivalent-rewrite differential (`rustc/rewrite-diff.py`, the +`reorder` rewrite: top-level items in reverse order, `use` items first) on +`tests/ui/imports/ambiguous-9.rs`; the same rewrite also flips `imports/ambiguous-14.rs` +(error → compiles) and `imports/overwrite-different-ambig-2.rs` (compiles → error). + +## What happens + +Two modules re-export each other's globs, and each brings its own `date_range` through a glob. +Rust's name resolution does not depend on the order in which items are written, but here the +verdict, and which function is called, do: + +[`tests/glob-ambiguity-order-a.rs`](tests/glob-ambiguity-order-a.rs) (as in the UI test): + +```rust +pub mod dsl { + mod range { pub fn date_range() { println!("dsl::range") } } + pub use self::range::*; + use super::prelude::*; +} +pub mod prelude { + mod t { pub fn date_range() { println!("prelude::t") } } + pub use self::t::*; + pub use super::dsl::*; +} +use dsl::*; +use prelude::*; +fn main() { date_range(); } +``` + +[`tests/glob-ambiguity-order-b.rs`](tests/glob-ambiguity-order-b.rs): the same with `mod prelude` +written before `mod dsl`. + +| rustc | order A (`dsl` first) | order B (`prelude` first) | +|---|---|---| +| 1.80.0 – 1.89.0 | prints `dsl::range` | prints `dsl::range` | +| 1.90.0 – 1.93.0 | `ambiguous_glob_imports` lint error | the same lint error | +| 1.94.0 – 1.95.0 | prints `dsl::range` | E0659 `date_range` is ambiguous | +| 1.96.1 – 1.97.1 | lint error | E0659 | +| 1.98.0, nightly-2026-10-06 | E0659 | **prints `prelude::t`** | + +On current compilers, one order is a hard ambiguity error and the other compiles and calls a +different function than every compiler before 1.90 did. Across releases, the order that is +accepted has swapped (1.94: A; 1.98: B). + +## Expected + +The same verdict for both orders. The UI test expects the ambiguity (an `ambiguous glob +re-exports` warning and an ambiguity error at the call), so B should be rejected as A is. + +## Not narrowed + +Which change in the glob resolution (the import resolution fixpoint, ambiguity detection for +glob re-export cycles) makes the outcome order-dependent was not investigated. diff --git a/docs/hunt/tests/glob-ambiguity-order-a.rs b/docs/hunt/tests/glob-ambiguity-order-a.rs new file mode 100644 index 0000000..32a3252 --- /dev/null +++ b/docs/hunt/tests/glob-ambiguity-order-a.rs @@ -0,0 +1,13 @@ +pub mod dsl { + mod range { pub fn date_range() { println!("dsl::range") } } + pub use self::range::*; + use super::prelude::*; +} +pub mod prelude { + mod t { pub fn date_range() { println!("prelude::t") } } + pub use self::t::*; + pub use super::dsl::*; +} +use dsl::*; +use prelude::*; +fn main() { date_range(); } diff --git a/docs/hunt/tests/glob-ambiguity-order-b.rs b/docs/hunt/tests/glob-ambiguity-order-b.rs new file mode 100644 index 0000000..1a0efa4 --- /dev/null +++ b/docs/hunt/tests/glob-ambiguity-order-b.rs @@ -0,0 +1,13 @@ +pub mod prelude { + mod t { pub fn date_range() { println!("prelude::t") } } + pub use self::t::*; + pub use super::dsl::*; +} +pub mod dsl { + mod range { pub fn date_range() { println!("dsl::range") } } + pub use self::range::*; + use super::prelude::*; +} +use dsl::*; +use prelude::*; +fn main() { date_range(); } diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py index ba34eb3..21651e6 100644 --- a/rustc/rewrite-diff.py +++ b/rustc/rewrite-diff.py @@ -40,6 +40,9 @@ NOISE = { ("consts/chained-constants-stackoverflow.rs", "reorder"), # 10,000 chained consts: query depth ("consts/interior-mut-const-via-union.rs", "generic-wrap"), # finding 25 (docs/hunt.md) + ("imports/ambiguous-9.rs", "reorder"), # finding 28 + ("imports/ambiguous-14.rs", "reorder"), # finding 28 + ("imports/overwrite-different-ambig-2.rs", "reorder"), # finding 28 } NOT_MOVABLE = re.compile(r"^\s*(pub(\([^)]*\))?\s+)?mod\s+\w+\s*;|include(_str|_bytes)?!|#\[path|#!\[no_core\]", re.M) # Item order matters to textual macro scoping: no reordering where macros are defined. From 0d308593be53f68b01c457aa4922240770253d93 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 04:51:25 +0000 Subject: [PATCH 12/14] checks.md: what was built and what each sweep found; rewrite-diff noise for a recursion_limit=6 test Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- docs/checks.md | 18 ++++++++++++++++++ rustc/rewrite-diff.py | 2 ++ 2 files changed, 20 insertions(+) diff --git a/docs/checks.md b/docs/checks.md index dc955bb..7027f53 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -342,3 +342,21 @@ about 600 of the 1,000. - "Would have caught" assumes the input that reaches the bug is in the corpus. For most checks, the input side (feature mutation, real crates, runnable programs) is as much work as the check. + +## Built (2026-10-09/10) + +The first seven checks of the build order, as scripts in `rustc/` sharing `rustc/uitest.py`, run +over the standalone UI tests at the pin (and real crates for release-to-release). Each has +`--recheck`, `--known` and `--pause-on-finding` for the frontier loop. + +| check | script | swept | result | +|---|---|---|---| +| optimization and pass differential | `opt-diff.py` | 3,217 runnable tests × 13 configurations (opt levels, MIR opt levels, LTO, target CPU, Cranelift) | nothing; Cranelift's gaps (tail calls, some linkages and SIMD intrinsics) noted | +| solver differential | `solver-diff.py` | 17,634 tests × old/new solver × NLL/Polonius | the 26 rejections and 3 crashes of [`solver.md`](solver.md); Polonius agrees with NLL everywhere | +| Miri differential | `miri-diff.py` | 3,094 runnable tests at MIR opt levels 0, 2, 4 and natively | nothing; tests asserting unspecified behavior (function pointer equality, ZST addresses) listed | +| equivalent rewrites | `mirth-rewrite` + `rewrite-diff.py` | 18,624 tests × generic-wrap, alias, reorder, unused | findings 25 (generic-wrap) and 28 (reorder) | +| ABI vs clang | `abi-diff.py` | 21 main targets × 10 seeds × 300 random signatures | findings 19 and 20; #163911 reproduced; i686 MSVC small-struct returns and a PowerPC64 `inreg` float undecided | +| internal checks on | `crash-diff.py` + a debug-assertions compiler | 18,624 tests with `-Zvalidate-mir` | findings 21–24 (17 tests) | +| release-to-release | `release-diff.py` | 87 real repositories, nightly-2026-07-18 → 10-06 | findings 26 and 27; `allocative` (unstable features) noted | + +Ten new findings (19–28) in [`hunt.md`](hunt.md), none from the checks mirth had before. diff --git a/rustc/rewrite-diff.py b/rustc/rewrite-diff.py index 21651e6..f87ad5a 100644 --- a/rustc/rewrite-diff.py +++ b/rustc/rewrite-diff.py @@ -40,6 +40,8 @@ NOISE = { ("consts/chained-constants-stackoverflow.rs", "reorder"), # 10,000 chained consts: query depth ("consts/interior-mut-const-via-union.rs", "generic-wrap"), # finding 25 (docs/hunt.md) + # recursion_limit = "6": evaluation order nests the query stack one level deeper + ("traits/next-solver/overflow/dont-lower-depth-for-witness-and-rigid-opaque.rs", "reorder"), ("imports/ambiguous-9.rs", "reorder"), # finding 28 ("imports/ambiguous-14.rs", "reorder"), # finding 28 ("imports/overwrite-different-ambig-2.rs", "reorder"), # finding 28 From faecce7e108b55f86eef251a8b6d0b39f3ca54b5 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 05:04:45 +0000 Subject: [PATCH 13/14] Finding 27 is known and intended (#160895, diskann-wide listed) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- docs/hunt.md | 2 +- docs/hunt/release-regressions.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/hunt.md b/docs/hunt.md index b29e637..69e35b7 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -50,7 +50,7 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 24 | `-Zvalidate-mir` rejects a move of a dereferenced unsized place into a call (`unsized-locals/unsized-exprs2.rs`) | incomplete `unsized_fn_params`; [facts](hunt/internal-checks.md) | | 25 | an invalid constant (E0080, `UnsafeCell` in read-only memory) is rejected when an unused `let _ = &C` is in a non-generic function, and accepted when it is in a generic one, unless `-Zmir-opt-level=0`: a MIR pass removes the promoted's last use and nothing validates it at monomorphization | **looks new**; stable code; found by the equivalent-rewrite differential (`generic-wrap`); since at least 1.80; pass located (`SimplifyLocals-before-const-prop` removes the last use), cause not narrowed further; [facts](hunt/promoted-validation-generic.md) | | 26 | meilisearch (edition 2021) stops compiling on nightly-2026-10-06: `Ok(()) as Result<_>` now infers `!` (never-type fallback in edition 2021), and neither 1.98 nor the July nightly warned, also with the future-compatibility lints on | **looks new** as a lint false negative; found by release-to-release; reduced to 11 lines; [facts](hunt/release-regressions.md) | -| 27 | under the new trait solver (nightly's default), a type parameter that appears only in a projection (`T0::Of<'_>`) of a function-pointer coercion is not inferred (E0283); breaks surrealdb through `diskann-wide 0.54.0` | new-solver behavior since at least July, user-visible since the default changed; found by release-to-release; reduced to 15 lines; [facts](hunt/release-regressions.md) | +| 27 | under the new trait solver (nightly's default), a type parameter that appears only in a projection (`T0::Of<'_>`) of a function-pointer coercion is not inferred (E0283); breaks surrealdb through `diskann-wide 0.54.0` | **known, intended**: `diskann-wide` is listed in #160895 ("higher-ranked associated type", the intended breakage of trait-system-refactor-initiative#168; 0.55 not yet patched); surrealdb is an affected project not on that list; found by release-to-release; [facts](hunt/release-regressions.md) | | 28 | glob-import ambiguity depends on item order: with two modules re-exporting each other's globs, one order is E0659 and the other compiles and calls a different function (1.98, nightly); the accepted order has swapped between releases | **looks new**; stable code; found by the equivalent-rewrite differential (`reorder`) on 3 UI tests; [facts](hunt/glob-ambiguity-order.md) | Findings 1 and 2 are single-threaded: an ordinary `cargo build`, an edit, another diff --git a/docs/hunt/release-regressions.md b/docs/hunt/release-regressions.md index a2905f3..b5a44cc 100644 --- a/docs/hunt/release-regressions.md +++ b/docs/hunt/release-regressions.md @@ -74,4 +74,9 @@ impl V { `T0` appears in the function pointer type only as `T0::Of<'_>`. The old solver infers it (the caller's where-clauses name the same projection); the new solver reports ambiguity. Since #160895 made the new solver nightly's default, real crates hit it. The UI-test differences of the -same kind are in [`solver.md`](../solver.md). `diskann-wide` was not found in the issue tracker. +same kind are in [`solver.md`](../solver.md). + +**Known and intended:** #160895 lists `diskann-wide` under "higher-ranked associated type" +(trait-system-refactor-initiative#168: the old solver sometimes guided inference incorrectly when +relating higher-ranked associated types), 0.55 not yet patched. surrealdb, which depends on 0.54, +is not in that issue's list of affected crates. From 57a7d5283fc54ec1a7151d80f0c17593c47d1591 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 05:35:09 +0000 Subject: [PATCH 14/14] docs/solver-triage.md: the new-solver differences against #160895 and the trackers, with severity and what to report Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- docs/solver-triage.md | 135 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/solver-triage.md diff --git a/docs/solver-triage.md b/docs/solver-triage.md new file mode 100644 index 0000000..9e31507 --- /dev/null +++ b/docs/solver-triage.md @@ -0,0 +1,135 @@ +# New trait solver: what mirth found, against what is already tracked + +Everything mirth found that differs between the old trait solver and nightly's default (the new +solver everywhere, since #160895), checked against the tracking issue +[#160895](https://github.com/rust-lang/rust/issues/160895) (its "known impact" categories, the +affected-crates tables and the "unintended breakage" list), rust-lang/rust issues and +rust-lang/trait-system-refactor-initiative (tsri) issues, on 2026-10-10. + +Sources: the UI-test solver differential ([`solver.md`](solver.md), `rustc/solver-diff.py`: +26 tests accepted by the old solver and rejected by the new, 3 crashes), the internal-checks sweep +(findings 22 and 23), release-to-release (finding 27). + +## Severity + +- **high**: stable code (no feature gates) rejected or crashing under nightly's default; it + reaches stable users when the solver does +- **medium**: a documented behavior not implemented, or an unstable feature that stops working + under the default solver +- **low**: incomplete features, debug-assertion builds only, or informational + +## Summary + +| # | what | tests | stable code? | tracked? | severity | worth reporting | +|---|---|---:|---|---|---|---| +| A | implied bounds through a higher-ranked supertrait projection (`gluon_salsa`): E0309 | 1 | yes | **no** | **high** | **yes**: a new issue, linked from #160895 | +| B | recursive `Peekable` instantiation: ICE `failed to resolve instance` | 3 | yes | [#152827](https://github.com/rust-lang/rust/issues/152827) (open) | high | a comment on #152827: three UI tests crash on default nightly | +| C | unconstrained RPIT (`fn test() -> impl Sized { test() }`): E0282 | 1 | yes | [tsri#144](https://github.com/rust-lang/trait-system-refactor-initiative/issues/144) (open) | high | no (known) | +| D | `-Zhigher-ranked-assumptions` does not fall back to the old solver as #160895 says | 12 | no (flag) | **no** | **medium** | **yes**: a comment on #160895 (the text or the code is wrong) | +| E | `TransmuteFrom` between references fails (`#![feature(transmutability)]`): E0277 | 8 | no | **no** | **medium** | **yes**: a new issue | +| F | higher-ranked associated type no longer guides inference (`escaping-bounds`; `diskann-wide`) | 1 + crate | yes | #160895, tsri#168: intended | low | surrealdb as an affected project on #160895 | +| G | type alias `impl Trait`: "does not constrain", a cycle | 2 | no | #160895: RPIT/TAIT handling changed | low | no | +| H | `fn_delegation` with `impl Trait` returns: E0282 | 1 | no (incomplete) | no | low | optional | +| 22 | debug assertion `!type_outlives.has_non_rigid_aliases()` in region outlives | 5 | some | sibling of closed #160206 | low | optional: debug builds only, but an invariant broken | +| 23 | integer overflow in `ty/instance.rs:421` (`recursion/issue-83150.rs`) | 1 | yes | no | low | optional | + +## Details + +### A. Implied bounds through a higher-ranked supertrait projection (high, untracked) + +`tests/ui/implied-bounds/gluon_salsa.rs`, a reduction of the gluon and salsa crates, no feature +gates: + +```rust +pub trait QueryBase { type Db; } +pub trait AsyncQueryFunction<'f>: + QueryBase>::SendDb> +{ type SendDb; } +pub struct QueryTable<'me, Q, DB> { _q: Option, _db: Option, _marker: Option<&'me ()> } +impl<'me, Q> QueryTable<'me, Q, ::Db> +where Q: for<'f> AsyncQueryFunction<'f>, +{ pub fn get_async<'a>(&'a mut self) { panic!(); } } +``` + +| toolchain | result | +|---|---| +| 1.98.0 | compiles | +| nightly-2026-07-18 `-Znext-solver=globally` | E0309: `>::SendDb` may not live long enough | +| nightly-2026-10-06 (default) | E0309 | +| nightly-2026-10-06 `-Znext-solver=coherence` | compiles | + +The test exists to keep this pattern compiling (it came from real crates). It matches none of +#160895's categories. + +### B. Recursive `Peekable` instantiation crashes (high, tracked) + +`codegen/normalization-overflow/recursion-issue-{122823,131342,92004}.rs`: the tests expect +"reached the recursion limit"; under the default solver, an ICE "failed to resolve instance for +<&mut Peekable<…>>". Matches #152827 (open). Details in [`solver.md`](solver.md). + +### C. Unconstrained RPIT (high, tracked) + +`impl-trait/recursive-impl-trait-type-direct.rs`: `fn test() -> impl Sized { test() }` compiles +with the old solver, E0282 with the new. Tracked as tsri#144 (open). + +### D. `-Zhigher-ranked-assumptions` does not fall back (medium, untracked) + +#160895: "This unstable flag is also not supported with the new solver and when set, we're also +automatically falling back to the stable `-Znext-solver=coherence`." It does not: +`TyCtxt::next_trait_solver_globally` (`compiler/rustc_middle/src/ty/context.rs:2819`) falls back +only for `generic_const_exprs`: + +```rust + pub fn next_trait_solver_globally(self) -> bool { + self.sess.opts.unstable_opts.next_solver == NextSolverConfig::Globally + && !self.features().generic_const_exprs() + } +``` + +The 12 UI tests whose `assumptions` revision passes `-Zhigher-ranked-assumptions` and expects +`check-pass` fail under the default solver ("higher-ranked lifetime error", "higher-ranked subtype +error", "lifetime bound not satisfied") and pass with `-Znext-solver=coherence` added: +`async-await/witness-auto-trait/higher-ranked-auto-trait-{1,4,5,6,8,9,12,15,17,18}.rs`, +`async-await/drop-tracking-unresolved-typeck-results.rs`, +`async-await/return-type-notation/issue-110963-early.rs`. CI does not see it, because compiletest +pins `-Znext-solver=coherence`. + +### E. `TransmuteFrom` between references (medium, untracked) + +Eight `check-pass` tests of `#![feature(transmutability)]`, all transmuting between references +(`&u8 → &Unit`, `&[u16; 0] → &[u8; 0]`, `&&u32 → &&i32`, recursive wrappers, `Assume` with +`lifetimes`/`safety`): E0277 "cannot be safely transmuted … unsatisfied trait bound" under the new +solver since at least July, accepted with `-Znext-solver=coherence`: +`transmutability/alignment/align-pass.rs`, +`transmutability/references/{accept_assume_lifetime_extension,recursive-wrapper-types,recursive-wrapper-types-bit-compatible,u8-to-unit,unit-to-itself}.rs`, +`transmutability/safety/assume/should_accept_if_ref_src_has_safety_invariant.rs`, +`transmutability/transmute-higher-ranked.rs`. Non-reference transmutability tests pass. + +### F. Higher-ranked associated types (intended) + +`borrowck/alias-liveness/escaping-bounds.rs` (E0283) and the crate `diskann-wide` (finding 27): +inference used to be guided through a higher-ranked projection; #160895 lists this as intended +breakage (tsri#168) and has `diskann-wide 0.55` in its table. surrealdb (through +`diskann-wide 0.54`) is not listed there. + +### G, H. Unstable `impl Trait` features (low) + +`impl-trait/recursive-type-alias-impl-trait-declaration-too-subtle-2.rs` ("item does not +constrain"), `type-alias-impl-trait/struct-assignment-validity.rs` (E0391 cycle), +`delegation/impl-trait.rs` (E0282, incomplete `fn_delegation`). #160895 says TAIT handling changed +substantially. + +### 22, 23. Internal checks (low) + +See [`hunt/internal-checks.md`](hunt/internal-checks.md): both need a debug-assertions or +overflow-checked compiler and the new solver. + +## What to report, in order + +1. A: a new issue (high, stable code, untracked). +2. E: a new issue (medium, a whole feature area under the default). +3. D: a comment on #160895 (the documented fallback does not exist). +4. B: a comment on #152827 (the crash is now on default nightly). +5. F: surrealdb on #160895's affected list. + +As with all mirth findings, the reports are written by a person; this page is the facts.