1use 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
108const READY_TIMEOUT: Duration = Duration::from_secs(30);
111
112const ALIVE_GRACE: Duration = Duration::from_millis(1500);
114
115const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
118
119const SHELL_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
121
122const ANDROID_PID_POLL: Duration = Duration::from_secs(2);
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum HostKind {
132 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 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum ControlRail {
229 UnixSocket,
231 AdbForward,
234 None,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "kebab-case")]
240pub enum HostState {
241 Ready,
243 Shutdown,
246 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct DeviceHost {
260 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 pub shares_host_network: bool,
276 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309pub struct Inventory {
310 pub hosts: Vec<DeviceHost>,
311 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
324pub 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 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 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
424async 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
475pub 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
551pub 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
589pub 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
622pub 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
645fn local_bind_addrs() -> Vec<BindAddr> {
649 let mut out = Vec::new();
650 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 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
703async 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
734pub 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
837fn 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#[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 pub fn with_log_buf(mut self, log_buf: LogBuffer) -> Self {
938 self.log_buf = Some(log_buf);
939 self
940 }
941}
942
943#[derive(Debug, Clone)]
945enum Watch {
946 Launcher,
949 AndroidPid { serial: String, package: String, pid: String },
951}
952
953#[derive(Debug, Clone)]
955enum Cleanup {
956 Simulator { udid: String, bundle_id: String },
957 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 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 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 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 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 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 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
1393fn 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
1422fn 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
1459async fn kill_gracefully(child: &mut Child) {
1462 if let Some(pid) = child.id() {
1463 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
1477async 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 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 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}