From 9a1535e5febae2f772873985d2b039b84b3f882a Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 09:16:55 +0000 Subject: [PATCH 1/2] mirth-lab gate-mutate: mutants of the UI tests that enable unstable features (splices, moved items, extra incomplete gates, edits) under the solver configuration columns; ICEs and hangs grouped by signature, reduced, triaged against rust-lang/rust issues Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- Cargo.lock | 13 + crates/mirth-lab/Cargo.toml | 3 + crates/mirth-lab/src/main.rs | 4 + crates/mirth-lab/src/tools/gate_mutate.rs | 812 ++++++++++++++++++++++ 4 files changed, 832 insertions(+) create mode 100644 crates/mirth-lab/src/tools/gate_mutate.rs diff --git a/Cargo.lock b/Cargo.lock index 2eb3cb9..021f0aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,8 @@ dependencies = [ "flate2", "libc", "mirth-rewrite", + "prettyplease", + "quote", "rand", "rayon", "regex", @@ -389,6 +391,7 @@ dependencies = [ "serde_json", "sha2", "similar", + "syn 2.0.119", "tar", "tempfile", "wait-timeout", @@ -439,6 +442,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" diff --git a/crates/mirth-lab/Cargo.toml b/crates/mirth-lab/Cargo.toml index 1c00609..ef9f327 100644 --- a/crates/mirth-lab/Cargo.toml +++ b/crates/mirth-lab/Cargo.toml @@ -27,3 +27,6 @@ libc = "0.2" flate2 = "1" tar = "0.4" mirth-rewrite = { path = "../mirth-rewrite" } +syn = { version = "2.0.119", features = ["full", "extra-traits"] } +quote = "1" +prettyplease = "0.2" diff --git a/crates/mirth-lab/src/main.rs b/crates/mirth-lab/src/main.rs index aabd4c0..0aabd15 100644 --- a/crates/mirth-lab/src/main.rs +++ b/crates/mirth-lab/src/main.rs @@ -25,6 +25,7 @@ mod tools { pub mod flag_universe; pub mod flag_walk; pub mod gate_check; + pub mod gate_mutate; pub mod grammar_coverage; pub mod instr_check; pub mod miri_diff; @@ -129,6 +130,8 @@ enum Check { SurveyUnroll(tools::survey::UnrollArgs), /// Each closed-bug Ur query still finds the code its bug's fix changed. VerifyClosed(tools::verify_closed::Args), + /// Mutants of the UI tests that use unstable features (splices, moved items, extra gates, edits): ICEs and hangs. + GateMutate(tools::gate_mutate::Args), } fn main() -> ExitCode { @@ -143,6 +146,7 @@ fn main() -> ExitCode { Check::SuggestDiff(a) => tools::suggest_diff::run(a), Check::ReproDiff(a) => tools::repro_diff::run(a), Check::GateCheck(a) => tools::gate_check::run(a), + Check::GateMutate(a) => tools::gate_mutate::run(a), Check::InstrCheck(a) => tools::instr_check::run(a), Check::ReleaseDiff(a) => tools::release_diff::run(a), Check::Xlink(a) => tools::xlink::run(a), diff --git a/crates/mirth-lab/src/tools/gate_mutate.rs b/crates/mirth-lab/src/tools/gate_mutate.rs new file mode 100644 index 0000000..35dfbc9 --- /dev/null +++ b/crates/mirth-lab/src/tools/gate_mutate.rs @@ -0,0 +1,812 @@ +//! Feature-gate mutation: inputs that reach the ICEs unstable features cause (checks.md Part 1: +//! 144 of the 334 crash bugs needed a nightly feature, 62 of them generic_const_args). +//! +//! The standalone UI tests are indexed by the features their `#![feature(…)]` enables; +//! incomplete features (from the checkout's rustc_feature) are picked three times as often. +//! Each mutant comes from one strategy: +//! +//! - `splice`: the items of a test using another feature, added to a test (flat, dropping names +//! that collide, or inside a module), with both crates' gates; +//! - `move`: an item copied (impls moved) into a generic fn, an async fn, a closure, an anonymous +//! const or a module; a free fn also into a trait (as a default method) or an inherent impl; +//! or the whole file through mirth-rewrite's generic-wrap; +//! - `gate`: an extra incomplete feature enabled on a test using any feature; +//! - `edit`: one to three of the fuzzers' mechanical edits. +//! +//! Each mutant is compiled (`--emit=link`) under one configuration column: the test's own +//! flags, plus `-Znext-solver=globally` or `-Zassumptions-on-binders` for some. A finding is an +//! ICE or a hang (a timeout confirmed with three times the time) that the unmutated test does +//! not give. Findings are grouped by signature (the panic's source location and the first +//! query on the stack); each keeps its smallest mutant and a greedy reduction of it (top-level +//! items, then lines, while the signature stays). `--triage` searches rust-lang/rust's issues +//! for each signature's message. +//! +//! Mutant i is a function of `--seed` and i, so a run resumes where it stopped. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use mirth_lab::mutations; +use mirth_lab::rustc::{Compile, Status}; +use mirth_lab::uitest::{self, Test}; +use quote::{ToTokens, quote}; +use rand::rngs::StdRng; +use rand::seq::IndexedRandom; +use rand::{Rng, SeedableRng}; +use rayon::prelude::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use syn::Item; + +#[derive(clap::Args, Debug)] +pub struct Args { + #[arg(long)] + rustc: PathBuf, + /// The rust checkout (tests/ui, and rustc_feature for the incomplete features). + #[arg(long)] + rust: PathBuf, + #[arg(long)] + work: PathBuf, + #[arg(long, default_value_t = 1)] + seed: u64, + /// Mutants to make (resuming skips those already done). + #[arg(long, default_value_t = 2000)] + count: usize, + #[arg(long, default_value_t = 4)] + jobs: usize, + /// Seconds per compilation. + #[arg(long, default_value_t = 30)] + timeout: u64, + /// Compilations per reduction. + #[arg(long, default_value_t = 300)] + reduce_budget: usize, + /// Signatures already known, one per line (a tab and a label may follow). + #[arg(long)] + known: Option, + /// Stop at the first new signature (exit 3). + #[arg(long)] + pause_on_finding: bool, + /// Only features whose name contains this. + #[arg(long)] + only: Option, + /// Search rust-lang/rust's issues for each signature found so far, and stop. + #[arg(long)] + triage: bool, + /// Reduce each signature's smallest mutant again (after the reducer changed), and stop. + #[arg(long)] + rereduce: bool, +} + +static FEATURE: LazyLock = LazyLock::new(|| Regex::new(r"#!\[feature\(([^)\]]*)\)\]").unwrap()); +static INCOMPLETE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^\s*\(incomplete, (\w+),").unwrap()); +/// Tests that are meant to crash, or whose output is already an ICE. +static EXPECTS_ICE: LazyLock = + LazyLock::new(|| Regex::new(r"failure-status:\s*101|internal compiler error|rustc-ice|//@\s*should-ice").unwrap()); + +fn features(text: &str) -> BTreeSet { + FEATURE + .captures_iter(text) + .flat_map(|c| c[1].split(',').map(|f| f.trim().to_owned()).collect::>()) + .filter(|f| !f.is_empty() && f.chars().all(|c| c.is_alphanumeric() || c == '_')) + .collect() +} + +/// The pool to draw from: tests by feature, each feature weighted. +struct Corpus { + tests: Vec, + feature_names: Vec, + by_feature: Vec>, + weights: rand::distr::weighted::WeightedIndex, + incomplete: Vec, +} + +impl Corpus { + fn load(args: &Args) -> anyhow::Result { + let unstable = std::fs::read_to_string(args.rust.join("compiler/rustc_feature/src/unstable.rs")).unwrap_or_default(); + let incomplete: Vec = INCOMPLETE.captures_iter(&unstable).map(|c| c[1].to_owned()).filter(|f| f != "test_incomplete_feature").collect(); + let tests = uitest::tests(&args.rust.join("tests/ui"), uitest::ALL, |t| features(&t.text).is_empty() || EXPECTS_ICE.is_match(&t.text)); + let mut index: BTreeMap> = BTreeMap::new(); + for (i, t) in tests.iter().enumerate() { + for f in features(&t.text) { + if args.only.as_deref().is_none_or(|o| f.contains(o)) { + index.entry(f).or_default().push(i); + } + } + } + anyhow::ensure!(!index.is_empty(), "no tests enable a matching feature"); + let feature_names: Vec = index.keys().cloned().collect(); + let weights = feature_names.iter().map(|f| if incomplete.contains(f) { 3 } else { 1 }); + let weights = rand::distr::weighted::WeightedIndex::new(weights)?; + let by_feature = index.into_values().collect(); + Ok(Corpus { tests, feature_names, by_feature, weights, incomplete }) + } + + fn pick(&self, rng: &mut StdRng) -> (usize, &Test) { + use rand::distr::Distribution; + let f = self.weights.sample(rng); + let i = *self.by_feature[f].choose(rng).unwrap(); + (f, &self.tests[i]) + } +} + +// ---- strategies ---- + +/// The file pretty-printed, or as tokens where prettyplease has no printer for the syntax +/// (syn keeps newer syntax as verbatim tokens). +fn print(file: &syn::File) -> String { + std::panic::catch_unwind(|| prettyplease::unparse(file)).unwrap_or_else(|_| file.to_token_stream().to_string()) +} + +fn item_ident(item: &Item) -> Option { + Some( + match item { + Item::Const(i) => &i.ident, + Item::Enum(i) => &i.ident, + Item::Fn(i) => &i.sig.ident, + Item::Mod(i) => &i.ident, + Item::Static(i) => &i.ident, + Item::Struct(i) => &i.ident, + Item::Trait(i) => &i.ident, + Item::TraitAlias(i) => &i.ident, + Item::Type(i) => &i.ident, + Item::Union(i) => &i.ident, + Item::Macro(i) => i.ident.as_ref()?, + _ => return None, + } + .to_string(), + ) +} + +fn is_main(item: &Item) -> bool { + matches!(item, Item::Fn(f) if f.sig.ident == "main") +} + +/// Crate-level attributes that may only appear once or only at the crate root. +fn crate_only(attr: &syn::Attribute) -> bool { + let p = attr.path(); + ["feature", "crate_type", "crate_name", "no_std", "no_core", "no_main", "recursion_limit", "type_length_limit", "rustc_coherence_is_core"] + .iter() + .any(|n| p.is_ident(n)) +} + +fn splice(rng: &mut StdRng, a: &str, b: &str) -> Option<(String, &'static str)> { + let mut fa = syn::parse_file(a).ok()?; + let fb = syn::parse_file(b).ok()?; + let mut have: BTreeSet = fa.items.iter().filter_map(item_ident).collect(); + for attr in fb.attrs.iter().filter(|a| a.path().is_ident("feature")) { + if !fa.attrs.iter().any(|x| x.to_token_stream().to_string() == attr.to_token_stream().to_string()) { + fa.attrs.push(attr.clone()); + } + } + let items: Vec = fb.items.into_iter().filter(|i| !is_main(i)).collect(); + if items.is_empty() { + return None; + } + if rng.random_bool(0.5) { + for item in items { + if let Some(n) = item_ident(&item) + && !have.insert(n) + { + continue; + } + fa.items.push(item); + } + Some((print(&fa), "splice-flat")) + } else { + let lints: Vec<&syn::Attribute> = fb.attrs.iter().filter(|a| !crate_only(a)).collect(); + let lints = lints.iter().map(|a| { + let meta = &a.meta; + quote!(#![#meta]) + }); + fa.items.push(syn::parse_quote! { mod __mirth_spliced { #(#lints)* #(#items)* } }); + Some((print(&fa), "splice-mod")) + } +} + +const HOSTS: &[&str] = &["generic-fn", "async-fn", "closure", "anon-const", "module"]; + +fn host(kind: &str, item: &Item) -> Item { + match kind { + "generic-fn" => syn::parse_quote! { #[allow(unused)] fn __mirth_host<__MirthT>() { #item } }, + "async-fn" => syn::parse_quote! { #[allow(unused)] async fn __mirth_host() { #item } }, + "closure" => syn::parse_quote! { #[allow(unused)] fn __mirth_host() { let _ = || { #item }; } }, + "anon-const" => syn::parse_quote! { const _: () = { #item }; }, + _ => syn::parse_quote! { #[allow(unused)] mod __mirth_host { use super::*; #item } }, + } +} + +fn movable(item: &Item) -> bool { + !is_main(item) && !matches!(item, Item::Use(_) | Item::ExternCrate(_) | Item::Macro(_) | Item::Verbatim(_) | Item::ForeignMod(_)) +} + +fn relocate(rng: &mut StdRng, text: &str) -> Option<(String, String)> { + if rng.random_ratio(1, 8) { + return match mirth_rewrite::rewrite("generic-wrap", text) { + mirth_rewrite::Outcome::Rewritten(t) => Some((syn::parse_file(&t).map(|f| print(&f)).unwrap_or(t), "move-generic-wrap".into())), + _ => None, + }; + } + let mut file = syn::parse_file(text).ok()?; + let candidates: Vec = (0..file.items.len()).filter(|&i| movable(&file.items[i])).collect(); + let &i = candidates.choose(rng)?; + let item = file.items[i].clone(); + let mut kinds: Vec<&str> = HOSTS.to_vec(); + if matches!(item, Item::Fn(ref f) if f.block.stmts.len() < 200) { + kinds.extend(["trait-default", "inherent"]); + } + let kind = *kinds.choose(rng)?; + let wrapped: Item = match (kind, &item) { + ("trait-default", Item::Fn(f)) => { + let mut f = f.clone(); + f.vis = syn::Visibility::Inherited; + let (attrs, sig, block) = (&f.attrs, &f.sig, &f.block); + syn::parse_quote! { #[allow(unused)] trait __MirthTr { #(#attrs)* #sig #block } } + } + ("inherent", Item::Fn(f)) => syn::parse_quote! { #[allow(unused)] const _: () = { struct __MirthS; impl __MirthS { #f } }; }, + _ => host(kind, &item), + }; + // Impls are global: a copy would conflict, so they move. Other items are copied, so what + // names them still resolves. + if matches!(item, Item::Impl(_)) { + file.items[i] = wrapped; + } else { + file.items.insert(i + 1, wrapped); + } + Some((print(&file), format!("move-{kind}"))) +} + +fn add_gate(rng: &mut StdRng, text: &str, incomplete: &[String]) -> Option<(String, String)> { + let have = features(text); + let options: Vec<&String> = incomplete.iter().filter(|f| !have.contains(*f)).collect(); + let f = options.choose(rng)?; + let at = FEATURE.find(text)?.start(); + Some((format!("{}#![feature({f})]\n{}", &text[..at], &text[at..]), format!("gate-{f}"))) +} + +fn edit(rng: &mut StdRng, text: &str, n: usize) -> Option<(String, String)> { + let mut t = text.to_owned(); + let mut names = Vec::new(); + for k in 0..rng.random_range(1..=3) { + let (name, f) = mutations::pick(rng); + if let Some(next) = f(&t, rng, n * 4 + k) { + t = next; + names.push(name); + } + } + (!names.is_empty()).then(|| (t, format!("edit-{}", names.join("+")))) +} + +/// Configuration columns: the test's own flags, and the experimental solver flags. +const CONFIGS: &[(&str, &[&str], u32)] = + &[("base", &[], 5), ("next-solver", &["-Znext-solver=globally"], 3), ("assumptions", &["-Zassumptions-on-binders"], 2)]; + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct Mutant { + i: usize, + strategy: String, + sources: Vec, + config: String, +} + +/// Mutant i: up to eight tries, since a strategy may not apply to the test drawn. +fn make(corpus: &Corpus, seed: u64, i: usize) -> Option<(Mutant, String, Vec, String)> { + (0..8u64).find_map(|attempt| make_one(corpus, seed, i, attempt)) +} + +fn make_one(corpus: &Corpus, seed: u64, i: usize, attempt: u64) -> Option<(Mutant, String, Vec, String)> { + let mut rng = StdRng::seed_from_u64(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ ((i as u64) << 3 | attempt)); + let (fa, a) = corpus.pick(&mut rng); + let mut sources = vec![a.rel.clone()]; + let r = rng.random_range(0..100); + let (text, strategy) = if r < 35 { + // A partner using a different feature. + let mut partner = None; + for _ in 0..8 { + let (fb, b) = corpus.pick(&mut rng); + if fb != fa && b.rel != a.rel { + partner = Some(b); + break; + } + } + let b = partner?; + sources.push(b.rel.clone()); + let (t, s) = splice(&mut rng, &a.text, &b.text)?; + (t, s.to_owned()) + } else if r < 65 { + relocate(&mut rng, &a.text)? + } else if r < 80 { + add_gate(&mut rng, &a.text, &corpus.incomplete)? + } else { + edit(&mut rng, &a.text, i)? + }; + use rand::distr::Distribution; + static CW: LazyLock> = + LazyLock::new(|| rand::distr::weighted::WeightedIndex::new(CONFIGS.iter().map(|c| c.2)).unwrap()); + let (config, extra, _) = CONFIGS[CW.sample(&mut rng)]; + let mut flags = a.flags.clone(); + flags.extend(extra.iter().map(|s| s.to_string())); + Some((Mutant { i, strategy, sources, config: config.into() }, text, flags, a.edition().to_owned())) +} + +// ---- signatures ---- + +static PANIC_AT: LazyLock = LazyLock::new(|| Regex::new(r"panicked at (\S+?\.rs):(\d+):\d+:\n(.*)").unwrap()); +static ICE_AT: LazyLock = + LazyLock::new(|| Regex::new(r"error: internal compiler error: (?:(\S+?\.rs):(\d+):\d+: )?(.*)").unwrap()); +static DELAYED_AT: LazyLock = LazyLock::new(|| Regex::new(r"delayed at (\S+?\.rs):(\d+):\d+").unwrap()); +static QUERY: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^#0 \[(\w+)\]").unwrap()); +static SIGNAL: LazyLock = LazyLock::new(|| Regex::new(r"rustc interrupted by (SIG\w+)").unwrap()); +static QUOTED: LazyLock = LazyLock::new(|| Regex::new(r"`[^`]*`").unwrap()); +static NUMBERS: LazyLock = LazyLock::new(|| Regex::new(r"\d+").unwrap()); +static OVERFLOW: LazyLock = LazyLock::new(|| Regex::new(r"has overflowed its stack").unwrap()); + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct Signature { + key: String, + message: String, +} + +fn signature(stderr: &str) -> Signature { + let query = QUERY.captures(stderr).map(|c| format!(" [{}]", &c[1])).unwrap_or_default(); + let short = |p: &str| p.split("compiler/").last().unwrap_or(p).to_owned(); + if OVERFLOW.is_match(stderr) { + return Signature { key: format!("stack overflow{query}"), message: "rustc has overflowed its stack".into() }; + } + if let Some(c) = PANIC_AT.captures(stderr) { + return Signature { key: format!("{}:{}{query}", short(&c[1]), &c[2]), message: c[3].trim().chars().take(300).collect() }; + } + if let Some(c) = ICE_AT.captures(stderr) { + let loc = match (c.get(1), DELAYED_AT.captures(stderr)) { + (Some(f), _) => format!("{}:{}", short(f.as_str()), &c[2]), + (None, Some(d)) => format!("{}:{}", short(&d[1]), &d[2]), + // A delayed bug: the location is only in the backtrace, so the message names it. + (None, None) => format!("delayed: {}", NUMBERS.replace_all("ED.replace_all(c[3].trim(), "`_`"), "N")), + }; + return Signature { key: format!("{loc}{query}"), message: c[3].trim().chars().take(300).collect() }; + } + if let Some(c) = SIGNAL.captures(stderr) { + return Signature { key: format!("{}{query}", &c[1]), message: c[0].to_owned() }; + } + Signature { key: format!("ice{query}"), message: stderr.lines().find(|l| l.contains("error")).unwrap_or("").chars().take(300).collect() } +} + +// ---- compiling and reducing ---- + +struct Env<'a> { + args: &'a Args, + scratch: PathBuf, +} + +/// Status and signature (for an ICE, or "hang" for a confirmed timeout), and whether the +/// compiler complained of a missing feature gate. +fn compile(env: &Env, text: &str, flags: &[String], edition: &str) -> (Status, Option, bool) { + let d = tempfile::tempdir_in(&env.scratch).expect("scratch"); + let src = d.path().join("m.rs"); + let _ = std::fs::write(&src, text); + let run = |secs| Compile::new(&env.args.rustc, &src, d.path(), flags, edition).emit("link").timeout(secs).run(); + let c = run(env.args.timeout); + let ungated = c.stderr.contains("E0658"); + match c.status { + Status::Ice => (Status::Ice, Some(signature(&c.stderr)), ungated), + Status::Timeout => match run(env.args.timeout * 3) { + t if t.status == Status::Timeout => { + (Status::Timeout, Some(Signature { key: "hang".into(), message: format!("no result in {}s", env.args.timeout * 3) }), ungated) + } + t if t.status == Status::Ice => (Status::Ice, Some(signature(&t.stderr)), t.stderr.contains("E0658")), + t => (t.status, None, t.stderr.contains("E0658")), + }, + s => (s, None, ungated), + } +} + +/// A reduction step keeps the signature, and does not reach it through a missing feature +/// gate when the input did not (an ICE after "feature not enabled" is another bug). +struct Reducer<'a> { + env: &'a Env<'a>, + flags: &'a [String], + edition: &'a str, + key: &'a str, + ungated_ok: bool, + budget: usize, +} + +impl Reducer<'_> { + fn same(&mut self, text: &str) -> bool { + if self.budget == 0 { + return false; + } + self.budget -= 1; + let (_, sig, ungated) = compile(self.env, text, self.flags, self.edition); + sig.is_some_and(|s| s.key == self.key) && (self.ungated_ok || !ungated) + } + + /// Top-level items, when syn parses the file. + fn items(&mut self, best: &mut String) { + let Ok(mut cur) = syn::parse_file(best) else { return }; + let mut k = cur.items.len(); + while k > 0 && self.budget > 0 { + k -= 1; + let mut trial = cur.clone(); + trial.items.remove(k); + let t = print(&trial); + if self.same(&t) { + cur = trial; + *best = t; + } + } + } + + /// Runs of lines: halves, quarters, … down to single lines, and every brace-balanced block + /// (a line that opens braces through the line that closes them). + fn lines(&mut self, best: &mut String) { + let mut lines: Vec = best.lines().map(str::to_owned).collect(); + let try_cut = |this: &mut Self, lines: &mut Vec, a: usize, b: usize| -> bool { + if b > lines.len() || a >= b { + return false; + } + let mut trial = lines.clone(); + trial.drain(a..b); + if this.same(&(trial.join("\n") + "\n")) { + *lines = trial; + true + } else { + false + } + }; + // Brace-balanced blocks, last first, so indices stay valid. + let mut i = lines.len(); + while i > 0 && self.budget > 0 { + i -= 1; + let opens = lines[i].matches('{').count() as i64 - lines[i].matches('}').count() as i64; + if opens <= 0 { + continue; + } + let mut depth = 0i64; + let mut j = i; + while j < lines.len() { + depth += lines[j].matches('{').count() as i64 - lines[j].matches('}').count() as i64; + if depth <= 0 { + break; + } + j += 1; + } + if j < lines.len() { + try_cut(self, &mut lines, i, j + 1); + } + } + let mut size = (lines.len() / 2).max(1); + loop { + let mut a = lines.len(); + while a > 0 && self.budget > 0 { + let start = a.saturating_sub(size); + if lines[start..a].iter().any(|l| !l.trim().is_empty()) { + try_cut(self, &mut lines, start, a); + } + a = start; + } + if size == 1 || self.budget == 0 { + break; + } + size /= 2; + } + *best = lines.join("\n") + "\n"; + } +} + +/// Greedy reduction: top-level items, then blocks and runs of lines, until nothing shrinks. +fn reduce(env: &Env, text: &str, flags: &[String], edition: &str, key: &str) -> String { + let ungated_ok = compile(env, text, flags, edition).2; + let mut r = Reducer { env, flags, edition, key, ungated_ok, budget: env.args.reduce_budget }; + let mut best = text.to_owned(); + loop { + let before = best.len(); + r.items(&mut best); + r.lines(&mut best); + if best.len() >= before || r.budget == 0 { + return best; + } + } +} + +// ---- the run ---- + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct Group { + key: String, + message: String, + count: usize, + first: Mutant, + smallest: usize, + dir: String, + #[serde(default)] + label: String, +} + +#[derive(Serialize)] +struct Line { + #[serde(flatten)] + mutant: Mutant, + status: Status, + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, +} + +fn short_hash(s: &str) -> String { + mirth_lab::artifacts::sha256(s.as_bytes())[..10].to_owned() +} + +pub fn run(args: Args) -> anyhow::Result { + std::fs::create_dir_all(args.work.join("findings"))?; + let groups_path = args.work.join("signatures.json"); + let groups: BTreeMap = + std::fs::read_to_string(&groups_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); + if args.triage { + return triage(&args, groups); + } + if args.rereduce { + let scratch = args.work.join("scratch"); + std::fs::create_dir_all(&scratch)?; + let env = Env { args: &args, scratch }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(args.jobs).stack_size(256 << 20).build()?; + pool.install(|| { + groups.values().collect::>().par_iter().for_each(|g| { + let dir = args.work.join("findings").join(&g.dir); + let (Ok(text), Ok(detail)) = (std::fs::read_to_string(dir.join("mutant.rs")), std::fs::read_to_string(dir.join("finding.json"))) else { return }; + let detail: serde_json::Value = serde_json::from_str(&detail).unwrap_or_default(); + let flags: Vec = detail["flags"].as_array().into_iter().flatten().filter_map(|f| f.as_str().map(str::to_owned)).collect(); + let edition = detail["edition"].as_str().unwrap_or("2015"); + let reduced = reduce(&env, &text, &flags, edition, &g.key); + println!("{:60} {} -> {} bytes", g.key, text.len(), reduced.len()); + let _ = std::fs::write(dir.join("reduced.rs"), reduced); + }) + }); + return Ok(ExitCode::SUCCESS); + } + let known: HashMap = args + .known + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .unwrap_or_default() + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + let (k, label) = l.split_once('\t').unwrap_or((l, "known")); + (k.trim().to_owned(), label.trim().to_owned()) + }) + .collect(); + // prettyplease's panics on syntax it cannot print are caught (see `print`): keep them quiet. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if !info.location().is_some_and(|l| l.file().contains("prettyplease")) { + default_hook(info); + } + })); + let corpus = Corpus::load(&args)?; + let log_path = args.work.join("results.jsonl"); + let done: BTreeSet = std::fs::read_to_string(&log_path) + .unwrap_or_default() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()?["i"].as_u64().map(|i| i as usize)) + .collect(); + let pending: Vec = (0..args.count).filter(|i| !done.contains(i)).collect(); + println!( + "{} tests over {} features ({} incomplete); {} mutants to make, {} done", + corpus.tests.len(), + corpus.feature_names.len(), + corpus.incomplete.len(), + pending.len(), + done.len() + ); + let scratch = args.work.join("scratch"); + std::fs::create_dir_all(&scratch)?; + let env = Env { args: &args, scratch }; + let log = Mutex::new(std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?); + let groups = Mutex::new(groups); + let baselines: Mutex>> = Mutex::new(HashMap::new()); + let stop = AtomicBool::new(false); + let progress = AtomicUsize::new(0); + let (ices, new_groups) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let pool = rayon::ThreadPoolBuilder::new().num_threads(args.jobs).stack_size(256 << 20).build()?; + pool.install(|| { + pending.par_iter().for_each(|&i| { + if stop.load(Ordering::Relaxed) { + return; + } + let Some((mutant, text, flags, edition)) = make(&corpus, args.seed, i) else { + let mut l = log.lock().unwrap(); + let _ = writeln!(l, "{}", serde_json::json!({"i": i, "strategy": "none"})); + return; + }; + let (status, sig, _) = compile(&env, &text, &flags, &edition); + let mut key = None; + if let Some(sig) = sig { + // Not a finding when the unmutated test already gives it. + let base_key = (mutant.sources[0].clone(), mutant.config.clone()); + let cached = baselines.lock().unwrap().get(&base_key).cloned(); + let base = cached.unwrap_or_else(|| { + let t = corpus.tests.iter().find(|t| t.rel == mutant.sources[0]).unwrap(); + let b = compile(&env, &t.text, &flags, &edition).1.map(|s| s.key); + baselines.lock().unwrap().insert(base_key, b.clone()); + b + }); + if base.as_deref() != Some(sig.key.as_str()) { + ices.fetch_add(1, Ordering::Relaxed); + key = Some(sig.key.clone()); + record(&env, &groups, &groups_path, &known, &mutant, &text, &flags, &edition, &sig, &new_groups, &stop); + } + } + let line = Line { mutant, status, key }; + let mut l = log.lock().unwrap(); + let _ = writeln!(l, "{}", serde_json::to_string(&line).unwrap()); + drop(l); + let n = progress.fetch_add(1, Ordering::Relaxed) + 1; + if n % 100 == 0 { + println!("{n}/{} made, {} ICEs or hangs, {} signatures new this run", pending.len(), ices.load(Ordering::Relaxed), new_groups.load(Ordering::Relaxed)); + } + }) + }); + let groups = groups.into_inner().unwrap(); + println!("{} signatures:", groups.len()); + for g in groups.values() { + println!(" {:5} {:60} {}", g.count, g.key, g.message.chars().take(90).collect::()); + } + if stop.load(Ordering::Relaxed) { + return Ok(ExitCode::from(3)); + } + Ok(ExitCode::SUCCESS) +} + +#[allow(clippy::too_many_arguments)] +fn record( + env: &Env, + groups: &Mutex>, + path: &Path, + known: &HashMap, + mutant: &Mutant, + text: &str, + flags: &[String], + edition: &str, + sig: &Signature, + new_groups: &AtomicUsize, + stop: &AtomicBool, +) { + let dir = env.args.work.join("findings").join(short_hash(&sig.key)); + let (is_new, smaller) = { + let mut g = groups.lock().unwrap(); + let is_new = !g.contains_key(&sig.key); + let entry = g.entry(sig.key.clone()).or_insert_with(|| Group { + key: sig.key.clone(), + message: sig.message.clone(), + count: 0, + first: mutant.clone(), + smallest: usize::MAX, + dir: dir.file_name().unwrap().to_string_lossy().into_owned(), + label: known.get(&sig.key).cloned().unwrap_or_default(), + }); + entry.count += 1; + let smaller = text.len() < entry.smallest; + if smaller { + entry.smallest = text.len(); + } + let _ = std::fs::write(path, serde_json::to_string_pretty(&*g).unwrap()); + (is_new, smaller) + }; + if !smaller { + return; + } + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join("mutant.rs"), text); + let detail = serde_json::json!({"signature": sig, "mutant": mutant, "flags": flags, "edition": edition}); + let _ = std::fs::write(dir.join("finding.json"), serde_json::to_string_pretty(&detail).unwrap()); + if is_new { + let reduced = reduce(env, text, flags, edition, &sig.key); + let _ = std::fs::write(dir.join("reduced.rs"), &reduced); + let unknown = !known.contains_key(&sig.key); + println!( + "FINDING {} {} ({}, {}, {} -> {} bytes){}", + sig.key, + sig.message.chars().take(100).collect::(), + mutant.strategy, + mutant.config, + text.len(), + reduced.len(), + if unknown { "" } else { " [known]" } + ); + if unknown { + new_groups.fetch_add(1, Ordering::Relaxed); + if env.args.pause_on_finding { + let _ = std::fs::write(env.args.work.join("PAUSED"), &sig.key); + stop.store(true, Ordering::Relaxed); + } + } + } +} + +// ---- triage ---- + +#[derive(Deserialize)] +struct Hit { + number: u64, + title: String, + state: String, +} + +fn search_words(message: &str) -> String { + // Words that survive GitHub's tokenizer: no punctuation, no paths, no numbers. + let words: Vec = message + .split(|c: char| !c.is_alphanumeric() && c != '_') + .filter(|w| w.len() > 2 && !w.chars().all(|c| c.is_ascii_digit()) && !w.starts_with("DefId")) + .take(8) + .map(str::to_owned) + .collect(); + words.join(" ") +} + +fn triage(args: &Args, mut groups: BTreeMap) -> anyhow::Result { + let mut out = String::new(); + for g in groups.values_mut() { + let loc_file = g.key.split(':').next().unwrap_or("").rsplit('/').next().unwrap_or("").to_owned(); + let mut hits: Vec = Vec::new(); + for q in [search_words(&g.message), format!("{} {}", loc_file.trim_end_matches(".rs"), search_words(&g.message).split(' ').take(3).collect::>().join(" "))] { + if q.trim().is_empty() { + continue; + } + let o = Command::new("gh") + .args(["search", "issues", "--repo", "rust-lang/rust", "--json", "number,title,state", "--limit", "5", "--", &q]) + .env("GH_HOST", "github.com") + .output()?; + hits = serde_json::from_slice(&o.stdout).unwrap_or_default(); + if !hits.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + g.label = match hits.iter().find(|h| h.state == "open").or(hits.first()) { + Some(h) => format!("candidates: {}", hits.iter().map(|h| format!("#{} ({}) {}", h.number, h.state, h.title.chars().take(70).collect::())).collect::>().join("; ")).chars().take(600).collect::() + &format!(" [best #{}]", h.number), + None => "looks-new (no issue matched the message)".into(), + }; + let _ = writeln!(out, "{:60} {:5} {}\n {}", g.key, g.count, g.message.chars().take(100).collect::(), g.label); + std::thread::sleep(std::time::Duration::from_secs(3)); + } + std::fs::write(args.work.join("signatures.json"), serde_json::to_string_pretty(&groups)?)?; + std::fs::write(args.work.join("triage.txt"), &out)?; + print!("{out}"); + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signatures() { + let p = "thread 'rustc' panicked at compiler/rustc_middle/src/ty/x.rs:12:5:\nbad thing 3\nquery stack during panic:\n#0 [typeck] type-checking `f`\n#1 [x] y"; + let s = signature(p); + assert_eq!(s.key, "rustc_middle/src/ty/x.rs:12 [typeck]"); + assert_eq!(s.message, "bad thing 3"); + let d = "error: internal compiler error: compiler/rustc_hir_typeck/src/a.rs:3:9: no type\n"; + assert_eq!(signature(d).key, "rustc_hir_typeck/src/a.rs:3"); + let delayed = "m.rs:21:15: error: internal compiler error: explicit deref of `T` 3 times\n"; + assert_eq!(signature(delayed).key, "delayed: explicit deref of `_` N times"); + assert_eq!(features("#![feature(a, b_c)]\n#![feature(d)]").into_iter().collect::>(), ["a", "b_c", "d"]); + } + + #[test] + fn strategies_produce_code() { + let a = "#![feature(x)]\nstruct A;\nimpl A { fn f(&self) {} }\nfn g() -> u8 { 1 }\nfn main() {}\n"; + let b = "#![feature(y)]\n#![allow(dead_code)]\nstruct A;\nfn h() {}\nfn main() {}\n"; + let mut rng = StdRng::seed_from_u64(1); + for _ in 0..20 { + let (t, _) = splice(&mut rng, a, b).unwrap(); + assert!(t.contains("feature(y)") && syn::parse_file(&t).is_ok()); + if let Some((t, _)) = relocate(&mut rng, a) { + assert!(syn::parse_file(&t).is_ok(), "{t}"); + } + } + } +} From d6733a96bac22eb82a8beaf8107de2c075a0a5ef Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sat, 10 Oct 2026 11:20:04 +0000 Subject: [PATCH 2/2] gate-mutate: delayed bugs keyed by message, a stronger reducer (brace blocks, line runs, no new E0658), --rereduce, triage checks each reduced file alone (with --test when the source uses the harness); one-hour run: 88,174 mutants, 19 signatures, findings 40-45 with reduced repros Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QXiEXbESemwqMLYKaWLDbT --- crates/mirth-lab/src/tools/gate_mutate.rs | 57 ++++++++++++++++--- docs/checks.md | 15 +++++ docs/hunt.md | 6 ++ .../hunt/tests/gate-mutate/assumptions-gce.rs | 2 + .../gate-mutate/assumptions-specialization.rs | 11 ++++ docs/hunt/tests/gate-mutate/gca-adts-leaf.rs | 3 + .../gce-gca-old-solver-normalization.rs | 15 +++++ .../tests/gate-mutate/gce-inherent-self.rs | 10 ++++ .../tests/gate-mutate/naked-test-inner-fn.rs | 5 ++ .../tests/gate-mutate/next-solver-hang.rs | 26 +++++++++ 10 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 docs/hunt/tests/gate-mutate/assumptions-gce.rs create mode 100644 docs/hunt/tests/gate-mutate/assumptions-specialization.rs create mode 100644 docs/hunt/tests/gate-mutate/gca-adts-leaf.rs create mode 100644 docs/hunt/tests/gate-mutate/gce-gca-old-solver-normalization.rs create mode 100644 docs/hunt/tests/gate-mutate/gce-inherent-self.rs create mode 100644 docs/hunt/tests/gate-mutate/naked-test-inner-fn.rs create mode 100644 docs/hunt/tests/gate-mutate/next-solver-hang.rs diff --git a/crates/mirth-lab/src/tools/gate_mutate.rs b/crates/mirth-lab/src/tools/gate_mutate.rs index 35dfbc9..94065dc 100644 --- a/crates/mirth-lab/src/tools/gate_mutate.rs +++ b/crates/mirth-lab/src/tools/gate_mutate.rs @@ -358,7 +358,12 @@ fn signature(stderr: &str) -> Signature { return Signature { key: format!("stack overflow{query}"), message: "rustc has overflowed its stack".into() }; } if let Some(c) = PANIC_AT.captures(stderr) { - return Signature { key: format!("{}:{}{query}", short(&c[1]), &c[2]), message: c[3].trim().chars().take(300).collect() }; + // `bug!` panics with a non-string payload; its text is on the ICE line. + let message = match ICE_AT.captures(stderr) { + Some(i) if c[3].trim() == "Box" => i[3].trim().to_owned(), + _ => c[3].trim().to_owned(), + }; + return Signature { key: format!("{}:{}{query}", short(&c[1]), &c[2]), message: message.chars().take(300).collect() }; } if let Some(c) = ICE_AT.captures(stderr) { let loc = match (c.get(1), DELAYED_AT.captures(stderr)) { @@ -525,6 +530,9 @@ struct Group { dir: String, #[serde(default)] label: String, + /// The command that reproduces the reduced file alone (`--triage`), or why none does. + #[serde(default)] + repro: String, } #[derive(Serialize)] @@ -541,6 +549,13 @@ fn short_hash(s: &str) -> String { } pub fn run(args: Args) -> anyhow::Result { + // prettyplease's panics on syntax it cannot print are caught (see `print`): keep them quiet. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if !info.location().is_some_and(|l| l.file().contains("prettyplease")) { + default_hook(info); + } + })); std::fs::create_dir_all(args.work.join("findings"))?; let groups_path = args.work.join("signatures.json"); let groups: BTreeMap = @@ -579,13 +594,6 @@ pub fn run(args: Args) -> anyhow::Result { (k.trim().to_owned(), label.trim().to_owned()) }) .collect(); - // prettyplease's panics on syntax it cannot print are caught (see `print`): keep them quiet. - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - if !info.location().is_some_and(|l| l.file().contains("prettyplease")) { - default_hook(info); - } - })); let corpus = Corpus::load(&args)?; let log_path = args.work.join("results.jsonl"); let done: BTreeSet = std::fs::read_to_string(&log_path) @@ -687,6 +695,7 @@ fn record( smallest: usize::MAX, dir: dir.file_name().unwrap().to_string_lossy().into_owned(), label: known.get(&sig.key).cloned().unwrap_or_default(), + repro: String::new(), }); entry.count += 1; let smaller = text.len() < entry.smallest; @@ -747,9 +756,39 @@ fn search_words(message: &str) -> String { words.join(" ") } +static TEST_ATTR: LazyLock = LazyLock::new(|| Regex::new(r"#\[(test|bench)\b").unwrap()); + +/// The flags with which the reduced file gives the signature on its own: the mutant's flags, +/// else with `--test` added when the file or its source test uses the test harness. +fn reproduce(args: &Args, g: &mut Group) -> String { + let dir = args.work.join("findings").join(&g.dir); + let (Ok(text), Ok(detail)) = (std::fs::read_to_string(dir.join("reduced.rs")), std::fs::read_to_string(dir.join("finding.json"))) else { + return "no reduced file".into(); + }; + let detail: serde_json::Value = serde_json::from_str(&detail).unwrap_or_default(); + let flags: Vec = detail["flags"].as_array().into_iter().flatten().filter_map(|f| f.as_str().map(str::to_owned)).collect(); + let edition = detail["edition"].as_str().unwrap_or("2015").to_owned(); + let source = g.first.sources.first().map(|s| std::fs::read_to_string(args.rust.join("tests/ui").join(s)).unwrap_or_default()).unwrap_or_default(); + let mut tries = vec![flags.clone()]; + if !flags.iter().any(|f| f == "--test") && (TEST_ATTR.is_match(&text) || TEST_ATTR.is_match(&source) || source.contains("--test")) { + tries.push([flags.clone(), vec!["--test".to_owned()]].concat()); + } + let scratch = args.work.join("scratch"); + let _ = std::fs::create_dir_all(&scratch); + let env = Env { args, scratch }; + for f in tries { + if let Some(sig) = compile(&env, &text, &f, &edition).1.filter(|s| s.key == g.key) { + g.message = sig.message; + return format!("RUSTC_BOOTSTRAP=1 rustc reduced.rs --edition {edition} {}", f.join(" ")).trim().to_owned(); + } + } + "does not reproduce alone (the mutant does)".into() +} + fn triage(args: &Args, mut groups: BTreeMap) -> anyhow::Result { let mut out = String::new(); for g in groups.values_mut() { + g.repro = reproduce(args, g); let loc_file = g.key.split(':').next().unwrap_or("").rsplit('/').next().unwrap_or("").to_owned(); let mut hits: Vec = Vec::new(); for q in [search_words(&g.message), format!("{} {}", loc_file.trim_end_matches(".rs"), search_words(&g.message).split(' ').take(3).collect::>().join(" "))] { @@ -770,7 +809,7 @@ fn triage(args: &Args, mut groups: BTreeMap) -> anyhow::Result format!("candidates: {}", hits.iter().map(|h| format!("#{} ({}) {}", h.number, h.state, h.title.chars().take(70).collect::())).collect::>().join("; ")).chars().take(600).collect::() + &format!(" [best #{}]", h.number), None => "looks-new (no issue matched the message)".into(), }; - let _ = writeln!(out, "{:60} {:5} {}\n {}", g.key, g.count, g.message.chars().take(100).collect::(), g.label); + let _ = writeln!(out, "{:60} {:5} {}\n {}\n repro: {}", g.key, g.count, g.message.chars().take(100).collect::(), g.label, g.repro); std::thread::sleep(std::time::Duration::from_secs(3)); } std::fs::write(args.work.join("signatures.json"), serde_json::to_string_pretty(&groups)?)?; diff --git a/docs/checks.md b/docs/checks.md index 1e149f5..50b38a6 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -370,6 +370,19 @@ Ten new findings (19–28) in [`hunt.md`](hunt.md), none from the checks mirth h | determinism (15) | `mirth-lab repro-diff` | 6,886 tests × repeat, other directory with `--remap-path-prefix`, `-Zthreads=8`, decoy `-L` library | nothing new: only `-Zthreads` differences, all in the known async fn (#162202) and RPIT (#163878) families | | feature gates (17) | `mirth-lab gate-check` | 143 unstable attributes × 14 positions; 156 unstable library items with resolvable paths × use, renamed use, glob, impl, value, type | every library spelling gated; finding 31 (an ICE after the gate error for `#[rustc_main]` on non-functions); `#[feature]` outside the crate root only warns (intended) | +### Third batch (2026-10-10) + +| check | subcommand | swept | result | +|---|---|---|---| +| feature-gate mutation (Part 1, item 1) | `mirth-lab gate-mutate` | 88,174 mutants of the 4,419 standalone UI tests that enable a feature (348 features; the 37 incomplete ones drawn three times as often): 35% splices of two tests with different features, 30% items moved into a generic fn, async fn, closure, anonymous const, module, trait default or inherent impl, 15% an extra incomplete gate, 20% fuzzer edits; each compiled once, under the test's flags (half), `-Znext-solver=globally` (30%) or `-Zassumptions-on-binders` (20%); about 23,000 mutants an hour on 4 cores | 482 ICEs and 16 timeouts; 197 mutants gave a signature their unmutated test does not give, in 19 signatures and 12 families. Known: #153733 (pin_ergonomics, explicit deref), #156099 (transmutability `Assume`), #151310 (blanket `CoerceUnsized`, fixed after the pin), #156410 (`#[const_continue]` to an associated const), #153735 (gca + `generic_const_exprs` "can't type-check body"), and the async-drop "insta-stable" assertion of closed #162756, still reached through `staged_api` (internal) by two other routes. Looks new: findings 40–45 (one stable-reachable, 44; one hang in the default configuration, 45) | + +`gate-mutate` keeps each signature's smallest mutant, reduces it (top-level items, brace blocks, +runs of lines; a step may not introduce E0658), checks the reduced file alone (with `--test` +when the source test uses the harness) and searches rust-lang/rust's issues for the message +(`--triage`); the search only proposes candidates, which were read by hand. Signatures are the +panic's location and the first query on the stack, or a delayed bug's message, so one bug can +show as several signatures (finding 40 as six). + ## Running the checks The checks are subcommands of `mirth-lab` (`crates/mirth-lab`; `mirth-lab --help` lists them): @@ -380,6 +393,8 @@ R=~/mirth-work/campaign/rustc/bin/rustc T=~/mirth-work/rust/tests/ui target/release/mirth-lab opt-diff --rustc $R --cranelift "$(rustup +nightly-2026-10-06 which rustc)" --tests $T --work target/release/mirth-lab solver-diff --rustc $R --tests $T --work target/release/mirth-lab abi-diff --rustc $R --rust ~/mirth-work/rust --work --seed 3 +target/release/mirth-lab gate-mutate --rustc $R --rust ~/mirth-work/rust --work --count 20000 --jobs 4 +target/release/mirth-lab gate-mutate --rustc $R --rust ~/mirth-work/rust --work --triage target/release/mirth-lab release-diff --corpus ~/proofhouse-repos/rust --old nightly-2026-07-18 --new nightly-2026-10-06 --work ``` diff --git a/docs/hunt.md b/docs/hunt.md index e52fcc5..d91c4e0 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -56,6 +56,12 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 30 | compiler-internal debug output in user-facing diagnostics: under the default (new) solver, E0308 help suggests `as fn(?0t) -> ?0t`; an E0391 cycle note prints `Binder { value: ConstEvaluatable(AliasConst(… DefId(0:7 ~ …` (blessed in `offset-of/inside-array-length.stderr`) | low, diagnostics; found by the diagnostic-invariants check over 18,374 UI tests (excluding tests that ask for verbose output); the first not in CI because of the solver pin ([`solver-triage.md`](solver-triage.md) item I) | | 31 | `#[rustc_main]` on a struct, impl, trait or module, on stable: after the expected E0658 and "cannot be used on structs", rustc ICEs ("unexpected sort of node in fn_sig()", `collect.rs`): the item is still taken as the entry point | **looks new**, low (error recovery, internal attribute); regression between 1.91.0 and 1.93.0; found by the feature-gate check; [repro](hunt/tests/rustc-main-on-struct.rs) | | 32 | new-solver compile-time regression: a chain of N `.map()` calls type-checks in 4.5 s / 520 MB at N=200 on nightly-2026-08-03 and 37–58 s / 2.0–2.9 GB from nightly-2026-08-04, with a new "overflow evaluating the requirement `Map<…>: Iterator`" future-compat warning; nightly's default solver is the new one, so default builds regressed from 2.2 s (old solver, July) to 53 s | **looks new**, medium (compile time, realistic code shape); bisected over nightlies to #160254 (the only solver PR in the range); found by the scaling check; [facts](hunt/iter-chain-solver-regression.md) | +| 40 | `-Zassumptions-on-binders` crashes on any crate that enables `generic_const_exprs`: `#![feature(generic_const_exprs)] fn main() {}` panics with "assertion failed: self.next_trait_solver()" (`rustc_infer/src/infer/outlives/obligations.rs:280`, in `check_well_formed`; also from `param_env`, `type_of`, `adt_destructor`, `mir_borrowck`); a trait with a `T: Freeze + 'static` parameter gives the sibling assertion `!self.tcx.assumptions_on_binders()` (line 165); with `-Znext-solver`, two overlapping `min_specialization` impls give a delayed bug "`[…]` is not fully resolved" | **looks new** (none of the 8 open assumptions-on-binders ICE issues; tracking #158130), low (experimental flag); nightly-2026-07-18 (line 241) and 10-06; found by `gate-mutate` (165 of its 197 new-signature mutants); [repro](hunt/tests/gate-mutate/assumptions-gce.rs), [repro](hunt/tests/gate-mutate/assumptions-specialization.rs) | +| 41 | `generic_const_exprs` with gca generic const items under `-Znext-solver`: `const ARR: [(); gca!(ADD1::<0>)] = [(); gca!(INC::<0>)]` with `ADD1`/`INC` generic consts gives the delayed bug "encountered regular consts in the old solver's const normalization" | **looks new** (no issue with the message; same shape as open #153735, a different ICE), low (two incomplete features); nightly-2026-10-06 (the gca_* gates are newer than nightly-2026-07-18); found by `gate-mutate` (an extra `generic_const_exprs` gate on `const-generics/gca/basic-different-definitions.rs`); [repro](hunt/tests/gate-mutate/gce-gca-old-solver-normalization.rs) | +| 42 | "expected branch, got Leaf(0x01)" (`rustc_type_ir/src/const_kind.rs:256`, `valtree_to_const_val`) for a tuple constant with a mistyped field, with `gca_adts`, `gca_macroless_items` and `gca_min_const_items` together; no type error is reported first. #162392's program, fixed and closed 2026-09-14; its regression test (`mgca/valtree-leaf-const.rs`) uses `gca_macroless_args` and passes; with any two of the three gates the program gets the expected E0308 | **looks new** (a route the fix of closed #162392 does not cover), low; nightly-2026-10-06; found by `gate-mutate` (a splice); [repro](hunt/tests/gate-mutate/gca-adts-leaf.rs) | +| 43 | "AliasConst::type_of got InherentSelf - args should always be InherentImpl at this point" (`const_kind.rs:85`, `check_well_formed`) for `fn to_bytes() -> [u8; gca!(Self::SIZE)]` in an inherent impl, when `generic_const_exprs` is also enabled; without it the program is accepted as in #162147's regression test (`gca/wf-inherentimpl.rs`) | **looks new** (a route around closed #162147, fixed 2026-09-03), low; nightly-2026-10-06; found by `gate-mutate` (a splice whose `--cfg full` turns on `generic_const_exprs`); [repro](hunt/tests/gate-mutate/gce-inherent-self.rs) | +| 44 | `rustc --test` panics "expected statement" (`rustc_expand/src/base.rs:172`) after E0736 for a `#[test] #[unsafe(naked)] extern "C" fn` nested inside another function's body; a `#[test]` inner fn without `naked` only warns "cannot test inner items" | **looks new** (closed issues with the message: #112360, #109816 (both `--test`), #83469, #149980; none open, none with `naked`), low (error recovery), **stable**: 1.82.0 through 1.88.0, 1.90.0, 1.98.0 and nightly-2026-10-06 (each tested) (1.81.0 rejects with E0658/E0787; before 1.88 the panic comes before the gate error); found by `gate-mutate` (an item moved into a generic fn); [repro](hunt/tests/gate-mutate/naked-test-inner-fn.rs) | +| 45 | a hang under the new trait solver: a closure with a `for<'a, 'b>` binder returning a TAIT with two lifetimes, passed where a `for<'a> AsyncFn<&'a mut C, …>` bound (a trait with an `FnMut` supertrait and an associated future) is required, does not finish compiling (still running after 200 s); with `-Znext-solver=coherence` (the old solver) it reports E0046/E0308/E0277 in 0.05 s | **looks new** (no issue found), medium: the new solver is nightly's default, so the plain `rustc` hangs on nightly-2026-10-06; also hangs on nightly-2026-07-18 with `-Znext-solver=globally` (not a recent regression); found by `gate-mutate` (a module splice of two tests); [repro](hunt/tests/gate-mutate/next-solver-hang.rs) | 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/tests/gate-mutate/assumptions-gce.rs b/docs/hunt/tests/gate-mutate/assumptions-gce.rs new file mode 100644 index 0000000..3a8cb95 --- /dev/null +++ b/docs/hunt/tests/gate-mutate/assumptions-gce.rs @@ -0,0 +1,2 @@ +#![feature(generic_const_exprs)] +fn main() {} diff --git a/docs/hunt/tests/gate-mutate/assumptions-specialization.rs b/docs/hunt/tests/gate-mutate/assumptions-specialization.rs new file mode 100644 index 0000000..a22c505 --- /dev/null +++ b/docs/hunt/tests/gate-mutate/assumptions-specialization.rs @@ -0,0 +1,11 @@ +#![feature(min_specialization)] +fn main() {} +trait SpecFromIter { + fn f(&self); +} +impl<'a, T: 'a, I: Iterator> SpecFromIter for I { + default fn f(&self) {} +} +impl<'a, T> SpecFromIter for std::slice::Iter<'a, T> { + fn f(&self) {} +} diff --git a/docs/hunt/tests/gate-mutate/gca-adts-leaf.rs b/docs/hunt/tests/gate-mutate/gca-adts-leaf.rs new file mode 100644 index 0000000..3afb5a4 --- /dev/null +++ b/docs/hunt/tests/gate-mutate/gca-adts-leaf.rs @@ -0,0 +1,3 @@ +#![feature(gca_adts, gca_macroless_items, gca_min_const_items)] +const TUPLE: (&'static str, &'static str) = ("a", true); +fn main() { TUPLE; } diff --git a/docs/hunt/tests/gate-mutate/gce-gca-old-solver-normalization.rs b/docs/hunt/tests/gate-mutate/gce-gca-old-solver-normalization.rs new file mode 100644 index 0000000..97dfd34 --- /dev/null +++ b/docs/hunt/tests/gate-mutate/gce-gca-old-solver-normalization.rs @@ -0,0 +1,15 @@ + +#![feature(generic_const_exprs)] +#![feature(generic_const_items)] +#![feature(gca_min_const_items)] +#![feature(gca_const_items)] + +use std::gca; + +const ADD1: usize = N + 1; + +const INC: usize = N + 1; + +const ARR: [(); gca!(ADD1::<0>)] = [(); gca!(INC::<0>)]; + +fn main() {} diff --git a/docs/hunt/tests/gate-mutate/gce-inherent-self.rs b/docs/hunt/tests/gate-mutate/gce-inherent-self.rs new file mode 100644 index 0000000..688979e --- /dev/null +++ b/docs/hunt/tests/gate-mutate/gce-inherent-self.rs @@ -0,0 +1,10 @@ +#![feature(adt_const_params, unsized_const_params, generic_const_exprs)] +#![feature(inherent_associated_types)] +#![feature(gca_const_items, gca_min_const_items)] +use std::gca; +struct Foo; +impl Foo { + const SIZE: usize = { todo!() }; + fn to_bytes() -> [u8; gca!(Self::SIZE)] { + } +} diff --git a/docs/hunt/tests/gate-mutate/naked-test-inner-fn.rs b/docs/hunt/tests/gate-mutate/naked-test-inner-fn.rs new file mode 100644 index 0000000..3d22be9 --- /dev/null +++ b/docs/hunt/tests/gate-mutate/naked-test-inner-fn.rs @@ -0,0 +1,5 @@ +fn host() { + #[test] + #[unsafe(naked)] + extern "C" fn t() {} +} diff --git a/docs/hunt/tests/gate-mutate/next-solver-hang.rs b/docs/hunt/tests/gate-mutate/next-solver-hang.rs new file mode 100644 index 0000000..2e3e41c --- /dev/null +++ b/docs/hunt/tests/gate-mutate/next-solver-hang.rs @@ -0,0 +1,26 @@ +// A hang (no result in minutes) under the new trait solver; the old solver reports errors. +#![feature(type_alias_impl_trait)] +#![feature(closure_lifetime_binder)] +use std::future::Future; +trait AsyncFn: FnMut(I) -> Self::Fut { + type Fut: Future; +} +impl AsyncFn for F +where + F: FnMut(I) -> Fut, +{ +} +async fn call(mut ctx: C, mut f: F) -> Result +where + F: for<'a> AsyncFn<&'a mut C, Result>, +{ +} +trait Cap<'a> {} +fn doesnt_work_but_should(ctx: &mut usize) { + type Ret<'a, 'b: 'a> = impl Future> + 'a + Cap<'b>; + call( + ctx, + for<'a, 'b> |c: &'a mut &'b mut usize| -> Ret<'a, 'b> { + }, + ); +}