use smix_simctl::SimctlClient;
use smix_simctl::registry::SimRegistry;
use std::path::Path;
fn pkill(sig: &str, pattern: &str, label: &str) -> bool {
let hit = std::process::Command::new("pkill")
.args([sig, "-f", pattern])
.status()
.map(|s| s.success())
.unwrap_or(false);
if hit {
println!(" {sig} {label}");
}
hit
}
#[derive(Debug, PartialEq)]
pub enum Teardown {
Proceed,
NotOurs,
OursButHeld { pid: u32 },
}
pub fn teardown_verdict(
ledger: Option<&smix_lease::Lease>,
admission: Option<&smix_lease::Admission>,
) -> Teardown {
if !smix_lease::may_shut_down(ledger) {
return Teardown::NotOurs;
}
if let Some(smix_lease::Admission::Denied(c)) = admission
&& c.holder_alive
{
return Teardown::OursButHeld { pid: c.holder.pid };
}
Teardown::Proceed
}
#[derive(Debug, PartialEq)]
pub enum Residue {
Ours(String),
Held {
line: String,
device: String,
pid: u32,
},
Elsewhere { line: String, device: String },
}
pub fn classify_residue(
lines: &[String],
held: &std::collections::HashMap<String, u32>,
known: &std::collections::HashSet<String>,
) -> Vec<Residue> {
lines
.iter()
.map(|line| {
let named = device_named_by(line).and_then(|d| {
held.iter()
.find(|(id, _)| id.eq_ignore_ascii_case(d))
.map(|(id, pid)| (id.clone(), *pid))
});
if let Some((device, pid)) = named {
return Residue::Held {
line: line.clone(),
device,
pid,
};
}
match device_named_by(line) {
Some(d) if !known.iter().any(|k| k.eq_ignore_ascii_case(d)) => Residue::Elsewhere {
line: line.clone(),
device: d.to_string(),
},
_ => Residue::Ours(line.clone()),
}
})
.collect()
}
fn device_named_by(line: &str) -> Option<&str> {
let rest = if let Some(at) = line.find("id=") {
&line[at + 3..]
} else {
let at = line.find("simctl io ")?;
&line[at + "simctl io ".len()..]
};
leading_device_id(rest)
}
fn leading_device_id(rest: &str) -> Option<&str> {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.unwrap_or(rest.len());
(end > 0).then(|| &rest[..end])
}
fn held_by_others(leases: &smix_lease::store::LeaseDir) -> std::collections::HashMap<String, u32> {
let mine = std::process::id();
let Ok(entries) = std::fs::read_dir(leases.path()) else {
return std::collections::HashMap::new();
};
entries
.flatten()
.filter_map(|e| {
let id = e
.file_name()
.to_string_lossy()
.strip_suffix(".json")?
.to_string();
let held = smix_lease::store::collect_facts(leases, &id)
.ok()?
.existing?;
let alive = held.holder.pid_exists && held.holder.identity_matches;
(alive && held.lease.holder.pid != mine).then_some((id, held.lease.holder.pid))
})
.collect()
}
fn settle_ledgers(leases: &smix_lease::store::LeaseDir) {
let Ok(entries) = std::fs::read_dir(leases.path()) else {
println!(" no device ledgers");
return;
};
let mut ids: Vec<String> = entries
.flatten()
.filter_map(|e| {
e.file_name()
.to_string_lossy()
.strip_suffix(".json")
.map(str::to_string)
})
.collect();
ids.sort();
if ids.is_empty() {
println!(" no device ledgers");
return;
}
for id in ids {
let facts = match smix_lease::store::collect_facts(leases, &id) {
Ok(f) => f,
Err(e) => {
eprintln!(" {id}: ledger unreadable: {e}");
continue;
}
};
let Some(held) = facts.existing else {
println!(" {id}: nothing owed");
continue;
};
let mine = std::process::id();
let holder_alive = held.holder.pid_exists && held.holder.identity_matches;
if holder_alive && held.lease.holder.pid != mine {
println!(
" {id}: held by pid {} ({}) — still alive, left alone",
held.lease.holder.pid, held.lease.holder.cmd
);
continue;
}
let cleanup = smix_lease::plan_cleanup(&held.lease);
if cleanup.is_empty() {
println!(" {id}: nothing owed");
}
let mut all_clean = true;
for outcome in smix_capsule::reconcile::execute(&cleanup) {
println!(" {id}: {}", outcome.line());
all_clean &= outcome.is_clean();
}
if all_clean {
if let Err(e) = smix_lease::store::remove(leases, &id) {
eprintln!(" {id}: ledger not updated: {e}");
}
} else {
println!(" {id}: some closes failed — ledger kept so they stay visible");
}
}
}
pub async fn run(root: &Path, runner_port: u16) -> Result<(), String> {
let leases = smix_capsule::runner::machine_leases()?;
println!("=== 1. device ledgers (close what we opened) ===");
settle_ledgers(&leases);
println!("=== 2. XCUITest runner ===");
smix_capsule::runner::down(runner_port)?;
println!("=== 3. web demo stack ===");
pkill("-TERM", "smix/web/node_modules/.bin/vite", "vite");
pkill("-TERM", "smix-demo-target/debug/smix-server", "smix-server");
let smix_dir = root.join(".smix");
let reg = {
let merged = SimRegistry::open_all(root);
(!merged.registry.sims().is_empty()).then_some(merged.registry)
};
println!("=== 4. orphan recorders / motion loops (registered UDIDs only) ===");
if let Some(reg) = ® {
for sim in reg.sims().values() {
pkill(
"-INT",
&format!("simctl io.*{}.*recordVideo", sim.udid),
&format!("recordVideo ({})", sim.device_name),
);
pkill(
"-TERM",
&format!("simctl launch.*{}.*com.apple.Preferences", sim.udid),
&format!("motion loop ({})", sim.device_name),
);
}
}
println!("=== 5. shutdown registered sims (per-UDID) ===");
if let Some(reg) = ® {
let simctl = SimctlClient::new();
let devices = simctl.list_devices().await.map_err(|e| e.to_string())?;
let adb = smix_adb::AdbClient::new();
let running_emulators: std::collections::HashSet<String> = adb
.devices()
.await
.map(|list| {
list.into_iter()
.filter(|d| d.state == "device")
.map(|d| d.serial)
.collect()
})
.unwrap_or_default();
for (alias, sim) in reg.sims() {
let is_emulator = sim.kind == smix_simctl::registry::DeviceKind::Emulator;
let booted = if is_emulator {
running_emulators.contains(&sim.udid)
} else {
devices
.iter()
.any(|d| d.udid.eq_ignore_ascii_case(&sim.udid) && d.state == "Booted")
};
if !booted {
continue;
}
let ledger = smix_lease::store::read(&leases, &sim.udid).ok().flatten();
let admission = smix_lease::store::collect_facts(&leases, &sim.udid)
.ok()
.map(|f| smix_lease::assess(&f));
match teardown_verdict(ledger.as_ref(), admission.as_ref()) {
Teardown::NotOurs => {
println!(" {alias} ({}) is up but not ours — left alone", sim.udid);
continue;
}
Teardown::OursButHeld { pid } => {
println!(
" {alias} ({}) is ours by boot row but pid {pid} is alive on it — left alone",
sim.udid
);
continue;
}
Teardown::Proceed => {}
}
{
if is_emulator {
adb.stop_emulator(&sim.udid)
.await
.map_err(|e| format!("shutdown {alias} ({}): {e}", sim.udid))?;
} else {
simctl
.shutdown(&sim.udid)
.await
.map_err(|e| format!("shutdown {alias} ({}): {e}", sim.udid))?;
}
if let Err(e) = smix_lease::store::drop_resource_kind(
&leases,
&sim.udid,
&smix_lease::Resource::Booted { by_us: true },
) {
eprintln!(" {}: boot row not cleared: {e}", sim.udid);
}
println!(" shutdown {alias} ({})", sim.udid);
}
}
} else {
println!(
" no registry at {} — skipping sim shutdown",
smix_dir.display()
);
}
println!("=== 6. residue report ===");
let mut patterns = vec!["xcodebuild.*Smix|smix-server|smix/web.*vite".to_string()];
if let Some(reg) = ® {
for sim in reg.sims().values() {
patterns.push(format!("simctl io.*{}.*recordVideo", sim.udid));
}
}
let mut lines: Vec<String> = Vec::new();
for p in &patterns {
let out = std::process::Command::new("pgrep")
.args(["-fl", p])
.output()
.map_err(|e| format!("pgrep: {e}"))?;
lines.extend(
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string),
);
}
let mut residue = String::new();
let mut known: std::collections::HashSet<String> = std::fs::read_dir(leases.path())
.map(|it| {
it.flatten()
.filter_map(|e| {
e.file_name()
.to_string_lossy()
.strip_suffix(".json")
.map(str::to_string)
})
.collect()
})
.unwrap_or_default();
if let Some(reg) = ® {
known.extend(reg.sims().values().map(|s| s.udid.clone()));
}
for r in classify_residue(&lines, &held_by_others(&leases), &known) {
match r {
Residue::Ours(line) => {
residue.push_str(&line);
residue.push('\n');
}
Residue::Held { line, device, pid } => {
println!(" not this sweep's: {device} is held by live pid {pid} — {line}");
}
Residue::Elsewhere { line, device } => {
println!(" not this sweep's: {device} is in none of its books — {line}");
}
}
}
if !residue.is_empty() {
return Err(format!("STILL RUNNING (inspect manually):\n{residue}"));
}
println!("clean — no smix residual processes.");
Ok(())
}
#[cfg(test)]
mod teardown_verdict_tests {
use super::*;
use smix_lease::{Admission, Contention, Lease, ProcIdentity, Resource, Row};
fn proc(pid: u32) -> ProcIdentity {
ProcIdentity {
pid,
started_at: String::new(),
cmd: String::new(),
}
}
fn ours() -> Lease {
Lease {
device_id: "emulator-5560".into(),
holder: proc(1),
acquired_at: String::new(),
heartbeat_at: String::new(),
resources: vec![Row::Known(Resource::Booted { by_us: true })],
}
}
fn held_by(pid: u32) -> Admission {
Admission::Denied(Contention {
holder: proc(pid),
acquired_at: String::new(),
holder_alive: true,
})
}
#[test]
fn a_device_without_our_boot_row_is_not_ours() {
assert_eq!(teardown_verdict(None, None), Teardown::NotOurs);
}
#[test]
fn our_boot_row_does_not_outrank_a_live_session() {
let l = ours();
assert_eq!(
teardown_verdict(Some(&l), Some(&held_by(42098))),
Teardown::OursButHeld { pid: 42098 }
);
}
#[test]
fn our_boot_row_and_nobody_on_it_proceeds() {
let l = ours();
assert_eq!(
teardown_verdict(Some(&l), Some(&Admission::Granted)),
Teardown::Proceed
);
}
}
#[cfg(test)]
mod residue_tests {
use super::*;
use std::collections::HashMap;
const THEIRS: &str = "FFC57DAE-4B26-4B0C-9FAD-4F5735C0C2B1";
const OURS: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
fn known() -> std::collections::HashSet<String> {
[THEIRS.to_string(), OURS.to_string()].into()
}
#[test]
fn a_runner_on_a_device_outside_this_sweeps_books_is_elsewhere() {
let out = classify_residue(
&[runner(THEIRS)],
&HashMap::new(),
&[OURS.to_string()].into(),
);
assert_eq!(
out,
vec![Residue::Elsewhere {
line: runner(THEIRS),
device: THEIRS.into()
}]
);
}
fn runner(udid: &str) -> String {
format!(
"47915 /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test \
-project /x/SmixRunner.xcodeproj -scheme SmixRunner \
-destination platform=iOS Simulator,id={udid} -derivedDataPath .smix"
)
}
#[test]
fn a_runner_on_a_device_someone_else_holds_is_theirs() {
let held = HashMap::from([(THEIRS.to_string(), 812)]);
let out = classify_residue(&[runner(THEIRS)], &held, &known());
assert_eq!(
out,
vec![Residue::Held {
line: runner(THEIRS),
device: THEIRS.into(),
pid: 812
}]
);
}
#[test]
fn a_runner_on_a_device_nobody_holds_is_ours() {
let held = HashMap::from([(THEIRS.to_string(), 812)]);
let out = classify_residue(&[runner(OURS)], &held, &known());
assert_eq!(out, vec![Residue::Ours(runner(OURS))]);
}
#[test]
fn a_line_that_names_no_device_is_ours() {
let line = "999 /x/smix-demo-target/debug/smix-server".to_string();
let held = HashMap::from([(THEIRS.to_string(), 812)]);
assert_eq!(
classify_residue(std::slice::from_ref(&line), &held, &known()),
vec![Residue::Ours(line)]
);
}
#[test]
fn a_recorder_names_its_device_as_the_simctl_io_operand() {
let line = format!("321 xcrun simctl io {THEIRS} recordVideo /tmp/a.mov");
let held = HashMap::from([(THEIRS.to_string(), 812)]);
assert_eq!(
classify_residue(std::slice::from_ref(&line), &held, &known()),
vec![Residue::Held {
line,
device: THEIRS.into(),
pid: 812
}]
);
}
}