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
}
fn settle_ledgers(root: &Path, 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(root, &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(root, &leases);
println!("=== 2. XCUITest runner ===");
smix_capsule::runner::down(root, 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 residue = String::new();
for p in &patterns {
let out = std::process::Command::new("pgrep")
.args(["-fl", p])
.output()
.map_err(|e| format!("pgrep: {e}"))?;
let hits = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !hits.is_empty() {
residue.push_str(&hits);
residue.push('\n');
}
}
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};
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![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
);
}
}