diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 289904ffc..6fb506f7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: ! grep -q '^unavailable:' "$RUNNER_TEMP/test-inventory.txt" # All workspace crates were built above. Avoid linking empty test binaries # for the target crates and command. - - run: cargo test --locked -p mold-common -p mold-elf -p mold-elf-tests -p mold --lib --test integration + - run: cargo test --locked -p mold-common -p mold-elf -p mold-tests -p mold --lib --test integration - name: archive test results uses: actions/upload-artifact@v7 if: failure() diff --git a/.github/workflows/macho.yml b/.github/workflows/macho.yml index 0e82cf8b6..bb66bb773 100644 --- a/.github/workflows/macho.yml +++ b/.github/workflows/macho.yml @@ -40,7 +40,7 @@ jobs: # session alone takes ten seconds on a Mac, and the first of which # ran past a minute on the hosted runners. The executable is built # without the ELF targets, which these tests don't use. - - run: cargo test --locked -p mold-macho -p mold-macho-tests --lib + - run: cargo test --locked -p mold-macho -p mold-tests --lib - run: | cargo test --locked -p mold --no-default-features --features macho-arm64,macho-x86_64 \ --test integration -- --timeout 120 diff --git a/Cargo.lock b/Cargo.lock index 79bc152df..bc6bd02e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,13 +283,12 @@ dependencies = [ "mold-elf-sh4", "mold-elf-sh4be", "mold-elf-sparc64", - "mold-elf-tests", "mold-elf-x86_64", "mold-macho", "mold-macho-arm64", - "mold-macho-tests", "mold-macho-x86_64", "mold-mimalloc", + "mold-tests", ] [[package]] @@ -463,13 +462,6 @@ dependencies = [ "mold-elf", ] -[[package]] -name = "mold-elf-tests" -version = "3.0.0" -dependencies = [ - "libc", -] - [[package]] name = "mold-elf-x86_64" version = "3.0.0" @@ -502,14 +494,6 @@ dependencies = [ "mold-macho", ] -[[package]] -name = "mold-macho-tests" -version = "3.0.0" -dependencies = [ - "libc", - "serde_json", -] - [[package]] name = "mold-macho-x86_64" version = "3.0.0" @@ -535,6 +519,14 @@ dependencies = [ "cc", ] +[[package]] +name = "mold-tests" +version = "3.0.0" +dependencies = [ + "libc", + "serde_json", +] + [[package]] name = "pkg-config" version = "0.3.34" diff --git a/Cargo.toml b/Cargo.toml index 38679a9ce..eeff16af8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ # arch/, and the executable in cli/ dispatches to them. [workspace] resolver = "3" -members = ["cli", "common", "elf", "macho", "arch/*", "tests/elf", "tests/macho"] +members = ["cli", "common", "elf", "macho", "arch/*", "tests"] [workspace.package] version = "3.0.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d83ad9a28..8c2073c64 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -47,8 +47,7 @@ mold-macho-arm64 = { workspace = true, optional = true } mold-macho-x86_64 = { workspace = true, optional = true } [dev-dependencies] -mold-elf-tests = { path = "../tests/elf" } -mold-macho-tests = { path = "../tests/macho" } +mold-tests = { path = "../tests" } # One feature per target. All are built by default; a build for one # target alone (`--no-default-features --features x86_64`) is much faster. diff --git a/cli/integration.rs b/cli/integration.rs index 3724c7287..f9bc5909a 100644 --- a/cli/integration.rs +++ b/cli/integration.rs @@ -7,10 +7,10 @@ use std::process::ExitCode; fn main() -> ExitCode { #[cfg(target_os = "macos")] - let (cases, run) = ("tests/macho", mold_macho_tests::run); + let (cases, run) = ("tests/macho", mold_tests::macho::run); #[cfg(not(target_os = "macos"))] - let (cases, run) = ("tests/elf", mold_elf_tests::run); + let (cases, run) = ("tests/elf", mold_tests::elf::run); let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - run(&[root.join(cases)], Path::new(env!("CARGO_BIN_EXE_mold"))) + run(&root.join(cases), Path::new(env!("CARGO_BIN_EXE_mold"))) } diff --git a/tests/macho/Cargo.toml b/tests/Cargo.toml similarity index 91% rename from tests/macho/Cargo.toml rename to tests/Cargo.toml index 2f36d9471..e6868f376 100644 --- a/tests/macho/Cargo.toml +++ b/tests/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mold-macho-tests" +name = "mold-tests" version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/tests/elf.rs b/tests/elf.rs new file mode 100644 index 000000000..264b55c4c --- /dev/null +++ b/tests/elf.rs @@ -0,0 +1,201 @@ +//! The ELF tests' targets: the host's, and each cross target whose cross +//! compiler and QEMU are installed, whose programs run under QEMU. + +use std::env; +use std::fs; +use std::path::Path; +use std::process::{Command, ExitCode, Stdio}; + +use crate::{ + Mode, Options, Target, fail, make_jobs, parse_options, prepare_work_dir, print_inventory, + print_summary, run_jobs, selected_scripts, +}; + +struct TargetSpec { + machine: &'static str, + triple: &'static str, + qemu: &'static str, +} + +// The targets tested by default. A target other than the host's is +// tested only if its cross compiler and QEMU are installed. +const TARGETS: &[TargetSpec] = &[ + TargetSpec { machine: "x86_64", triple: "x86_64-linux-gnu", qemu: "qemu-x86_64" }, + TargetSpec { machine: "i686", triple: "i686-linux-gnu", qemu: "qemu-i386" }, + TargetSpec { machine: "aarch64", triple: "aarch64-linux-gnu", qemu: "qemu-aarch64" }, + TargetSpec { machine: "aarch64_be", triple: "aarch64_be-linux-gnu", qemu: "qemu-aarch64_be" }, + TargetSpec { machine: "arm", triple: "arm-linux-gnueabihf", qemu: "qemu-arm" }, + TargetSpec { machine: "armeb", triple: "armeb-linux-gnueabihf", qemu: "qemu-armeb" }, + TargetSpec { machine: "riscv64", triple: "riscv64-linux-gnu", qemu: "qemu-riscv64" }, + TargetSpec { machine: "riscv32", triple: "riscv32-linux-gnu", qemu: "qemu-riscv32" }, + TargetSpec { machine: "ppc", triple: "powerpc-linux-gnu", qemu: "qemu-ppc" }, + TargetSpec { machine: "ppc64", triple: "powerpc64-linux-gnu", qemu: "qemu-ppc64" }, + TargetSpec { machine: "ppc64le", triple: "powerpc64le-linux-gnu", qemu: "qemu-ppc64le" }, + TargetSpec { machine: "sparc64", triple: "sparc64-linux-gnu", qemu: "qemu-sparc64" }, + TargetSpec { machine: "s390x", triple: "s390x-linux-gnu", qemu: "qemu-s390x" }, + TargetSpec { machine: "sh4", triple: "sh4-linux-gnu", qemu: "qemu-sh4" }, + TargetSpec { machine: "sh4aeb", triple: "sh4aeb-linux-gnu", qemu: "qemu-sh4eb" }, + TargetSpec { machine: "m68k", triple: "m68k-linux-gnu", qemu: "qemu-m68k" }, + TargetSpec { + machine: "loongarch64", + triple: "loongarch64-linux-gnu", + qemu: "qemu-loongarch64", + }, +]; + +/// A target, its programs run natively without a triple, under QEMU +/// with one. +fn target(machine: &str, triple: Option<&str>, cpu: Option<&str>) -> Target { + let label = match cpu { + Some(cpu) => format!("{machine}-{cpu}"), + None => machine.to_owned(), + }; + Target { + label, + arch: Some(machine.to_owned()), + env: vec![ + ("MACHINE", Some(machine.to_owned())), + ("TRIPLE", triple.map(str::to_owned)), + ("CPU", cpu.map(str::to_owned)), + ], + } +} + +fn canonical_machine(machine: &str) -> String { + let machine = machine.trim(); + if machine == "amd64" { + return "x86_64".to_owned(); + } + if machine.len() == 4 && machine.starts_with('i') && machine.ends_with("86") { + return "i686".to_owned(); + } + if machine.starts_with("armeb") { + return "armeb".to_owned(); + } + if machine.starts_with("arm") { + return "arm".to_owned(); + } + match machine { + "powerpc" => "ppc".to_owned(), + "powerpc64" => "ppc64".to_owned(), + "powerpc64le" => "ppc64le".to_owned(), + _ => machine.to_owned(), + } +} + +fn machine_from_triple(triple: &str) -> String { + canonical_machine(triple.split('-').next().unwrap_or(triple)) +} + +fn native_machine() -> String { + if let Some(machine) = env::var_os("MACHINE").filter(|s| !s.is_empty()) { + return canonical_machine(&machine.to_string_lossy()); + } + + if let Ok(output) = Command::new("cc").arg("-dumpmachine").output() + && output.status.success() + { + let triple = String::from_utf8_lossy(&output.stdout); + if !triple.trim().is_empty() { + return machine_from_triple(&triple); + } + } + canonical_machine(env::consts::ARCH) +} + +fn command_exists(command: &str) -> bool { + let path = Path::new(command); + if path.components().count() > 1 { + return path.is_file(); + } + env::var_os("PATH") + .is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(command).is_file())) +} + +fn supports_power10() -> bool { + if !command_exists("powerpc64le-linux-gnu-gcc") || !command_exists("qemu-ppc64le") { + return false; + } + + let compiler = Command::new("powerpc64le-linux-gnu-gcc") + .args(["-mcpu=power10", "-E", "-x", "c", "-"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + let qemu = Command::new("qemu-ppc64le") + .args(["-cpu", "help"]) + .output() + .is_ok_and(|output| String::from_utf8_lossy(&output.stdout).contains("power10_v2.0")); + compiler && qemu +} + +fn all_targets(native: &str) -> (Vec, Vec) { + let mut targets = Vec::new(); + let mut unavailable = Vec::new(); + + for spec in TARGETS { + if spec.machine == native { + targets.push(target(native, None, None)); + } else if command_exists(spec.qemu) && command_exists(&format!("{}-gcc", spec.triple)) { + targets.push(target(spec.machine, Some(spec.triple), None)); + } else { + unavailable.push(spec.machine.to_owned()); + } + } + + if native == "ppc64le" + && fs::read_to_string("/proc/cpuinfo").is_ok_and(|s| s.contains("POWER10")) + { + targets.push(target(native, None, Some("power10"))); + } else if supports_power10() { + targets.push(target("ppc64le", Some("powerpc64le-linux-gnu"), Some("power10"))); + } + + (targets, unavailable) +} + +fn selected_targets(options: &Options) -> (Vec, Vec) { + let native = native_machine(); + match &options.mode { + Mode::Native => (vec![target(&native, None, None)], Vec::new()), + Mode::Default | Mode::All => all_targets(&native), + Mode::Triple(triple) => { + let machine = machine_from_triple(triple); + (vec![target(&machine, Some(triple), options.cpu.as_deref())], Vec::new()) + } + } +} + +/// Runs the ELF test scripts in `cases_dir` with the linker at `mold`. +/// Command line arguments are substring patterns selecting a subset of +/// tests. +pub fn run(cases_dir: &Path, mold: &Path) -> ExitCode { + let options = parse_options(); + let work_dir = prepare_work_dir(mold).unwrap_or_else(|err| fail(err)); + let (targets, unavailable) = selected_targets(&options); + let scripts = selected_scripts(cases_dir, &options.patterns).unwrap_or_else(|err| fail(err)); + if options.list { + return print_inventory(&scripts, &work_dir, targets, &unavailable); + } + if !unavailable.is_empty() { + eprintln!("skipping targets without both compiler and QEMU: {}", unavailable.join(", ")); + } + let jobs = make_jobs(&scripts, &work_dir, targets, true).unwrap_or_else(|err| fail(err)); + print_summary(&run_jobs(&work_dir, jobs, &options)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalizes_machine_names() { + assert_eq!(canonical_machine("amd64"), "x86_64"); + assert_eq!(canonical_machine("i386"), "i686"); + assert_eq!(canonical_machine("armv7l"), "arm"); + assert_eq!(canonical_machine("aarch64"), "aarch64"); + assert_eq!(canonical_machine("powerpc64le"), "ppc64le"); + } +} diff --git a/tests/elf/Cargo.toml b/tests/elf/Cargo.toml deleted file mode 100644 index 447b11d5b..000000000 --- a/tests/elf/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "mold-elf-tests" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -description = "Test support for mold's shell test suite" -license.workspace = true -publish = false - -[lib] -path = "lib.rs" - -[dependencies] -libc.workspace = true diff --git a/tests/elf/lib.rs b/tests/lib.rs similarity index 51% rename from tests/elf/lib.rs rename to tests/lib.rs index f1350f05d..7ac09f69e 100644 --- a/tests/elf/lib.rs +++ b/tests/lib.rs @@ -1,8 +1,18 @@ //! Runs mold's shell tests in parallel for Cargo's test harness. //! -//! The tests themselves are shell scripts so that they exercise mold with -//! the real compilers and binutils of each target. The runner owns test -//! discovery, target selection, scheduling, timeouts and reporting. +//! The tests are shell scripts, in elf/ and macho/, so that they exercise +//! mold with the real compilers, binutils and loaders of each target. The +//! runner owns test discovery, target selection, scheduling, timeouts and +//! reporting. The ELF tests run for the host and, under QEMU, for each +//! cross target whose toolchain is installed (see elf.rs). The Mach-O +//! tests, which drive Apple's toolchain, run on macOS for the host, for +//! x86_64 under Rosetta and on simulator devices (see macho.rs). + +pub mod elf; +#[cfg(target_os = "macos")] +pub mod macho; +#[cfg(target_os = "macos")] +mod simulator; use std::collections::BTreeMap; use std::env; @@ -21,61 +31,25 @@ use std::os::unix::process::CommandExt; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); -#[derive(Clone, Copy)] -struct TargetSpec { - machine: &'static str, - triple: &'static str, - qemu: &'static str, -} - -// The targets tested by --all, the default mode. A target other than the -// host's is tested only if its cross compiler and QEMU are installed. -const TARGETS: &[TargetSpec] = &[ - TargetSpec { machine: "x86_64", triple: "x86_64-linux-gnu", qemu: "qemu-x86_64" }, - TargetSpec { machine: "i686", triple: "i686-linux-gnu", qemu: "qemu-i386" }, - TargetSpec { machine: "aarch64", triple: "aarch64-linux-gnu", qemu: "qemu-aarch64" }, - TargetSpec { machine: "aarch64_be", triple: "aarch64_be-linux-gnu", qemu: "qemu-aarch64_be" }, - TargetSpec { machine: "arm", triple: "arm-linux-gnueabihf", qemu: "qemu-arm" }, - TargetSpec { machine: "armeb", triple: "armeb-linux-gnueabihf", qemu: "qemu-armeb" }, - TargetSpec { machine: "riscv64", triple: "riscv64-linux-gnu", qemu: "qemu-riscv64" }, - TargetSpec { machine: "riscv32", triple: "riscv32-linux-gnu", qemu: "qemu-riscv32" }, - TargetSpec { machine: "ppc", triple: "powerpc-linux-gnu", qemu: "qemu-ppc" }, - TargetSpec { machine: "ppc64", triple: "powerpc64-linux-gnu", qemu: "qemu-ppc64" }, - TargetSpec { machine: "ppc64le", triple: "powerpc64le-linux-gnu", qemu: "qemu-ppc64le" }, - TargetSpec { machine: "sparc64", triple: "sparc64-linux-gnu", qemu: "qemu-sparc64" }, - TargetSpec { machine: "s390x", triple: "s390x-linux-gnu", qemu: "qemu-s390x" }, - TargetSpec { machine: "sh4", triple: "sh4-linux-gnu", qemu: "qemu-sh4" }, - TargetSpec { machine: "sh4aeb", triple: "sh4aeb-linux-gnu", qemu: "qemu-sh4eb" }, - TargetSpec { machine: "m68k", triple: "m68k-linux-gnu", qemu: "qemu-m68k" }, - TargetSpec { - machine: "loongarch64", - triple: "loongarch64-linux-gnu", - qemu: "qemu-loongarch64", - }, -]; - -#[derive(Clone, Debug)] +/// A configuration the scripts run in. struct Target { - machine: String, - triple: Option, - cpu: Option, + /// The name its results go under. label: String, -} - -impl Target { - fn native(machine: String) -> Self { - Self { label: machine.clone(), machine, triple: None, cpu: None } - } - - fn cross(machine: String, triple: String) -> Self { - Self { label: machine.clone(), machine, triple: Some(triple), cpu: None } - } + /// If set, of the scripts named arch-*, only arch--* run. + arch: Option, + /// The environment variables the scripts get, each set or removed. + env: Vec<(&'static str, Option)>, } #[derive(Clone, Debug, PartialEq, Eq)] enum Mode { + /// The targets the host runs directly, without QEMU or a simulator. Native, + /// The usual targets. + Default, + /// Every target there is a toolchain for. All, + /// One cross or simulator target. Triple(String), } @@ -147,19 +121,29 @@ impl Counts { fn usage() -> ! { eprintln!( "Usage: cargo test [pattern] [-- [--test-threads N] \ - [--native | --all | --triple TRIPLE] [--cpu CPU] \ - [--timeout SECONDS] [--list]]" + [--native | --all | --triple TRIPLE] [--cpu CPU] [--timeout SECONDS] [--list]]\n\ + By default the ELF tests run for every target whose cross compiler and QEMU are \ + installed, and the Mach-O tests for macOS (arm64, and x86_64 under Rosetta) and the \ + arm64 iOS simulator; --all adds the other simulators. --native runs the targets the \ + host runs directly, --triple one cross or simulator target (e.g. aarch64-linux-gnu \ + or arm64-apple-tvos-simulator)." ); std::process::exit(2); } +/// Reports an error of the runner itself and exits. +fn fail(err: impl std::fmt::Display) -> ! { + eprintln!("mold-tests: {err}"); + std::process::exit(1); +} + fn parse_usize(value: Option) -> usize { value.and_then(|s| s.parse().ok()).filter(|&n| n != 0).unwrap_or_else(|| usage()) } fn parse_options() -> Options { let mut jobs = thread::available_parallelism().map_or(1, usize::from); - let mut mode = Mode::All; + let mut mode = Mode::Default; let mut mode_was_set = false; let mut cpu = None; let mut patterns = Vec::new(); @@ -182,12 +166,7 @@ fn parse_options() -> Options { mode = Mode::Triple(args.next().unwrap_or_else(|| usage())); mode_was_set = true; } - "--cpu" => { - cpu = args.next(); - if cpu.is_none() { - usage(); - } - } + "--cpu" => cpu = Some(args.next().unwrap_or_else(|| usage())), "--timeout" => timeout = Duration::from_secs(parse_usize(args.next()) as u64), "--list" => list = true, "--nocapture" | "--show-output" => {} @@ -203,158 +182,43 @@ fn parse_options() -> Options { } } - // Preserve the old runner's TRIPLE/CPU interface for callers that set - // the cross target in the environment. - if !mode_was_set { - if let Some(value) = env::var_os("TRIPLE").filter(|s| !s.is_empty()) { - mode = Mode::Triple(value.to_string_lossy().into_owned()); - } - if cpu.is_none() { - cpu = env::var_os("CPU") - .filter(|s| !s.is_empty()) - .map(|s| s.to_string_lossy().into_owned()); - } + // TRIPLE and CPU in the environment select a target too. + let var = |name| env::var_os(name).filter(|s| !s.is_empty()); + if !mode_was_set && let Some(triple) = var("TRIPLE") { + mode = Mode::Triple(triple.to_string_lossy().into_owned()); + } + if cpu.is_none() { + cpu = var("CPU").map(|s| s.to_string_lossy().into_owned()); } Options { jobs, mode, cpu, patterns, timeout, list } } -fn canonical_machine(machine: &str) -> String { - let machine = machine.trim(); - if machine == "amd64" { - return "x86_64".to_owned(); +fn matches_target(name: &str, target: &Target) -> bool { + match &target.arch { + Some(arch) => !name.starts_with("arch-") || name.starts_with(&format!("arch-{arch}-")), + None => true, } - if machine.len() == 4 && machine.starts_with('i') && machine.ends_with("86") { - return "i686".to_owned(); - } - if machine.starts_with("armeb") { - return "armeb".to_owned(); - } - if machine.starts_with("arm") { - return "arm".to_owned(); - } - match machine { - "powerpc" => "ppc".to_owned(), - "powerpc64" => "ppc64".to_owned(), - "powerpc64le" => "ppc64le".to_owned(), - _ => machine.to_owned(), - } -} - -fn machine_from_triple(triple: &str) -> String { - canonical_machine(triple.split('-').next().unwrap_or(triple)) -} - -fn native_machine() -> String { - if let Some(machine) = env::var_os("MACHINE").filter(|s| !s.is_empty()) { - return canonical_machine(&machine.to_string_lossy()); - } - - if let Ok(output) = Command::new("cc").arg("-dumpmachine").output() - && output.status.success() - { - let triple = String::from_utf8_lossy(&output.stdout); - if !triple.trim().is_empty() { - return machine_from_triple(&triple); - } - } - canonical_machine(env::consts::ARCH) -} - -fn command_exists(command: &str) -> bool { - let path = Path::new(command); - if path.components().count() > 1 { - return path.is_file(); - } - env::var_os("PATH") - .is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(command).is_file())) -} - -fn supports_power10() -> bool { - if !command_exists("powerpc64le-linux-gnu-gcc") || !command_exists("qemu-ppc64le") { - return false; - } - - let compiler = Command::new("powerpc64le-linux-gnu-gcc") - .args(["-mcpu=power10", "-E", "-x", "c", "-"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()); - let qemu = Command::new("qemu-ppc64le") - .args(["-cpu", "help"]) - .output() - .is_ok_and(|output| String::from_utf8_lossy(&output.stdout).contains("power10_v2.0")); - compiler && qemu -} - -fn all_targets(native: &str) -> (Vec, Vec<&'static TargetSpec>) { - let mut targets = Vec::new(); - let mut unavailable = Vec::new(); - - for spec in TARGETS { - if spec.machine == native { - targets.push(Target::native(native.to_owned())); - } else if command_exists(spec.qemu) && command_exists(&format!("{}-gcc", spec.triple)) { - targets.push(Target::cross(spec.machine.to_owned(), spec.triple.to_owned())); - } else { - unavailable.push(spec); - } - } - - if native == "ppc64le" - && fs::read_to_string("/proc/cpuinfo").is_ok_and(|s| s.contains("POWER10")) - { - let mut target = Target::native(native.to_owned()); - target.cpu = Some("power10".to_owned()); - target.label = "ppc64le-power10".to_owned(); - targets.push(target); - } else if supports_power10() { - let mut target = Target::cross("ppc64le".to_owned(), "powerpc64le-linux-gnu".to_owned()); - target.cpu = Some("power10".to_owned()); - target.label = "ppc64le-power10".to_owned(); - targets.push(target); - } - - (targets, unavailable) -} - -fn selected_targets(options: &Options) -> (Vec, Vec<&'static TargetSpec>) { - let native = native_machine(); - match &options.mode { - Mode::Native => (vec![Target::native(native)], Vec::new()), - Mode::All => all_targets(&native), - Mode::Triple(triple) => { - let machine = machine_from_triple(triple); - let mut target = Target::cross(machine, triple.clone()); - target.cpu.clone_from(&options.cpu); - if let Some(cpu) = &target.cpu { - target.label = format!("{}-{cpu}", target.machine); - } - (vec![target], Vec::new()) - } - } -} - -fn matches_target(name: &str, machine: &str) -> bool { - !name.starts_with("arch-") || name.starts_with(&format!("arch-{machine}-")) } fn matches_patterns(name: &str, patterns: &[String]) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| name.contains(pattern)) } -fn discover_scripts(test_dir: &Path) -> io::Result> { +/// The scripts in `dir` that the patterns select, by name. +fn selected_scripts(dir: &Path, patterns: &[String]) -> io::Result> { let mut scripts = Vec::new(); - for entry in fs::read_dir(test_dir)? { + for entry in fs::read_dir(dir)? { let path = entry?.path(); if path.extension() != Some(OsStr::new("sh")) { continue; } let name = path.file_stem().unwrap().to_string_lossy().into_owned(); - scripts.push((name, path)); + if matches_patterns(&name, patterns) { + scripts.push((name, path)); + } } + scripts.sort(); Ok(scripts) } @@ -392,6 +256,13 @@ fn replace_file_link(source: &Path, destination: &Path) -> io::Result<()> { } } +/// Tests run from a directory beside the linker under test and write +/// their outputs under out/test there, so nothing generated lands in the +/// source tree. The directory holds the linker under the names the +/// scripts invoke it by: mold, ld and ld.lld, which the ELF scripts find +/// with ./mold and the compiler's -B. option (upstream LLVM defaults to +/// ld.lld on FreeBSD, so overriding only ld is not enough), and +/// ld64.mold, the name under which mold acts as the Mach-O linker. fn prepare_work_dir(mold: &Path) -> io::Result { let mold = mold.canonicalize()?; let profile_dir = mold.parent().ok_or_else(|| { @@ -400,44 +271,29 @@ fn prepare_work_dir(mold: &Path) -> io::Result { format!("{} has no parent directory", mold.display()), ) })?; - - // The test scripts run in a directory containing mold and ld, which - // they find with ./mold and the compiler's -B. option, and write their - // outputs under out/test there. Create that directory next to the mold - // binary so that generated files stay out of the source tree. let work_dir = profile_dir.join("mold-test"); fs::create_dir_all(&work_dir)?; - - // Upstream LLVM defaults to ld.lld on FreeBSD, so overriding only ld - // with -B is not enough. - for name in ["mold", "ld", "ld.lld"] { + for name in ["mold", "ld", "ld.lld", "ld64.mold"] { replace_file_link(&mold, &work_dir.join(name))?; } Ok(work_dir) } fn make_jobs( - cases_dirs: &[PathBuf], + scripts: &[(String, PathBuf)], work_dir: &Path, targets: Vec, - patterns: &[String], clean: bool, ) -> io::Result> { - let mut scripts = Vec::new(); - for dir in cases_dirs { - scripts.extend(discover_scripts(dir)?); - } - scripts.sort_by(|a, b| a.0.cmp(&b.0)); let mut jobs = Vec::new(); - for target in targets { let target = Arc::new(target); let result_dir = work_dir.join("out/test/results").join(&target.label); if clean { clear_results(&result_dir)?; } - for (name, script) in &scripts { - if matches_target(name, &target.machine) && matches_patterns(name, patterns) { + for (name, script) in scripts { + if matches_target(name, &target) { jobs.push(TestJob { target: Arc::clone(&target), script: script.clone(), @@ -451,11 +307,12 @@ fn make_jobs( Ok(jobs) } +/// A script that skips itself ends a line of its log with "skipped". fn log_says_skipped(path: &Path) -> bool { fs::read(path).is_ok_and(|bytes| { bytes .split(|&byte| byte == b'\n') - .any(|line| line.strip_suffix(b"\r").unwrap_or(line) == b"skipped") + .any(|line| line.strip_suffix(b"\r").unwrap_or(line).ends_with(b"skipped")) }) } @@ -465,23 +322,21 @@ fn run_process(root: &Path, job: &TestJob, timeout: Duration) -> Result command.env(name, value), + None => command.env_remove(name), + }; } - // A timeout must also kill compiler and QEMU children. + // A timeout must also kill compiler and QEMU children, so a test runs + // in its own process group. That is a background group of the + // terminal cargo was started from, so the test must not inherit the + // terminal as stdin: a program that reads it or restores its settings + // on exit (lldb does, even in batch mode) is stopped by SIGTTIN or + // SIGTTOU and hangs until the timeout. + command.stdin(Stdio::null()); #[cfg(unix)] command.process_group(0); let mut child = @@ -500,8 +355,7 @@ fn run_process(root: &Path, job: &TestJob, timeout: Duration) -> Result thread::sleep(Duration::from_millis(20)), None => { - // SAFETY: kill has no memory-safety preconditions; the group - // id is the pid of the still-unreaped child. + // SAFETY: kill only signals the test's own process group. #[cfg(unix)] unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); @@ -593,9 +447,17 @@ fn run_jobs(root: &Path, jobs: Vec, options: &Options) -> Vec, + unavailable: &[String], +) -> ExitCode { + let jobs = make_jobs(scripts, work_dir, targets, false).unwrap_or_else(|err| fail(err)); let mut counts = BTreeMap::new(); - for job in jobs { + for job in &jobs { *counts.entry(job.target.label.as_str()).or_insert(0usize) += 1; } for (target, count) in counts { @@ -603,13 +465,12 @@ fn print_inventory(jobs: &[TestJob], unavailable: &[&TargetSpec]) { } println!("total: tests={}", jobs.len()); if !unavailable.is_empty() { - let targets = - unavailable.iter().map(|target| target.machine).collect::>().join(", "); - println!("unavailable: {targets}"); + println!("unavailable: {}", unavailable.join(", ")); } + ExitCode::SUCCESS } -fn print_summary(results: &[TestResult]) -> bool { +fn print_summary(results: &[TestResult]) -> ExitCode { let mut by_target: BTreeMap<&str, Counts> = BTreeMap::new(); for result in results { by_target.entry(&result.target.label).or_default().add(result.outcome); @@ -625,39 +486,7 @@ fn print_summary(results: &[TestResult]) -> bool { } else { println!("pass={} skip={} fail={}", total.pass, total.skip, total.fail); } - total.fail == 0 -} - -pub fn run(cases_dirs: &[PathBuf], mold: &Path) -> ExitCode { - let options = parse_options(); - let work_dir = match prepare_work_dir(mold) { - Ok(dir) => dir, - Err(err) => { - eprintln!("mold-elf-tests: {err}"); - return ExitCode::FAILURE; - } - }; - let (targets, unavailable) = selected_targets(&options); - let jobs = match make_jobs(cases_dirs, &work_dir, targets, &options.patterns, !options.list) { - Ok(jobs) => jobs, - Err(err) => { - eprintln!("mold-elf-tests: {err}"); - return ExitCode::FAILURE; - } - }; - - if options.list { - print_inventory(&jobs, &unavailable); - return ExitCode::SUCCESS; - } - if options.mode == Mode::All && !unavailable.is_empty() { - let targets = - unavailable.iter().map(|target| target.machine).collect::>().join(", "); - eprintln!("skipping targets without both compiler and QEMU: {targets}"); - } - - let results = run_jobs(&work_dir, jobs, &options); - if print_summary(&results) { ExitCode::SUCCESS } else { ExitCode::FAILURE } + if total.fail == 0 { ExitCode::SUCCESS } else { ExitCode::FAILURE } } #[cfg(test)] @@ -665,18 +494,19 @@ mod tests { use super::*; #[test] - fn canonicalizes_machine_names() { - assert_eq!(canonical_machine("amd64"), "x86_64"); - assert_eq!(canonical_machine("i386"), "i686"); - assert_eq!(canonical_machine("armv7l"), "arm"); - assert_eq!(canonical_machine("aarch64"), "aarch64"); - assert_eq!(canonical_machine("powerpc64le"), "ppc64le"); + fn selects_tests_by_substring() { + assert!(matches_patterns("dead-strip", &[])); + assert!(matches_patterns("dead-strip", &["strip".to_owned()])); + assert!(!matches_patterns("hello", &["strip".to_owned()])); } #[test] fn selects_generic_and_target_tests() { - assert!(matches_target("gc-sections", "aarch64")); - assert!(matches_target("arch-aarch64-reloc", "aarch64")); - assert!(!matches_target("arch-x86_64-reloc", "aarch64")); + let target = Target { label: String::new(), arch: Some("aarch64".to_owned()), env: vec![] }; + assert!(matches_target("gc-sections", &target)); + assert!(matches_target("arch-aarch64-reloc", &target)); + assert!(!matches_target("arch-x86_64-reloc", &target)); + let target = Target { arch: None, ..target }; + assert!(matches_target("arch-x86_64-reloc", &target)); } } diff --git a/tests/macho.rs b/tests/macho.rs new file mode 100644 index 000000000..5ac1edf07 --- /dev/null +++ b/tests/macho.rs @@ -0,0 +1,246 @@ +//! The Mach-O tests' targets. Tests run natively on the host, on an arm64 +//! host also for x86_64 under Rosetta, and for a simulator triple on a +//! simulator device, which runs the programs as QEMU runs a cross +//! target's in the ELF tests. + +use std::path::Path; +use std::process::{Command, ExitCode, Stdio}; + +use crate::simulator::{self, Device, Runtime, Simulator}; +use crate::{ + Mode, Options, Target, fail, make_jobs, parse_options, prepare_work_dir, print_inventory, + print_summary, run_jobs, selected_scripts, usage, +}; + +/// The simulator triples a run adds to the host's targets by default, and +/// those --all adds too, each when a runtime runs programs of its +/// architecture (an x86_64 one also needs Rosetta). Only an older runtime +/// runs x86_64 programs, iOS 17's. +const DEFAULT_SIMULATORS: &[&str] = &["arm64-apple-ios-simulator"]; +const MORE_SIMULATORS: &[&str] = + &["x86_64-apple-ios-simulator", "arm64-apple-tvos-simulator", "arm64-apple-xros-simulator"]; + +/// A target, with the simulator runtime that runs its programs if it +/// isn't the host's. A host target builds and runs macOS programs; a +/// simulator target builds for the triple (whose OS version is that of +/// the newest runtime, unless the triple names one) and runs the programs +/// on a device of the runtime. +struct Config { + target: Target, + runtime: Option, +} + +fn host(arch: &str, linker: &Path) -> Config { + let target = Target { + label: arch.to_owned(), + arch: None, + env: vec![ + ("mold", Some(linker.display().to_string())), + ("ARCH", Some(arch.to_owned())), + ("TRIPLE", None), + ("SIMULATOR", None), + ], + }; + Config { target, runtime: None } +} + +fn simulator(simulator: &Simulator, runtime: Runtime, linker: &Path) -> Config { + let triple = format!("{}-apple-{}{}-simulator", simulator.arch, simulator.os, runtime.version); + let target = Target { + label: simulator.label(), + arch: None, + env: vec![ + ("mold", Some(linker.display().to_string())), + ("ARCH", Some(simulator.arch.clone())), + ("TRIPLE", Some(triple)), + ], + }; + Config { target, runtime: Some(runtime) } +} + +fn native_arch() -> &'static str { + if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" } +} + +/// Whether x86_64 binaries run on this arm64 host. +fn rosetta_available() -> bool { + Command::new("arch") + .args(["-x86_64", "/usr/bin/true"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// Returns the targets to run and the simulator triples left out for want +/// of a runtime (as on a machine without Xcode's simulators). +fn selected_targets(options: &Options, linker: &Path) -> (Vec, Vec) { + let native = native_arch(); + let mut targets = vec![host(native, linker)]; + let mut unavailable = Vec::new(); + match &options.mode { + Mode::Native | Mode::Default | Mode::All => { + let rosetta = native == "arm64" && rosetta_available(); + if rosetta { + targets.push(host("x86_64", linker)); + } + let simulators: &[&[&str]] = match options.mode { + Mode::Default => &[DEFAULT_SIMULATORS], + Mode::All => &[DEFAULT_SIMULATORS, MORE_SIMULATORS], + _ => &[], + }; + for triple in simulators.concat() { + let sim = Simulator::parse(triple).unwrap(); + match simulator::find_runtime(&sim) { + Some(runtime) if sim.arch == native || rosetta => { + targets.push(simulator(&sim, runtime, linker)) + } + _ => unavailable.push(triple.to_owned()), + } + } + } + Mode::Triple(triple) => { + let Some(sim) = Simulator::parse(triple) else { + eprintln!("mold-tests: {triple}: not a simulator triple"); + usage(); + }; + let Some(runtime) = simulator::find_runtime(&sim) else { + fail(format!("no simulator runtime for {triple}")); + }; + targets = vec![simulator(&sim, runtime, linker)]; + } + } + (targets, unavailable) +} + +/// Boots a device for each simulator target, which its scripts run their +/// programs on. Dropping a device shuts it down. +fn boot_devices(configs: Vec) -> Result<(Vec, Vec), String> { + let mut targets = Vec::new(); + let mut devices = Vec::new(); + for Config { mut target, runtime } in configs { + if let Some(runtime) = &runtime { + let device = Device::boot(runtime)?; + target.env.push(("SIMULATOR", Some(device.udid.clone()))); + devices.push(device); + } + targets.push(target); + } + Ok((targets, devices)) +} + +/// Runs the Mach-O test scripts in `cases_dir` with the linker at `mold`. +/// Command line arguments are substring patterns selecting a subset of +/// tests. +pub fn run(cases_dir: &Path, mold: &Path) -> ExitCode { + let options = parse_options(); + let work_dir = prepare_work_dir(mold).unwrap_or_else(|err| fail(err)); + let linker = work_dir.join("ld64.mold"); + let (configs, unavailable) = selected_targets(&options, &linker); + let scripts = selected_scripts(cases_dir, &options.patterns).unwrap_or_else(|err| fail(err)); + if options.list { + let targets = configs.into_iter().map(|config| config.target).collect(); + return print_inventory(&scripts, &work_dir, targets, &unavailable); + } + if !unavailable.is_empty() { + eprintln!("skipping simulators without a runtime: {}", unavailable.join(", ")); + } + + // The host's targets run together, then each simulator's on its own, + // its device booted for it alone and shut down once it is done: a + // booted simulator is a whole OS, and one at a time is load enough. + // Errors return rather than exit, so that a device is shut down. + let (hosts, simulators): (Vec<_>, Vec<_>) = + configs.into_iter().partition(|config| config.runtime.is_none()); + let mut results = Vec::new(); + for phase in std::iter::once(hosts).chain(simulators.into_iter().map(|config| vec![config])) { + if phase.is_empty() || scripts.is_empty() { + continue; + } + let (targets, devices) = match boot_devices(phase) { + Ok(booted) => booted, + Err(err) => { + eprintln!("mold-tests: {err}"); + return ExitCode::FAILURE; + } + }; + let jobs = match make_jobs(&scripts, &work_dir, targets, true) { + Ok(jobs) => jobs, + Err(err) => { + eprintln!("mold-tests: {err}"); + return ExitCode::FAILURE; + } + }; + results.extend(run_jobs(&work_dir, jobs, &options)); + drop(devices); + } + print_summary(&results) +} + +#[cfg(test)] +mod tests { + use std::env; + use std::fs; + use std::process::Output; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + fn run_script(body: &str) -> Output { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = env::temp_dir().join(format!( + "mold-harness-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&dir).unwrap(); + let common = Path::new(env!("CARGO_MANIFEST_DIR")).join("macho/common.inc"); + let output = Command::new("bash") + .args(["-c", &format!("source \"$1\"\n{body}"), "harness-test"]) + .arg(common) + .env("mold", "unused") + .current_dir(&dir) + .output() + .unwrap(); + fs::remove_dir_all(dir).unwrap(); + output + } + + #[test] + fn negated_failure_at_exit_is_not_success() { + let output = run_script("! true"); + assert!(!output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("OK")); + } + + #[test] + fn negative_assertion_stops_before_later_commands() { + let output = run_script("not true\necho reached"); + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("reached")); + assert!(!stdout.contains("OK")); + assert!(String::from_utf8_lossy(&output.stderr).contains("unexpectedly succeeded")); + } + + #[test] + fn explicit_exit_status_is_preserved() { + assert_eq!(run_script("exit 7").status.code(), Some(7)); + } + + #[test] + fn expected_failure_and_success_pass() { + let output = run_script("not false\ntrue"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("OK")); + } + + #[test] + fn skip_remains_successful() { + let output = run_script("skip"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("skipped")); + assert!(!stdout.contains("OK")); + } +} diff --git a/tests/macho/lib.rs b/tests/macho/lib.rs deleted file mode 100644 index b9c8611d4..000000000 --- a/tests/macho/lib.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! Runs mold's shell tests in parallel for Cargo's test harness. -//! -//! The tests themselves deliberately remain shell scripts so that this port -//! exercises exactly the same inputs and toolchains as the system linker. -//! The runner owns test discovery, target selection, scheduling, timeouts -//! and reporting. Tests run natively on the host, on an arm64 host also -//! for x86_64 under Rosetta, and for a simulator triple on a simulator -//! device, which runs the programs as QEMU runs a cross target's in mold's -//! ELF suite. - -// The scripts drive Apple's toolchain, so they run only on macOS. -// Elsewhere this crate is empty, which lets the workspace build there. -#![cfg(target_os = "macos")] - -mod simulator; - -use simulator::{Device, Runtime, Simulator}; -use std::collections::BTreeMap; -use std::env; -use std::ffi::OsStr; -use std::fs::{self, File}; -use std::io; -use std::iter; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; -use std::thread; -use std::time::{Duration, Instant}; - -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); - -/// The simulator configurations a run adds to the host's by default, -/// and those --all adds too, each when a runtime runs programs of its -/// architecture (an x86_64 one also needs Rosetta). Only an older -/// runtime runs x86_64 programs, iOS 17's. -const DEFAULT_SIMULATORS: &[&str] = &["arm64-apple-ios-simulator"]; -const MORE_SIMULATORS: &[&str] = - &["x86_64-apple-ios-simulator", "arm64-apple-tvos-simulator", "arm64-apple-xros-simulator"]; - -/// A configuration the scripts run in. A host configuration builds and -/// runs macOS programs; a simulator configuration builds for the target -/// triple (whose OS version is that of the newest runtime, unless the -/// triple names one) and runs the programs on a device of the runtime. -struct Target { - arch: String, - triple: Option, - runtime: Option, - /// The UDID of the booted device that runs the programs. - device: Option, - label: String, -} - -impl Target { - fn host(arch: &str) -> Self { - Self { - arch: arch.to_owned(), - triple: None, - runtime: None, - device: None, - label: arch.to_owned(), - } - } - - fn simulator(simulator: &Simulator, runtime: Runtime) -> Self { - let triple = - format!("{}-apple-{}{}-simulator", simulator.arch, simulator.os, runtime.version); - Self { - arch: simulator.arch.clone(), - triple: Some(triple), - runtime: Some(runtime), - device: None, - label: simulator.label(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum Mode { - /// The host architecture only. - Native, - /// The host architecture, plus x86_64 under Rosetta when available. - Host, - /// The host's configurations plus DEFAULT_SIMULATORS. - Default, - /// The host's configurations plus every simulator's. - All, - /// One simulator configuration. - Triple(String), -} - -struct Options { - jobs: usize, - mode: Mode, - patterns: Vec, - timeout: Duration, - list: bool, -} - -struct TestJob { - target: Arc, - script: PathBuf, - name: String, - log: PathBuf, - status_file: PathBuf, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Outcome { - Pass, - Skip, - Fail, - Timeout, -} - -impl Outcome { - fn status(self) -> &'static str { - match self { - Self::Pass => "pass", - Self::Skip => "skip", - Self::Fail | Self::Timeout => "fail", - } - } -} - -struct TestResult { - target: Arc, - name: String, - log: PathBuf, - outcome: Outcome, -} - -#[derive(Default)] -struct Counts { - pass: usize, - skip: usize, - fail: usize, -} - -impl Counts { - fn add(&mut self, outcome: Outcome) { - match outcome { - Outcome::Pass => self.pass += 1, - Outcome::Skip => self.skip += 1, - Outcome::Fail | Outcome::Timeout => self.fail += 1, - } - } - - fn merge(&mut self, other: &Self) { - self.pass += other.pass; - self.skip += other.skip; - self.fail += other.fail; - } -} - -fn usage() -> ! { - eprintln!( - "Usage: cargo test [pattern] [-- [--test-threads N] \ - [--native | --host | --all | --triple TRIPLE] [--timeout SECONDS] [--list]]\n\ - By default the tests run for macOS (arm64, and x86_64 under Rosetta) and the arm64 \ - iOS simulator. --all adds the x86_64 iOS, tvOS and visionOS simulators; --host runs \ - macOS's alone, --native the host architecture's, --triple one simulator's (e.g. \ - arm64-apple-tvos-simulator)." - ); - std::process::exit(2); -} - -fn parse_usize(value: Option) -> usize { - value.and_then(|s| s.parse().ok()).filter(|&n| n != 0).unwrap_or_else(|| usage()) -} - -fn parse_options() -> Options { - let mut jobs = thread::available_parallelism().map_or(1, usize::from); - let mut mode = Mode::Default; - let mut mode_was_set = false; - let mut patterns = Vec::new(); - let mut timeout = DEFAULT_TIMEOUT; - let mut list = false; - - let mut args = env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "-j" | "--jobs" | "--test-threads" => jobs = parse_usize(args.next()), - "--native" => { - mode = Mode::Native; - mode_was_set = true; - } - "--host" => { - mode = Mode::Host; - mode_was_set = true; - } - "--all" => { - mode = Mode::All; - mode_was_set = true; - } - "--triple" => { - mode = Mode::Triple(args.next().unwrap_or_else(|| usage())); - mode_was_set = true; - } - "--timeout" => timeout = Duration::from_secs(parse_usize(args.next()) as u64), - "--list" => list = true, - "--nocapture" | "--show-output" => {} - "-h" | "--help" => usage(), - _ if arg.starts_with("-j") && arg.len() > 2 => { - jobs = parse_usize(Some(arg[2..].to_owned())) - } - _ if arg.starts_with("--test-threads=") => { - jobs = parse_usize(arg.split_once('=').map(|(_, value)| value.to_owned())) - } - _ if arg.starts_with('-') => usage(), - _ => patterns.push(arg), - } - } - - // As in mold's ELF suite, TRIPLE in the environment selects the - // configuration too. - if !mode_was_set && let Some(triple) = env::var_os("TRIPLE").filter(|s| !s.is_empty()) { - mode = Mode::Triple(triple.to_string_lossy().into_owned()); - } - - Options { jobs, mode, patterns, timeout, list } -} - -fn native_arch() -> &'static str { - if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" } -} - -/// Whether x86_64 binaries run on this arm64 host. -fn rosetta_available() -> bool { - Command::new("arch") - .args(["-x86_64", "/usr/bin/true"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) -} - -/// Returns the configurations to run and the simulator triples that a -/// run skips, silently, for want of a runtime (as on a machine without -/// Xcode's simulators). -fn selected_targets(options: &Options) -> (Vec, Vec<&'static str>) { - let native = native_arch(); - let mut targets = vec![Target::host(native)]; - let mut unavailable = Vec::new(); - match &options.mode { - Mode::Native => {} - Mode::Host | Mode::Default | Mode::All => { - let rosetta = native == "arm64" && rosetta_available(); - if rosetta { - targets.push(Target::host("x86_64")); - } - let simulators: &[&[&str]] = match options.mode { - Mode::Default => &[DEFAULT_SIMULATORS], - Mode::All => &[DEFAULT_SIMULATORS, MORE_SIMULATORS], - _ => &[], - }; - for triple in simulators.concat() { - let simulator = Simulator::parse(triple).unwrap(); - match simulator::find_runtime(&simulator) { - Some(runtime) if simulator.arch == native || rosetta => { - targets.push(Target::simulator(&simulator, runtime)) - } - _ => unavailable.push(triple), - } - } - } - Mode::Triple(triple) => { - let Some(simulator) = Simulator::parse(triple) else { - eprintln!("mold-macho-tests: {triple}: not a simulator triple"); - usage(); - }; - let Some(runtime) = simulator::find_runtime(&simulator) else { - eprintln!("mold-macho-tests: no simulator runtime for {triple}"); - std::process::exit(1); - }; - targets = vec![Target::simulator(&simulator, runtime)]; - } - } - (targets, unavailable) -} - -/// Boots a device for each simulator configuration. Dropping a device -/// shuts it down. -fn boot_devices(targets: &mut [Target]) -> Result, String> { - let mut devices = Vec::new(); - for target in targets { - if let Some(runtime) = &target.runtime { - let device = Device::boot(runtime)?; - target.device = Some(device.udid.clone()); - devices.push(device); - } - } - Ok(devices) -} - -fn matches_patterns(name: &str, patterns: &[String]) -> bool { - patterns.is_empty() || patterns.iter().any(|pattern| name.contains(pattern)) -} - -fn discover_scripts(test_dir: &Path) -> io::Result> { - let mut scripts = Vec::new(); - for entry in fs::read_dir(test_dir)? { - let path = entry?.path(); - if path.extension() != Some(OsStr::new("sh")) { - continue; - } - let name = path.file_stem().unwrap().to_string_lossy().into_owned(); - scripts.push((name, path)); - } - scripts.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(scripts) -} - -fn clear_results(dir: &Path) -> io::Result<()> { - fs::create_dir_all(dir)?; - for entry in fs::read_dir(dir)? { - let path = entry?.path(); - if matches!(path.extension().and_then(OsStr::to_str), Some("log" | "status")) { - fs::remove_file(path)?; - } - } - Ok(()) -} - -/// Tests run from a directory beside the linker under test and write -/// their outputs under out/test there, so nothing generated lands in the -/// source tree. The directory also holds a symlink to the linker named -/// ld64.mold, the name under which mold acts as the Mach-O linker. -fn prepare_work_dir(mold: &Path) -> io::Result { - let mold = mold.canonicalize()?; - let profile_dir = mold.parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{} has no parent directory", mold.display()), - ) - })?; - let work_dir = profile_dir.join("mold-test"); - fs::create_dir_all(&work_dir)?; - - let link = work_dir.join("ld64.mold"); - match fs::remove_file(&link) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => return Err(err), - } - std::os::unix::fs::symlink(&mold, &link)?; - Ok(work_dir) -} - -/// The scripts in `cases_dirs` that the patterns select, by name. -fn selected_scripts( - cases_dirs: &[PathBuf], - patterns: &[String], -) -> io::Result> { - let mut scripts = Vec::new(); - for dir in cases_dirs { - scripts.extend(discover_scripts(dir)?); - } - scripts.retain(|(name, _)| matches_patterns(name, patterns)); - scripts.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(scripts) -} - -fn make_jobs( - scripts: &[(String, PathBuf)], - work_dir: &Path, - targets: Vec, - clean: bool, -) -> io::Result> { - let mut jobs = Vec::new(); - for target in targets { - let target = Arc::new(target); - let result_dir = work_dir.join("out/test/results").join(&target.label); - if clean { - clear_results(&result_dir)?; - } - for (name, script) in scripts { - jobs.push(TestJob { - target: Arc::clone(&target), - script: script.clone(), - name: name.clone(), - log: result_dir.join(format!("{name}.log")), - status_file: result_dir.join(format!("{name}.status")), - }); - } - } - Ok(jobs) -} - -/// A script that skips itself ends its startup line with "skipped". -fn log_says_skipped(path: &Path) -> bool { - fs::read(path).is_ok_and(|bytes| { - bytes - .split(|&byte| byte == b'\n') - .any(|line| line.strip_suffix(b"\r").unwrap_or(line).ends_with(b"skipped")) - }) -} - -fn run_process( - root: &Path, - job: &TestJob, - linker: &Path, - timeout: Duration, -) -> Result { - let log = File::create(&job.log) - .map_err(|err| format!("cannot create {}: {err}", job.log.display()))?; - let stderr = - log.try_clone().map_err(|err| format!("cannot clone {}: {err}", job.log.display()))?; - let mut command = Command::new(&job.script); - command - .current_dir(root) - .env("mold", linker) - .env("ARCH", &job.target.arch) - .stdin(Stdio::null()) - .stdout(Stdio::from(log)) - .stderr(Stdio::from(stderr)); - for (name, value) in [("TRIPLE", &job.target.triple), ("SIMULATOR", &job.target.device)] { - if let Some(value) = value { - command.env(name, value); - } else { - command.env_remove(name); - } - } - - // A timeout must also kill compiler children. The test runs in - // its own process group, which is a background group of the - // terminal cargo was started from, so it must not inherit that - // terminal as stdin: a program that reads it or restores its - // settings on exit (lldb does, even in batch mode) is stopped by - // SIGTTIN or SIGTTOU and hangs until the timeout. - command.process_group(0); - let mut child = - command.spawn().map_err(|err| format!("cannot run {}: {err}", job.script.display()))?; - let start = Instant::now(); - loop { - match child.try_wait().map_err(|err| format!("cannot wait for test: {err}"))? { - Some(status) => { - return Ok(if !status.success() { - Outcome::Fail - } else if log_says_skipped(&job.log) { - Outcome::Skip - } else { - Outcome::Pass - }); - } - None if start.elapsed() < timeout => thread::sleep(Duration::from_millis(20)), - None => { - // SAFETY: kill only signals the test's own process group. - unsafe { - libc::kill(-(child.id() as i32), libc::SIGKILL); - } - let _ = child.wait(); - return Ok(Outcome::Timeout); - } - } - } -} - -fn run_job(root: &Path, job: &TestJob, linker: &Path, timeout: Duration) -> TestResult { - let mut outcome = run_process(root, job, linker, timeout).unwrap_or_else(|err| { - eprintln!("{}: {err}", job.name); - Outcome::Fail - }); - - // Keep failed test directories for diagnosis, but do not retain the - // successful tests' potentially large temporary files. - if matches!(outcome, Outcome::Pass | Outcome::Skip) { - let dir = root.join("out/test").join(&job.target.label).join(&job.name); - match fs::remove_dir_all(&dir) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => { - eprintln!("{}: cannot remove {}: {err}", job.name, dir.display()); - outcome = Outcome::Fail; - } - } - } - - if let Err(err) = fs::write(&job.status_file, format!("{}\n", outcome.status())) { - eprintln!("{}: cannot write {}: {err}", job.name, job.status_file.display()); - } - TestResult { - target: Arc::clone(&job.target), - name: job.name.clone(), - log: job.log.clone(), - outcome, - } -} - -fn run_jobs(root: &Path, jobs: Vec, linker: &Path, options: &Options) -> Vec { - if jobs.is_empty() { - return Vec::new(); - } - let next = AtomicUsize::new(0); - let (sender, receiver) = mpsc::channel(); - let workers = options.jobs.min(jobs.len()); - - thread::scope(|scope| { - for _ in 0..workers { - let jobs = &jobs; - let next = &next; - let sender = sender.clone(); - scope.spawn(move || { - loop { - let index = next.fetch_add(1, Ordering::Relaxed); - let Some(job) = jobs.get(index) else { - break; - }; - if sender.send(run_job(root, job, linker, options.timeout)).is_err() { - break; - } - } - }); - } - drop(sender); - - let mut results = Vec::with_capacity(jobs.len()); - while let Ok(result) = receiver.recv() { - if matches!(result.outcome, Outcome::Fail | Outcome::Timeout) { - eprintln!( - "FAIL {}:{}{} ({})", - result.target.label, - result.name, - if result.outcome == Outcome::Timeout { " [timeout]" } else { "" }, - result.log.display() - ); - } - results.push(result); - if results.len().is_multiple_of(100) || results.len() == jobs.len() { - eprintln!("completed {}/{}", results.len(), jobs.len()); - } - } - results - }) -} - -fn print_inventory(jobs: &[TestJob], unavailable: &[&str]) { - let mut counts = BTreeMap::new(); - for job in jobs { - *counts.entry(job.target.label.as_str()).or_insert(0usize) += 1; - } - for (target, count) in counts { - println!("{target}: tests={count}"); - } - println!("total: tests={}", jobs.len()); - if !unavailable.is_empty() { - println!("unavailable: {}", unavailable.join(", ")); - } -} - -fn print_summary(results: &[TestResult]) -> bool { - let mut by_target: BTreeMap<&str, Counts> = BTreeMap::new(); - for result in results { - by_target.entry(&result.target.label).or_default().add(result.outcome); - } - - let mut total = Counts::default(); - for (target, counts) in &by_target { - println!("{target}: pass={} skip={} fail={}", counts.pass, counts.skip, counts.fail); - total.merge(counts); - } - if by_target.len() > 1 { - println!("total: pass={} skip={} fail={}", total.pass, total.skip, total.fail); - } else { - println!("pass={} skip={} fail={}", total.pass, total.skip, total.fail); - } - total.fail == 0 -} - -/// Discovers and runs the test scripts in `cases_dirs`, using the linker -/// at `mold`. Command line arguments are substring patterns selecting a -/// subset of tests. -pub fn run(cases_dirs: &[PathBuf], mold: &Path) -> ExitCode { - let options = parse_options(); - let work_dir = prepare_work_dir(mold).unwrap_or_else(|err| { - eprintln!("mold-macho-tests: {err}"); - std::process::exit(1); - }); - let linker = work_dir.join("ld64.mold"); - let (targets, unavailable) = selected_targets(&options); - let scripts = selected_scripts(cases_dirs, &options.patterns).unwrap_or_else(|err| { - eprintln!("mold-macho-tests: {err}"); - std::process::exit(1); - }); - if options.list { - let jobs = make_jobs(&scripts, &work_dir, targets, false).unwrap_or_else(|err| { - eprintln!("mold-macho-tests: {err}"); - std::process::exit(1); - }); - print_inventory(&jobs, &unavailable); - return ExitCode::SUCCESS; - } - - // The host's configurations run together, then each simulator's on - // its own, its device booted for it alone and shut down once it is - // done: a booted simulator is a whole OS, and one at a time is load - // enough. Errors return rather than exit, so that a device is shut - // down. - let (hosts, simulators): (Vec<_>, Vec<_>) = - targets.into_iter().partition(|target| target.runtime.is_none()); - let mut results = Vec::new(); - for mut phase in iter::once(hosts).chain(simulators.into_iter().map(|target| vec![target])) { - if phase.is_empty() || scripts.is_empty() { - continue; - } - let devices = match boot_devices(&mut phase) { - Ok(devices) => devices, - Err(err) => { - eprintln!("mold-macho-tests: {err}"); - return ExitCode::FAILURE; - } - }; - let jobs = match make_jobs(&scripts, &work_dir, phase, true) { - Ok(jobs) => jobs, - Err(err) => { - eprintln!("mold-macho-tests: {err}"); - return ExitCode::FAILURE; - } - }; - results.extend(run_jobs(&work_dir, jobs, &linker, &options)); - drop(devices); - } - if print_summary(&results) { ExitCode::SUCCESS } else { ExitCode::FAILURE } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Output; - - fn run_script(body: &str) -> Output { - static NEXT: AtomicUsize = AtomicUsize::new(0); - let dir = env::temp_dir().join(format!( - "mold-harness-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&dir).unwrap(); - let common = Path::new(env!("CARGO_MANIFEST_DIR")).join("common.inc"); - let output = Command::new("bash") - .args(["-c", &format!("source \"$1\"\n{body}"), "harness-test"]) - .arg(common) - .env("mold", "unused") - .current_dir(&dir) - .output() - .unwrap(); - fs::remove_dir_all(dir).unwrap(); - output - } - - #[test] - fn negated_failure_at_exit_is_not_success() { - let output = run_script("! true"); - assert!(!output.status.success()); - assert!(!String::from_utf8_lossy(&output.stdout).contains("OK")); - } - - #[test] - fn negative_assertion_stops_before_later_commands() { - let output = run_script("not true\necho reached"); - assert!(!output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(!stdout.contains("reached")); - assert!(!stdout.contains("OK")); - assert!(String::from_utf8_lossy(&output.stderr).contains("unexpectedly succeeded")); - } - - #[test] - fn explicit_exit_status_is_preserved() { - assert_eq!(run_script("exit 7").status.code(), Some(7)); - } - - #[test] - fn expected_failure_and_success_pass() { - let output = run_script("not false\ntrue"); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("OK")); - } - - #[test] - fn skip_remains_successful() { - let output = run_script("skip"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("skipped")); - assert!(!stdout.contains("OK")); - } - - #[test] - fn selects_tests_by_substring() { - assert!(matches_patterns("dead-strip", &[])); - assert!(matches_patterns("dead-strip", &["strip".to_owned()])); - assert!(!matches_patterns("hello", &["strip".to_owned()])); - } -} diff --git a/tests/macho/simulator.rs b/tests/simulator.rs similarity index 98% rename from tests/macho/simulator.rs rename to tests/simulator.rs index 0cea1a4dc..5a69ff355 100644 --- a/tests/macho/simulator.rs +++ b/tests/simulator.rs @@ -171,7 +171,7 @@ impl Slot { thread::sleep(Duration::from_secs(1)); waited += 1; if waited % 60 == 0 { - eprintln!("mold-macho-tests: waiting for the simulator slot ({waited}s)"); + eprintln!("mold-tests: waiting for the simulator slot ({waited}s)"); } } } @@ -227,7 +227,7 @@ impl Device { impl Drop for Device { fn drop(&mut self) { if let Err(err) = simctl(&["shutdown", &self.udid]) { - eprintln!("mold-macho-tests: {err}"); + eprintln!("mold-tests: {err}"); } } }