Merge the ELF and Mach-O test runners into one crate

tests/elf and tests/macho each had a crate of their own for running
their shell scripts, which did much the same thing. A single mold-tests
crate directly under tests/ now runs both: lib.rs discovers, schedules,
times out and reports the tests, and elf.rs and macho.rs only pick the
targets, the latter booting a simulator device for each simulator
target in turn.

The two runners' options and conventions are made one. Both take
--native, --all, --triple and --cpu, the Mach-O tests' --native now
running the targets the Mac runs directly, its own architecture and
x86_64 under Rosetta, as --host did. The work directory links mold
under every name the scripts use, a log line ending in "skipped" marks
a skipped test, and unavailable targets are reported the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Rui UeyamaandClaude Opus 5.5 committed 2026-10-09 22:10:53 +09:00
1 parent eb21f960fd
commit d66a911074
13 files changed
+573 -1015

No files matched your search

+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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
Generated
+9 -17
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -2
View File
@@ -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.
+3 -3
View File
@@ -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")))
}
+1 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "mold-macho-tests"
name = "mold-tests"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
+201
View File
@@ -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<Target>, Vec<String>) {
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<Target>, Vec<String>) {
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");
}
}
-14
View File
@@ -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
+107 -277
View File
@@ -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<String>,
cpu: Option<String>,
/// 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-<arch>-* run.
arch: Option<String>,
/// The environment variables the scripts get, each set or removed.
env: Vec<(&'static str, Option<String>)>,
}
#[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<String>) -> 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<Target>, 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<Target>, 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<Vec<(String, PathBuf)>> {
/// The scripts in `dir` that the patterns select, by name.
fn selected_scripts(dir: &Path, patterns: &[String]) -> io::Result<Vec<(String, PathBuf)>> {
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<PathBuf> {
let mold = mold.canonicalize()?;
let profile_dir = mold.parent().ok_or_else(|| {
@@ -400,44 +271,29 @@ fn prepare_work_dir(mold: &Path) -> io::Result<PathBuf> {
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<Target>,
patterns: &[String],
clean: bool,
) -> io::Result<Vec<TestJob>> {
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<Outcome,
let stderr =
log.try_clone().map_err(|err| format!("cannot clone {}: {err}", job.log.display()))?;
let mut command = Command::new(&job.script);
// A script that read the terminal would stop in its background process
// group until the timeout; give it end-of-file instead.
command
.current_dir(root)
.stdin(Stdio::null())
.env("MACHINE", &job.target.machine)
.stdout(Stdio::from(log))
.stderr(Stdio::from(stderr));
for (name, value) in [("TRIPLE", &job.target.triple), ("CPU", &job.target.cpu)] {
if let Some(value) = value {
command.env(name, value);
} else {
command.env_remove(name);
}
command.current_dir(root).stdout(Stdio::from(log)).stderr(Stdio::from(stderr));
for (name, value) in &job.target.env {
match value {
Some(value) => 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<Outcome,
}
None if start.elapsed() < timeout => 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<TestJob>, options: &Options) -> Vec<TestResul
})
}
fn print_inventory(jobs: &[TestJob], unavailable: &[&TargetSpec]) {
/// Lists the tests the options select, by target, instead of running
/// them, and the targets left out for want of a toolchain.
fn print_inventory(
scripts: &[(String, PathBuf)],
work_dir: &Path,
targets: Vec<Target>,
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::<Vec<_>>().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::<Vec<_>>().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));
}
}
+246
View File
@@ -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<Runtime>,
}
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<Config>, Vec<String>) {
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<Config>) -> Result<(Vec<Target>, Vec<Device>), 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"));
}
}
-696
View File
@@ -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<String>,
runtime: Option<Runtime>,
/// The UDID of the booted device that runs the programs.
device: Option<String>,
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<String>,
timeout: Duration,
list: bool,
}
struct TestJob {
target: Arc<Target>,
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<Target>,
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<String>) -> 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<Target>, 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<Vec<Device>, 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<Vec<(String, PathBuf)>> {
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<PathBuf> {
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<Vec<(String, PathBuf)>> {
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<Target>,
clean: bool,
) -> io::Result<Vec<TestJob>> {
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<Outcome, String> {
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<TestJob>, linker: &Path, options: &Options) -> Vec<TestResult> {
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()]));
}
}
@@ -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}");
}
}
}