Skip to main content

smix_simctl/
surface_capture.rs

1//! Persistent CoreSimulator framebuffer capture via a resident per-sim
2//! `smix-capture-host` process in request-response ("serve") mode.
3//!
4//! The per-shot `xcrun simctl io <udid> screenshot` path pays a ~74 ms
5//! process-spawn + dyld floor and a ~66 ms PNG encode every shot (see
6//! `docs/perf/v2.8-c3-screenshot-decomposition.md`). This module holds one
7//! resident host per booted UDID that resolves the display `IOSurface` once
8//! (~58 ms, amortized) and then answers each capture request by locking +
9//! copying the framebuffer directly — ~0.3 ms for a raw BGRA frame, ~66 ms
10//! for an in-host ImageIO PNG encode, with **no** per-shot process spawn.
11//!
12//! Correctness: the host **revalidates the surface on every grab** (re-fetches
13//! the device's current `framebufferSurface` and adopts it if the sim rebooted
14//! and vended a new one). If the surface can no longer be resolved (sim shut
15//! down, framework layout changed) the host answers with a
16//! surface-unavailable status and exits, and the caller falls back to the
17//! `xcrun simctl io screenshot` path. A stale/garbage frame is never returned.
18//!
19//! Wire protocol (host `serve` mode):
20//!   - host emits `<W>x<H>\n` on stderr once, then loops.
21//!   - request:  one opcode byte on stdin — [`OP_RAW`] (raw BGRA) or
22//!     [`OP_PNG`] (ImageIO PNG). EOF ends the host.
23//!   - response: one status byte — [`STATUS_OK`] or [`STATUS_UNAVAILABLE`].
24//!     On OK, followed by a 12-byte header `w:u32 h:u32 len:u32` (little
25//!     endian) then `len` payload bytes. On UNAVAILABLE the host exits.
26
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::process::Stdio;
30use std::time::Duration;
31
32use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
33use tokio::process::{Child, ChildStdin, ChildStdout, Command};
34
35/// Opcode: grab a raw BGRA frame (row padding stripped).
36pub const OP_RAW: u8 = b'R';
37/// Opcode: grab an in-host ImageIO-encoded PNG frame.
38pub const OP_PNG: u8 = b'P';
39/// Response status: an `w/h/len` header + payload follow.
40pub const STATUS_OK: u8 = 0;
41/// Response status: the surface could not be resolved; the host is exiting
42/// and the caller must fall back to `simctl`.
43pub const STATUS_UNAVAILABLE: u8 = 1;
44
45/// A captured frame — either raw BGRA pixels (the fast diff-loop path) or a
46/// PNG (the file-save path, or the `simctl` fallback which is always PNG).
47#[derive(Clone, PartialEq, Eq)]
48pub enum CapturedFrame {
49    /// Raw BGRA8888 pixels, `width * height * 4` bytes, row padding stripped.
50    Bgra {
51        /// Frame width in pixels.
52        width: u32,
53        /// Frame height in pixels.
54        height: u32,
55        /// `width * height * 4` bytes, BGRA order, no row padding.
56        data: Vec<u8>,
57    },
58    /// PNG-encoded frame.
59    Png(Vec<u8>),
60}
61
62impl std::fmt::Debug for CapturedFrame {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            CapturedFrame::Bgra {
66                width,
67                height,
68                data,
69            } => f
70                .debug_struct("CapturedFrame::Bgra")
71                .field("width", width)
72                .field("height", height)
73                .field("bytes", &data.len())
74                .finish(),
75            CapturedFrame::Png(b) => f
76                .debug_struct("CapturedFrame::Png")
77                .field("bytes", &b.len())
78                .finish(),
79        }
80    }
81}
82
83/// Why a resident-host capture could not be produced. Every variant means
84/// "fall back to `simctl`", but they are distinguished for diagnostics.
85#[derive(Debug)]
86pub enum HostError {
87    /// The `smix-capture-host` binary was not found on disk.
88    BinaryMissing(PathBuf),
89    /// The host was spawned but never emitted its `WxH` geometry header
90    /// (surface resolve failed, or it crashed on launch).
91    ResolveFailed(String),
92    /// The host process died mid-conversation (stdout EOF on a grab).
93    HostGone,
94    /// An I/O error talking to the host.
95    Io(std::io::Error),
96    /// The host sent a malformed response header.
97    Protocol(String),
98}
99
100impl std::fmt::Display for HostError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            HostError::BinaryMissing(p) => write!(f, "smix-capture-host not found at {p:?}"),
104            HostError::ResolveFailed(s) => write!(f, "capture-host surface resolve failed: {s}"),
105            HostError::HostGone => write!(f, "capture-host process gone"),
106            HostError::Io(e) => write!(f, "capture-host io: {e}"),
107            HostError::Protocol(s) => write!(f, "capture-host protocol: {s}"),
108        }
109    }
110}
111
112impl std::error::Error for HostError {}
113
114impl From<std::io::Error> for HostError {
115    fn from(e: std::io::Error) -> Self {
116        HostError::Io(e)
117    }
118}
119
120/// Parsed `w:u32 h:u32 len:u32` little-endian frame header.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct FrameHeader {
123    /// Frame width in pixels.
124    pub width: u32,
125    /// Frame height in pixels.
126    pub height: u32,
127    /// Payload length in bytes.
128    pub len: u32,
129}
130
131impl FrameHeader {
132    /// Parse a 12-byte little-endian `w/h/len` header.
133    pub fn parse(buf: &[u8; 12]) -> FrameHeader {
134        FrameHeader {
135            width: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
136            height: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
137            len: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
138        }
139    }
140}
141
142/// Locate the `smix-capture-host` binary the same way the `/live` pipeline
143/// does: honor `SMIX_CAPTURE_HOST_BIN`, else the in-repo release build path.
144pub fn capture_host_bin() -> PathBuf {
145    std::env::var_os("SMIX_CAPTURE_HOST_BIN").map_or_else(
146        || PathBuf::from("swift-bridge/.build/release/smix-capture-host"),
147        PathBuf::from,
148    )
149}
150
151/// A resident `smix-capture-host` in `serve` mode for one booted UDID.
152///
153/// Holds the child + its stdin/stdout. Dropping it kills the child
154/// (`kill_on_drop`), so a dropped [`SimctlClient`](crate::SimctlClient) leaves
155/// no stray capture host behind.
156pub struct SurfaceCaptureHost {
157    child: Child,
158    stdin: ChildStdin,
159    stdout: BufReader<ChildStdout>,
160    /// Geometry from the startup header (informational; each frame carries its
161    /// own `w/h`, which is authoritative across a rotation/reboot).
162    pub width: u32,
163    /// Startup-header height (see [`SurfaceCaptureHost::width`]).
164    pub height: u32,
165}
166
167impl SurfaceCaptureHost {
168    /// Spawn a resident host for `udid` and wait (≤5 s) for its `WxH`
169    /// geometry header, which confirms the surface resolved.
170    pub async fn spawn(udid: &str) -> Result<SurfaceCaptureHost, HostError> {
171        let bin = capture_host_bin();
172        if !bin.exists() {
173            return Err(HostError::BinaryMissing(bin));
174        }
175        let mut child = Command::new(&bin)
176            .arg(udid)
177            .arg("serve")
178            .stdin(Stdio::piped())
179            .stdout(Stdio::piped())
180            .stderr(Stdio::piped())
181            .kill_on_drop(true)
182            .spawn()
183            .map_err(HostError::Io)?;
184
185        let stdin = child
186            .stdin
187            .take()
188            .ok_or_else(|| HostError::ResolveFailed("stdin not piped".into()))?;
189        let stdout = child
190            .stdout
191            .take()
192            .ok_or_else(|| HostError::ResolveFailed("stdout not piped".into()))?;
193        let stderr = child
194            .stderr
195            .take()
196            .ok_or_else(|| HostError::ResolveFailed("stderr not piped".into()))?;
197
198        let mut stderr_reader = BufReader::new(stderr);
199        let mut header = String::new();
200        let read =
201            tokio::time::timeout(Duration::from_secs(5), stderr_reader.read_line(&mut header))
202                .await;
203        let (width, height) = match read {
204            Ok(Ok(n)) if n > 0 => parse_geometry_line(&header)
205                .ok_or_else(|| HostError::ResolveFailed(format!("bad WxH header: {header:?}")))?,
206            Ok(Ok(_)) => {
207                return Err(HostError::ResolveFailed(
208                    "host exited before WxH header".into(),
209                ));
210            }
211            Ok(Err(e)) => return Err(HostError::ResolveFailed(format!("read header: {e}"))),
212            Err(_) => {
213                return Err(HostError::ResolveFailed(
214                    "WxH header not received within 5s".into(),
215                ));
216            }
217        };
218
219        // Drain the rest of stderr in the background so the host never blocks
220        // on a full pipe. Ends naturally when the host's stderr closes.
221        tokio::spawn(async move {
222            let mut lines = stderr_reader.lines();
223            while let Ok(Some(_line)) = lines.next_line().await {}
224        });
225
226        Ok(SurfaceCaptureHost {
227            child,
228            stdin,
229            stdout: BufReader::new(stdout),
230            width,
231            height,
232        })
233    }
234
235    /// Request one frame. `want_png` selects an in-host ImageIO PNG encode;
236    /// otherwise a raw BGRA frame.
237    ///
238    /// Returns `Ok(Some(frame))` on success, `Ok(None)` when the host reports
239    /// the surface is no longer resolvable (caller falls back to `simctl`),
240    /// and `Err` on a transport failure (caller drops this host + falls back).
241    pub async fn grab(&mut self, want_png: bool) -> Result<Option<CapturedFrame>, HostError> {
242        let op = if want_png { OP_PNG } else { OP_RAW };
243        self.stdin.write_all(&[op]).await?;
244        self.stdin.flush().await?;
245
246        let mut status = [0u8; 1];
247        if let Err(e) = self.stdout.read_exact(&mut status).await {
248            return if e.kind() == std::io::ErrorKind::UnexpectedEof {
249                Err(HostError::HostGone)
250            } else {
251                Err(HostError::Io(e))
252            };
253        }
254        match status[0] {
255            STATUS_UNAVAILABLE => Ok(None),
256            STATUS_OK => {
257                let mut hdr = [0u8; 12];
258                self.read_exact_or_gone(&mut hdr).await?;
259                let h = FrameHeader::parse(&hdr);
260                let len = h.len as usize;
261                // Guard against a corrupt length demanding an unbounded read.
262                // A full BGRA frame is w*h*4; a PNG is smaller. 128 MB ceiling
263                // is generous for any realistic device framebuffer.
264                if len > 128 * 1024 * 1024 {
265                    return Err(HostError::Protocol(format!("payload len too large: {len}")));
266                }
267                let mut payload = vec![0u8; len];
268                self.read_exact_or_gone(&mut payload).await?;
269                let frame = if want_png {
270                    CapturedFrame::Png(payload)
271                } else {
272                    CapturedFrame::Bgra {
273                        width: h.width,
274                        height: h.height,
275                        data: payload,
276                    }
277                };
278                Ok(Some(frame))
279            }
280            other => Err(HostError::Protocol(format!("unknown status byte {other}"))),
281        }
282    }
283
284    async fn read_exact_or_gone(&mut self, buf: &mut [u8]) -> Result<(), HostError> {
285        match self.stdout.read_exact(buf).await {
286            Ok(_) => Ok(()),
287            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Err(HostError::HostGone),
288            Err(e) => Err(HostError::Io(e)),
289        }
290    }
291
292    /// Best-effort clean shutdown: close stdin (host sees EOF → exits 0) and
293    /// reap. `kill_on_drop` is the backstop if this is skipped.
294    pub async fn shutdown(mut self) {
295        drop(self.stdin);
296        let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
297    }
298}
299
300/// Parse a `WxH\n` geometry line.
301pub fn parse_geometry_line(s: &str) -> Option<(u32, u32)> {
302    let (w, h) = s.trim().split_once('x')?;
303    Some((w.parse().ok()?, h.parse().ok()?))
304}
305
306/// Per-UDID resident-host registry. Held behind an async mutex inside
307/// [`SimctlClient`](crate::SimctlClient).
308#[derive(Default)]
309pub struct CaptureHostRegistry {
310    hosts: HashMap<String, SurfaceCaptureHost>,
311}
312
313impl std::fmt::Debug for CaptureHostRegistry {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("CaptureHostRegistry")
316            .field("resident_hosts", &self.hosts.len())
317            .finish()
318    }
319}
320
321impl CaptureHostRegistry {
322    /// Take (removing) the host for `udid`, if resident.
323    pub fn take(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
324        self.hosts.remove(udid)
325    }
326
327    /// Re-insert a live host for `udid`.
328    pub fn put(&mut self, udid: &str, host: SurfaceCaptureHost) {
329        self.hosts.insert(udid.to_string(), host);
330    }
331
332    /// Drop the resident host for `udid` (e.g. on a lifecycle change).
333    pub fn evict(&mut self, udid: &str) -> Option<SurfaceCaptureHost> {
334        self.hosts.remove(udid)
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn frame_header_parses_little_endian() {
344        // w=1206 (0x000004B6), h=2622 (0x00000A3E), len=12648528 (0x00C10050)
345        let buf = [
346            0xB6, 0x04, 0x00, 0x00, 0x3E, 0x0A, 0x00, 0x00, 0x50, 0x00, 0xC1, 0x00,
347        ];
348        let h = FrameHeader::parse(&buf);
349        assert_eq!(h.width, 1206);
350        assert_eq!(h.height, 2622);
351        assert_eq!(h.len, 12_648_528);
352    }
353
354    #[test]
355    fn status_constants_are_distinct_and_ops_are_ascii() {
356        assert_ne!(STATUS_OK, STATUS_UNAVAILABLE);
357        assert_eq!(OP_RAW, b'R');
358        assert_eq!(OP_PNG, b'P');
359    }
360
361    #[test]
362    fn geometry_line_parses_and_rejects_junk() {
363        assert_eq!(parse_geometry_line("1206x2622\n"), Some((1206, 2622)));
364        assert_eq!(parse_geometry_line("  800x600  "), Some((800, 600)));
365        assert_eq!(parse_geometry_line("not-a-size"), None);
366        assert_eq!(parse_geometry_line("1206x"), None);
367    }
368
369    #[test]
370    fn registry_take_put_evict_roundtrip_key() {
371        // No live host needed: exercise the key bookkeeping (put/take/evict)
372        // which is the fallback-selection state the async path relies on.
373        let mut reg = CaptureHostRegistry::default();
374        assert!(reg.take("UDID-A").is_none());
375        assert!(reg.evict("UDID-A").is_none());
376        // A resident host cannot be fabricated without a child; assert the
377        // empty-registry contract that drives "spawn on miss".
378        assert!(reg.hosts.is_empty());
379    }
380
381    #[test]
382    fn bin_path_honors_env_override() {
383        // The env var is process-global; set + clear around the assertion.
384        let prev = std::env::var_os("SMIX_CAPTURE_HOST_BIN");
385        // SAFETY: single-threaded test; restored below.
386        unsafe { std::env::set_var("SMIX_CAPTURE_HOST_BIN", "/tmp/custom-host") };
387        assert_eq!(capture_host_bin(), PathBuf::from("/tmp/custom-host"));
388        unsafe {
389            match prev {
390                Some(v) => std::env::set_var("SMIX_CAPTURE_HOST_BIN", v),
391                None => std::env::remove_var("SMIX_CAPTURE_HOST_BIN"),
392            }
393        }
394    }
395}