From f33a0fa0fe984bf73a944d69e925d1e9d05b63c0 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 04:33:44 +0200 Subject: [PATCH 1/6] The compiler keeps a loop's exit at its counter's maximum, and a sysroot whose compiler does not is refused The fork's LLVM gave a header phi's recurrence the wrap flags of its increment, which say only that the increment is poison where it wraps, and `indvars` folded the exit an inclusive range takes at its integer type's maximum to `false`: safe Rust became an endless loop for `aarch64-unknown-toyos` (`u16`, `u128`) and `x86_64-unknown-toyos` (`u128`). Upstream knows it as llvm/llvm-project#175729, open. - `rust` moves to 9e7b17b52f9, whose `src/llvm-project` is 81b496be0342: ScalarEvolution transfers the flags only where they are proven for the recurrence, behind the hidden `-scev-unconditional-preinc-nowrap-flags`. - `src/miscompile.rs` compiles the reproducer over `u16` and `u128` for both userland targets with every sysroot's compiler before the sysroot is published, and refuses one whose `caller` is not `ret i1 true`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- rust | 2 +- src/lib.rs | 1 + src/miscompile.rs | 123 ++++++++++++++++++++++++++++++++++++ src/miscompile/last_exit.rs | 48 ++++++++++++++ src/sysroot.rs | 8 ++- 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/miscompile.rs create mode 100644 src/miscompile/last_exit.rs diff --git a/rust b/rust index 6d6ad8c7190..9e7b17b52f9 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 6d6ad8c71906c4e7ddb67e34720f297704d6c282 +Subproject commit 9e7b17b52f955a9462a55c46a8ba2440c51a9c9f diff --git a/src/lib.rs b/src/lib.rs index 45229856279..7785ce21e49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod metal; pub mod metaldevices; pub mod metalimage; pub mod metaltimings; +pub mod miscompile; pub mod n2; pub mod release; pub mod sdkversion; diff --git a/src/miscompile.rs b/src/miscompile.rs new file mode 100644 index 00000000000..6790f0fe3c6 --- /dev/null +++ b/src/miscompile.rs @@ -0,0 +1,123 @@ +//! What a sysroot's compiler has been seen to miscompile, compiled by every +//! sysroot before it is published and refused where the answer is wrong. +//! +//! **A loop keeps the exit its counter's maximum takes** ([`LAST_EXIT`]). An +//! LLVM whose ScalarEvolution gives a header phi's recurrence the wrap flags of +//! its increment without proving them for it (llvm/llvm-project#175729, the +//! fork's `-scev-unconditional-preinc-nowrap-flags`) lets `indvars` fold such a +//! loop's last exit to `false`: safe Rust becomes an endless loop. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use crate::arch::Arch; + +/// An inclusive range walked to its integer type's maximum, whose `caller` +/// returns `true`. +const LAST_EXIT: &str = include_str!("miscompile/last_exit.rs"); + +/// The line of [`LAST_EXIT`] that names the range's integer type. +const PORT: &str = "type Port = u16;"; + +/// The types [`LAST_EXIT`] is compiled over: the two an unfixed compiler made +/// an endless loop of, `u16` for AArch64 and `u128` for both architectures. +const PORTS: [&str; 2] = ["u16", "u128"]; + +/// Refuse the toolchain at `toolchain` unless its rustc compiles [`LAST_EXIT`] +/// to `true` for each of [`PORTS`] and each architecture's userland, at the +/// optimisation level guests are built with. Sources and IR are written in +/// `scratch`. +pub(crate) fn refuse(toolchain: &Path, scratch: &Path) { + fs::create_dir_all(scratch).unwrap_or_else(|e| panic!("create {}: {e}", scratch.display())); + assert!(LAST_EXIT.contains(PORT), "the reproducer names its integer type as `{PORT}`, and no longer does"); + for port in PORTS { + let source = scratch.join(format!("last_exit_{port}.rs")); + fs::write(&source, LAST_EXIT.replace(PORT, &format!("type Port = {port};"))) + .unwrap_or_else(|e| panic!("write {}: {e}", source.display())); + for arch in Arch::ALL { + let target = arch.userland(); + let ir = scratch.join(format!("last_exit_{port}-{target}.ll")); + let rustc = toolchain.join("bin/rustc"); + let output = Command::new(&rustc) + .args(["--edition", "2021", "--crate-type", "lib", "--target", target]) + .args(["-C", "opt-level=2", "-C", "codegen-units=1", "--emit", "llvm-ir", "-o"]) + .arg(&ir) + .arg(&source) + .env_remove("RUSTFLAGS") + .output() + .unwrap_or_else(|e| panic!("run {}: {e}", rustc.display())); + assert!( + output.status.success(), + "{} did not compile {} for {target}:\n{}", + rustc.display(), + source.display(), + String::from_utf8_lossy(&output.stderr), + ); + let text = fs::read_to_string(&ir).unwrap_or_else(|e| panic!("read {}: {e}", ir.display())); + assert!( + returns_true(&text, "caller"), + "{} miscompiles a loop over `{port}` that ends at `{port}::MAX` for {target}: `caller` \ + returns `true` and its IR does not (llvm/llvm-project#175729):\n{}", + rustc.display(), + body(&text, "caller").unwrap_or("there is no `caller`"), + ); + } + } +} + +/// The definition of `function` in the LLVM IR `ir`, from its `define` to its +/// closing brace. +fn body<'a>(ir: &'a str, function: &str) -> Option<&'a str> { + let named = format!("@{function}("); + let mut at = 0; + let start = ir.split_inclusive('\n').find_map(|line| { + let here = at; + at += line.len(); + (line.starts_with("define ") && line.contains(&named)).then_some(here) + })?; + let end = ir[start..].find("\n}")?; + Some(&ir[start..start + end + 2]) +} + +/// Whether `function` in `ir` is one block that returns `true`. +fn returns_true(ir: &str, function: &str) -> bool { + let Some(body) = body(ir, function) else { return false }; + let instructions: Vec<&str> = + body.lines().skip(1).map(str::trim).filter(|line| !line.is_empty() && !line.ends_with(':') && *line != "}").collect(); + instructions == ["ret i1 true"] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **The negative control is the defect itself**: `caller` verbatim as the + /// unfixed compiler emitted it for `aarch64-unknown-toyos` over `u16`, and + /// as the same source compiles for `x86_64-unknown-toyos`. + #[test] + fn an_endless_caller_is_not_one_that_returns_true() { + let endless = "\ +; Function Attrs: nofree norecurse noreturn nosync nounwind memory(none) +define noundef zeroext i1 @caller() unnamed_addr #0 personality ptr @rust_eh_personality { +start: + br label %bb4.i.backedge.i + +bb4.i.backedge.i: ; preds = %bb4.i.backedge.i, %start + br label %bb4.i.backedge.i +} + +attributes #0 = { nofree norecurse noreturn nosync nounwind memory(none) } +"; + assert!(!returns_true(endless, "caller")); + let right = "\ +define noundef zeroext i1 @caller() unnamed_addr #0 { +start: + ret i1 true +} +"; + assert!(returns_true(right, "caller")); + assert!(!returns_true(&right.replace("ret i1 true", "ret i1 false"), "caller")); + assert!(!returns_true(&right.replace("@caller(", "@other("), "caller"), "a module with no `caller` answers nothing"); + } +} diff --git a/src/miscompile/last_exit.rs b/src/miscompile/last_exit.rs new file mode 100644 index 00000000000..732f597025f --- /dev/null +++ b/src/miscompile/last_exit.rs @@ -0,0 +1,48 @@ +//! `caller` returns `true`: the first range's last port is `Port::MAX`, where +//! its loop ends. +#![no_std] + +type Port = u16; + +#[derive(Clone, Copy)] +pub enum Mediated { + Kept, + ReadOnly, +} + +#[derive(Clone, Copy)] +pub enum Standing { + Free, + Declared(Mediated), +} + +fn port(standing: impl Fn(Port) -> Standing, port: Port, width: Port, write: bool) -> Result { + for port in port..=port + (width - 1) { + match standing(port) { + Standing::Free => {} + Standing::Declared(Mediated::ReadOnly) if !write => {} + Standing::Declared(Mediated::ReadOnly) => return Err(9), + Standing::Declared(Mediated::Kept) => return Err(8), + } + } + Ok(port) +} + +fn standing(port: Port) -> Standing { + match port { + 0x38..=0x3F | 0x20..=0x21 | 0xA0..=0xA1 | 0x70..=0x71 | 0xC8 | 0xCC..=0xCF | 0xC9 => { + Standing::Declared(Mediated::Kept) + } + 0xB2 => Standing::Declared(Mediated::ReadOnly), + _ => Standing::Free, + } +} + +fn probe() -> bool { + port(standing, Port::MAX - 3, 4, false).is_ok() & port(standing, 0xB2, 1, true).is_err() +} + +#[no_mangle] +pub fn caller() -> bool { + probe() +} diff --git a/src/sysroot.rs b/src/sysroot.rs index dc2f6a3946e..dd04a5e0006 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -92,12 +92,13 @@ const SOURCES: &str = "SOURCES"; /// What changes how a key's sources become its libraries and its sysroot and /// is none of them, nor std's configuration ([`std_config`]), which the /// freestanding key reads whole. Moving it moves every key. -const RECIPE: &str = "bootstrap stage-0 local rebuild, libraries from the stamp, libtoyos_c merged, \ +const RECIPE: &str = "bootstrap stage-0 local rebuild, libraries from the stamp, refused where its \ + compiler miscompiles what `src/miscompile.rs` holds, libtoyos_c merged, \ a C sysroot of libc's staticlib, the empty libraries beside it, headers and \ CMake's description of ToyOS per target, refused unless a C program naming \ each library links against it, and its C++ runtime built under n2 from the \ runtimes' sources of the compiler's LLVM, the freestanding libraries cloned \ - from their key's; 13"; + from their key's; 14"; /// Whose sources a guest target's libraries compile. #[derive(Clone, Copy, PartialEq, Eq)] @@ -764,6 +765,9 @@ fn build(root: &Path, store: &Path, compiler: &Compiler, fork: &Path, keys: &Key } } } + let miscompiles = dir.with_extension("miscompile"); + crate::miscompile::refuse(partial, &miscompiles); + let _ = fs::remove_dir_all(&miscompiles); let libc_target = dir.with_extension("libc-target"); for arch in Arch::ALL { crate::libc::build(root, partial, &libc_target, arch); From 1b519245461e60e3be679d1458a32f12d8b87432 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 09:20:47 +0200 Subject: [PATCH 2/6] The fork's ScalarEvolution issue is closed: its four exit items are met `issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md` goes, with its three citations rewritten to stand without it. Its exit, under LLVM 2b9f1f003570f5b2 and sysroot d45d2461afc0b940: 1. `m1.ll` through that LLVM's own `opt -passes=indvars -S`: no `br i1 false`, `@f` returns; one with the switch. 2. `caller` is `ret i1 true` in `minns.rs` and `c_u128.rs` for `aarch64-unknown-toyos`, `x86_64-unknown-toyos`, `aarch64-unknown-none-softfloat` and `aarch64-unknown-uefi`. 3. `cargo test --locked -p toyos-userbound --test firmware` built by that compiler without incremental state on Apple silicon: exit 0, 24 passed. 4. The tree built twice with the one compiler, the switch given and withheld, compared function by function: no function lost a loop exit. The rule the file carried is `src/miscompile.rs`'s: every sysroot's compiler compiles the reproducer right or the sysroot is refused, which is the measurement the file asked of whoever moves the fork to a later upstream. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...-under-host-load-can-go-silent-for-15-s.md | 14 +- ...ss-loop-of-a-port-test-on-apple-silicon.md | 15 +- ...-scalar-evolution-gives-the-wrong-value.md | 505 ------------------ 3 files changed, 18 insertions(+), 516 deletions(-) delete mode 100644 issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md diff --git a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md index 6de1f2db100..11ad875e5e8 100644 --- a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md +++ b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md @@ -136,11 +136,15 @@ the step each stopped in is not read from it: 77 (38 s, with ceilings paid at 8.00x and 31 s between its image and its first guest line) and 75 to 67 (7 s). -Not known of any of them: whether the fork compiler's fault reaches -`test_rs_counters_read` or the kernel under it. -`issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md` -has LLVM's ScalarEvolution deleting a loop's exit on both architectures, and -records every function but the ones it names as not measured. +The fork compiler's ScalarEvolution fault (llvm/llvm-project#175729, which +`src/miscompile.rs` now refuses a sysroot for) is not their cause. +`tests/virtsmpcase`'s image was built and `virt_el1_smp` run with the fixed +compiler and with the fault switched back on +(`-C llvm-args=-scev-unconditional-preinc-nowrap-flags`), and every function +compared in IR and in object code: `counters_read`, `test-runner`, `supervisor`, +`logkeeper`, `toybox`, `kernelprobe` and the loader are byte-identical between +the two, and the kernel differs in seven functions of `rustc_demangle`'s `v0` +printer, a loop peeled or not, which a counters read does not call. The slow ones, with registers: a probe that captured `info registers -a` over QMP whenever the read had not ended 3 s after `unmap_touch` (`debug-slow.patch` diff --git a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md b/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md index b209d33d0fb..5a2c0ea2e6d 100644 --- a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md +++ b/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md @@ -12,9 +12,12 @@ host suite on macOS, from the first nightly that had the test (#749): the compiler that job installed, `rustc 1.99.0 (b940084d7 2026-09-28)`, LLVM 23.1.1, compiles the test's function to one instruction, a branch to itself. The job is pinned to 1.98.1 for it. The fault is LLVM's ScalarEvolution's, -on every target, and is not this compiler's alone: -`issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md` -has its cause, its reproducers and the compilers that have it. +on every target, and is not this compiler's alone: it copies an increment's +no-wrap flag onto its phi's recurrence, where it holds only if the wrapped +increment is observed, and `indvars` folds the loop's last exit to `false`. +The fork's LLVM no longer does, and `src/miscompile.rs` holds a reproducer +every sysroot's compiler must compile right; the host's `rustc` is upstream's +and still does. ## What the runner showed @@ -103,9 +106,9 @@ host suite right, and nothing measured says either way. nightlies were run, of 10, 13 and 22 July, 25 September and 8 October 2026, the last the newest there was, and each hangs the table's second row; none between them was run. Upstream's LLVM has merged no fix. That its open report -llvm/llvm-project#175729 is of this fault is the reading of -`issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md`, -which lists it under "Not established". +llvm/llvm-project#175729 is of this fault is a reading, not upstream's word: +the same fold on the same code path, and the fork's compiler with that report's +proposed fix (llvm/llvm-project#118959) compiles this test right, 24 passed. `guest.yml`, and so `guest / suite` and the nightly's `tcg / suite`, and `nightly.yml`'s `portability-linux` install `stable` and log its version: diff --git a/issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md b/issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md deleted file mode 100644 index 3bab5c8305c..00000000000 --- a/issues/the-forks-llvm-deletes-a-loops-exit-on-a-no-wrap-flag-scalar-evolution-gives-the-wrong-value.md +++ /dev/null @@ -1,505 +0,0 @@ ---- -status: assigned -kind: defect -opened: 2026-10-09 ---- - -# The fork's LLVM deletes a loop's exit on a no-wrap flag ScalarEvolution gives the wrong value - -The compiler that builds every ToyOS kernel, loader and program -(`rust/` at `6d6ad8c71906`, `rustc 1.99.0-dev`, LLVM 22.1.8 from -`src/llvm-project` at `ceaf0fbb8440`) turns a correct loop of safe Rust into an -endless one. Measured: an inclusive range that ends at `u16::MAX`, for -`aarch64-unknown-toyos`, `aarch64-unknown-none-softfloat` and -`aarch64-unknown-uefi`; and one that ends at `u128::MAX`, for -`aarch64-unknown-toyos` and `x86_64-unknown-toyos`. The fault is in LLVM's -ScalarEvolution and is the same on every target: it moves a no-wrap flag from -an increment whose wrapped result nothing uses onto a value that is used, and -`indvars` deletes the loop's exit on it. Upstream LLVM has the fault and has -merged no fix. Its open report llvm/llvm-project#175729 has the same symptom -on another loop: that ToyOS's loop is that report's fault is this file's -reading, and that the report is the known fault is a maintainer's -"probably" ("Upstream" below, "Not established"). - -## The fault, with no front end - -`m1.ll`. `@f` returns `true` after four iterations: - -```llvm -define i1 @f() { -entry: - br label %header -header: - %next = phi i16 [ -3, %entry ], [ %nextnext, %latch ] - %iter = phi i16 [ -4, %entry ], [ %next, %latch ] - %done = icmp eq i16 %iter, -1 - br i1 %done, label %exit, label %latch -latch: - %nextnext = add nuw i16 %next, 1 - br label %header -exit: - ret i1 true -} -``` - -The `add` wraps when `%next` is -1. Its result is then poison, and nothing -uses it: the loop leaves on `%iter == -1` in the next header. `opt --passes=indvars -S m1.ll`, with the `opt` of LLVM 22.1.8 that -`nightly-2026-07-22`'s `llvm-tools` ships, writes nothing to stderr and -leaves nothing of `%done`: the header is - -```llvm -header: ; preds = %latch, %entry - br i1 false, label %exit, label %latch -``` - -and the function never returns. The same with no `target datalayout`, with -AArch64's and with x86-64's. No `opt` built from the fork's LLVM was run; the -fork's source has the lines step 3 below reads, unchanged. - -A fixed `opt` leaves `%done` as it is, `icmp eq i16 %iter, -1` with the -header's branch on it, or folds it to something under which `@f` still returns -`true`. It never leaves `br i1 false`. - -Controls, each through the same `opt -passes=indvars`. The exit is folded to -`false` in: `m1` with a second exit in the latch that calls an opaque function -(`m2`); that at `i8`, `i32` and `i64`; and that with a start that is not -constant but carries `range(i16 10, 100)`. It is not folded in: `m2` without -the `nuw`; `m2` with the `add` in the header, before the rotation; and `m2` -with a start of unknown range. `m2` and the three that do not fold gave the -same under each of the three data layouts. Of eleven passes tried on `m2` -(`indvars`, `loop-unroll`, `loop-reduce`, `loop-vectorize`, `loop-idiom`, -`loop-deletion`, `loop-predication`, `loop-flatten`, `irce`, -`constraint-elimination`, `nary-reassociate`) only `indvars` leaves `br i1 -false`. - -## The cause - -Four steps. The first two are sound, the third is the fault, the fourth is -where it shows. From `-print-changed` traces of the `u16` reproducer below, -its unoptimised IR for `aarch64-apple-darwin` through that `opt -O2` and the -fork's compiler's own for `aarch64-unknown-toyos`, which agree; LLVM source -lines are the fork's at `ceaf0fbb8440`. - -1. **CorrelatedValuePropagation, on `port`, marks the range's increment - `nuw`.** Measured: the `add i16 %iter, 1` before that pass's dump is `add - nuw i16` after it. Read from the source: the add's only use is the header - phi along the back edge, that edge implies `iter != 0xFFFF`, and - `LazyValueInfo.cpp:1821` (`getValueAtUse`) reasons from exactly that. Sound: - where `iter` is `0xFFFF` the add is poison and nothing uses it. -2. **LoopRotate, on `probe` after `port` is inlined with the constant start - `0xFFFC`, makes that add a header phi's increment.** Measured: after it the - loop is `m1`'s, `%next` from -3, `%iter` from -4, and `add nuw i16 %next, 1` - in the latch. Sound: the add wraps in the iteration where `%iter` is - `0xFFFE`, and the loop leaves before the poison is used. -3. **ScalarEvolution gives `%next` the recurrence `{-3,+,1}`: the - fault.** Measured: its own printed analysis of `m2` and of the real loop - copied by hand says `{-3,+,1}` with - the unsigned range `[-3,0)`, and for `m2` beside it `exit count for header: - i16 3`, the iteration at which that recurrence is 0. Read from the source: - `ScalarEvolution.cpp:5776-5783` (`createSimpleAffineAddRec`; the general - path at 5879-5909 does the same) copies the increment's flags onto the - phi's recurrence without a condition, where only the post-increment - expression is guarded by `isAddRecNeverPoison` (5798). The flag is right of - `%next` alone, which is poison exactly where the recurrence wraps; a - ScalarEvolution expression is uniqued without its flags, so every value - with that expression takes it. -4. **`indvars` asks whether `%iter == -1` can hold and is told no.** - Measured: `-C llvm-args=-opt-bisect-limit` under `nightly-2026-07-22`, the - reproducer as an executable that prints `caller()`: limit 690 prints - `true`, 691 prints `false`, and pass 691 is `indvars` on the loop in - `probe`, whose whole effect on that function is - - ``` - - %or.cond.not.i.not = icmp eq i16 %iter, -1 - - br i1 %or.cond.not.i.not, label %exit, label %backedge - + br i1 false, label %exit, label %backedge - - %2 = add nuw i16 %0, 1 - + %2 = add nuw nsw i16 %0, 1 - ``` - - Later passes fold what is left into the endless loop. Read from the source - and not measured, the path inside: `SimplifyIndVar.cpp:275` - (`eliminateIVComparison`) calls `evaluatePredicateAt`; - `ScalarEvolution.cpp:11490` takes `getMinusSCEV(iter, -1)`, which is - `{-4,+,1} + 1`, the node `{-3,+,1}` of step 3; its range excludes - zero, so the compare is "known" false. `%iter + 1` is a defined value, 0 in - the last iteration. Upstream's report traces the same calls on its own - loop. - -**What put the loop in the tree's test in that shape is `core`, not LLVM.** -`a_port_answers_as_its_declaration_says` is compiled right by -`nightly-2026-07-09` and wrong by `nightly-2026-07-10`. -`14cae681329a...af3d95584dbd` is 106 commits, none under `src/llvm-project`, -and both ends report LLVM 22.1.8. Among them is rust-lang/rust #155114 (commit -`b3c94df68bf4`, merged as `71c64160bd0f`), which rewrote `RangeInclusive`'s -`next` to step with `Step::forward_overflowing` and keep the overflow bit in -`exhausted`. With `-C no-prepopulate-passes` the reproducer's loop differs -between 1.98.1 and `nightly-2026-07-22` only in block numbering and in that -callee. The fork contains both commits (`git merge-base --is-ancestor` exits 0 -for each against `6d6ad8c71906`). That `next` is a correct program. - -## The reproducers - -`minns.rs`, the `u16` form. `caller()` returns `true`: - -```rust -#![no_std] -#[derive(Clone, Copy)] -pub enum Mediated { Kept, ReadOnly } - -#[derive(Clone, Copy)] -pub enum Standing { Free, Declared(Mediated) } - -fn port(standing: impl Fn(u16) -> Standing, port: u16, width: u16, write: bool) -> Result { - for port in port..=port + (width - 1) { - match standing(port) { - Standing::Free => {} - Standing::Declared(Mediated::ReadOnly) if !write => {} - Standing::Declared(Mediated::ReadOnly) => return Err(9), - Standing::Declared(Mediated::Kept) => return Err(8), - } - } - Ok(port) -} - -fn standing(port: u16) -> Standing { - match port { - 0x3F8..=0x3FF | 0x20..=0x21 | 0xA0..=0xA1 | 0x70..=0x71 | 0xCF8 | 0xCFC..=0xCFF | 0xCF9 => Standing::Declared(Mediated::Kept), - 0xB2 => Standing::Declared(Mediated::ReadOnly), - _ => Standing::Free, - } -} - -fn probe() -> bool { - port(standing, 0xFFFC, 4, false).is_ok() & port(standing, 0xB2, 1, true).is_err() -} -pub fn caller() -> bool { probe() } -``` - -`c_u128.rs`, the `u128` form. `caller()` returns `true`: - -```rust -#![no_std] -#[derive(Clone, Copy)] -pub enum Mediated { Kept, ReadOnly } -#[derive(Clone, Copy)] -pub enum Standing { Free, Declared(Mediated) } -fn port(standing: impl Fn(u128) -> Standing, port: u128, width: u128, write: bool) -> Result { - for port in port..=port + (width - 1) { - match standing(port) { - Standing::Free => {} - Standing::Declared(Mediated::ReadOnly) if !write => {} - Standing::Declared(Mediated::ReadOnly) => return Err(9), - Standing::Declared(Mediated::Kept) => return Err(8), - } - } - Ok(port) -} -fn standing(port: u128) -> Standing { - match port { - 0x38..=0x3F | 0x20..=0x21 | 0xA0..=0xA1 | 0x70..=0x71 | 0xC8 | 0xCC..=0xCF | 0xC9 => Standing::Declared(Mediated::Kept), - 0xB2 => Standing::Declared(Mediated::ReadOnly), - _ => Standing::Free, - } -} -fn probe() -> bool { - port(standing, ::MAX - 3, 4, false).is_ok() & port(standing, 0xB2, 1, true).is_err() -} -pub fn caller() -> bool { probe() } -``` - -`rustc --edition 2021 --crate-type lib --emit llvm-ir,asm --target -C -opt-level=2 `, each exit 0, with the fork's `rustc` run from the store's -`sysroots/8618c089fa736cb0`, by the report of the agent that ran them: no log -records the path. That is the key the worktree's `target/.deps-stamp` gave -`aarch64-unknown-toyos` and `x86_64-unknown-toyos` at `e52e6275b`, and every -row below was made from it. The stamp gives `aarch64-unknown-none-softfloat`, -`aarch64-unknown-uefi`, `x86_64-unknown-none` and `x86_64-unknown-uefi` another -key, `dc7c468f0c07446e`, which no row here was made with: whoever repeats a -row for one of those four from the key the kernel and loader take has a -different library set than the row had. The `core` crate hash in the fork's -compiler's pass traces for the two ToyOS targets is the one in the two ToyOS -`libcore` files under `8618c089fa736cb0`. - -What `caller` became, `minns.rs`: - -| target | `caller` | -|---|---| -| `aarch64-unknown-toyos` | no `ret` in its IR; frame setup, then `.LBB0_1: b .LBB0_1` | -| `aarch64-unknown-none-softfloat` | no `ret`; `.LBB0_1: b .LBB0_1` | -| `aarch64-unknown-uefi` | no `ret`; `.LBB0_1: b .LBB0_1` | -| `aarch64-apple-darwin` | no `ret`; `LBB0_1: b LBB0_1` | -| `x86_64-unknown-toyos` | `ret i1 true`; `movb $1, %al`, `retq` | -| `x86_64-unknown-none` | `ret i1 true`; `movb $1, %al`, `retq` | -| `x86_64-unknown-uefi` | `ret i1 true`; `movb $1, %al`, `retq` | - -And by width: `c_u128.rs` with `u128` replaced by each type, `--emit llvm-ir`, -`caller` read in the IR. The `u16` row is that file's `u16` form, which -differs from `minns.rs` in `standing`'s arms and in writing the start as -`::MAX - 3`: - -| type | `aarch64-unknown-toyos` | `x86_64-unknown-toyos` | -|---|---|---| -| `u8` | `ret i1 true` | `ret i1 true` | -| `u16` | **no `ret`: a block that branches to itself** | `ret i1 true` | -| `u32` | `ret i1 true` | `ret i1 true` | -| `u64` | `ret i1 true` | `ret i1 true` | -| `u128` | **no `ret`: a block that branches to itself** | **no `ret`: a block that branches to itself** | -| `i16` | 41 lines with one `ret`, not `ret i1 true`; whether it is right was not checked | the same | - -The `i8` form does not compile (its literals are out of range). - -Both sources are fragile. Without the second call in `probe`, without the -private `probe` between `caller` and the calls, or with three of the `Kept` -arms gone, the same compiler compiles `minns.rs` right. - -The same fault in the tree's own code: `toyos-userbound/tests/firmware.rs`'s -`a_port_answers_as_its_declaration_says`, whose `(0xFFFC, Width::DWord)` case -runs `firmware::port`'s range to `0xFFFF`. On an Apple-silicon development -machine, `CARGO_INCREMENTAL=0 cargo test --locked -p toyos-userbound --test -firmware --no-run` with `RUSTUP_TOOLCHAIN` naming that sysroot directory, which -holds the fork's `aarch64-apple-darwin` libraries, exits 0, and the binary run -whole prints 20 tests `ok` and never ends (ended by PID at 120 s). Its test -function is one instruction, a branch to itself. - -## Which compilers - -The same test binary, built and run the same way under upstream's compilers on -the same machine, one rustup toolchain installed and removed per row. `r4.rs` -and `d1.rs` are two earlier single-file forms of the reproducer, compiled to -assembly beside each row. - -| toolchain | rustc | LLVM | the test binary | `caller` in `r4.rs`, `d1.rs` | -|---|---|---|---|---| -| nightly | 1.96.0-nightly (d9563937f 2026-03-03) | 22.1.0 | exit 0, 21 passed | not compiled | -| nightly-2026-05-13 | 1.97.0-nightly (8b03437a8 2026-05-12) | 22.1.4 | exit 0 | returns | -| nightly-2026-06-17 | 1.98.0-nightly (9e2abe0c6 2026-06-16) | 22.1.7 | exit 0 | returns | -| nightly-2026-07-04 | 1.98.0-nightly (c397dae80 2026-07-02) | 22.1.8 | exit 0 | returns | -| nightly-2026-07-08 | 1.99.0-nightly (f10db292a 2026-07-07) | 22.1.8 | exit 0 | returns | -| nightly-2026-07-09 | 1.99.0-nightly (14cae6813 2026-07-08) | 22.1.8 | exit 0 | returns | -| nightly-2026-07-10 | 1.99.0-nightly (af3d95584 2026-07-09) | 22.1.8 | hung, ended at 120 s | a branch to itself in both | -| nightly-2026-07-13 | 1.99.0-nightly (77cf889bc 2026-07-12) | 22.1.8 | hung | a branch to itself in both | -| nightly-2026-07-22 | 1.99.0-nightly (0e29c21d9 2026-07-21) | 22.1.8 | hung | a branch to itself in both | -| nightly-2026-09-25 | 1.100.0-nightly (f7575a9da 2026-09-24) | 23.1.1 | hung; the test's function is `b .` | not compiled; `minns.rs`: no `ret` | -| nightly-2026-10-08 | 1.101.0-nightly (1d81eb4ad 2026-10-07) | 23.1.3 | hung; the test's function is `b .` | not compiled | - -Stable 1.99.0 (LLVM 23.1.1) hangs it and stable 1.98.1 (LLVM 22.1.8) does not: -`issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md` -has those rows. Five nightlies from `nightly-2026-07-10` on were run, the five -in the table, and each hangs it; no other was. `nightly-2026-10-08` was the -newest there was. - -**A compiler that passes the test escapes by the shape its `core` gives the -loop, and has the fault.** The `u16` reproducer with its range replaced by -this iterator, so that no compiler's `core` decides the loop's shape: - -```rust -pub struct Overflowing { start: u16, end: u16, exhausted: bool } -impl Iterator for Overflowing { - type Item = u16; - #[inline] - fn next(&mut self) -> Option { - if self.exhausted || !(self.start <= self.end) { - return None; - } - let (n, o) = self.start.overflowing_add(1); - self.exhausted = o; - Some(core::mem::replace(&mut self.start, n)) - } -} -``` - -| compiler | LLVM | `caller` at `-C opt-level=2` | -|---|---|---| -| 1.88.0 | 20.1.5 | right, by shape: as an executable for `aarch64-apple-darwin` it prints `true`. In the loop that runs to `u16::MAX` the `overflowing_add` is a call of `llvm.uadd.with.overflow.i16` in every dump of its trace, so there is no `add` for step 1 to mark. It says nothing of LLVM 20.1.5's ScalarEvolution | -| 1.91.0 | 21.1.2 | wrong, `aarch64-apple-darwin`; in its trace the increment is an `add`, and `add nuw` after CorrelatedValuePropagation on `port` | -| nightly, 1.96.0-nightly (d9563937f 2026-03-03) | 22.1.0 | wrong, `aarch64-apple-darwin` | -| 1.95.0 | 22.1.2 | wrong, `aarch64-apple-darwin` | -| 1.98.0 | 22.1.8 | wrong, `aarch64-apple-darwin` | -| 1.98.1 | 22.1.8 | no `ret` for `aarch64-apple-darwin` and `aarch64-unknown-none-softfloat`; the executable hangs at `opt-level` 2 and 3 and prints `true` at 0 and 1; right for `x86_64-unknown-none` | -| the fork's | 22.1.8 | no `ret` for `aarch64-unknown-toyos` and `aarch64-unknown-none-softfloat`; right for `x86_64-unknown-toyos` and `x86_64-unknown-none` | - -So 1.88.0 and 1.98.1 each pass something by a shape, the first this iterator -and the second the tree's test. Which LLVM change between 20.1.5 and 21.1.2 -made the intrinsic an `add` that early is not determined, and is not where the -fault is. - -## Upstream - -Read from GitHub's API without credentials on 9 October 2026: - -- **llvm/llvm-project#175729**, "[SCEV] Long-standing miscompile due to - absence of per-use flags in SCEV expressions": open, opened 13 January - 2026, labelled `miscompilation`. Its loop is another, with a `nuw` the loop - vectoriser left; its symptom is this one, `opt -passes=indvars` folding the - exit to `false`, and its reporter's trace runs through - `eliminateIVComparison`, `getMinusSCEV` and `isKnownNonZero`. -- **llvm/llvm-project pull request #118959**, "[SCEV] Don't blindly transfer - nowrap flags to pre-inc addrec": open, a draft, unmerged, opened 6 December - 2024, 22 months before that reading, last updated 26 March 2026. Its body - says "Test updates incomplete". It changes `ScalarEvolution.cpp` (+73 −37) - and `ScalarEvolution.h` (+10 −4) and 22 test files. It is in no LLVM - release. -- rust-lang/rust: no report of this was found by six searches. - -**Not established:** - -- **that #175729 is the known fault.** A maintainer's sentence there is "The - nuw is fine as the value is never used. I've only glanced at it, but this is - probably the known issue where we incorrectly unconditionally transfer - nowrap flags for preinc addrecs from IR to SCEV", and on 17 February 2026: - "I believe" that pull request "is the fix for this issue. However, it has - some problematic impact"; -- **that ToyOS's loop is #175729's.** Nobody upstream has seen it: that is - this file's reading, from the same symptom and the same source path; -- **that #118959 fixes either.** Nobody has built an LLVM with it, here or by - anything upstream's thread says. By reading, it drops the flag in `m2` and - in the real loop; -- **when.** Waiting for upstream has no date. - -The fork's `src/llvm-project` is one shallow commit, so its history answers -nothing; its source has no `canPreservePreIncAddRecNoWrapFlags`, the name -#118959 adds. - -No report or pull request is sent for now (root `CLAUDE.md`, "Dependencies"). - -## How far it reaches - -**Exposed: the kernel, the loader and userland, on both architectures.** -Nothing wrong has been found in them, and nothing was looked for. - -**An inclusive range to its type's maximum is one instance of a class.** The -class is a loop in which an increment's wrapped result is dead, so that step 1 -may mark it; a later rotation makes that increment a header phi's; the phi's -start has a known range; and a client of ScalarEvolution reasons about another -value with the same expression. The hand-written iterator above is in it -without `core`'s range, and `m1` without a range at all. A loop of the class -that never reaches the wrap loses an exit it never takes. - -**Why AArch64 showed it for `u16` and x86-64 did not**, read from one pair of -traces of `minns.rs` under the fork's compiler. For `x86_64-unknown-toyos`, -`indvars` changes `port`'s loop before the run of CorrelatedValuePropagation -that marks the add for AArch64: it rewrites the exit test onto the -incremented value, the add has a second use, and no dump in the trace has -`nuw` on the `i16` increment. For `aarch64-unknown-toyos` the trace has no -`indvars` change on `port`; read from the source, `IndVarSimplify.cpp:964` -refuses that rewrite for a counter whose width is not `DL.isLegalInteger`, and -16 is not in AArch64's `n32:64` where x86-64's layout is `n8:16:32:64`. So the -width decides whether the shape is reached, and nothing else: `u128`, legal on -neither, is wrong on both, by steps 1, 2 and 4 in the x86-64 trace of it. `u8` -on AArch64, not legal either, came out right in this one source, and `m2` at -`i8`, `i32` and `i64` folds: no width is safe. - -**x86-64: 0 of 40 other sources miscompiled, and one counterexample.** 39 -source variants made while reducing, judged by whether a function is left with -no `ret`, `unreachable` or `resume`, which sees the endless loop and nothing -else: 18 are miscompiled for `aarch64-unknown-none-softfloat` and the same 18 -for `aarch64-unknown-toyos`, none for `x86_64-unknown-none` or -`x86_64-unknown-toyos`; the hand-written iterator's file is the fortieth. None -of the forty names an integer wider than 64 bits, so by the paragraph above -each had a legal counter on x86-64; that is read for `minns.rs` and inferred -for the other thirty-nine. The counterexample is the `u128` row. Stable 1.99.0 -compiles the tree's test right for `x86_64-apple-darwin`. - -**The outcomes seen** are the endless loop and, with the passes after `indvars` -withheld, a wrong value: `false` for `true`. So a wrong answer without a hang -is possible, and no test in the tree would name it. - -**The x86-64 kernel's own instance of the loop has its exit.** The kernel -calls `firmware::port` once, from `kernel/src/arch/x86_64/acpi_mode.rs`'s -`port`, with a port and width the `acpi` claim's holder chooses. From `CI=true -cargo run -- --build-only` (exit 0) at `e0a61d070`, built from nothing with and -without incremental state and disassembled: a function of its own of 0x11a -bytes in the first, inlined into `acpi_mode::port` (0x13a bytes) in the second. -In both the loop counts the width's bytes down in a 16-bit register and leaves -at zero, every refusal leaves it, and no branch back is unconditional: - -``` -eef55: inc r12d -eef58: dec r13w -eef5c: je 0xeef93 <+0xb3> ; every port asked: the access is made -eef5e: mov esi, 0x1 -eef63: mov edi, r12d -eef66: call - ... ; a refusal jumps out of the loop, a pass back to eef55: -eef79: je 0xeef55 <+0x75> -eef8d: je 0xeef55 <+0x75> -``` - -The AArch64 kernel has no instance of that call: it is under `arch/x86_64`, by -the source and not by a disassembly. - -**Not measured:** - -- **any other function of the kernel, the loader or userland, on either - architecture.** This is the larger gap, and no reading closes it: the class - is not `..=` loops, nor narrow integers, nor one architecture. Today's - compiler counts nothing that would: `-C llvm-args=-stats` prints nothing - from the fork's `rustc`, `-debug-only=indvars` is refused as an unknown - argument, and read from the source `IndVarSimplify.cpp` emits no - optimisation remark, while the counter the fold increments, `NumElimCmp` - (`SimplifyIndVar.cpp:46`), counts every legitimate elimination with it. The - measurement owed is in the exit; -- the `u128` form for `x86_64-unknown-none`, `x86_64-unknown-uefi`, - `aarch64-unknown-none-softfloat` and `aarch64-unknown-uefi`, the targets of - the kernel and the loader; -- code generation's loop strength reduction, which reads ScalarEvolution too, - past `opt -passes=loop-reduce` on `m2`; -- `aarch64-unknown-none`, `aarch64-unknown-linux-gnu` and - `x86_64-unknown-linux-gnu` under an affected compiler. - -## What does not end it - -- **Moving the fork to a later upstream**: the newest nightly there was hangs - the test, and upstream's LLVM has merged no fix. -- **Waiting for upstream**: it has no date. The proposed fix has been open and - unmerged for 22 months. -- **Taking #155114 out of the fork's `core`**: `library/core` carries no delta - (`.claude/agents/implementer.md`, "A fork"), and the fault would stay for - any other code of the class, as the hand-written iterator shows. -- **Rewriting `firmware::port`**: the loop is right, and it is one loop. -- **A change to `indvars`** that makes the reproducers return: `indvars` asks - a question and is answered wrongly, and every other client of - ScalarEvolution would go on being answered so. -- **`nightly.yml`'s pin of `portability-macos` to 1.98.1**: no job installs the - fork's compiler by a version; it is the tree's own. - -## Owner and exit - -Held by the toolchain, `rust/` and its `src/llvm-project` -(`ToyOSOrg/llvm-project`, branch `toyos-rustc-22.1-2026-05-19`). A fix in the -fork's LLVM is being built on `wt/toyos-scevfix`. - -Whoever moves the fork to a later upstream runs the measurements below under -the moved compiler before the move lands, and corrects this file by what they -show. - -**Exit**: ScalarEvolution in the fork's LLVM no longer gives a value a no-wrap -flag that holds only where another value is poison, by a change to -ScalarEvolution and to no client of it, measured under the LLVM and the -compiler the tree builds with by all of: - -1. `m1.ll` above through that LLVM's `opt -passes=indvars -S`: the output has - no `br i1 false`, and `@f` returns; -2. `caller` is `ret i1 true` by the command above: for `aarch64-unknown-toyos` - and for `x86_64-unknown-toyos`, in `minns.rs` and in `c_u128.rs`, from the - key `target/.deps-stamp` gives the ToyOS targets; and for - `aarch64-unknown-none-softfloat` and `aarch64-unknown-uefi`, in `minns.rs`, - from the key it gives those two, the one the kernel and the loader take, - which is not the key the table's rows for them were made from - (`8618c089fa736cb0`, the ToyOS targets'); -3. the test binary above, built with that compiler on Apple silicon without - incremental state, exits 0; -4. the tree's own functions read: the fix behind an LLVM option, the tree - built twice with the one compiler, the option given and withheld through - `-C llvm-args`, and the two builds compared function by function. The - functions that differ are a superset of those the fault changed; one that - loses an exit branch in the build without the fix is a hit, and each hit is - named in the pull request that closes this file. The same option is the - fix's negative control. - -Items 2 and 3 rest on shapes `rustc` may stop making, so a compiler that -merely stops making them meets those two and not the exit; item 1 has no front -end in it, and item 4 is the only one that reads the code ToyOS ships. The -check that holds the fix in this tree arrives with the fix, green. From 264ec4bd2587a0b54077615a6d0e58c9fa32dd45 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 10:55:25 +0200 Subject: [PATCH 3/6] The sysroot's check holds the loop as IR through clang, and its Rust over every guest target The check held the fix only through the shape `core`'s `RangeInclusive::next` and rustc give the reproducer today. `src/miscompile/last_exit.ll` is the loop itself, compiled by the toolchain's clang, which links the LLVM its rustc does: no front end stands between it and `indvars`. It walks a thousand passes because a loop short enough to be evaluated is folded right before `indvars` meets it, by a compiler with the fault too. The Rust reproducer stays where a compiler with the fault gets it wrong: over `u128`, for all six guest targets. Over `u16` the x86-64 targets were compiled right with the fault, so that type goes. `rustc` reads no `RUSTFLAGS`; the `env_remove` goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- src/miscompile.rs | 106 ++++++++++++++++++------------------ src/miscompile/last_exit.ll | 20 +++++++ src/miscompile/last_exit.rs | 2 +- src/sysroot.rs | 2 +- 4 files changed, 76 insertions(+), 54 deletions(-) create mode 100644 src/miscompile/last_exit.ll diff --git a/src/miscompile.rs b/src/miscompile.rs index 6790f0fe3c6..0d3b44d2c22 100644 --- a/src/miscompile.rs +++ b/src/miscompile.rs @@ -1,69 +1,71 @@ //! What a sysroot's compiler has been seen to miscompile, compiled by every //! sysroot before it is published and refused where the answer is wrong. //! -//! **A loop keeps the exit its counter's maximum takes** ([`LAST_EXIT`]). An -//! LLVM whose ScalarEvolution gives a header phi's recurrence the wrap flags of -//! its increment without proving them for it (llvm/llvm-project#175729, the -//! fork's `-scev-unconditional-preinc-nowrap-flags`) lets `indvars` fold such a -//! loop's last exit to `false`: safe Rust becomes an endless loop. +//! **A loop keeps the exit its counter's maximum takes.** An LLVM whose +//! ScalarEvolution gives a header phi's recurrence the wrap flags of its +//! increment without proving them for it (llvm/llvm-project#175729) lets +//! `indvars` fold such a loop's last exit to `false`: safe Rust becomes an +//! endless loop. Held twice, by the two compilers a toolchain carries of one +//! LLVM: [`LAST_EXIT_IR`] is the loop itself, which no front end can give +//! another shape, and [`LAST_EXIT`] the Rust that was seen to make it. use std::fs; use std::path::Path; use std::process::Command; use crate::arch::Arch; +use crate::clang::CSysroot; +use crate::toolchain::GUEST_TARGETS; + +/// The loop as LLVM IR, whose `caller` returns `true`. +const LAST_EXIT_IR: &str = include_str!("miscompile/last_exit.ll"); /// An inclusive range walked to its integer type's maximum, whose `caller` /// returns `true`. const LAST_EXIT: &str = include_str!("miscompile/last_exit.rs"); -/// The line of [`LAST_EXIT`] that names the range's integer type. -const PORT: &str = "type Port = u16;"; - -/// The types [`LAST_EXIT`] is compiled over: the two an unfixed compiler made -/// an endless loop of, `u16` for AArch64 and `u128` for both architectures. -const PORTS: [&str; 2] = ["u16", "u128"]; - -/// Refuse the toolchain at `toolchain` unless its rustc compiles [`LAST_EXIT`] -/// to `true` for each of [`PORTS`] and each architecture's userland, at the -/// optimisation level guests are built with. Sources and IR are written in -/// `scratch`. +/// Refuse the toolchain at `toolchain` unless its clang compiles +/// [`LAST_EXIT_IR`] to `true` for each architecture, and its rustc +/// [`LAST_EXIT`] for every guest target, at the optimisation level guests are +/// built with. Sources and IR are written in `scratch`. pub(crate) fn refuse(toolchain: &Path, scratch: &Path) { + eprintln!("Checking that the compilers of {} keep a loop's last exit", toolchain.display()); fs::create_dir_all(scratch).unwrap_or_else(|e| panic!("create {}: {e}", scratch.display())); - assert!(LAST_EXIT.contains(PORT), "the reproducer names its integer type as `{PORT}`, and no longer does"); - for port in PORTS { - let source = scratch.join(format!("last_exit_{port}.rs")); - fs::write(&source, LAST_EXIT.replace(PORT, &format!("type Port = {port};"))) - .unwrap_or_else(|e| panic!("write {}: {e}", source.display())); - for arch in Arch::ALL { - let target = arch.userland(); - let ir = scratch.join(format!("last_exit_{port}-{target}.ll")); - let rustc = toolchain.join("bin/rustc"); - let output = Command::new(&rustc) - .args(["--edition", "2021", "--crate-type", "lib", "--target", target]) - .args(["-C", "opt-level=2", "-C", "codegen-units=1", "--emit", "llvm-ir", "-o"]) - .arg(&ir) - .arg(&source) - .env_remove("RUSTFLAGS") - .output() - .unwrap_or_else(|e| panic!("run {}: {e}", rustc.display())); - assert!( - output.status.success(), - "{} did not compile {} for {target}:\n{}", - rustc.display(), - source.display(), - String::from_utf8_lossy(&output.stderr), - ); - let text = fs::read_to_string(&ir).unwrap_or_else(|e| panic!("read {}: {e}", ir.display())); - assert!( - returns_true(&text, "caller"), - "{} miscompiles a loop over `{port}` that ends at `{port}::MAX` for {target}: `caller` \ - returns `true` and its IR does not (llvm/llvm-project#175729):\n{}", - rustc.display(), - body(&text, "caller").unwrap_or("there is no `caller`"), - ); - } + let loop_ir = scratch.join("last_exit.ll"); + fs::write(&loop_ir, LAST_EXIT_IR).unwrap_or_else(|e| panic!("write {}: {e}", loop_ir.display())); + for arch in Arch::ALL { + let c = CSysroot::of(toolchain, arch); + let ir = scratch.join(format!("last_exit-clang-{}.ll", c.target)); + let mut clang = Command::new(&c.clang); + clang.arg(format!("--target={}", c.target)).args(["-O2", "-S", "-emit-llvm", "-o"]).arg(&ir).arg(&loop_ir); + keeps_the_exit(&mut clang, &ir, c.target); } + let source = scratch.join("last_exit.rs"); + fs::write(&source, LAST_EXIT).unwrap_or_else(|e| panic!("write {}: {e}", source.display())); + for target in GUEST_TARGETS.map(|target| target.triple()) { + let ir = scratch.join(format!("last_exit-rustc-{target}.ll")); + let mut rustc = Command::new(toolchain.join("bin/rustc")); + rustc + .args(["--edition", "2021", "--crate-type", "lib", "--target", target]) + .args(["-C", "opt-level=2", "-C", "codegen-units=1", "--emit", "llvm-ir", "-o"]) + .arg(&ir) + .arg(&source); + keeps_the_exit(&mut rustc, &ir, target); + } +} + +/// Run `compiler`, which writes `ir` for `target`, and refuse it unless +/// `caller` there is one block that returns `true`. +fn keeps_the_exit(compiler: &mut Command, ir: &Path, target: &str) { + let output = compiler.output().unwrap_or_else(|e| panic!("run {compiler:?}: {e}")); + assert!(output.status.success(), "{compiler:?} failed:\n{}", String::from_utf8_lossy(&output.stderr)); + let text = fs::read_to_string(ir).unwrap_or_else(|e| panic!("read {}: {e}", ir.display())); + assert!( + returns_true(&text, "caller"), + "{compiler:?} miscompiles a loop that ends at its counter's maximum for {target}: `caller` returns \ + `true` and its IR does not (llvm/llvm-project#175729):\n{}", + body(&text, "caller").unwrap_or("there is no `caller`"), + ); } /// The definition of `function` in the LLVM IR `ir`, from its `define` to its @@ -92,9 +94,9 @@ fn returns_true(ir: &str, function: &str) -> bool { mod tests { use super::*; - /// **The negative control is the defect itself**: `caller` verbatim as the - /// unfixed compiler emitted it for `aarch64-unknown-toyos` over `u16`, and - /// as the same source compiles for `x86_64-unknown-toyos`. + /// **The negative control is the defect itself**: `caller` verbatim as a + /// compiler with the fault emitted it for `aarch64-unknown-toyos`, and as + /// one without it does. #[test] fn an_endless_caller_is_not_one_that_returns_true() { let endless = "\ diff --git a/src/miscompile/last_exit.ll b/src/miscompile/last_exit.ll new file mode 100644 index 00000000000..c72cf103d7d --- /dev/null +++ b/src/miscompile/last_exit.ll @@ -0,0 +1,20 @@ +; `caller` returns `true`: `%iter` is -1 on the loop's thousandth pass, the one +; after `%nextnext` wrapped, which nothing reads. A loop of few enough passes +; to be evaluated is, before `indvars` meets it. +define i1 @caller() { +entry: + br label %header + +header: + %next = phi i16 [ -999, %entry ], [ %nextnext, %latch ] + %iter = phi i16 [ -1000, %entry ], [ %next, %latch ] + %done = icmp eq i16 %iter, -1 + br i1 %done, label %exit, label %latch + +latch: + %nextnext = add nuw i16 %next, 1 + br label %header + +exit: + ret i1 true +} diff --git a/src/miscompile/last_exit.rs b/src/miscompile/last_exit.rs index 732f597025f..a3a2ff8ec85 100644 --- a/src/miscompile/last_exit.rs +++ b/src/miscompile/last_exit.rs @@ -2,7 +2,7 @@ //! its loop ends. #![no_std] -type Port = u16; +type Port = u128; #[derive(Clone, Copy)] pub enum Mediated { diff --git a/src/sysroot.rs b/src/sysroot.rs index dd04a5e0006..e472c884d92 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -98,7 +98,7 @@ const RECIPE: &str = "bootstrap stage-0 local rebuild, libraries from the stamp, CMake's description of ToyOS per target, refused unless a C program naming \ each library links against it, and its C++ runtime built under n2 from the \ runtimes' sources of the compiler's LLVM, the freestanding libraries cloned \ - from their key's; 14"; + from their key's; 15"; /// Whose sources a guest target's libraries compile. #[derive(Clone, Copy, PartialEq, Eq)] From facbe90313d27842d08dbbb280c13f6e752fcc5f Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 10:55:25 +0200 Subject: [PATCH 4/6] The fork pin moves to the ScalarEvolution fix without its measurement switch `rust` moves to ToyOSOrg/rust b9cc8392f0eb, whose `src/llvm-project` is ToyOSOrg/llvm-project b7420fe534bf: the fix as it was, less `-scev-unconditional-preinc-nowrap-flags`. Nothing ships in the compiler for a test or a measurement; the control of every measurement is the compiler before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- rust | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust b/rust index 9e7b17b52f9..b9cc8392f0e 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 9e7b17b52f955a9462a55c46a8ba2440c51a9c9f +Subproject commit b9cc8392f0eb21330160352e0abd85602b419b23 From d01c42e24e79826f0fe77c0f5f1d38ecaef03f89 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 13:15:37 +0200 Subject: [PATCH 5/6] The pin issue says what 1.99.0 does to the port test today, and the counters issue names the compilers compared Under stable 1.99.0 the port test has passed on Apple silicon since #780 changed its crate: measured at this branch, with the row hanging again at main before #780 as its control. 1.99.0 still compiles the reproducer to an endless loop. The pin is the orchestrator's to decide. The counters issue cited the switch the fork no longer has: its comparison is now the compiler before the fix against the fixed one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...-under-host-load-can-go-silent-for-15-s.md | 15 ++-- ...ss-loop-of-a-port-test-on-apple-silicon.md | 81 ++++++++++++------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md index 11ad875e5e8..2940cb30a33 100644 --- a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md +++ b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md @@ -138,13 +138,14 @@ the step each stopped in is not read from it: The fork compiler's ScalarEvolution fault (llvm/llvm-project#175729, which `src/miscompile.rs` now refuses a sysroot for) is not their cause. -`tests/virtsmpcase`'s image was built and `virt_el1_smp` run with the fixed -compiler and with the fault switched back on -(`-C llvm-args=-scev-unconditional-preinc-nowrap-flags`), and every function -compared in IR and in object code: `counters_read`, `test-runner`, `supervisor`, -`logkeeper`, `toybox`, `kernelprobe` and the loader are byte-identical between -the two, and the kernel differs in seven functions of `rustc_demangle`'s `v0` -printer, a loop peeled or not, which a counters read does not call. +`tests/virtsmpcase`'s image was built and `virt_el1_smp` run by the compiler +before the fix and by the fixed one, each with the sysroot it built, and every +function of the image's 124 crates compared in object code as the image build +makes it. The two differ in six functions of `rustc_demangle`'s `v0` printer, +a loop peeled or not, and in the sysroot's std in `fs::DirBuilder::_create`, +which tests a count where the other tests sixteen times it; a counters read +calls neither, and no function of `counters_read`, `test-runner`, +`supervisor`, `logkeeper`, `toybox`, `kernelprobe` or the loader differs. The slow ones, with registers: a probe that captured `info registers -a` over QMP whenever the read had not ended 3 s after `unmap_touch` (`debug-slow.patch` diff --git a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md b/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md index 5a2c0ea2e6d..89e9e17ae8d 100644 --- a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md +++ b/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md @@ -10,8 +10,9 @@ opened: 2026-10-08 never ended in `nightly.yml`'s `portability-macos`, the one job that runs the host suite on macOS, from the first nightly that had the test (#749): the compiler that job installed, `rustc 1.99.0 (b940084d7 2026-09-28)`, LLVM -23.1.1, compiles the test's function to one instruction, a branch to itself. -The job is pinned to 1.98.1 for it. The fault is LLVM's ScalarEvolution's, +23.1.1, compiled the test's function to one instruction, a branch to itself. +The job is pinned to 1.98.1 for it, and since #780 the test passes under +1.99.0 too (below). The fault is LLVM's ScalarEvolution's, on every target, and is not this compiler's alone: it copies an increment's no-wrap flag onto its phi's recurrence, where it holds only if the wrapped increment is observed, and `indvars` folds the loop's last exit to `false`. @@ -91,24 +92,50 @@ inlined with `standing` over the four ports `0xFFFC..=0xFFFF`. The source ends: the crate forbids `unsafe`, and an inclusive range that ends at `u16::MAX` is what `RangeInclusive` exists to get right. +## Since #780 the test no longer shows it + +The table above was made before #780 (`5f2657703`), which changed +`toyos-userbound` and `toyos-abi`. Its second row again, on an Apple-silicon +development machine, with stable `1.99.0 (b940084d7 2026-09-28)` installed for +the measurement and removed after it: + +| tree | the binary | +|---|---| +| `main` before #780 (`b3322379c`'s `toyos-userbound`, `toyos-abi` and `toyos-bootmap`) | hung: 20 tests `ok`, then libtest's line for this one, ended by PID after 120 s | +| `main` at `425f3eb9e`, whose three crates are as #780 left them (#790's worktree at its merge of it) | exit 0: `24 passed`, this test `ok` | + +So the test stopped showing the fault before #778, which pinned the job for +it, had landed: nothing reran the row at the tree that landed. **The +compiler has the fault as it had**: `src/miscompile/last_exit.rs`, this +test's loop cut out, compiled by 1.99.0 for `aarch64-apple-darwin` at +`opt-level=2` over `u16` and over `u128`, has `caller` as a branch to +itself. What moved is the code around the loop, which no longer gives it the +shape LLVM miscompiles; which of #780's changes did that was not looked for. + ## What holds it, and what the pin is worth `portability-macos` installs `1.98.1` where it installed `stable` (`nightly.yml`). That is a pin on a compiler nothing else in the tree pins. -**1.98.1 has the faulty LLVM too.** It compiles this test right because its -`core` does not yet give the range's loop the shape LLVM miscompiles; given -that shape written by hand it makes the same endless loop. The pin keeps one -test of one job green. It is no statement that 1.98.1 compiles the rest of the -host suite right, and nothing measured says either way. - -**No stable is known to be coming that the job can go back to.** Five -nightlies were run, of 10, 13 and 22 July, 25 September and 8 October 2026, -the last the newest there was, and each hangs the table's second row; none -between them was run. Upstream's LLVM has merged no fix. That its open report -llvm/llvm-project#175729 is of this fault is a reading, not upstream's word: -the same fold on the same code path, and the fork's compiler with that report's -proposed fix (llvm/llvm-project#118959) compiles this test right, 24 passed. +**1.98.1 has the faulty LLVM too.** It compiled this test right, at the tree +1.99.0 hung it at, because its `core` does not yet give the range's loop the +shape LLVM miscompiles; given that shape written by hand it makes the same +endless loop. **The pin holds nothing today**: since #780 the one test it was +taken for passes under 1.99.0 as well. It was never a statement that either +compiler compiles the rest of the host suite right, and nothing measured says +either way. + +**No stable is known to be coming whose LLVM has the fault fixed.** Five +nightlies were run before #780, of 10, 13 and 22 July, 25 September and +8 October 2026, the last the newest there was, and each hung the table's +second row; none between them was run. Upstream's LLVM has merged no fix. +That its open report llvm/llvm-project#175729 is of this fault is a reading, +not upstream's word: the same fold on the same code path, and at `main` +before #780 the fork's compiler hangs the second row as 1.99.0 does (ended by +PID after 120 s) and with that report's proposed fix +(llvm/llvm-project#118959) exits 0, `21 passed`. At `main` since #780 both of +the fork's compilers exit 0, so the row there tells them apart no more than +it tells 1.99.0 from 1.98.1. `guest.yml`, and so `guest / suite` and the nightly's `tcg / suite`, and `nightly.yml`'s `portability-linux` install `stable` and log its version: @@ -117,9 +144,10 @@ the `tcg / suite` of run 37778826093 logged `rustc 1.99.0 (b940084d7 `x86_64-apple-darwin` (the table's last row); `x86_64-unknown-linux-gnu` was not built. The two `host` jobs, `ci.yml`'s and `nightly.yml`'s, install nothing and take the rustc of their runner's image. A developer's machine -takes whatever its `stable` is: one on Apple silicon with 1.99.0 or later who -runs `cargo run -- --ci host` with `CI` set, or without incremental -compilation, gets the step ended after 15 silent minutes with this test named. +takes whatever its `stable` is: before #780, one on Apple silicon with 1.99.0 +or later who ran `cargo run -- --ci host` with `CI` set, or without +incremental compilation, got the step ended after 15 silent minutes with this +test named. Whether the host's toolchain is pinned once for every job is `issues/the-host-job-runs-the-toolchain-the-runner-ships.md`'s to decide, and this is a second measured case for it. @@ -128,15 +156,12 @@ and this is a second measured case for it. loop is right, and a compiler that drops a loop's exit here is not made safe by rewriting the one loop where it was seen. -Assigned: the orchestrator, who holds the pin and its exit. Before #778 -lands he dispatches `nightly.yml` on its branch and reads -`portability-macos`: success, with `test -a_port_answers_as_its_declaration_says ... ok` in `the workspace's host -members`. He reads the same in the first nightly on a `main` that has #778. -At each stable release he reruns the table's second row under it. +Assigned: the orchestrator, who holds the pin and its exit, and decides what +becomes of a pin whose test no longer needs it. **Exit**: `portability-macos` installs `stable` again and is green in a -nightly on `main`, which takes a stable rustc under which the table's second -row exits 0 on Apple silicon. None exists; one arrives when upstream's LLVM -has the fault fixed, or when `core` gives the range's loop another shape -again, which is how 1.98.1 passes today and fixes nothing. +nightly on `main`. The condition this issue first gave it, a stable rustc +under which the table's second row exits 0 on Apple silicon, is met by 1.99.0 +since #780, by the test's shape and not by a compiler without the fault: no +stable has one, and the host suite is compiled by one that has it whichever +the job installs. From 8f55e8b9088d0feb4194c414969d9204d622ad2b Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 14:31:41 +0200 Subject: [PATCH 6/6] The pin issue's slug says what the tree shows, the host issue keeps the weakness, and the check's scratch removal fails loudly Round 2's review of #790. The pin issue's slug and heading claimed that rustc 1.99.0 makes an endless loop of the port test; its body has said since d01c42e24 that the test passes under 1.99.0 since #780. It is renamed to what is true, the nightly's macOS job pins 1.98.1 for a hang its test no longer shows, and its one citation, a comment in nightly.yml, moves with it. Nothing else in the workflow changes. The weakness that outlives the pin goes to the issue that owns the host's toolchain: every host binary is compiled by an upstream rustc whose LLVM has the ScalarEvolution fault. sysroot::build discarded the result of removing the miscompile check's scratch directory; it goes through keystore::remove, which fails. No key reads src/sysroot.rs outside RECIPE, so no toolchain key moves. The same discard one statement on, of libc's scratch target, is main's and outside this round's fence: filed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- .github/workflows/nightly.yml | 2 +- ...t-job-runs-the-toolchain-the-runner-ships.md | 13 +++++++++++++ ...98-1-for-a-hang-its-test-no-longer-shows.md} | 2 +- ...e-result-of-removing-libcs-scratch-target.md | 17 +++++++++++++++++ src/sysroot.rs | 2 +- 5 files changed, 33 insertions(+), 3 deletions(-) rename issues/{rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md => the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md} (98%) create mode 100644 issues/the-sysroot-build-discards-the-result-of-removing-libcs-scratch-target.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6acf84dd649..8cd7fbfdc50 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -118,7 +118,7 @@ jobs: - run: | brew install qemu cmake # 1.98.1 and not `stable`: 1.99.0 compiles a host test into an endless loop - # here (issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md). + # here (issues/the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md). - run: | curl --proto '=https' --tlsv1.2 -sSf -o "$RUNNER_TEMP/rustup-init.sh" https://sh.rustup.rs sh "$RUNNER_TEMP/rustup-init.sh" -y --profile minimal --default-toolchain 1.98.1 diff --git a/issues/the-host-job-runs-the-toolchain-the-runner-ships.md b/issues/the-host-job-runs-the-toolchain-the-runner-ships.md index 3ecf97592e4..0b3587c818b 100644 --- a/issues/the-host-job-runs-the-toolchain-the-runner-ships.md +++ b/issues/the-host-job-runs-the-toolchain-the-runner-ships.md @@ -74,3 +74,16 @@ the tree is ready to adopt a new compiler's lints — or keep tracking whatever ships and accept that a runner-image roll reds every open pull request until someone lands the fix, the way today's did. Both are legitimate engineering positions; this entry does not choose between them. + +## Whichever it is, its LLVM drops a loop's exit + +Every host binary — `toyos-build`, the harness, every host test, every app's +host build — is compiled by an upstream rustc whose LLVM has the +ScalarEvolution fault the fork's no longer has (llvm/llvm-project#175729, open +upstream; `src/miscompile.rs` holds its reproducers for the fork's compilers +and for no host compiler). Measured for stable 1.99.0 on Apple silicon, whose +`caller` of `src/miscompile/last_exit.rs` over `u16` and over `u128` is a +branch to itself, and for 1.98.1 given the loop's shape by hand +(`issues/the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md`). +No version to pin is free of it, so a pin answers the lints above and not +this; it ends when a stable rustc compiles those reproducers right. diff --git a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md b/issues/the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md similarity index 98% rename from issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md rename to issues/the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md index 89e9e17ae8d..28d37b7e2dc 100644 --- a/issues/rustc-1-99-0-makes-an-endless-loop-of-a-port-test-on-apple-silicon.md +++ b/issues/the-nightlys-macos-job-pins-rustc-1-98-1-for-a-hang-its-test-no-longer-shows.md @@ -4,7 +4,7 @@ kind: tooling opened: 2026-10-08 --- -# rustc 1.99.0 makes an endless loop of `a_port_answers_as_its_declaration_says` on Apple silicon +# The nightly's macOS job pins rustc 1.98.1 for a hang its test no longer shows, and the host's rustc still has the fault `toyos-userbound/tests/firmware.rs`'s `a_port_answers_as_its_declaration_says` never ended in `nightly.yml`'s `portability-macos`, the one job that runs the diff --git a/issues/the-sysroot-build-discards-the-result-of-removing-libcs-scratch-target.md b/issues/the-sysroot-build-discards-the-result-of-removing-libcs-scratch-target.md new file mode 100644 index 00000000000..8af19554a18 --- /dev/null +++ b/issues/the-sysroot-build-discards-the-result-of-removing-libcs-scratch-target.md @@ -0,0 +1,17 @@ +--- +status: open +kind: tooling +opened: 2026-10-09 +--- + +# The sysroot build discards the result of removing libc's scratch target directory + +`src/sysroot.rs`'s `build` ends libc's two builds with `let _ = +fs::remove_dir_all(&libc_target);`: a removal that fails leaves +`.libc-target` beside the published sysroot in the store, and nothing +says so. The line above it, the miscompile check's scratch, fails through +`keystore::remove`; this one was left as it was because #790's fence named +that line alone. Owner: the build system. + +**Exit**: the line is `keystore::remove(&libc_target);`, and a build that +makes a sysroot is green with it. diff --git a/src/sysroot.rs b/src/sysroot.rs index e472c884d92..b3abf7989b6 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -767,7 +767,7 @@ fn build(root: &Path, store: &Path, compiler: &Compiler, fork: &Path, keys: &Key } let miscompiles = dir.with_extension("miscompile"); crate::miscompile::refuse(partial, &miscompiles); - let _ = fs::remove_dir_all(&miscompiles); + keystore::remove(&miscompiles); let libc_target = dir.with_extension("libc-target"); for arch in Arch::ALL { crate::libc::build(root, partial, &libc_target, arch);