1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
//! `smix runner list` — what is running here, next to what is written down.
//!
//! The question this exists to answer was asked on 2026-08-11 and could
//! not be: a runner held port 22087, the rule said find its owner before
//! touching it, and the ledger the rule pointed at had no record of it.
//! It was on the books the whole time, in another workspace's books.
//!
//! Neither side alone answers it. The machine's ledgers say what somebody
//! *said* they opened; `lsof` says what is *actually* listening. This
//! prints both, and when only one has something the row says which.
//!
//! It never acts. No process is signalled, no ledger is written, and it
//! always exits 0 — a command whose job is to be run before touching
//! anything must be safe to run, and a non-zero code would make the
//! scripts that most need it afraid to call it.
use smix_capsule::runner_view::{self, Seen};
use smix_lease::store::{self, CheckoutLedgers, LeaseDir};
/// Read both sides and print them.
///
/// # Errors
///
/// Only a ledger that will not parse. A machine with no ledgers and
/// nothing listening is a normal answer, not a failure.
pub fn run(leases: &LeaseDir, prune: bool) -> Result<u8, crate::CliError> {
let machine = ledgers_in(leases);
// The tree underfoot, read for one purpose: to say which checkout
// has a record of a runner this machine's ledgers do not. It names
// nothing else and decides nothing — the rule since the ledgers
// moved is that a checkout's book may stop a decision, and here
// there is no decision to stop.
let checkout_dir = std::env::current_dir()
.ok()
.and_then(|cwd| CheckoutLedgers::discover(&cwd));
let checkout: Vec<(String, smix_lease::Lease)> = match &checkout_dir {
Some(c) => c
.device_ids()
.into_iter()
.filter_map(|id| c.read(&id).ok().flatten().map(|l| (id, l)))
.collect(),
None => Vec::new(),
};
let listeners = runner_view::listeners();
// The Android half of "is this row still alive". An iOS runner is
// found by its listener; an Android one has none, and this command
// used to answer `not-probed` about every Android row — so a reader
// was sent to `ps` with a bare pid, which a consumer did four times
// in one session and found four dead processes.
//
// `store::probe` is the right instrument rather than `kill -0`: it
// compares the recorded start time too, so a pid that now belongs to
// something else reads as gone instead of as the runner. The I/O
// lives here because `attribute` reads nothing.
let live_pids: std::collections::HashSet<u32> = machine
.iter()
.flat_map(|(_, lease)| lease.known_resources())
.filter_map(|r| match r {
smix_lease::Resource::AndroidRunner { proc, .. } => Some(proc),
_ => None,
})
.filter(|proc| {
let p = smix_lease::store::probe(proc);
p.pid_exists && p.identity_matches
})
.map(|proc| proc.pid)
.collect();
let rows = runner_view::attribute(&machine, &listeners, &checkout, &live_pids);
let mut pruned = 0usize;
let mut failed_prunes = 0usize;
if rows.is_empty() {
println!(
"no runner ledgers under {leases}, and nothing on this machine is \
listening as a smix runner"
);
return Ok(0);
}
for row in &rows {
match &row.seen {
Seen::Both {
app_pid,
ledger_session_pid,
live_session_pid,
} => {
println!(
":{:<6} {:<40} both app pid {app_pid}, ledger session pid \
{ledger_session_pid}",
row.port, row.device_id
);
// Two pids for one runner is the healthy state — the
// ledger records the `xcodebuild` on this host, the
// socket belongs to the app inside the simulator. Only
// say something when the live session is not the one
// written down.
match live_session_pid {
Some(live) if live != ledger_session_pid => println!(
"{:>8} the session driving it now is pid {live}, not the one \
recorded",
""
),
None => println!(
"{:>8} no xcodebuild session here answers for it — it keeps \
serving after the session that started it exits",
""
),
_ => {}
}
}
Seen::LedgerOnly { ledger_pid } => {
println!(
":{:<6} {:<40} ledger-only recorded at pid {ledger_pid}; nothing is \
listening there",
row.port, row.device_id
);
}
Seen::ProcessGone { ledger_pid } => {
println!(
":{:<6} {:<40} process-gone recorded at pid {ledger_pid}; that \
process is no longer running",
row.port, row.device_id
);
if prune {
// Only the runner row. A device may also carry a boot
// row or a claim, and those answer a different
// question — who may switch it off — which this
// command has said nothing about.
match smix_lease::store::drop_resource_kind(
leases,
&row.device_id,
&smix_lease::Resource::AndroidRunner {
port: row.port,
serial: row.device_id.clone(),
proc: smix_lease::store::identify_self(),
},
) {
Ok(()) => {
pruned += 1;
println!("{:>8} pruned.", "");
}
Err(e) => {
println!("{:>8} could not prune it: {e}", "");
failed_prunes += 1;
}
}
} else {
println!(
"{:>8} nothing to stop. `smix runner list --prune` clears \
rows like this one.",
""
);
}
}
Seen::ProcessOnly {
app_pid,
session_pid,
named_by,
} => {
println!(
":{:<6} {:<40} process-only app pid {app_pid}{}",
row.port,
row.device_id,
match session_pid {
Some(p) => format!(", session pid {p}"),
None => String::new(),
}
);
println!("{:>8} this machine's ledgers have no record of it.", "");
if let (Some(c), Some(_)) = (&checkout_dir, named_by) {
println!(
"{:>8} {c} names this device — that book is evidence about \
where to look, not authority.",
""
);
}
}
Seen::NotProbed { ledger_pid, why } => {
println!(
":{:<6} {:<40} not-probed recorded at pid {ledger_pid} — {why}",
row.port, row.device_id
);
}
}
}
if prune {
println!();
if failed_prunes > 0 {
// Kept visible rather than folded into the count: a row that
// would not go is the one worth looking at.
println!(
"pruned {pruned} row(s); {failed_prunes} would not go — \
the ledger still holds them and the next run will say so again"
);
return Ok(1);
}
println!(
"pruned {pruned} row(s) whose recorded process had ended. \
Boot rows and claims are untouched: they answer who may switch a \
device off, which this command has not looked at."
);
}
Ok(0)
}
/// Every device ledger under `leases`, paired with its device id.
fn ledgers_in(leases: &LeaseDir) -> Vec<(String, smix_lease::Lease)> {
let Ok(entries) = std::fs::read_dir(leases.path()) else {
return Vec::new();
};
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();
ids.into_iter()
.filter_map(|id| store::read(leases, &id).ok().flatten().map(|l| (id, l)))
.collect()
}