use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunnerState {
pub pid: u32,
pub udid: String,
pub port: u16,
pub log: PathBuf,
#[serde(default)]
pub bundle: Option<String>,
}
pub fn runner_env(bundle: Option<&str>, record_enabled: bool, port: u16) -> Vec<(String, String)> {
let mut env = Vec::new();
if let Some(b) = bundle {
env.push((
"TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE".to_string(),
b.to_string(),
));
}
if record_enabled {
env.push((
"TEST_RUNNER_SMIX_RECORD_ENABLED".to_string(),
"1".to_string(),
));
}
env.push(("TEST_RUNNER_SMIX_RUNNER_PORT".to_string(), port.to_string()));
env
}
pub fn workspace_root(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(".smix").is_dir() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
pub fn xcodebuild_argv(project: &Path, udid: &str) -> Vec<String> {
let derived = format!(".smix/runner/derived-data-{udid}");
vec![
"test".into(),
"-project".into(),
project.display().to_string(),
"-scheme".into(),
"SmixRunner".into(),
"-destination".into(),
format!("platform=iOS Simulator,id={udid}"),
"-derivedDataPath".into(),
derived,
]
}
pub fn health_ok(port: u16) -> bool {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let Ok(mut s) =
TcpStream::connect_timeout(&([127, 0, 0, 1], port).into(), Duration::from_secs(1))
else {
return false;
};
let _ = s.set_read_timeout(Some(Duration::from_secs(2)));
if s.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.is_err()
{
return false;
}
let mut buf = [0u8; 64];
let Ok(n) = s.read(&mut buf) else {
return false;
};
String::from_utf8_lossy(&buf[..n]).contains(" 200")
}
fn state_path(root: &Path) -> PathBuf {
root.join(".smix/runner/state.json")
}
fn read_state(root: &Path) -> Option<RunnerState> {
let text = std::fs::read_to_string(state_path(root)).ok()?;
serde_json::from_str(&text).ok()
}
fn pid_command(pid: u32) -> Option<String> {
let out = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let cmd = String::from_utf8_lossy(&out.stdout).trim().to_string();
if cmd.is_empty() { None } else { Some(cmd) }
}
fn signal(pid: u32, sig: &str) {
let _ = std::process::Command::new("kill")
.args([sig, &pid.to_string()])
.status();
}
fn tail_log(log: &Path, lines: usize) -> String {
let Ok(text) = std::fs::read_to_string(log) else {
return String::new();
};
let all: Vec<&str> = text.lines().collect();
let start = all.len().saturating_sub(lines);
all[start..].join("\n")
}
pub fn resolve_runner_project(
root: &Path,
override_path: Option<&Path>,
) -> Result<PathBuf, String> {
let explicit_flag = override_path.map(Path::to_path_buf);
let explicit_env = std::env::var_os("SMIX_RUNNER_PROJECT").map(PathBuf::from);
if let Some(p) = explicit_flag.or(explicit_env) {
if p.exists() {
return Ok(p);
}
return Err(format!(
"runner project missing: {}\n\
explicit override (--runner-project or $SMIX_RUNNER_PROJECT) \
does not point to an existing SmixRunner.xcodeproj — \
fix the path, or unset the override to fall back to \
install-shipped / repo-local defaults.",
p.display()
));
}
let candidates: Vec<PathBuf> = std::iter::empty()
.chain(installed_runner_project())
.chain(std::iter::once(
root.join("swift-bridge/SmixRunner.xcodeproj"),
))
.collect();
for cand in &candidates {
if cand.exists() {
return Ok(cand.clone());
}
}
let last = candidates
.last()
.cloned()
.unwrap_or_else(|| PathBuf::from("<no candidates — this should not happen>"));
let attempted = candidates
.iter()
.map(|p| format!(" - {}", p.display()))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"runner project missing: {}\n\
tried:\n{attempted}\n\
fix: (a) install via `bash scripts/install-local.sh` to populate ~/.local/share/smix/runner/, \
or (b) pass `--runner-project <path>` on `smix runner up`, \
or (c) set $SMIX_RUNNER_PROJECT",
last.display()
))
}
fn installed_runner_project() -> Option<PathBuf> {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
Some(base.join("smix/runner/SmixRunner.xcodeproj"))
}
pub fn up(
root: &Path,
udid: &str,
port: u16,
bundle: Option<&str>,
record_enabled: bool,
runner_project: Option<&Path>,
) -> Result<(), String> {
if health_ok(port) {
match read_state(root) {
Some(st) if st.udid == udid && st.bundle.as_deref() == bundle => {
println!("runner already up: udid={udid} port={port} pid={}", st.pid);
return Ok(());
}
Some(st) => {
return Err(format!(
"port {port} already serves a runner recorded for udid={} \
bundle={:?} — run `smix runner down` first",
st.udid, st.bundle
));
}
None => {
return Err(format!(
"port {port} already serves /health but no \
.smix/runner/state.json records it — not killing blindly; \
investigate (pgrep -fl xcodebuild), then `smix runner down`"
));
}
}
}
let project = resolve_runner_project(root, runner_project)?;
let runner_dir = root.join(".smix/runner");
std::fs::create_dir_all(&runner_dir).map_err(|e| format!("mkdir .smix/runner: {e}"))?;
let log = runner_dir.join(format!("runner-{udid}.log"));
let log_file =
std::fs::File::create(&log).map_err(|e| format!("create {}: {e}", log.display()))?;
let log_err = log_file
.try_clone()
.map_err(|e| format!("clone log handle: {e}"))?;
let mut cmd = std::process::Command::new("xcodebuild");
cmd.args(xcodebuild_argv(&project, udid))
.envs(runner_env(bundle, record_enabled, port))
.stdin(std::process::Stdio::null())
.stdout(log_file)
.stderr(log_err);
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd.spawn().map_err(|e| format!("spawn xcodebuild: {e}"))?;
let pid = child.id();
let st = RunnerState {
pid,
udid: udid.to_string(),
port,
log: log.clone(),
bundle: bundle.map(str::to_string),
};
std::fs::write(
state_path(root),
serde_json::to_string_pretty(&st).expect("state serializes"),
)
.map_err(|e| format!("write state.json: {e}"))?;
let timeout_secs: u64 = std::env::var("SMIX_RUNNER_UP_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
println!(
"runner starting: udid={udid} port={port} pid={pid} (log: {}, timeout {timeout_secs}s)",
log.display()
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if let Ok(Some(status)) = child.try_wait() {
let _ = std::fs::remove_file(state_path(root));
return Err(format!(
"xcodebuild exited early ({status}) — log tail:\n{}",
tail_log(&log, 25)
));
}
if health_ok(port) {
println!("runner up: http://localhost:{port}/health = 200");
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
signal(pid, "-INT");
let _ = std::fs::remove_file(state_path(root));
Err(format!(
"runner did not become healthy within {timeout_secs}s — sent SIGINT; log tail:\n{}",
tail_log(&log, 25)
))
}
pub fn down(root: &Path, port: u16) -> Result<(), String> {
let mut acted = false;
if let Some(st) = read_state(root) {
match pid_command(st.pid) {
Some(cmd) if cmd.contains("xcodebuild") => {
println!("stopping runner: pid={} udid={}", st.pid, st.udid);
signal(st.pid, "-INT");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while pid_command(st.pid).is_some() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(500));
}
if pid_command(st.pid).is_some() {
eprintln!(
"warning: pid {} ignored SIGINT for 30s — escalating to \
SIGKILL (expect a macOS crash-report dialog from the \
runner app)",
st.pid
);
signal(st.pid, "-9");
}
acted = true;
}
Some(other) => {
eprintln!(
"stale handle: pid {} is now {:?} (not xcodebuild) — \
dropping state without killing",
st.pid, other
);
}
None => {
println!("runner pid {} already gone — dropping stale handle", st.pid);
}
}
let _ = std::fs::remove_file(state_path(root));
}
let swept = std::process::Command::new("pkill")
.args(["-INT", "-f", "xcodebuild.*SmixRunner"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if swept {
println!("swept unrecorded xcodebuild SmixRunner session(s)");
acted = true;
}
if acted {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while health_ok(port) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
if health_ok(port) {
return Err(format!(
"port {port} still answers /health after teardown — inspect \
`pgrep -fl xcodebuild`"
));
}
println!("runner down: port {port} closed");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const UDID: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
#[test]
fn xcodebuild_argv_targets_explicit_udid() {
let argv = xcodebuild_argv(Path::new("/repo/swift-bridge/SmixRunner.xcodeproj"), UDID);
assert_eq!(argv[0], "test");
assert!(argv.contains(&"SmixRunner".to_string()));
assert!(
argv.iter()
.any(|a| a == &format!("platform=iOS Simulator,id={UDID}"))
);
assert!(!argv.iter().any(|a| a.contains("name=")));
let derived_pos = argv
.iter()
.position(|a| a == "-derivedDataPath")
.expect("argv missing -derivedDataPath");
assert_eq!(
argv[derived_pos + 1],
format!(".smix/runner/derived-data-{UDID}")
);
}
#[test]
fn runner_state_round_trips() {
let st = RunnerState {
pid: 4242,
udid: UDID.into(),
port: 22087,
log: PathBuf::from("/tmp/runner.log"),
bundle: Some("dev.smix.SelftestFixture".into()),
};
let json = serde_json::to_string(&st).unwrap();
let back: RunnerState = serde_json::from_str(&json).unwrap();
assert_eq!(back, st);
}
#[test]
fn runner_env_uses_test_runner_prefix() {
let env = runner_env(Some("dev.smix.SelftestFixture"), false, 22087);
let map: std::collections::HashMap<String, String> = env.iter().cloned().collect();
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE")
.map(String::as_str),
Some("dev.smix.SelftestFixture")
);
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_PORT").map(String::as_str),
Some("22087")
);
let env_no_bundle = runner_env(None, false, 22090);
assert_eq!(env_no_bundle.len(), 1);
assert_eq!(env_no_bundle[0].0, "TEST_RUNNER_SMIX_RUNNER_PORT");
assert_eq!(env_no_bundle[0].1, "22090");
}
#[test]
fn runner_env_with_record_adds_enabled_var() {
let env = runner_env(Some("dev.smix.SelftestFixture"), true, 22087);
let map: std::collections::HashMap<String, String> = env.into_iter().collect();
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE")
.map(String::as_str),
Some("dev.smix.SelftestFixture")
);
assert_eq!(
map.get("TEST_RUNNER_SMIX_RECORD_ENABLED")
.map(String::as_str),
Some("1")
);
}
#[test]
fn workspace_root_walks_up_to_smix_dir() {
let root = std::env::temp_dir().join(format!("smix-runner-ws-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".smix")).unwrap();
let nested = root.join("crates/smix-cli");
fs::create_dir_all(&nested).unwrap();
assert_eq!(workspace_root(&nested).unwrap(), root);
let outside =
std::env::temp_dir().join(format!("smix-runner-no-ws-{}", std::process::id()));
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(&outside).unwrap();
assert!(workspace_root(&outside).is_none());
}
}