Files
mold/tests/simulator.rs
T
Rui UeyamaandClaude Opus 5.5 d66a911074 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]>
2026-10-09 22:10:53 +09:00

253 lines
9.2 KiB
Rust

//! Simulator devices for the configurations whose programs run on a
//! simulator.
//!
//! mold's ELF suite runs a cross target's programs under QEMU, which
//! needs no setup. A simulator's programs run as host processes, but only
//! on a booted device of the simulator's runtime, which `xcrun simctl
//! spawn` starts them on. The runner keeps a device of its own per
//! runtime, named mold-test- and the runtime, so that the user's devices
//! stay untouched. A run boots it once for all of a configuration's tests
//! and shuts it down after them, holding the machine's one simulator slot
//! all the while, which serializes the runs that use a simulator.
use serde_json::Value;
use std::ffi::CStr;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
/// The simulator platforms, by their names in target triples and in
/// simctl's runtime list.
const PLATFORMS: &[(&str, &str)] = &[("ios", "iOS"), ("tvos", "tvOS"), ("xros", "xrOS")];
/// A simulator configuration as a target triple names it:
/// `<arch>-apple-<os>[<version>]-simulator`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Simulator {
pub arch: String,
pub os: String,
pub version: Option<String>,
}
impl Simulator {
pub fn parse(triple: &str) -> Option<Self> {
let [arch, "apple", os, "simulator"] = *triple.split('-').collect::<Vec<_>>() else {
return None;
};
if !matches!(arch, "arm64" | "x86_64") {
return None;
}
let digits = os.find(|c: char| c.is_ascii_digit()).unwrap_or(os.len());
let (os, version) = os.split_at(digits);
PLATFORMS.iter().any(|&(name, _)| name == os).then(|| Self {
arch: arch.to_owned(),
os: os.to_owned(),
version: (!version.is_empty()).then(|| version.to_owned()),
})
}
/// The name of the configuration's results; common.inc names its
/// test directories alike.
pub fn label(&self) -> String {
format!("{}-{}-simulator", self.arch, self.os)
}
fn platform(&self) -> &'static str {
PLATFORMS.iter().find(|&&(name, _)| name == self.os).unwrap().1
}
}
/// An installed simulator runtime.
pub struct Runtime {
identifier: String,
/// The OS version, which the configuration also builds for.
pub version: String,
device_type: String,
}
fn simctl(args: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("xcrun")
.arg("simctl")
.args(args)
.stdin(Stdio::null())
.output()
.map_err(|err| format!("cannot run xcrun simctl: {err}"))?;
if !output.status.success() {
return Err(format!(
"xcrun simctl {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output.stdout)
}
fn simctl_list(what: &str) -> Result<Value, String> {
let stdout = simctl(&["list", "--json", what])?;
serde_json::from_slice(&stdout)
.map_err(|err| format!("cannot parse xcrun simctl list {what}: {err}"))
}
fn version_key(version: &str) -> Vec<u32> {
version.split('.').map(|part| part.parse().unwrap_or(0)).collect()
}
/// Finds the runtime of the triple's version or, without one, the newest
/// runtime that runs programs of its architecture.
pub fn find_runtime(simulator: &Simulator) -> Option<Runtime> {
let list = simctl_list("runtimes").ok()?;
let supports_arch = |runtime: &Value| {
runtime["supportedArchitectures"]
.as_array()
.is_some_and(|archs| archs.iter().any(|arch| *arch == *simulator.arch))
};
list["runtimes"]
.as_array()?
.iter()
.filter(|runtime| {
runtime["platform"] == simulator.platform()
&& runtime["isAvailable"] == true
&& supports_arch(runtime)
&& simulator.version.as_ref().is_none_or(|version| runtime["version"] == **version)
})
.filter_map(|runtime| {
Some(Runtime {
identifier: runtime["identifier"].as_str()?.to_owned(),
version: runtime["version"].as_str()?.to_owned(),
device_type: runtime["supportedDeviceTypes"][0]["identifier"].as_str()?.to_owned(),
})
})
.max_by_key(|runtime| version_key(&runtime.version))
}
/// The per-user temporary directory, which is as private to the user as
/// the devices are, whatever TMPDIR says.
fn user_temp_dir() -> io::Result<PathBuf> {
let mut buf = [0u8; 1024];
// SAFETY: confstr writes at most `buf.len()` bytes, NUL-terminated.
let len = unsafe {
libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len())
};
if len == 0 || len > buf.len() {
return Err(io::Error::other("cannot find the user's temporary directory"));
}
let dir = CStr::from_bytes_until_nul(&buf).unwrap().to_str().unwrap();
Ok(PathBuf::from(dir))
}
/// The one simulator slot of the user's machine, which a run holds while
/// a device of its is booted: a booted simulator is a whole OS, and
/// several at once leave the Mac so busy that their own system apps miss
/// their launch watchdog's deadline. The slot is a directory holding its
/// owner's pid, which a shell script can take with mkdir as well; one
/// whose owner is gone is taken over.
struct Slot {
dir: PathBuf,
}
impl Slot {
fn take() -> io::Result<Self> {
let dir = user_temp_dir()?.join("mold-test-simulator-slot");
let mut waited = 0;
loop {
match fs::create_dir(&dir) {
Ok(()) => {
fs::write(dir.join("pid"), format!("{}\n", std::process::id()))?;
return Ok(Self { dir });
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
let owner = fs::read_to_string(dir.join("pid")).ok();
if let Some(pid) = owner.and_then(|pid| pid.trim().parse::<libc::pid_t>().ok())
&& !process_exists(pid)
{
fs::remove_dir_all(&dir)?;
continue;
}
thread::sleep(Duration::from_secs(1));
waited += 1;
if waited % 60 == 0 {
eprintln!("mold-tests: waiting for the simulator slot ({waited}s)");
}
}
}
}
impl Drop for Slot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn process_exists(pid: libc::pid_t) -> bool {
// SAFETY: signal 0 only checks that the process exists.
let sent = unsafe { libc::kill(pid, 0) } == 0;
sent || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
/// A booted device of a runtime, named mold-test- and the runtime, which
/// the runner creates the first time. It holds the simulator slot from
/// before it boots until it has shut down.
pub struct Device {
pub udid: String,
_slot: Slot,
}
impl Device {
pub fn boot(runtime: &Runtime) -> Result<Self, String> {
let slot = Slot::take().map_err(|err| format!("cannot take the simulator slot: {err}"))?;
let suffix = runtime.identifier.rsplit('.').next().unwrap();
let name = format!("mold-test-{suffix}");
let devices = simctl_list("devices")?;
let found = devices["devices"][&runtime.identifier].as_array().and_then(|devices| {
devices.iter().find(|device| device["name"] == *name && device["isAvailable"] == true)
});
let udid = match found {
Some(device) => device["udid"].as_str().unwrap_or_default().to_owned(),
None => {
let udid = simctl(&["create", &name, &runtime.device_type, &runtime.identifier])?;
String::from_utf8_lossy(&udid).trim().to_owned()
}
};
// simctl boot returns as the device starts booting, and a new
// device's first boot, which migrates its data and starts its
// system apps, keeps every core busy for a minute (the CI runners'
// fresh devices longer), stalling the first tests past their
// timeout. bootstatus -b boots a device that is shut down and
// returns once it has finished booting.
simctl(&["bootstatus", &udid, "-b"])?;
Ok(Self { udid, _slot: slot })
}
}
impl Drop for Device {
fn drop(&mut self) {
if let Err(err) = simctl(&["shutdown", &self.udid]) {
eprintln!("mold-tests: {err}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simulator_triples() {
let simulator = Simulator::parse("arm64-apple-ios26.5-simulator").unwrap();
assert_eq!(simulator.arch, "arm64");
assert_eq!(simulator.os, "ios");
assert_eq!(simulator.version.as_deref(), Some("26.5"));
assert_eq!(simulator.label(), "arm64-ios-simulator");
assert_eq!(Simulator::parse("x86_64-apple-tvos-simulator").unwrap().version, None);
assert!(Simulator::parse("arm64-apple-xros-simulator").is_some());
assert!(Simulator::parse("arm64-apple-ios17.0").is_none());
assert!(Simulator::parse("arm64-apple-macos-simulator").is_none());
assert!(Simulator::parse("riscv64-apple-ios-simulator").is_none());
}
}