Skip to main content

cloud/reconciler/
device.rs

1//! Device compute — a component running on an attached iOS device or
2//! simulator, or on an Android device or emulator (R941).
3//!
4//! The sibling of [`super::local_process`] for a process that runs somewhere
5//! other than the host. Like it, this is a **mirror compute slot, not a
6//! component kind** (R715): the component's `workload.toml` says *what* the app
7//! is — a bundle id, a package — and the mirror says *where* it runs:
8//!
9//! ```toml
10//! [providers.compute]
11//! kind = "device"
12//! target = "ios-simulator"   # ios-device | android-emulator | android-device
13//! device = "iPhone 17 Pro"   # optional: an id (udid / serial) or a name
14//! ```
15//!
16//! ```toml
17//! # workload.toml
18//! [device]
19//! pre_build = ["cargo", "mobile", "build"]   # optional, runs on the host
20//! env = { RUST_LOG = "info" }                # iOS: process env; Android: intent extras
21//! control = true                             # opt in to the W315 status channel
22//!
23//! [device.ios]
24//! bundle_id = "dev.yah.noisetable"
25//! app = "target/ios-sim/NoiseTable.app"      # optional; installed before launch
26//!
27//! [device.android]
28//! package = "dev.yah.noisetable"
29//! activity = "android.app.NativeActivity"
30//! apk = "target/android/noisetable.apk"      # optional; installed before launch
31//! ```
32//!
33//! Everything goes through the host tools that are already installed — `xcrun
34//! simctl`, `xcrun devicectl`, `adb`. No daemon is added, on either side.
35//!
36//! ## Inventory
37//!
38//! [`enumerate`] is the other half, and has its own consumer: noisetable's
39//! W203 étude controller (Gap 4) reads one [`DeviceHost`] record per host —
40//! platform, the discovery lanes it can claim, its bind addresses, and which
41//! control rail reaches it — instead of building its own inventory. The
42//! reconciler picks its target out of the same records, so "what can run here"
43//! has one answer.
44//!
45//! ## Status rail, per host
46//!
47//! The app side is W315's process-control channel (`kamaji-procctl`, default
48//! features). How the supervisor reaches it differs by host:
49//!
50//! - **Simulator** shares the host filesystem, so `$YAH_CONTROL_SOCK` is a
51//!   host path exactly as for `local-process` (delivered via `SIMCTL_CHILD_`).
52//! - **Android** apps cannot bind anywhere the host can see, so the app is
53//!   handed `@yah.<ident>` — an abstract-namespace name, as the intent string
54//!   extra `YAH_CONTROL_SOCK`, since an activity has no process env — and the
55//!   host reaches it through `adb forward tcp:0 localabstract:yah.<ident>` as
56//!   [`ControlEndpoint::Tcp`].
57//! - **Physical iOS** has no Unix-socket path to the host at all. That needs a
58//!   network rail, which W203 leaves open (can a pinned endpoint ride the
59//!   CoreDevice USB tunnel?). Until then readiness there is liveness only, and
60//!   the running workload says so in its notes rather than claiming health.
61//!
62//! @arch:see(oss/yubaba/crates/cloud/src/reconciler/local_process.rs)
63//!
64//! @yah:relay(R941, "Device compute provider: launch/enumerate/health for iOS devices+simulators and Android devices+emulators via devicectl/simctl/adb")
65//! @yah:status(review)
66//! @yah:at(2026-09-26T05:19:22Z)
67//! @yah:assignee(agent:bundle-anthropic-ashguard)
68//! @yah:next("Sibling of R715's local-process provider, for a process that runs on an attached device or simulator instead of on the host. Uses only the existing host tools: `xcrun devicectl`, `xcrun simctl`, `adb`. No new daemons.")
69//! @yah:next("Host enumeration + one capability record per host (platform, lanes it can claim, bind addresses, which control rail reaches it). noisetable W203 Gap 4 consumes this instead of building its own inventory.")
70//! @yah:next("W315 status rail per host: simulator shares the host FS, so $YAH_CONTROL_SOCK works as is; Android via `adb forward tcp:N localabstract:<name>`; physical iOS has no Unix-socket path (network rail, open question in W203). The app side uses kamaji-procctl (default features: serde + serde_json only).")
71//! @yah:next("Per R715's gotcha: the runtime is a property of the MIRROR, not the component. A device target is a mirror compute slot, not a new component kind.")
72//! @yah:next("Consumer doc: noisetable .yah/docs/working/W203-multi-instance-etudes.md (Gap 4, Phase 4).")
73//! @yah:handoff("LANDED: device compute provider oss/yubaba/crates/cloud/src/reconciler/device.rs (unix-gated like local_process). Mirror slot `[providers.compute] kind=\"device\" target=ios-simulator|ios-device|android-emulator|android-device device=<id|name>`; component `workload.toml [device]` + `[device.ios] bundle_id/app` / `[device.android] package/activity/apk`, optional pre_build/env/args/control. Launch via simctl launch --console / devicectl process launch --console / adb am start + logcat --pid; supervisor = launcher child (Android: pidof poll); teardown = simctl terminate / SIGTERM forwarded by devicectl / am force-stop + adb forward --remove.")
74//! @yah:handoff("INVENTORY: `cloud::reconciler::device::enumerate()` -> Inventory{hosts: Vec<DeviceHost>, probes} — one capability record per host (kind, platform, os_version, state+detail, W203 lanes, shares_host_network, bind_addrs, control_rail). Emulator claims no `lan` (W203). Consumer surface for noisetable W203 Gap 4: `yah cloud devices [--json]` (app/yah/cli/src/cloud.rs handle_devices).")
75//! @yah:handoff("STATUS RAIL: simulator = host-path $YAH_CONTROL_SOCK via SIMCTL_CHILD_; Android = app gets `@yah.<ident>` as intent string extra YAH_CONTROL_SOCK, host reaches it via `adb forward tcp:0 localabstract:` -> new `ControlEndpoint::Tcp` (proc_control.rs; newline-JSON shared with the UDS arm). Physical iOS = no rail, readiness liveness-only and the notes say so.")
76//! @yah:handoff("WIDER THAN THE TITLE: (1) oss/kamaji/crates/procctl serve.rs/lib.rs — producer now binds `@name` as a Linux/Android abstract-namespace socket (needed for the adb rail; no file created/unlinked). (2) Provider::Device in config.rs + sync_status.rs label; .yah/schema/{mirror,provider}.toml.schema.json regenerated. (3) dispatch arms in app/yah/cli/src/cloud.rs reconcile_component and app/yah/desktop/src/mirror_run.rs. (4) local_process::run_pre_build -> pub(super) for reuse. (5) wedged-emulator handling: `adb devices` lists a hung guest as `device`; a 5s getprop liveness probe now marks it offline (found live on emulator-5554).")
77//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib -- reconciler::device proc_control: 24 passed (13 device unit + TCP transport test).")
78//! @yah:verify("LIVE (2026-09-26): YAH_DEVICE_LIVE_ANDROID=emulator-5580 YAH_DEVICE_LIVE_SIM=\"iPhone 17 Pro\" ... reconciler::device --include-ignored: both pass — Android Settings launched with a quoted extra, supervised, force-stopped (pidof empty after); simulator booted from Shutdown, Safari launched, stopped.")
79//! @yah:verify("`yah cloud devices` live: 27 hosts, simctl/devicectl/adb probes all ok; iPad correctly offline (tunnel unavailable).")
80//! @yah:verify("cargo test -p kamaji-procctl green; cargo check -p kamaji-procctl --target aarch64-linux-android EXIT=0. cargo build -p yah and cargo check -p desktop EXIT=0.")
81//! @yah:gotcha("NOT LIVE-VERIFIED: the control rail end to end on a device (sim socket / adb-forward TCP to an abstract socket) — no producer app exists yet; that is noisetable W203 Phase 4's first step (kamaji-procctl in winit, then mobile). Pieces are verified separately: TCP arm unit test, procctl abstract bind compiles for android, its linux-only test cannot run on this mac.")
82//! @yah:gotcha("Live sim test leaves the simulator booted (by design: booting is expensive; teardown only terminates the app).")
83//! @yah:cleanup("select_host name match: two simulators can share a name across runtimes (this machine has two `iPhone 17 Pro`, iOS 26.4 + 26.5); the first by udid wins silently. Prefer Ready, then newest os_version, and note the rest.")
84//! @yah:cleanup("Physical-iOS network rail (W203 open question: pinned endpoint over the CoreDevice USB tunnel) — ControlRail::None until answered.")
85
86use std::collections::BTreeMap;
87use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
88use std::path::{Path, PathBuf};
89use std::process::Stdio;
90use std::time::Duration;
91
92use anyhow::{bail, Context, Result};
93use async_trait::async_trait;
94use serde::{Deserialize, Serialize};
95use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
96use tokio::process::{Child, Command};
97use tokio::sync::oneshot;
98use tracing::{info, warn};
99
100use super::local_process::run_pre_build;
101use super::native_support::sanitize_ident;
102use super::{into_running, LogBuffer, ReconcileCtx, Reconciler, RunningWorkload};
103use crate::proc_control::{self, ControlEndpoint, ReadyOutcome};
104use crate::{MirrorProviderSlot, MirrorShape, Provider};
105
106const SLOT: &str = "compute";
107
108/// A device app has further to go than a host process: a simulator may be
109/// cold-booting, and a physical device is reached over a tunnel.
110const READY_TIMEOUT: Duration = Duration::from_secs(30);
111
112/// How long a channel-less app must stay up to count as launched.
113const ALIVE_GRACE: Duration = Duration::from_millis(1500);
114
115/// Ceiling on any one host-tool call during enumeration. `devicectl` in
116/// particular can sit for a long time on a device it half-sees.
117const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
118
119/// A responsive `adb shell getprop` answers well inside a second.
120const SHELL_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
121
122/// How often the Android supervisor checks the app's pid is still there —
123/// `logcat --pid` does not exit when the process does.
124const ANDROID_PID_POLL: Duration = Duration::from_secs(2);
125
126// ─── Inventory ──────────────────────────────────────────────────────────────
127
128/// What kind of host a [`DeviceHost`] is. Also the mirror slot's `target`.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum HostKind {
132    /// The machine this runs on. Always present; it can always spawn a
133    /// process (that is `local-process`'s job, not this module's).
134    Local,
135    IosSimulator,
136    IosDevice,
137    AndroidEmulator,
138    AndroidDevice,
139}
140
141impl HostKind {
142    pub fn as_str(self) -> &'static str {
143        match self {
144            HostKind::Local => "local",
145            HostKind::IosSimulator => "ios-simulator",
146            HostKind::IosDevice => "ios-device",
147            HostKind::AndroidEmulator => "android-emulator",
148            HostKind::AndroidDevice => "android-device",
149        }
150    }
151
152    pub fn platform(self) -> Platform {
153        match self {
154            HostKind::Local if cfg!(target_os = "macos") => Platform::Macos,
155            HostKind::Local => Platform::Linux,
156            HostKind::IosSimulator | HostKind::IosDevice => Platform::Ios,
157            HostKind::AndroidEmulator | HostKind::AndroidDevice => Platform::Android,
158        }
159    }
160
161    /// Discovery lanes a process on this host can claim — W203's `lookup=`
162    /// vocabulary (society's `PeerLookup` lane names).
163    ///
164    /// The emulator is the one host W203 names as LAN-less: it sits behind the
165    /// emulator's own NAT, and whether multicast reaches the host from it under
166    /// any network mode is an open question there. Until that is answered it
167    /// does not claim `lan`, so a placement asking for it gets a legible skip
168    /// rather than a run that silently never discovers its peer. The simulator
169    /// *does* claim `lan` — it uses the host's stack — but see
170    /// [`DeviceHost::shares_host_network`].
171    pub fn lanes(self) -> Vec<Lane> {
172        match self {
173            HostKind::AndroidEmulator => vec![Lane::Direct, Lane::Roster, Lane::Wan],
174            _ => vec![Lane::Direct, Lane::Lan, Lane::Roster, Lane::Wan],
175        }
176    }
177
178    /// Which W315 control rail reaches a process on this host.
179    pub fn control_rail(self) -> ControlRail {
180        match self {
181            HostKind::Local | HostKind::IosSimulator => ControlRail::UnixSocket,
182            HostKind::AndroidEmulator | HostKind::AndroidDevice => ControlRail::AdbForward,
183            HostKind::IosDevice => ControlRail::None,
184        }
185    }
186
187    fn parse(s: &str) -> Option<Self> {
188        [
189            HostKind::IosSimulator,
190            HostKind::IosDevice,
191            HostKind::AndroidEmulator,
192            HostKind::AndroidDevice,
193            HostKind::Local,
194        ]
195        .into_iter()
196        .find(|k| k.as_str() == s)
197    }
198}
199
200impl std::fmt::Display for HostKind {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.write_str(self.as_str())
203    }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum Platform {
209    Macos,
210    Linux,
211    Ios,
212    Android,
213}
214
215/// A W203 `lookup=` lane.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "kebab-case")]
218pub enum Lane {
219    Direct,
220    Lan,
221    Roster,
222    Wan,
223}
224
225/// How the supervisor reaches a process's W315 status channel on a host.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum ControlRail {
229    /// `$YAH_CONTROL_SOCK` as a host filesystem path.
230    UnixSocket,
231    /// An abstract-namespace socket on the device, forwarded to a host TCP
232    /// port by `adb forward`.
233    AdbForward,
234    /// No rail yet — physical iOS. Readiness is liveness only.
235    None,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "kebab-case")]
240pub enum HostState {
241    /// Can launch now.
242    Ready,
243    /// A simulator that exists but is not booted. Launchable — the reconciler
244    /// boots it — when a mirror names it explicitly.
245    Shutdown,
246    /// Known but not usable: unpaired, unreachable, `unauthorized`, …
247    /// `state_detail` says which.
248    Offline,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct BindAddr {
253    pub interface: String,
254    pub addr: IpAddr,
255}
256
257/// One capability record per host (W203 Gap 4).
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct DeviceHost {
260    /// The identifier the host tool takes: a simulator/device UDID, an adb
261    /// serial, or `local`.
262    pub id: String,
263    pub name: String,
264    pub kind: HostKind,
265    pub platform: Platform,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub os_version: Option<String>,
268    pub state: HostState,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub state_detail: Option<String>,
271    pub lanes: Vec<Lane>,
272    /// True when the host has no network identity of its own — a simulator
273    /// runs on the Mac's stack. Two roles on such a pair are separated by bind
274    /// address at best, never by interface; W203's `host!=` needs this.
275    pub shares_host_network: bool,
276    /// Addresses a process there can bind. `None` means not knowable from the
277    /// host tools (a physical iOS device), which is different from empty.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub bind_addrs: Option<Vec<BindAddr>>,
280    pub control_rail: ControlRail,
281}
282
283impl DeviceHost {
284    fn new(id: impl Into<String>, name: impl Into<String>, kind: HostKind) -> Self {
285        Self {
286            id: id.into(),
287            name: name.into(),
288            kind,
289            platform: kind.platform(),
290            os_version: None,
291            state: HostState::Ready,
292            state_detail: None,
293            lanes: kind.lanes(),
294            shares_host_network: kind == HostKind::IosSimulator,
295            bind_addrs: None,
296            control_rail: kind.control_rail(),
297        }
298    }
299
300    fn offline(mut self, detail: impl Into<String>) -> Self {
301        self.state = HostState::Offline;
302        self.state_detail = Some(detail.into());
303        self
304    }
305}
306
307/// Result of one enumeration pass.
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309pub struct Inventory {
310    pub hosts: Vec<DeviceHost>,
311    /// One entry per host tool consulted, so "no Android hosts" can be told
312    /// apart from "`adb` is not installed".
313    pub probes: Vec<Probe>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct Probe {
318    pub tool: String,
319    pub ok: bool,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub detail: Option<String>,
322}
323
324/// Enumerate every host this machine can launch onto: itself, iOS simulators
325/// and devices (macOS only), and whatever `adb` sees.
326///
327/// Never fails. A missing or broken tool becomes a failed [`Probe`] and simply
328/// contributes no hosts — an inventory is useful precisely when some of it is
329/// absent.
330pub async fn enumerate() -> Inventory {
331    let mut inv = Inventory::default();
332    let host_addrs = local_bind_addrs();
333
334    let mut local = DeviceHost::new("local", local_hostname(), HostKind::Local);
335    local.bind_addrs = Some(host_addrs.clone());
336    inv.hosts.push(local);
337
338    if cfg!(target_os = "macos") {
339        match tool_output("xcrun", &["simctl", "list", "devices", "--json"]).await {
340            Ok(json) => match parse_simctl(&json) {
341                Ok(mut sims) => {
342                    for sim in &mut sims {
343                        sim.bind_addrs = Some(host_addrs.clone());
344                    }
345                    inv.probes.push(probe_ok("simctl", sims.len()));
346                    inv.hosts.extend(sims);
347                }
348                Err(e) => inv.probes.push(probe_err("simctl", e)),
349            },
350            Err(e) => inv.probes.push(probe_err("simctl", e)),
351        }
352
353        match devicectl_devices().await {
354            Ok(devices) => {
355                inv.probes.push(probe_ok("devicectl", devices.len()));
356                inv.hosts.extend(devices);
357            }
358            Err(e) => inv.probes.push(probe_err("devicectl", e)),
359        }
360    }
361
362    match tool_output("adb", &["devices", "-l"]).await {
363        Ok(text) => {
364            // Concurrently: a wedged device costs one probe timeout in
365            // total, not one per device.
366            let probes: Vec<_> = parse_adb_devices(&text)
367                .into_iter()
368                .map(|host| {
369                    tokio::spawn(async move {
370                        if host.state == HostState::Ready {
371                            probe_android(host).await
372                        } else {
373                            host
374                        }
375                    })
376                })
377                .collect();
378            let mut androids = Vec::with_capacity(probes.len());
379            for p in probes {
380                if let Ok(host) = p.await {
381                    androids.push(host);
382                }
383            }
384            inv.probes.push(probe_ok("adb", androids.len()));
385            inv.hosts.extend(androids);
386        }
387        Err(e) => inv.probes.push(probe_err("adb", e)),
388    }
389
390    inv
391}
392
393fn probe_ok(tool: &str, n: usize) -> Probe {
394    Probe {
395        tool: tool.to_string(),
396        ok: true,
397        detail: Some(format!("{n} host(s)")),
398    }
399}
400
401fn probe_err(tool: &str, e: anyhow::Error) -> Probe {
402    Probe {
403        tool: tool.to_string(),
404        ok: false,
405        detail: Some(format!("{e:#}")),
406    }
407}
408
409async fn devicectl_devices() -> Result<Vec<DeviceHost>> {
410    // devicectl's JSON goes to a file, not stdout.
411    let out = tempfile::NamedTempFile::new().context("creating devicectl --json-output file")?;
412    let path = out.path().display().to_string();
413    tool_output(
414        "xcrun",
415        &["devicectl", "list", "devices", "--quiet", "--json-output", &path],
416    )
417    .await?;
418    let json = tokio::fs::read_to_string(out.path())
419        .await
420        .context("reading devicectl --json-output")?;
421    parse_devicectl(&json)
422}
423
424/// Fill the Android fields `adb devices -l` does not carry. Best-effort: a
425/// failed probe leaves the field unset rather than the host dropped.
426///
427/// The `getprop` doubles as a shell liveness check. `adb devices` reports an
428/// emulator whose guest has wedged as `device` all the same, and every
429/// `adb shell` against it then hangs — seen on this machine, not hypothesised.
430/// Such a host cannot launch anything, so it is reported offline rather than
431/// ready-but-useless.
432async fn probe_android(mut host: DeviceHost) -> DeviceHost {
433    let serial = host.id.clone();
434    match tool_output_within(
435        SHELL_PROBE_TIMEOUT,
436        "adb",
437        &["-s", &serial, "shell", "getprop", "ro.build.version.release"],
438    )
439    .await
440    {
441        Ok(v) => {
442            let v = v.trim();
443            if !v.is_empty() {
444                host.os_version = Some(v.to_string());
445            }
446        }
447        Err(e) if format!("{e}").contains("timed out") => {
448            return host.offline(format!(
449                "`adb shell` did not answer within {SHELL_PROBE_TIMEOUT:?}"
450            ));
451        }
452        Err(_) => {}
453    }
454    if let Ok(text) = tool_output("adb", &["-s", &serial, "shell", "ip", "-o", "-4", "addr", "show"]).await {
455        host.bind_addrs = Some(parse_ip_addr(&text));
456    }
457    host
458}
459
460#[derive(Deserialize)]
461struct SimctlList {
462    devices: BTreeMap<String, Vec<SimctlDevice>>,
463}
464
465#[derive(Deserialize)]
466#[serde(rename_all = "camelCase")]
467struct SimctlDevice {
468    udid: String,
469    name: String,
470    state: String,
471    #[serde(default)]
472    is_available: bool,
473}
474
475/// `xcrun simctl list devices --json` → iOS simulator hosts.
476///
477/// Only iOS runtimes (iPadOS simulators report as iOS too); watchOS, tvOS and
478/// visionOS simulators are not app targets here. Unavailable ones — a runtime
479/// that has been deleted — are dropped rather than reported offline: they
480/// cannot come back without reinstalling Xcode components.
481pub fn parse_simctl(json: &str) -> Result<Vec<DeviceHost>> {
482    let list: SimctlList = serde_json::from_str(json).context("parsing simctl --json")?;
483    let mut out = Vec::new();
484    for (runtime, devices) in list.devices {
485        let Some(version) = runtime
486            .rsplit_once(".iOS-")
487            .map(|(_, v)| v.replace('-', "."))
488        else {
489            continue;
490        };
491        for d in devices.into_iter().filter(|d| d.is_available) {
492            let mut host = DeviceHost::new(d.udid, d.name, HostKind::IosSimulator);
493            host.os_version = Some(version.clone());
494            match d.state.as_str() {
495                "Booted" => {}
496                "Shutdown" => host.state = HostState::Shutdown,
497                other => host = host.offline(other.to_lowercase()),
498            }
499            out.push(host);
500        }
501    }
502    Ok(out)
503}
504
505#[derive(Deserialize)]
506struct DevicectlList {
507    result: DevicectlResult,
508}
509
510#[derive(Deserialize)]
511struct DevicectlResult {
512    #[serde(default)]
513    devices: Vec<DevicectlDevice>,
514}
515
516#[derive(Deserialize)]
517#[serde(rename_all = "camelCase")]
518struct DevicectlDevice {
519    identifier: String,
520    #[serde(default)]
521    connection_properties: DevicectlConnection,
522    #[serde(default)]
523    device_properties: DevicectlProps,
524    #[serde(default)]
525    hardware_properties: DevicectlHardware,
526}
527
528#[derive(Deserialize, Default)]
529#[serde(rename_all = "camelCase")]
530struct DevicectlConnection {
531    pairing_state: Option<String>,
532    tunnel_state: Option<String>,
533}
534
535#[derive(Deserialize, Default)]
536#[serde(rename_all = "camelCase")]
537struct DevicectlProps {
538    name: Option<String>,
539    os_version_number: Option<String>,
540}
541
542#[derive(Deserialize, Default)]
543#[serde(rename_all = "camelCase")]
544struct DevicectlHardware {
545    platform: Option<String>,
546    reality: Option<String>,
547    udid: Option<String>,
548    marketing_name: Option<String>,
549}
550
551/// `xcrun devicectl list devices --json-output` → physical iOS hosts.
552///
553/// `tunnelState == "unavailable"` is read as unreachable (the device is not on
554/// USB or the local network right now); any other state — `disconnected`
555/// included — as launchable, because devicectl brings the tunnel up on demand
556/// for a reachable device. That reading of `disconnected` is an inference from
557/// devicectl's behaviour, not a documented contract.
558pub fn parse_devicectl(json: &str) -> Result<Vec<DeviceHost>> {
559    let list: DevicectlList = serde_json::from_str(json).context("parsing devicectl JSON")?;
560    let mut out = Vec::new();
561    for d in list.result.devices {
562        let hw = &d.hardware_properties;
563        if hw.reality.as_deref() != Some("physical") || hw.platform.as_deref() != Some("iOS") {
564            continue;
565        }
566        let id = hw.udid.clone().unwrap_or_else(|| d.identifier.clone());
567        let name = d
568            .device_properties
569            .name
570            .clone()
571            .or_else(|| hw.marketing_name.clone())
572            .unwrap_or_else(|| id.clone());
573        let mut host = DeviceHost::new(id, name, HostKind::IosDevice);
574        host.os_version = d.device_properties.os_version_number.clone();
575        let conn = &d.connection_properties;
576        if conn.pairing_state.as_deref().is_some_and(|p| p != "paired") {
577            host = host.offline(format!(
578                "not paired ({})",
579                conn.pairing_state.as_deref().unwrap_or_default()
580            ));
581        } else if conn.tunnel_state.as_deref() == Some("unavailable") {
582            host = host.offline("not reachable (CoreDevice tunnel unavailable)");
583        }
584        out.push(host);
585    }
586    Ok(out)
587}
588
589/// `adb devices -l` → Android hosts. An `emulator-` serial is an emulator;
590/// anything else a device. Any state but `device` (`offline`,
591/// `unauthorized`, `recovery`, …) is reported offline under that name.
592pub fn parse_adb_devices(text: &str) -> Vec<DeviceHost> {
593    let mut out = Vec::new();
594    for line in text.lines() {
595        let line = line.trim();
596        if line.is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
597            continue;
598        }
599        let mut parts = line.split_whitespace();
600        let (Some(serial), Some(state)) = (parts.next(), parts.next()) else {
601            continue;
602        };
603        let props: BTreeMap<&str, &str> = parts.filter_map(|kv| kv.split_once(':')).collect();
604        let kind = if serial.starts_with("emulator-") {
605            HostKind::AndroidEmulator
606        } else {
607            HostKind::AndroidDevice
608        };
609        let name = props
610            .get("model")
611            .map(|m| m.replace('_', " "))
612            .unwrap_or_else(|| serial.to_string());
613        let mut host = DeviceHost::new(serial, name, kind);
614        if state != "device" {
615            host = host.offline(state.to_string());
616        }
617        out.push(host);
618    }
619    out
620}
621
622/// `ip -o -4 addr show` → bind addresses. One line per address:
623/// `15: eth0    inet 10.0.2.15/8 brd … scope global eth0\ …`.
624pub fn parse_ip_addr(text: &str) -> Vec<BindAddr> {
625    let mut out = Vec::new();
626    for line in text.lines() {
627        let tokens: Vec<&str> = line.split_whitespace().collect();
628        let Some(inet) = tokens.iter().position(|t| *t == "inet" || *t == "inet6") else {
629            continue;
630        };
631        let (Some(iface), Some(cidr)) = (tokens.get(1), tokens.get(inet + 1)) else {
632            continue;
633        };
634        let addr = cidr.split('/').next().unwrap_or(cidr);
635        if let Ok(addr) = addr.parse() {
636            out.push(BindAddr {
637                interface: iface.to_string(),
638                addr,
639            });
640        }
641    }
642    out
643}
644
645/// This machine's interface addresses, via `getifaddrs`. IPv6 link-local is
646/// left out: it is not bindable without a scope id, which a placement record
647/// has no way to carry.
648fn local_bind_addrs() -> Vec<BindAddr> {
649    let mut out = Vec::new();
650    // SAFETY: getifaddrs hands back a linked list we only read, and free with
651    // freeifaddrs exactly once. Each ifa_addr is checked for null and its
652    // family before being cast to the matching sockaddr type.
653    unsafe {
654        let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
655        if libc::getifaddrs(&mut head) != 0 {
656            return out;
657        }
658        let mut cur = head;
659        while !cur.is_null() {
660            let ifa = &*cur;
661            cur = ifa.ifa_next;
662            if ifa.ifa_addr.is_null() || (ifa.ifa_flags & libc::IFF_UP as libc::c_uint) == 0 {
663                continue;
664            }
665            let addr = match (*ifa.ifa_addr).sa_family as libc::c_int {
666                libc::AF_INET => {
667                    let sin = &*(ifa.ifa_addr as *const libc::sockaddr_in);
668                    IpAddr::V4(Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)))
669                }
670                libc::AF_INET6 => {
671                    let sin6 = &*(ifa.ifa_addr as *const libc::sockaddr_in6);
672                    let v6 = Ipv6Addr::from(sin6.sin6_addr.s6_addr);
673                    if (v6.segments()[0] & 0xffc0) == 0xfe80 {
674                        continue;
675                    }
676                    IpAddr::V6(v6)
677                }
678                _ => continue,
679            };
680            let interface = std::ffi::CStr::from_ptr(ifa.ifa_name)
681                .to_string_lossy()
682                .into_owned();
683            out.push(BindAddr { interface, addr });
684        }
685        libc::freeifaddrs(head);
686    }
687    out.sort_by(|a, b| (&a.interface, a.addr).cmp(&(&b.interface, b.addr)));
688    out.dedup();
689    out
690}
691
692fn local_hostname() -> String {
693    let mut buf = [0u8; 256];
694    // SAFETY: gethostname writes at most buf.len() bytes into buf.
695    let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
696    if rc != 0 {
697        return "localhost".to_string();
698    }
699    let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
700    String::from_utf8_lossy(&buf[..end]).into_owned()
701}
702
703/// Run a host tool to completion and return its stdout. A missing binary is
704/// reported as such — "adb not installed" is a different answer from "adb
705/// failed".
706async fn tool_output(program: &str, args: &[&str]) -> Result<String> {
707    tool_output_within(PROBE_TIMEOUT, program, args).await
708}
709
710async fn tool_output_within(timeout: Duration, program: &str, args: &[&str]) -> Result<String> {
711    let mut cmd = Command::new(program);
712    cmd.args(args).stdin(Stdio::null()).kill_on_drop(true);
713    let out = match tokio::time::timeout(timeout, cmd.output()).await {
714        Err(_) => bail!("`{program} {}` timed out after {timeout:?}", args.join(" ")),
715        Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {
716            bail!("`{program}` is not installed (not on PATH)")
717        }
718        Ok(r) => r.with_context(|| format!("running `{program} {}`", args.join(" ")))?,
719    };
720    if !out.status.success() {
721        let stderr = String::from_utf8_lossy(&out.stderr);
722        let stdout = String::from_utf8_lossy(&out.stdout);
723        let why = if stderr.trim().is_empty() { stdout } else { stderr };
724        bail!(
725            "`{program} {}` failed ({}): {}",
726            args.join(" "),
727            out.status,
728            why.trim()
729        );
730    }
731    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
732}
733
734// ─── Mirror slot + component spec ───────────────────────────────────────────
735
736/// Whether this mirror binds its compute slot to the device provider.
737pub fn slot_declared(mirror: &crate::MirrorConfig) -> bool {
738    matches!(
739        mirror.providers.get(SLOT),
740        Some(MirrorProviderSlot::Inline {
741            kind: Provider::Device,
742            ..
743        })
744    )
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
748struct DeviceSlot {
749    target: HostKind,
750    device: Option<String>,
751}
752
753fn device_slot(mirror: &crate::MirrorConfig) -> Result<DeviceSlot> {
754    let fields = match mirror.providers.get(SLOT) {
755        Some(slot @ MirrorProviderSlot::Inline { .. }) => slot.fields(),
756        _ => bail!("mirror has no inline `[providers.compute]` slot of kind `device`"),
757    };
758    let target = fields
759        .get("target")
760        .and_then(|v| v.as_str())
761        .context(
762            "`[providers.compute] kind = \"device\"` needs `target` — one of ios-simulator, \
763             ios-device, android-emulator, android-device",
764        )?;
765    let target = match HostKind::parse(target) {
766        Some(HostKind::Local) | None => bail!(
767            "`[providers.compute] target = \"{target}\"` is not a device target — use one of \
768             ios-simulator, ios-device, android-emulator, android-device (a host process is \
769             `kind = \"local-process\"`)"
770        ),
771        Some(k) => k,
772    };
773    let device = fields
774        .get("device")
775        .and_then(|v| v.as_str())
776        .map(str::to_string);
777    Ok(DeviceSlot { target, device })
778}
779
780#[derive(Debug, Default, Deserialize)]
781struct DeviceComponent {
782    #[serde(default)]
783    device: Option<DeviceSpec>,
784}
785
786#[derive(Debug, Default, Deserialize)]
787struct DeviceSpec {
788    #[serde(default)]
789    pre_build: Option<Vec<String>>,
790    #[serde(default)]
791    args: Vec<String>,
792    #[serde(default)]
793    env: BTreeMap<String, String>,
794    #[serde(default)]
795    control: bool,
796    #[serde(default)]
797    ios: Option<IosApp>,
798    #[serde(default)]
799    android: Option<AndroidApp>,
800}
801
802#[derive(Debug, Deserialize)]
803struct IosApp {
804    bundle_id: String,
805    #[serde(default)]
806    app: Option<String>,
807}
808
809#[derive(Debug, Deserialize)]
810struct AndroidApp {
811    package: String,
812    activity: String,
813    #[serde(default)]
814    apk: Option<String>,
815}
816
817fn load_device_spec(ctx: &ReconcileCtx<'_>) -> Result<DeviceSpec> {
818    let path = ctx.workload_dir().join("workload.toml");
819    let src =
820        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
821    parse_device_spec(&src).with_context(|| {
822        format!(
823            "component {} is bound to a `device` compute slot: {}",
824            ctx.component.id,
825            path.display()
826        )
827    })
828}
829
830fn parse_device_spec(src: &str) -> Result<DeviceSpec> {
831    let parsed: DeviceComponent = toml::from_str(src).context("parsing workload.toml")?;
832    parsed
833        .device
834        .context("declares no [device] section — add one with [device.ios] and/or [device.android]")
835}
836
837/// Pick the host a slot names out of an inventory.
838///
839/// With `device` set, it must match a host of the target kind by id or by name
840/// (names case-insensitively). Without it, the first `Ready` host of the kind
841/// wins, ordered by id so the choice is stable across runs; the caller notes
842/// which one and what else was eligible, so a two-emulator machine is legible.
843fn select_host<'a>(inv: &'a Inventory, slot: &DeviceSlot) -> Result<(&'a DeviceHost, Vec<&'a DeviceHost>)> {
844    let mut of_kind: Vec<&DeviceHost> = inv.hosts.iter().filter(|h| h.kind == slot.target).collect();
845    of_kind.sort_by(|a, b| a.id.cmp(&b.id));
846    let describe = |hs: &[&DeviceHost]| {
847        if hs.is_empty() {
848            "none".to_string()
849        } else {
850            hs.iter()
851                .map(|h| format!("{} [{}] ({:?})", h.name, h.id, h.state).to_lowercase())
852                .collect::<Vec<_>>()
853                .join(", ")
854        }
855    };
856    let probe_notes = || {
857        let failed: Vec<String> = inv
858            .probes
859            .iter()
860            .filter(|p| !p.ok)
861            .map(|p| format!("{}: {}", p.tool, p.detail.as_deref().unwrap_or("failed")))
862            .collect();
863        if failed.is_empty() {
864            String::new()
865        } else {
866            format!(" — probe failures: {}", failed.join("; "))
867        }
868    };
869
870    if let Some(want) = &slot.device {
871        let host = of_kind
872            .iter()
873            .find(|h| h.id == *want || h.name.eq_ignore_ascii_case(want))
874            .copied()
875            .with_context(|| {
876                format!(
877                    "no {} matches `device = \"{want}\"`; known: {}{}",
878                    slot.target,
879                    describe(&of_kind),
880                    probe_notes()
881                )
882            })?;
883        let launchable = host.state == HostState::Ready
884            || (host.state == HostState::Shutdown && host.kind == HostKind::IosSimulator);
885        if !launchable {
886            bail!(
887                "{} `{}` [{}] is {:?}{}",
888                slot.target,
889                host.name,
890                host.id,
891                host.state,
892                host.state_detail
893                    .as_deref()
894                    .map(|d| format!(": {d}"))
895                    .unwrap_or_default()
896            );
897        }
898        return Ok((host, Vec::new()));
899    }
900
901    let ready: Vec<&DeviceHost> = of_kind
902        .iter()
903        .filter(|h| h.state == HostState::Ready)
904        .copied()
905        .collect();
906    let Some(first) = ready.first().copied() else {
907        let hint = if slot.target == HostKind::IosSimulator {
908            " — name one with `device = \"<name or udid>\"` and it will be booted"
909        } else {
910            ""
911        };
912        bail!(
913            "no ready {} attached; known: {}{hint}{}",
914            slot.target,
915            describe(&of_kind),
916            probe_notes()
917        );
918    };
919    Ok((first, ready[1..].to_vec()))
920}
921
922// ─── Reconciler ─────────────────────────────────────────────────────────────
923
924/// Launches a component's app on a device or simulator and supervises it.
925#[derive(Debug, Default)]
926pub struct DeviceReconciler {
927    log_buf: Option<LogBuffer>,
928}
929
930impl DeviceReconciler {
931    pub fn new() -> Self {
932        Self::default()
933    }
934
935    /// Stream into a caller-owned buffer, so it can be registered for polling
936    /// before `up` returns — same contract as `LocalProcessReconciler`.
937    pub fn with_log_buf(mut self, log_buf: LogBuffer) -> Self {
938        self.log_buf = Some(log_buf);
939        self
940    }
941}
942
943/// What the supervisor watches to decide the app is still up.
944#[derive(Debug, Clone)]
945enum Watch {
946    /// The launcher child itself: `simctl launch --console` and `devicectl
947    /// device process launch --console` both block until the app exits.
948    Launcher,
949    /// `logcat --pid` outlives the app, so poll `pidof` instead.
950    AndroidPid { serial: String, package: String, pid: String },
951}
952
953/// What undoing a launch takes, beyond stopping the launcher child.
954#[derive(Debug, Clone)]
955enum Cleanup {
956    Simulator { udid: String, bundle_id: String },
957    /// SIGTERM to `devicectl --console` is forwarded to the app; nothing more.
958    Device,
959    Android { serial: String, package: String, forward: Option<u16> },
960}
961
962impl Cleanup {
963    async fn run(self) -> Result<()> {
964        match self {
965            Cleanup::Simulator { udid, bundle_id } => {
966                // Already gone is fine: the app may have exited on its own.
967                tool_output("xcrun", &["simctl", "terminate", &udid, &bundle_id])
968                    .await
969                    .ok();
970            }
971            Cleanup::Device => {}
972            Cleanup::Android {
973                serial,
974                package,
975                forward,
976            } => {
977                tool_output("adb", &["-s", &serial, "shell", "am", "force-stop", &package])
978                    .await
979                    .ok();
980                if let Some(port) = forward {
981                    tool_output(
982                        "adb",
983                        &["-s", &serial, "forward", "--remove", &format!("tcp:{port}")],
984                    )
985                    .await
986                    .ok();
987                }
988            }
989        }
990        Ok(())
991    }
992}
993
994struct Launched {
995    child: Child,
996    watch: Watch,
997    cleanup: Cleanup,
998    control: Option<ControlEndpoint>,
999    notes: Vec<String>,
1000}
1001
1002#[async_trait]
1003impl Reconciler for DeviceReconciler {
1004    fn kind(&self) -> &'static str {
1005        "device"
1006    }
1007
1008    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
1009        ctx.materialize().await?;
1010
1011        // Attached devices are attached to *this* machine. Same rule, same
1012        // reason, as `local-process`.
1013        if !matches!(ctx.mirror.shape, MirrorShape::Local) {
1014            bail!(
1015                "component {}: `device` is a dev-tier compute slot — mirror shape is {:?}, not \
1016                 `local`",
1017                ctx.component.id,
1018                ctx.mirror.shape,
1019            );
1020        }
1021
1022        let slot = device_slot(ctx.mirror)?;
1023        let spec = load_device_spec(&ctx)?;
1024        let log_buf = self.log_buf.clone().unwrap_or_default();
1025
1026        if let Some(argv) = &spec.pre_build {
1027            run_pre_build(ctx.workspace_root, argv, &log_buf).await?;
1028        }
1029
1030        let inventory = enumerate().await;
1031        let (host, others) = select_host(&inventory, &slot)?;
1032        let host = host.clone();
1033        let mut notes = vec![format!(
1034            "host: {} [{}] ({}{})",
1035            host.name,
1036            host.id,
1037            host.kind,
1038            host.os_version
1039                .as_deref()
1040                .map(|v| format!(" {v}"))
1041                .unwrap_or_default()
1042        )];
1043        if !others.is_empty() {
1044            notes.push(format!(
1045                "also ready: {} — pin one with `device = \"…\"` in [providers.compute]",
1046                others
1047                    .iter()
1048                    .map(|h| format!("{} [{}]", h.name, h.id))
1049                    .collect::<Vec<_>>()
1050                    .join(", ")
1051            ));
1052        }
1053
1054        let ident = sanitize_ident(&format!(
1055            "device-{}-{}-{}",
1056            ctx.service.name, ctx.env, ctx.component.id
1057        ));
1058        info!(host = %host.id, kind = %host.kind, ident = %ident, "launching device component");
1059
1060        let launched = match host.kind {
1061            HostKind::IosSimulator => {
1062                launch_simulator(&ctx, &spec, &host, &ident, &log_buf).await?
1063            }
1064            HostKind::IosDevice => launch_ios_device(&ctx, &spec, &host, &log_buf).await?,
1065            HostKind::AndroidEmulator | HostKind::AndroidDevice => {
1066                launch_android(&ctx, &spec, &host, &ident, &log_buf).await?
1067            }
1068            HostKind::Local => unreachable!("device_slot rejects a local target"),
1069        };
1070        notes.extend(launched.notes);
1071        let Launched {
1072            mut child,
1073            watch,
1074            cleanup,
1075            control,
1076            ..
1077        } = launched;
1078
1079        // Stream the launcher's output from the start, so a readiness failure
1080        // below has the app's own last words to show.
1081        let pumps = pump_output(&mut child, &log_buf);
1082
1083        let fail = |what: String| {
1084            let log_buf = log_buf.clone();
1085            let cleanup = cleanup.clone();
1086            async move {
1087                cleanup.run().await.ok();
1088                let (lines, _) = log_buf.since(0).await;
1089                let tail: Vec<&String> = lines.iter().rev().take(20).collect();
1090                let tail: Vec<&str> = tail.into_iter().rev().map(String::as_str).collect();
1091                if tail.is_empty() {
1092                    anyhow::anyhow!("{what}")
1093                } else {
1094                    anyhow::anyhow!("{what}\n--- last output ---\n{}", tail.join("\n"))
1095                }
1096            }
1097        };
1098
1099        if let Some(endpoint) = &control {
1100            match proc_control::wait_ready(endpoint, READY_TIMEOUT).await {
1101                ReadyOutcome::Ready(status) => {
1102                    notes.push(format!("control: {}", status.summary()));
1103                }
1104                ReadyOutcome::Terminal(status) => {
1105                    kill_gracefully(&mut child).await;
1106                    return Err(fail(format!(
1107                        "component {} reported `{}` on its control channel ({endpoint})",
1108                        ctx.component.id,
1109                        status.summary()
1110                    ))
1111                    .await);
1112                }
1113                ReadyOutcome::TimedOut { last } => {
1114                    kill_gracefully(&mut child).await;
1115                    let seen = match last {
1116                        Some(s) => format!("last reported `{}`", s.summary()),
1117                        None => format!(
1118                            "never answered — is the app serving {} ?",
1119                            proc_control::CONTROL_SOCK_ENV
1120                        ),
1121                    };
1122                    return Err(fail(format!(
1123                        "component {} was not ready within {READY_TIMEOUT:?} on {endpoint} ({seen})",
1124                        ctx.component.id
1125                    ))
1126                    .await);
1127                }
1128            }
1129        } else {
1130            tokio::time::sleep(ALIVE_GRACE).await;
1131            if !still_alive(&mut child, &watch).await {
1132                kill_gracefully(&mut child).await;
1133                return Err(fail(format!(
1134                    "component {} exited within {ALIVE_GRACE:?} of launching on {} [{}]",
1135                    ctx.component.id, host.name, host.id
1136                ))
1137                .await);
1138            }
1139            notes.push(if spec.control && host.control_rail == ControlRail::None {
1140                "control requested, but a physical iOS device has no status rail yet (W203 \
1141                 open question) — readiness is liveness only"
1142                    .to_string()
1143            } else {
1144                "no [device] control — readiness is liveness only".to_string()
1145            });
1146        }
1147        info!(host = %host.id, "device component ready");
1148
1149        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
1150        let supervisor = tokio::spawn(supervise(child, watch, pumps, shutdown_rx));
1151
1152        Ok(into_running(
1153            "device",
1154            SLOT,
1155            None,
1156            None,
1157            Some(log_buf),
1158            shutdown_tx,
1159            supervisor,
1160        )
1161        .with_notes(notes)
1162        .with_control(control)
1163        .with_teardown(move || cleanup.run()))
1164    }
1165}
1166
1167fn resolve_artifact(ctx: &ReconcileCtx<'_>, rel: &str) -> Result<PathBuf> {
1168    let p = Path::new(rel);
1169    let path = if p.is_absolute() {
1170        p.to_path_buf()
1171    } else {
1172        ctx.workspace_root.join(p)
1173    };
1174    if !path.exists() {
1175        bail!(
1176            "[device] artifact {} does not exist — build it first (a `pre_build` step runs \
1177             before install)",
1178            path.display()
1179        );
1180    }
1181    Ok(path)
1182}
1183
1184async fn launch_simulator(
1185    ctx: &ReconcileCtx<'_>,
1186    spec: &DeviceSpec,
1187    host: &DeviceHost,
1188    ident: &str,
1189    log_buf: &LogBuffer,
1190) -> Result<Launched> {
1191    let ios = spec
1192        .ios
1193        .as_ref()
1194        .context("target is ios-simulator but workload.toml declares no [device.ios]")?;
1195    let udid = host.id.as_str();
1196    let mut notes = Vec::new();
1197
1198    if host.state == HostState::Shutdown {
1199        log_buf.push(format!("booting simulator {} [{udid}]", host.name)).await;
1200        tool_output("xcrun", &["simctl", "bootstatus", udid, "-b"])
1201            .await
1202            .with_context(|| format!("booting simulator {udid}"))?;
1203        notes.push(format!("booted {}", host.name));
1204    }
1205    if let Some(app) = &ios.app {
1206        let app = resolve_artifact(ctx, app)?;
1207        log_buf.push(format!("installing {}", app.display())).await;
1208        tool_output("xcrun", &["simctl", "install", udid, &app.display().to_string()]).await?;
1209    }
1210
1211    let mut env = spec.env.clone();
1212    let mut control = None;
1213    if spec.control {
1214        // The simulator shares the host filesystem: a host path works as is.
1215        let sock = ctx
1216            .workspace_root
1217            .join(".yah/jit/device")
1218            .join(ident)
1219            .join("control.sock");
1220        if let Some(dir) = sock.parent() {
1221            tokio::fs::create_dir_all(dir).await.ok();
1222        }
1223        tokio::fs::remove_file(&sock).await.ok();
1224        env.entry(proc_control::CONTROL_SOCK_ENV.to_string())
1225            .or_insert_with(|| sock.display().to_string());
1226        control = Some(ControlEndpoint::Socket(sock));
1227    }
1228
1229    let mut cmd = Command::new("xcrun");
1230    cmd.args(["simctl", "launch", "--console", "--terminate-running-process", udid]);
1231    cmd.arg(&ios.bundle_id).args(&spec.args);
1232    // simctl passes SIMCTL_CHILD_<K> through to the app as <K>.
1233    for (k, v) in &env {
1234        cmd.env(format!("SIMCTL_CHILD_{k}"), v);
1235    }
1236    let child = spawn_piped(cmd, "xcrun simctl launch")?;
1237    Ok(Launched {
1238        child,
1239        watch: Watch::Launcher,
1240        cleanup: Cleanup::Simulator {
1241            udid: udid.to_string(),
1242            bundle_id: ios.bundle_id.clone(),
1243        },
1244        control,
1245        notes,
1246    })
1247}
1248
1249async fn launch_ios_device(
1250    ctx: &ReconcileCtx<'_>,
1251    spec: &DeviceSpec,
1252    host: &DeviceHost,
1253    log_buf: &LogBuffer,
1254) -> Result<Launched> {
1255    let ios = spec
1256        .ios
1257        .as_ref()
1258        .context("target is ios-device but workload.toml declares no [device.ios]")?;
1259    let id = host.id.as_str();
1260    if let Some(app) = &ios.app {
1261        let app = resolve_artifact(ctx, app)?;
1262        log_buf.push(format!("installing {} on {}", app.display(), host.name)).await;
1263        tool_output(
1264            "xcrun",
1265            &["devicectl", "device", "install", "app", "--device", id, &app.display().to_string()],
1266        )
1267        .await?;
1268    }
1269
1270    let mut cmd = Command::new("xcrun");
1271    cmd.args([
1272        "devicectl",
1273        "device",
1274        "process",
1275        "launch",
1276        "--console",
1277        "--terminate-existing",
1278        "--device",
1279        id,
1280    ]);
1281    if !spec.env.is_empty() {
1282        cmd.arg("--environment-variables")
1283            .arg(serde_json::to_string(&spec.env)?);
1284    }
1285    cmd.arg(&ios.bundle_id).args(&spec.args);
1286    let child = spawn_piped(cmd, "xcrun devicectl device process launch")?;
1287    Ok(Launched {
1288        child,
1289        watch: Watch::Launcher,
1290        cleanup: Cleanup::Device,
1291        control: None,
1292        notes: Vec::new(),
1293    })
1294}
1295
1296async fn launch_android(
1297    ctx: &ReconcileCtx<'_>,
1298    spec: &DeviceSpec,
1299    host: &DeviceHost,
1300    ident: &str,
1301    log_buf: &LogBuffer,
1302) -> Result<Launched> {
1303    let android = spec.android.as_ref().with_context(|| {
1304        format!("target is {} but workload.toml declares no [device.android]", host.kind)
1305    })?;
1306    if !spec.args.is_empty() {
1307        bail!(
1308            "[device] args are not deliverable to an Android activity (it has no argv) — pass \
1309             values through [device.env], which arrive as intent string extras"
1310        );
1311    }
1312    let serial = host.id.as_str();
1313    if let Some(apk) = &android.apk {
1314        let apk = resolve_artifact(ctx, apk)?;
1315        log_buf.push(format!("installing {} on {}", apk.display(), host.name)).await;
1316        tool_output("adb", &["-s", serial, "install", "-r", &apk.display().to_string()]).await?;
1317    }
1318
1319    let mut extras = spec.env.clone();
1320    let mut control = None;
1321    let mut forward = None;
1322    if spec.control {
1323        let name = format!("yah.{ident}");
1324        let port = tool_output(
1325            "adb",
1326            &["-s", serial, "forward", "tcp:0", &format!("localabstract:{name}")],
1327        )
1328        .await?;
1329        let port: u16 = port
1330            .trim()
1331            .parse()
1332            .with_context(|| format!("`adb forward tcp:0` printed `{}`, not a port", port.trim()))?;
1333        forward = Some(port);
1334        extras
1335            .entry(proc_control::CONTROL_SOCK_ENV.to_string())
1336            .or_insert_with(|| format!("@{name}"));
1337        control = Some(ControlEndpoint::Tcp(SocketAddr::new(
1338            IpAddr::V4(Ipv4Addr::LOCALHOST),
1339            port,
1340        )));
1341    }
1342    let cleanup = Cleanup::Android {
1343        serial: serial.to_string(),
1344        package: android.package.clone(),
1345        forward,
1346    };
1347
1348    let component = format!("{}/{}", android.package, android.activity);
1349    let script = am_start_script(&component, &extras);
1350    let out = match tool_output("adb", &["-s", serial, "shell", &script]).await {
1351        Ok(out) => out,
1352        Err(e) => {
1353            cleanup.clone().run().await.ok();
1354            return Err(e.context(format!("launching {component}")));
1355        }
1356    };
1357    // `am start` exits 0 on most failures and says so on stdout.
1358    if let Some(err) = out.lines().find(|l| l.starts_with("Error")) {
1359        cleanup.clone().run().await.ok();
1360        bail!("`am start {component}` failed: {err}\n{}", out.trim());
1361    }
1362
1363    let mut pid = None;
1364    for _ in 0..20 {
1365        if let Some(p) = android_pid(serial, &android.package).await {
1366            pid = Some(p);
1367            break;
1368        }
1369        tokio::time::sleep(Duration::from_millis(250)).await;
1370    }
1371    let Some(pid) = pid else {
1372        cleanup.clone().run().await.ok();
1373        bail!("{} started but no process is running for {}", component, android.package);
1374    };
1375
1376    let mut cmd = Command::new("adb");
1377    cmd.args(["-s", serial, "logcat", "-v", "brief", &format!("--pid={pid}")]);
1378    let child = spawn_piped(cmd, "adb logcat")?;
1379    let notes = vec![format!("pid {pid}")];
1380    Ok(Launched {
1381        child,
1382        watch: Watch::AndroidPid {
1383            serial: serial.to_string(),
1384            package: android.package.clone(),
1385            pid,
1386        },
1387        cleanup,
1388        control,
1389        notes,
1390    })
1391}
1392
1393/// The device-side `am start` command line. `adb shell` hands its arguments to
1394/// the device's shell as one string, so every value is quoted there.
1395fn am_start_script(component: &str, extras: &BTreeMap<String, String>) -> String {
1396    let mut s = format!("am start -S -W -n {}", sh_quote(component));
1397    for (k, v) in extras {
1398        s.push_str(&format!(" --es {} {}", sh_quote(k), sh_quote(v)));
1399    }
1400    s
1401}
1402
1403fn sh_quote(s: &str) -> String {
1404    format!("'{}'", s.replace('\'', r"'\''"))
1405}
1406
1407async fn android_pid(serial: &str, package: &str) -> Option<String> {
1408    let out = tool_output("adb", &["-s", serial, "shell", "pidof", package])
1409        .await
1410        .ok()?;
1411    out.split_whitespace().next().map(str::to_string)
1412}
1413
1414fn spawn_piped(mut cmd: Command, what: &str) -> Result<Child> {
1415    cmd.stdin(Stdio::null())
1416        .stdout(Stdio::piped())
1417        .stderr(Stdio::piped())
1418        .kill_on_drop(true);
1419    cmd.spawn().with_context(|| format!("spawning `{what}`"))
1420}
1421
1422/// Copy the child's stdout and stderr into the log buffer, line by line.
1423fn pump_output(child: &mut Child, log_buf: &LogBuffer) -> Vec<tokio::task::JoinHandle<()>> {
1424    fn pump<R: AsyncRead + Unpin + Send + 'static>(
1425        r: R,
1426        log_buf: LogBuffer,
1427    ) -> tokio::task::JoinHandle<()> {
1428        tokio::spawn(async move {
1429            let mut lines = BufReader::new(r).lines();
1430            while let Ok(Some(line)) = lines.next_line().await {
1431                log_buf.push(line).await;
1432            }
1433        })
1434    }
1435    let mut out = Vec::new();
1436    if let Some(s) = child.stdout.take() {
1437        out.push(pump(s, log_buf.clone()));
1438    }
1439    if let Some(s) = child.stderr.take() {
1440        out.push(pump(s, log_buf.clone()));
1441    }
1442    out
1443}
1444
1445async fn still_alive(child: &mut Child, watch: &Watch) -> bool {
1446    if !matches!(child.try_wait(), Ok(None)) {
1447        return false;
1448    }
1449    match watch {
1450        Watch::Launcher => true,
1451        Watch::AndroidPid {
1452            serial,
1453            package,
1454            pid,
1455        } => android_pid(serial, package).await.as_deref() == Some(pid.as_str()),
1456    }
1457}
1458
1459/// SIGTERM first — `simctl`/`devicectl --console` forward catchable signals
1460/// to the app — then SIGKILL if it has not gone within a few seconds.
1461async fn kill_gracefully(child: &mut Child) {
1462    if let Some(pid) = child.id() {
1463        // SAFETY: plain kill(2) on a pid we spawned and have not yet reaped.
1464        unsafe {
1465            libc::kill(pid as libc::pid_t, libc::SIGTERM);
1466        }
1467        if tokio::time::timeout(Duration::from_secs(3), child.wait())
1468            .await
1469            .is_ok()
1470        {
1471            return;
1472        }
1473    }
1474    child.kill().await.ok();
1475}
1476
1477/// Health for the life of the workload: the supervisor task ends when the app
1478/// does, which is what `RunningWorkload::is_alive` reports.
1479async fn supervise(
1480    mut child: Child,
1481    watch: Watch,
1482    pumps: Vec<tokio::task::JoinHandle<()>>,
1483    mut shutdown: oneshot::Receiver<()>,
1484) -> Result<()> {
1485    let mut tick = tokio::time::interval(ANDROID_PID_POLL);
1486    tick.tick().await;
1487    loop {
1488        tokio::select! {
1489            _ = &mut shutdown => {
1490                kill_gracefully(&mut child).await;
1491                break;
1492            }
1493            status = child.wait() => {
1494                if let Ok(status) = status {
1495                    info!(%status, "device launcher exited");
1496                }
1497                break;
1498            }
1499            _ = tick.tick(), if matches!(watch, Watch::AndroidPid { .. }) => {
1500                if !still_alive(&mut child, &watch).await {
1501                    warn!("device app process is gone; stopping its log stream");
1502                    kill_gracefully(&mut child).await;
1503                    break;
1504                }
1505            }
1506        }
1507    }
1508    for p in pumps {
1509        p.await.ok();
1510    }
1511    Ok(())
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517
1518    // Trimmed from real output on the machine this was written on
1519    // (2026-09-25): Xcode 26 simctl, devicectl with a paired iPad that was not
1520    // in range, and two running emulators.
1521    const SIMCTL: &str = r#"{
1522      "devices" : {
1523        "com.apple.CoreSimulator.SimRuntime.iOS-26-5" : [
1524          { "udid" : "6D1DA2F9-EA11-40C0-85CC-8849D85077BF", "isAvailable" : true,
1525            "state" : "Shutdown", "name" : "iPhone 17 Pro" },
1526          { "udid" : "AAAA0000-0000-0000-0000-000000000001", "isAvailable" : true,
1527            "state" : "Booted", "name" : "iPad Air" },
1528          { "udid" : "AAAA0000-0000-0000-0000-000000000002", "isAvailable" : false,
1529            "state" : "Shutdown", "name" : "Gone" }
1530        ],
1531        "com.apple.CoreSimulator.SimRuntime.watchOS-26-0" : [
1532          { "udid" : "AAAA0000-0000-0000-0000-000000000003", "isAvailable" : true,
1533            "state" : "Booted", "name" : "Apple Watch" }
1534        ]
1535      }
1536    }"#;
1537
1538    const DEVICECTL: &str = r#"{ "result": { "devices": [ {
1539      "identifier": "CD97292E-F1E5-589B-898F-3039FCDA735E",
1540      "connectionProperties": { "pairingState": "paired", "tunnelState": "unavailable" },
1541      "deviceProperties": { "name": "Leif’s iPad", "osVersionNumber": "18.7.7" },
1542      "hardwareProperties": { "platform": "iOS", "reality": "physical",
1543        "udid": "00008112-001149E91423C01E", "marketingName": "iPad Pro" }
1544    }, {
1545      "identifier": "11111111-2222-3333-4444-555555555555",
1546      "connectionProperties": { "pairingState": "paired", "tunnelState": "connected" },
1547      "deviceProperties": { "name": "Test iPhone", "osVersionNumber": "26.0" },
1548      "hardwareProperties": { "platform": "iOS", "reality": "physical", "udid": "0000-PHONE" }
1549    }, {
1550      "identifier": "watch",
1551      "connectionProperties": { "pairingState": "paired", "tunnelState": "connected" },
1552      "deviceProperties": { "name": "Watch" },
1553      "hardwareProperties": { "platform": "watchOS", "reality": "physical", "udid": "0000-WATCH" }
1554    } ] } }"#;
1555
1556    const ADB: &str = "List of devices attached\n\
1557        emulator-5554          device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:3\n\
1558        R58M123ABC             unauthorized usb:1-1 transport_id:4\n\
1559        \n";
1560
1561    const IP_ADDR: &str = "1: lo    inet 127.0.0.1/8 scope host lo\\       valid_lft forever preferred_lft forever\n\
1562        15: eth0    inet 10.0.2.15/8 brd 10.255.255.255 scope global eth0\\       valid_lft forever preferred_lft forever\n\
1563        16: wlan0    inet 10.0.2.16/24 brd 10.0.2.255 scope global wlan0\\       valid_lft forever preferred_lft forever\n";
1564
1565    #[test]
1566    fn simctl_keeps_available_ios_simulators_and_maps_state() {
1567        let hosts = parse_simctl(SIMCTL).unwrap();
1568        assert_eq!(hosts.len(), 2, "unavailable + watchOS dropped: {hosts:?}");
1569        let phone = hosts.iter().find(|h| h.name == "iPhone 17 Pro").unwrap();
1570        assert_eq!(phone.state, HostState::Shutdown);
1571        assert_eq!(phone.os_version.as_deref(), Some("26.5"));
1572        assert_eq!(phone.kind, HostKind::IosSimulator);
1573        assert!(phone.shares_host_network);
1574        assert_eq!(phone.control_rail, ControlRail::UnixSocket);
1575        let pad = hosts.iter().find(|h| h.name == "iPad Air").unwrap();
1576        assert_eq!(pad.state, HostState::Ready);
1577    }
1578
1579    #[test]
1580    fn devicectl_keeps_physical_ios_and_reads_the_tunnel() {
1581        let hosts = parse_devicectl(DEVICECTL).unwrap();
1582        assert_eq!(hosts.len(), 2, "watch dropped: {hosts:?}");
1583        let ipad = &hosts[0];
1584        assert_eq!(ipad.id, "00008112-001149E91423C01E");
1585        assert_eq!(ipad.name, "Leif\u{2019}s iPad");
1586        assert_eq!(ipad.state, HostState::Offline);
1587        assert!(ipad.state_detail.as_deref().unwrap().contains("tunnel unavailable"));
1588        assert_eq!(ipad.control_rail, ControlRail::None);
1589        assert_eq!(ipad.bind_addrs, None, "unknowable, not empty");
1590        assert_eq!(hosts[1].state, HostState::Ready);
1591    }
1592
1593    #[test]
1594    fn adb_splits_emulators_from_devices_and_keeps_offline_ones() {
1595        let hosts = parse_adb_devices(ADB);
1596        assert_eq!(hosts.len(), 2);
1597        assert_eq!(hosts[0].kind, HostKind::AndroidEmulator);
1598        assert_eq!(hosts[0].name, "sdk gphone64 arm64");
1599        assert_eq!(hosts[0].state, HostState::Ready);
1600        assert!(!hosts[0].lanes.contains(&Lane::Lan), "W203: the emulator claims no LAN");
1601        assert_eq!(hosts[0].control_rail, ControlRail::AdbForward);
1602        assert_eq!(hosts[1].kind, HostKind::AndroidDevice);
1603        assert_eq!(hosts[1].state, HostState::Offline);
1604        assert_eq!(hosts[1].state_detail.as_deref(), Some("unauthorized"));
1605        assert!(hosts[1].lanes.contains(&Lane::Lan));
1606    }
1607
1608    #[test]
1609    fn ip_addr_lines_become_bind_addrs() {
1610        let addrs = parse_ip_addr(IP_ADDR);
1611        let pairs: Vec<(String, String)> = addrs
1612            .iter()
1613            .map(|a| (a.interface.clone(), a.addr.to_string()))
1614            .collect();
1615        assert_eq!(
1616            pairs,
1617            vec![
1618                ("lo".into(), "127.0.0.1".into()),
1619                ("eth0".into(), "10.0.2.15".into()),
1620                ("wlan0".into(), "10.0.2.16".into()),
1621            ]
1622        );
1623    }
1624
1625    #[test]
1626    fn the_capability_record_serializes_in_kebab_case() {
1627        let host = &parse_adb_devices(ADB)[0];
1628        let v = serde_json::to_value(host).unwrap();
1629        assert_eq!(v["kind"], "android-emulator");
1630        assert_eq!(v["platform"], "android");
1631        assert_eq!(v["control_rail"], "adb-forward");
1632        assert_eq!(v["lanes"], serde_json::json!(["direct", "roster", "wan"]));
1633    }
1634
1635    fn inventory() -> Inventory {
1636        let mut hosts = parse_simctl(SIMCTL).unwrap();
1637        hosts.extend(parse_adb_devices(ADB));
1638        let mut second = parse_adb_devices(ADB)[0].clone();
1639        second.id = "emulator-5580".into();
1640        hosts.push(second);
1641        Inventory {
1642            hosts,
1643            probes: vec![Probe {
1644                tool: "devicectl".into(),
1645                ok: false,
1646                detail: Some("timed out".into()),
1647            }],
1648        }
1649    }
1650
1651    fn slot(target: HostKind, device: Option<&str>) -> DeviceSlot {
1652        DeviceSlot {
1653            target,
1654            device: device.map(str::to_string),
1655        }
1656    }
1657
1658    #[test]
1659    fn select_picks_the_first_ready_host_by_id_and_names_the_rest() {
1660        let inv = inventory();
1661        let (host, others) = select_host(&inv, &slot(HostKind::AndroidEmulator, None)).unwrap();
1662        assert_eq!(host.id, "emulator-5554");
1663        assert_eq!(others.len(), 1);
1664        assert_eq!(others[0].id, "emulator-5580");
1665    }
1666
1667    #[test]
1668    fn select_by_name_can_target_a_shutdown_simulator() {
1669        let inv = inventory();
1670        let (host, _) =
1671            select_host(&inv, &slot(HostKind::IosSimulator, Some("iphone 17 pro"))).unwrap();
1672        assert_eq!(host.state, HostState::Shutdown);
1673    }
1674
1675    #[test]
1676    fn select_refuses_an_offline_device_and_says_why() {
1677        let inv = inventory();
1678        let err = select_host(&inv, &slot(HostKind::AndroidDevice, Some("R58M123ABC")))
1679            .unwrap_err()
1680            .to_string();
1681        assert!(err.contains("unauthorized"), "{err}");
1682    }
1683
1684    #[test]
1685    fn select_with_nothing_ready_names_failed_probes() {
1686        let inv = inventory();
1687        let err = select_host(&inv, &slot(HostKind::IosDevice, None))
1688            .unwrap_err()
1689            .to_string();
1690        assert!(err.contains("no ready ios-device"), "{err}");
1691        assert!(err.contains("devicectl: timed out"), "{err}");
1692    }
1693
1694    fn mirror(fields: &[(&str, &str)]) -> crate::MirrorConfig {
1695        let mut providers = BTreeMap::new();
1696        providers.insert(
1697            SLOT.to_string(),
1698            MirrorProviderSlot::Inline {
1699                kind: Provider::Device,
1700                fields: fields
1701                    .iter()
1702                    .map(|(k, v)| (k.to_string(), toml::Value::String(v.to_string())))
1703                    .collect(),
1704            },
1705        );
1706        crate::MirrorConfig {
1707            schema_version: 1,
1708            shape: MirrorShape::Local,
1709            providers,
1710            ingress: Default::default(),
1711            ingress_machines: Vec::new(),
1712            drivers: Default::default(),
1713            asset_aliases: Default::default(),
1714            build: Default::default(),
1715        }
1716    }
1717
1718    #[test]
1719    fn the_slot_is_read_from_the_mirror() {
1720        let m = mirror(&[("target", "ios-simulator"), ("device", "iPhone 17 Pro")]);
1721        assert!(slot_declared(&m));
1722        assert_eq!(
1723            device_slot(&m).unwrap(),
1724            slot(HostKind::IosSimulator, Some("iPhone 17 Pro"))
1725        );
1726        assert!(device_slot(&mirror(&[])).is_err());
1727        let err = device_slot(&mirror(&[("target", "local")])).unwrap_err().to_string();
1728        assert!(err.contains("local-process"), "{err}");
1729    }
1730
1731    #[test]
1732    fn the_component_spec_parses_both_platforms() {
1733        let spec = parse_device_spec(
1734            r#"
1735            kind = "container"
1736            [device]
1737            control = true
1738            env = { RUST_LOG = "info" }
1739            [device.ios]
1740            bundle_id = "dev.yah.noisetable"
1741            [device.android]
1742            package = "dev.yah.noisetable"
1743            activity = "android.app.NativeActivity"
1744            apk = "target/x.apk"
1745            "#,
1746        )
1747        .unwrap();
1748        assert!(spec.control);
1749        assert_eq!(spec.ios.unwrap().bundle_id, "dev.yah.noisetable");
1750        assert_eq!(spec.android.unwrap().apk.as_deref(), Some("target/x.apk"));
1751        assert!(parse_device_spec("kind = \"container\"").is_err());
1752    }
1753
1754    #[test]
1755    fn am_start_quotes_every_value_for_the_device_shell() {
1756        let mut extras = BTreeMap::new();
1757        extras.insert("YAH_CONTROL_SOCK".to_string(), "@yah.device-x".to_string());
1758        extras.insert("MSG".to_string(), "it's a test".to_string());
1759        assert_eq!(
1760            am_start_script("dev.yah.nt/android.app.NativeActivity", &extras),
1761            "am start -S -W -n 'dev.yah.nt/android.app.NativeActivity' \
1762             --es 'MSG' 'it'\\''s a test' --es 'YAH_CONTROL_SOCK' '@yah.device-x'"
1763        );
1764    }
1765
1766    // ── Live: the real reconciler against real hardware ──────────────────
1767    //
1768    // Ignored by default — they need an attached emulator / a simulator and
1769    // they launch real apps. Stock system apps stand in for a component so no
1770    // build is involved:
1771    //
1772    //   YAH_DEVICE_LIVE_ANDROID=emulator-5580 cargo test -p yah-cloud --lib \
1773    //     reconciler::device::tests::live -- --ignored --nocapture
1774    //   YAH_DEVICE_LIVE_SIM="iPhone 17 Pro" cargo test … (same filter)
1775
1776    fn live_service() -> crate::ServiceConfig {
1777        crate::ServiceConfig {
1778            schema_version: 1,
1779            name: "devlive".into(),
1780            address: crate::config::ServiceAddress::front_door("devlive.example"),
1781            description: None,
1782            components: vec![crate::ServiceComponent {
1783                mount: None,
1784                id: "app".into(),
1785                kind: "container".into(),
1786                path: "app".into(),
1787                role: "compute".into(),
1788                publishes: None,
1789                wave: 0,
1790                git: None,
1791                deploy: Default::default(),
1792            }],
1793            db: crate::DbCatalog::default(),
1794        }
1795    }
1796
1797    async fn live_up_and_down(target: &str, device: &str, workload: &str) {
1798        let ws = tempfile::tempdir().unwrap();
1799        std::fs::create_dir_all(ws.path().join("app")).unwrap();
1800        std::fs::write(ws.path().join("app/workload.toml"), workload).unwrap();
1801        let svc = live_service();
1802        let m = mirror(&[("target", target), ("device", device)]);
1803        let ctx = ReconcileCtx {
1804            workspace_root: ws.path(),
1805            service: &svc,
1806            component: &svc.components[0],
1807            mirror: &m,
1808            env: "dev",
1809            scope: crate::ProviderScope::singleton(),
1810        };
1811        let running = DeviceReconciler::new().up(ctx).await.expect("up");
1812        eprintln!("notes: {:#?}", running.notes);
1813        assert_eq!(running.kind, "device");
1814        assert!(running.dev_url.is_none());
1815        tokio::time::sleep(Duration::from_secs(2)).await;
1816        assert!(running.is_alive(), "supervisor ended while the app should be up");
1817        running.shutdown().await.expect("shutdown");
1818    }
1819
1820    #[tokio::test]
1821    #[ignore = "needs an attached Android emulator/device: YAH_DEVICE_LIVE_ANDROID=<serial>"]
1822    async fn live_android_launch_supervise_and_stop() {
1823        let Ok(serial) = std::env::var("YAH_DEVICE_LIVE_ANDROID") else {
1824            return;
1825        };
1826        let target = if serial.starts_with("emulator-") {
1827            "android-emulator"
1828        } else {
1829            "android-device"
1830        };
1831        live_up_and_down(
1832            target,
1833            &serial,
1834            "kind = \"container\"\n[device]\nenv = { R941 = \"it's live\" }\n\
1835             [device.android]\npackage = \"com.android.settings\"\nactivity = \".Settings\"\n",
1836        )
1837        .await;
1838        let pid = android_pid(&serial, "com.android.settings").await;
1839        assert_eq!(pid, None, "force-stop on teardown should leave no process");
1840    }
1841
1842    #[tokio::test]
1843    #[ignore = "needs Xcode simulators: YAH_DEVICE_LIVE_SIM=<name or udid>"]
1844    async fn live_simulator_launch_supervise_and_stop() {
1845        let Ok(sim) = std::env::var("YAH_DEVICE_LIVE_SIM") else {
1846            return;
1847        };
1848        live_up_and_down(
1849            "ios-simulator",
1850            &sim,
1851            "kind = \"container\"\n[device]\n[device.ios]\nbundle_id = \"com.apple.mobilesafari\"\n",
1852        )
1853        .await;
1854    }
1855
1856    #[test]
1857    fn local_bind_addrs_include_loopback() {
1858        let addrs = local_bind_addrs();
1859        assert!(
1860            addrs.iter().any(|a| a.addr == IpAddr::V4(Ipv4Addr::LOCALHOST)),
1861            "{addrs:?}"
1862        );
1863    }
1864}