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
//! `smix record start | stop | status` — screen recording as something the
//! ledger knows about.
//!
//! Recording used to live entirely in one process's memory: a
//! `tokio::process::Child` inside whoever called `start_recording`. That
//! made two questions unanswerable. "Is this device recording right now?"
//! had no one to ask. And when the process holding the handle was killed,
//! the `simctl io recordVideo` child kept writing into an mp4 that would
//! never get its trailer — because the trailer is written on SIGINT, and
//! nothing left alive knew there was a process to send one to.
//!
//! A ledger row answers both.
use crate::RecordAction;
use smix_lease::{Resource, store};
use smix_sdk::device_control::DeviceControl;
/// What `status` found, said in a sentence.
///
/// Pure so the wording can be tested. "Is it recording" is not the
/// question people actually have — they want to know where the file is
/// going, and a yes/no answer sends them to `ls` to guess.
pub fn describe_status(device_id: &str, lease: Option<&smix_lease::Lease>) -> String {
let recording = lease.and_then(|l| {
l.known_resources().find_map(|r| match r {
Resource::Recording { path, proc } => Some((path, proc)),
_ => None,
})
});
match recording {
None => format!("{device_id}: not recording"),
Some((path, proc)) => {
let live = store::probe(proc);
if live.pid_exists && live.identity_matches {
format!("{device_id}: recording to {path} (pid {})", proc.pid)
} else {
// The row outliving its process is exactly the state
// reconcile exists for, and saying "recording" here would
// send someone looking for a file that stopped growing.
format!(
"{device_id}: a recording to {path} was left behind by pid {} — \
run `smix lease reconcile {device_id}` to close it",
proc.pid
)
}
}
}
}
/// Run the subcommand.
pub async fn run(action: RecordAction) -> Result<(), crate::CliError> {
let leases = smix_capsule::runner::machine_leases().map_err(crate::CliError::Other)?;
match action {
RecordAction::Start { device, output } => {
let udid = crate::resolve_device(&device)?;
let control = smix_sdk::ios_device::IosDeviceControl::new();
let leased = smix_sdk::leased::Leased::acquire(
&control,
&leases,
&udid,
&smix_capsule::reconcile::Reconciler,
)
.map_err(|e| crate::CliError::Other(e.to_string()))?;
for report in leased.settled() {
println!("settled first: {}", report.line);
}
leased.start_recording(&output).await?;
// Hand the holder role to the recording itself.
//
// This command is about to exit, and a ledger whose holder is
// a dead process with a live recording reads as an orphan —
// so the next smix command would stop a recording somebody
// deliberately started. Naming the recording process as the
// holder makes the ledger say the true thing: this device is
// busy for exactly as long as that process runs.
if let Some(pid) = control.recording_pid().await
&& let Some(proc) = store::identify(pid)
&& let Err(e) = store::set_holder(&leases, &udid, proc)
{
eprintln!(
"warning: recording started but the ledger still names this command as holder: {e}"
);
}
// `release` is deliberately not called. The recording outlives
// this command, and releasing drops the process-backed rows —
// which would delete the row that is the only cross-process
// handle on the recording just started. Letting the guard fall
// out of scope is the whole of "keep it": there is no `Drop`
// on it, by design, precisely so that this reads as a choice
// rather than as something forgotten.
println!("recording {} to {}", udid, output.display());
}
RecordAction::Stop { device } => {
let udid = crate::resolve_device(&device)?;
// `stop` cannot go through `IosDeviceControl::stop_recording`:
// that reads the in-memory handle, and this is a different
// process from the one that started it. The ledger row is the
// only handle across a process boundary, so this closes it the
// same way an abandoned one is closed.
let Some(lease) = store::read(&leases, &udid).map_err(to_cli_error)? else {
println!("{udid}: not recording");
return Ok(());
};
let row = lease.known_resources().find_map(|r| match r {
Resource::Recording { path, proc } => Some((path.clone(), proc.clone())),
_ => None,
});
let Some((path, proc)) = row else {
println!("{udid}: not recording");
return Ok(());
};
let outcomes =
smix_capsule::reconcile::execute(&[smix_lease::CleanupAction::StopRecording {
path: path.clone(),
proc,
}]);
for o in &outcomes {
println!(" {}", o.line());
}
if outcomes
.iter()
.all(smix_capsule::reconcile::Outcome::is_clean)
{
store::drop_resource_kind(
&leases,
&udid,
&Resource::Recording {
path: String::new(),
proc: store::identify_self(),
},
)
.map_err(to_cli_error)?;
println!("stopped: {path}");
}
}
RecordAction::Status { device } => {
let udid = crate::resolve_device(&device)?;
let lease = store::read(&leases, &udid).map_err(to_cli_error)?;
println!("{}", describe_status(&udid, lease.as_ref()));
}
}
Ok(())
}
fn to_cli_error(e: store::LeaseError) -> crate::CliError {
crate::CliError::Other(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use smix_lease::{Lease, ProcIdentity};
fn lease_with(resources: Vec<Resource>) -> Lease {
Lease {
device_id: "UDID-1".into(),
holder: store::identify_self(),
acquired_at: "2026-08-06T10:00:00Z".into(),
heartbeat_at: "2026-08-06T10:00:00Z".into(),
resources: resources.into_iter().map(smix_lease::Row::Known).collect(),
}
}
#[test]
fn no_ledger_means_not_recording() {
assert_eq!(describe_status("UDID-1", None), "UDID-1: not recording");
}
#[test]
fn a_ledger_without_a_recording_row_means_not_recording() {
let lease = lease_with(vec![Resource::Booted { by_us: true }]);
assert_eq!(
describe_status("UDID-1", Some(&lease)),
"UDID-1: not recording"
);
}
#[test]
fn a_live_recording_says_where_it_is_writing() {
// "yes" alone sends people to `ls` to guess which file it is.
let lease = lease_with(vec![Resource::Recording {
path: "/tmp/run.mov".into(),
proc: store::identify_self(),
}]);
let msg = describe_status("UDID-1", Some(&lease));
assert!(msg.contains("/tmp/run.mov"), "got: {msg}");
assert!(msg.contains("recording to"), "got: {msg}");
}
#[test]
fn a_row_whose_process_is_gone_is_not_reported_as_recording() {
// The file stopped growing when that process died. Calling it
// "recording" would have someone wait for footage that is not
// coming.
let lease = lease_with(vec![Resource::Recording {
path: "/tmp/run.mov".into(),
proc: ProcIdentity {
pid: 0,
started_at: "Thu Aug 6 10:00:00 2026".into(),
cmd: "simctl io recordVideo".into(),
},
}]);
let msg = describe_status("UDID-1", Some(&lease));
assert!(msg.contains("left behind"), "got: {msg}");
assert!(msg.contains("lease reconcile"), "the way out must be named");
}
}