Skip to main content

waterui_cli/preview/
app_client.rs

1//! TCP client for communicating with the preview support app.
2
3use std::collections::HashSet;
4use std::fs;
5use std::io;
6use std::net::SocketAddr;
7use std::path::Path;
8use std::time::{Duration, Instant};
9
10use eyre::WrapErr as _;
11use eyre::{Result, bail};
12use futures_util::{FutureExt as _, pin_mut, select};
13use smol::Timer;
14use smol::net::TcpStream;
15use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
16
17use super::protocol::{
18    AppError, AppRequest, AppResponse, DylibId, DylibSource, PREVIEW_PROTOCOL_COMMIT,
19    PreviewProtocolInfo, PreviewRuntimePlatform, PreviewTcpConfig, Size,
20};
21
22use waterui_preview_protocol::registry::{PreviewAppInstance, preview_instance_registry_dir};
23use waterui_preview_protocol::transport::{read_frame, write_frame};
24
25/// TCP client for the preview support app.
26#[derive(Debug)]
27pub struct PreviewAppClient {
28    stream: TcpStream,
29    /// Dylib ids known to be present in the app for this connection.
30    present_dylibs: HashSet<DylibId>,
31}
32
33/// What probing one or more candidate preview apps produced.
34///
35/// The three-way split is the whole point. "Nothing answered" and "something
36/// answered and is the wrong build" are different failures with different
37/// remedies, and collapsing them into one `None` is what let a stale `water`
38/// binary present itself as a dead TCP server.
39#[derive(Debug)]
40pub enum PreviewProbe {
41    /// An app answered the handshake and is a build this CLI can drive.
42    Connected(PreviewAppClient),
43    /// An app answered and was turned away. The string is the explanation to
44    /// put in front of whoever ran `water preview`.
45    Rejected(String),
46    /// Nothing answered on any candidate address.
47    Silent,
48}
49
50/// Why an app that answered is not one this CLI can drive.
51///
52/// Both halves are reported, because either alone is a half-diagnosis: the
53/// protocol id says the support app and the CLI were built from different
54/// checkouts of the protocol crate, and the runtime fingerprint says they link
55/// different `waterui_core` builds.
56fn describe_incompatible_app(
57    addr: SocketAddr,
58    app: &PreviewProtocolInfo,
59    expected_core: &str,
60) -> String {
61    let mut reasons = Vec::new();
62    if app.build_commit != PREVIEW_PROTOCOL_COMMIT {
63        reasons.push(format!(
64            "  preview protocol: app {}, this CLI {PREVIEW_PROTOCOL_COMMIT}",
65            app.build_commit
66        ));
67    }
68    if app.waterui_core_fingerprint != expected_core {
69        reasons.push(format!(
70            "  runtime: app {}, expected {expected_core}",
71            app.waterui_core_fingerprint
72        ));
73    }
74    format!(
75        "A preview app is listening on {addr} and answering, but it is not a build this `water` \
76         can drive:\n{}\nRebuild whichever of the two is older, so the CLI and the support app \
77         come from one checkout.",
78        reasons.join("\n")
79    )
80}
81
82impl PreviewAppClient {
83    /// Probe a known preview app socket address.
84    pub async fn probe_addr(
85        addr: SocketAddr,
86        expected_waterui_core_fingerprint: &str,
87        expected_platform: PreviewRuntimePlatform,
88    ) -> PreviewProbe {
89        let stream = match connect_with_timeout(addr, connect_timeout()).await {
90            Ok(stream) => stream,
91            Err(error) => {
92                tracing::warn!("Preview TCP connect failed on {addr}: {error}");
93                return PreviewProbe::Silent;
94            }
95        };
96
97        tracing::info!("Connected to preview app on {addr}");
98        let _ = stream.set_nodelay(true);
99
100        let mut client = Self {
101            stream,
102            present_dylibs: HashSet::new(),
103        };
104
105        // Fast handshake: ensure the server is responsive (not just accepting TCP).
106        //
107        // Some failure modes leave the TCP listener alive while the single render worker
108        // is wedged, causing all requests to hang. A short Ping roundtrip detects this.
109        let handshake = AppRequest::Ping;
110        match client
111            .request_with_timeout(handshake, handshake_timeout())
112            .await
113        {
114            Ok(AppResponse::Pong { protocol }) => {
115                if protocol_is_compatible(
116                    &protocol,
117                    expected_waterui_core_fingerprint,
118                    expected_platform,
119                ) {
120                    return PreviewProbe::Connected(client);
121                }
122
123                tracing::warn!(
124                    "Preview runtime mismatch on {addr}: app waterui_core='{}' platform={:?} protocol={}, expected waterui_core='{}' platform={:?} protocol={}",
125                    protocol.waterui_core_fingerprint,
126                    protocol.platform,
127                    protocol.build_commit,
128                    expected_waterui_core_fingerprint,
129                    expected_platform,
130                    PREVIEW_PROTOCOL_COMMIT,
131                );
132                return PreviewProbe::Rejected(describe_incompatible_app(
133                    addr,
134                    &protocol,
135                    expected_waterui_core_fingerprint,
136                ));
137            }
138            Ok(other) => {
139                tracing::warn!("Preview handshake got unexpected response from {addr}: {other:?}");
140            }
141            Err(err) => {
142                tracing::warn!("Preview handshake failed on {addr}: {err}");
143            }
144        }
145
146        PreviewProbe::Silent
147    }
148
149    /// Probe every live registered local preview app instance.
150    ///
151    /// # Errors
152    /// Returns an error if the instance registry cannot be read.
153    pub async fn probe_registered(
154        expected_waterui_core_fingerprint: &str,
155        expected_platform: PreviewRuntimePlatform,
156    ) -> Result<PreviewProbe> {
157        let expected = expected_waterui_core_fingerprint.to_string();
158        let instances = smol::unblock(move || load_registered_instances_sync(&expected)).await?;
159        tracing::info!(
160            instance_count = instances.len(),
161            "Preview loaded matching registered app instances"
162        );
163
164        // An app that answered and was turned away is the one worth reporting:
165        // "nothing is listening" sends a reader to the network, and this is
166        // never the network.
167        let mut rejection = None;
168        for instance in instances {
169            tracing::info!(pid = instance.pid, host = %instance.host, port = instance.port, "Preview trying registered app instance");
170            let addr = SocketAddr::new(instance.host, instance.port);
171            match Self::probe_addr(addr, expected_waterui_core_fingerprint, expected_platform).await
172            {
173                PreviewProbe::Connected(client) => return Ok(PreviewProbe::Connected(client)),
174                PreviewProbe::Rejected(reason) => {
175                    rejection.get_or_insert(reason);
176                }
177                PreviewProbe::Silent => {}
178            }
179        }
180
181        Ok(rejection.map_or(PreviewProbe::Silent, PreviewProbe::Rejected))
182    }
183
184    /// Probe the configured port range for a running preview app.
185    pub async fn probe_ports(
186        config: PreviewTcpConfig,
187        expected_waterui_core_fingerprint: &str,
188        expected_platform: PreviewRuntimePlatform,
189    ) -> PreviewProbe {
190        // Same reasoning as `probe_registered`: an app that answered and was
191        // turned away outranks every silent port, because silence is the
192        // expected state of a port and an answer is the finding.
193        let mut rejection = None;
194        for port in config.ports() {
195            let addr = SocketAddr::new(config.host, port);
196            match Self::probe_addr(addr, expected_waterui_core_fingerprint, expected_platform).await
197            {
198                PreviewProbe::Connected(client) => return PreviewProbe::Connected(client),
199                PreviewProbe::Rejected(reason) => {
200                    rejection.get_or_insert(reason);
201                }
202                PreviewProbe::Silent => {}
203            }
204        }
205
206        rejection.map_or(PreviewProbe::Silent, PreviewProbe::Rejected)
207    }
208
209    /// Render a view symbol to PNG bytes.
210    ///
211    /// # Errors
212    /// Returns an error if the preview app rejects the request or the transport fails.
213    pub async fn render(
214        &mut self,
215        dylib_id: DylibId,
216        dylib_bytes: &[u8],
217        symbol: &str,
218        width: f32,
219        height: f32,
220    ) -> Result<Vec<u8>> {
221        self.render_with_dylib_source(dylib_id, dylib_bytes, symbol, width, height)
222            .await
223            .map_err(|e| eyre::eyre!("Preview app error: {e}"))
224    }
225
226    /// Render a view symbol, loading dylib bytes from file only when needed.
227    ///
228    /// # Errors
229    /// Returns an error if the preview app cannot be queried or the dylib file cannot be read.
230    pub async fn render_with_dylib_file(
231        &mut self,
232        dylib_id: DylibId,
233        dylib_path: &Path,
234        symbol: &str,
235        width: f32,
236        height: f32,
237        prefer_local_path: bool,
238    ) -> Result<Vec<u8>, AppError> {
239        let total_start = Instant::now();
240        if let Some(png) = self
241            .render_cached_if_present(dylib_id, symbol, width, height)
242            .await?
243        {
244            tracing::info!(
245                dylib_id = %dylib_id,
246                elapsed_ms = total_start.elapsed().as_millis(),
247                "Preview rendered with cached dylib"
248            );
249            return Ok(png);
250        }
251
252        if prefer_local_path {
253            if !dylib_path.is_absolute() {
254                return Err(AppError::RenderFailed(format!(
255                    "local preview dylib path must be absolute: {}",
256                    dylib_path.display()
257                )));
258            }
259
260            let render_start = Instant::now();
261            let result = self
262                .render_with_source(
263                    DylibSource::LocalPath {
264                        id: dylib_id,
265                        path: dylib_path.to_path_buf(),
266                    },
267                    symbol,
268                    width,
269                    height,
270                )
271                .await;
272            tracing::info!(
273                dylib_id = %dylib_id,
274                path = %dylib_path.display(),
275                elapsed_ms = render_start.elapsed().as_millis(),
276                total_elapsed_ms = total_start.elapsed().as_millis(),
277                "Preview rendered after transferring dylib path"
278            );
279
280            self.record_rendered_dylib(dylib_id, &result);
281
282            return result;
283        }
284
285        let read_start = Instant::now();
286        let dylib_bytes = smol::fs::read(dylib_path)
287            .await
288            .map_err(|e| AppError::RenderFailed(format!("failed to read dylib: {e}")))?;
289        tracing::info!(
290            dylib_id = %dylib_id,
291            bytes = dylib_bytes.len(),
292            elapsed_ms = read_start.elapsed().as_millis(),
293            "Preview loaded dylib bytes from disk"
294        );
295
296        let render_start = Instant::now();
297        let result = self
298            .render_with_source(
299                DylibSource::Bytes {
300                    id: dylib_id,
301                    bytes: dylib_bytes,
302                },
303                symbol,
304                width,
305                height,
306            )
307            .await;
308        tracing::info!(
309            dylib_id = %dylib_id,
310            elapsed_ms = render_start.elapsed().as_millis(),
311            total_elapsed_ms = total_start.elapsed().as_millis(),
312            "Preview rendered after transferring dylib bytes"
313        );
314
315        self.record_rendered_dylib(dylib_id, &result);
316
317        result
318    }
319
320    /// Render a view symbol, returning structured app errors for caller handling.
321    ///
322    /// # Errors
323    /// Returns an error if the preview app cannot render the symbol or the transport fails.
324    pub async fn render_with_dylib_source(
325        &mut self,
326        dylib_id: DylibId,
327        dylib_bytes: &[u8],
328        symbol: &str,
329        width: f32,
330        height: f32,
331    ) -> Result<Vec<u8>, AppError> {
332        if let Some(png) = self
333            .render_cached_if_present(dylib_id, symbol, width, height)
334            .await?
335        {
336            return Ok(png);
337        }
338
339        let result = self
340            .render_with_source(
341                DylibSource::Bytes {
342                    id: dylib_id,
343                    bytes: dylib_bytes.to_vec(),
344                },
345                symbol,
346                width,
347                height,
348            )
349            .await;
350        self.record_rendered_dylib(dylib_id, &result);
351        result
352    }
353
354    async fn render_cached_if_present(
355        &mut self,
356        dylib_id: DylibId,
357        symbol: &str,
358        width: f32,
359        height: f32,
360    ) -> Result<Option<Vec<u8>>, AppError> {
361        if self.present_dylibs.insert(dylib_id) {
362            let query_start = Instant::now();
363            let present = match self.has_dylib(dylib_id).await {
364                Ok(present) => present,
365                Err(error) => {
366                    self.present_dylibs.remove(&dylib_id);
367                    return Err(AppError::RenderFailed(format!("transport error: {error}")));
368                }
369            };
370            tracing::info!(
371                dylib_id = %dylib_id,
372                present,
373                elapsed_ms = query_start.elapsed().as_millis(),
374                "Preview queried support-app dylib cache"
375            );
376            if !present {
377                self.present_dylibs.remove(&dylib_id);
378                return Ok(None);
379            }
380        }
381
382        match self
383            .render_with_source(DylibSource::Cached { id: dylib_id }, symbol, width, height)
384            .await
385        {
386            Ok(png) => Ok(Some(png)),
387            Err(AppError::UnknownDylibId(_)) => {
388                self.present_dylibs.remove(&dylib_id);
389                Ok(None)
390            }
391            Err(error) => Err(error),
392        }
393    }
394
395    fn record_rendered_dylib(&mut self, dylib_id: DylibId, result: &Result<Vec<u8>, AppError>) {
396        match result {
397            Ok(_) | Err(AppError::SymbolNotFound(_)) => {
398                self.present_dylibs.insert(dylib_id);
399            }
400            Err(AppError::UnknownDylibId(_)) => {
401                self.present_dylibs.remove(&dylib_id);
402            }
403            Err(AppError::DylibLoad(_) | AppError::RenderFailed(_)) => {}
404        }
405    }
406
407    async fn render_with_source(
408        &mut self,
409        dylib: DylibSource,
410        symbol: &str,
411        width: f32,
412        height: f32,
413    ) -> Result<Vec<u8>, AppError> {
414        let request = AppRequest::Render {
415            dylib,
416            symbol: symbol.to_string(),
417            frame: Size::new(width, height),
418        };
419
420        let response = self
421            .request(request)
422            .await
423            .map_err(|e| AppError::RenderFailed(format!("transport error: {e}")))?;
424
425        match response {
426            waterui_preview_protocol::PreviewResponse::Render { result } => result.map(|output| {
427                tracing::info!(timings = ?output.timings, "Preview support app timing breakdown");
428                output.png_data
429            }),
430            other => Err(AppError::RenderFailed(format!(
431                "protocol error: unexpected response to Render: {other:?}"
432            ))),
433        }
434    }
435
436    /// Ask the preview app to shut down.
437    ///
438    /// # Errors
439    /// Returns an error if the shutdown request cannot be sent or the app replies with an unexpected message.
440    pub async fn shutdown(&mut self) -> Result<()> {
441        let response = self.request(AppRequest::Shutdown).await?;
442        match response {
443            waterui_preview_protocol::PreviewResponse::Shutdown => Ok(()),
444            other => {
445                bail!("Protocol error: unexpected response to Shutdown: {other:?}");
446            }
447        }
448    }
449
450    async fn has_dylib(&mut self, id: DylibId) -> Result<bool> {
451        let response = self.request(AppRequest::HasDylib { id }).await?;
452        match response {
453            waterui_preview_protocol::PreviewResponse::HasDylib { present } => Ok(present),
454            other => {
455                bail!("Protocol error: unexpected response to HasDylib: {other:?}");
456            }
457        }
458    }
459
460    async fn request(&mut self, request: AppRequest) -> Result<AppResponse> {
461        let timeout = request_timeout_for(&request);
462        self.request_with_timeout(request, timeout).await
463    }
464
465    async fn request_with_timeout(
466        &mut self,
467        request: AppRequest,
468        timeout: Duration,
469    ) -> Result<AppResponse> {
470        let kind = request_kind(&request);
471        let start = Instant::now();
472        write_frame(&mut self.stream, &request)
473            .await
474            .wrap_err("Failed to send request")?;
475
476        let recv = async {
477            match read_frame::<_, AppResponse>(&mut self.stream).await {
478                Ok(response) => Ok(response),
479                Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => {
480                    bail!(
481                        "Preview app connection closed unexpectedly (the preview process likely crashed). Check crash logs in ~/Library/Logs/DiagnosticReports/, filed under the preview application's own name"
482                    );
483                }
484                Err(err) => Err(err).wrap_err("Failed to receive response"),
485            }
486        }
487        .fuse();
488        let timeout_fut = Timer::after(timeout).fuse();
489
490        pin_mut!(recv);
491        pin_mut!(timeout_fut);
492
493        select! {
494            result = recv => {
495                if result.is_ok() {
496                    tracing::info!(
497                        request = kind,
498                        elapsed_ms = start.elapsed().as_millis(),
499                        "Preview app request completed"
500                    );
501                }
502                result
503            },
504            _ = timeout_fut => {
505                bail!("Preview app request timed out after {timeout:?} ({kind})");
506            }
507        }
508    }
509}
510
511fn protocol_is_compatible(
512    protocol: &PreviewProtocolInfo,
513    expected_waterui_core_fingerprint: &str,
514    expected_platform: PreviewRuntimePlatform,
515) -> bool {
516    protocol.waterui_core_fingerprint == expected_waterui_core_fingerprint
517        && protocol.platform == expected_platform
518        && protocol.build_commit == PREVIEW_PROTOCOL_COMMIT
519}
520
521fn load_registered_instances_sync(
522    expected_waterui_core_fingerprint: &str,
523) -> io::Result<Vec<PreviewAppInstance>> {
524    let dir = preview_instance_registry_dir();
525    fs::create_dir_all(&dir)?;
526
527    let mut candidates = Vec::new();
528    let mut stale_paths = Vec::new();
529
530    for entry in fs::read_dir(&dir)? {
531        let entry = entry?;
532        let path = entry.path();
533        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
534            continue;
535        }
536
537        let bytes = match fs::read(&path) {
538            Ok(bytes) => bytes,
539            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
540            Err(error) => return Err(error),
541        };
542
543        let Ok(instance) = serde_json::from_slice::<PreviewAppInstance>(&bytes) else {
544            stale_paths.push(path);
545            continue;
546        };
547
548        if instance.waterui_core_fingerprint == expected_waterui_core_fingerprint {
549            candidates.push((instance, path));
550        }
551    }
552
553    let mut matching = Vec::with_capacity(candidates.len());
554    if !candidates.is_empty() {
555        let mut processes = System::new();
556        processes.refresh_processes_specifics(
557            ProcessesToUpdate::All,
558            true,
559            ProcessRefreshKind::nothing(),
560        );
561        for (instance, path) in candidates {
562            if processes.process(Pid::from_u32(instance.pid)).is_some() {
563                matching.push(instance);
564            } else {
565                stale_paths.push(path);
566            }
567        }
568    }
569
570    matching.sort_by_key(|registration| std::cmp::Reverse(registration.registered_at_unix_ms));
571
572    for path in stale_paths {
573        match fs::remove_file(path) {
574            Ok(()) => {}
575            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
576            Err(error) => return Err(error),
577        }
578    }
579
580    Ok(matching)
581}
582
583fn connect_timeout() -> Duration {
584    const DEFAULT_MS: u64 = 100;
585    timeout_from_env("WATERUI_PREVIEW_CONNECT_TIMEOUT_MS", DEFAULT_MS)
586}
587
588fn handshake_timeout() -> Duration {
589    // 500 ms is plenty over loopback, but an `adb forward` channel to a
590    // network-connected device rides the adb transport: every frame pays the
591    // remote round trip, so a Ping/Pong handshake measures in the hundreds of
592    // milliseconds and can exceed a tight cap even on a healthy app.
593    const DEFAULT_MS: u64 = 5000;
594    timeout_from_env("WATERUI_PREVIEW_HANDSHAKE_TIMEOUT_MS", DEFAULT_MS)
595}
596
597fn request_timeout() -> Duration {
598    const DEFAULT_MS: u64 = 20_000;
599    timeout_from_env("WATERUI_PREVIEW_REQUEST_TIMEOUT_MS", DEFAULT_MS)
600}
601
602fn render_request_timeout() -> Duration {
603    const DEFAULT_MS: u64 = 120_000;
604    timeout_from_env("WATERUI_PREVIEW_RENDER_TIMEOUT_MS", DEFAULT_MS)
605}
606
607fn timeout_from_env(name: &str, default_ms: u64) -> Duration {
608    match std::env::var(name) {
609        Ok(value) => Duration::from_millis(
610            value
611                .parse::<u64>()
612                .unwrap_or_else(|error| panic!("invalid {name} value `{value}`: {error}")),
613        ),
614        Err(std::env::VarError::NotPresent) => Duration::from_millis(default_ms),
615        Err(std::env::VarError::NotUnicode(_)) => panic!("{name} must be valid UTF-8"),
616    }
617}
618
619fn request_timeout_for(request: &AppRequest) -> Duration {
620    match request {
621        AppRequest::Render { .. } => render_request_timeout(),
622        _ => request_timeout(),
623    }
624}
625
626const fn request_kind(request: &AppRequest) -> &'static str {
627    match request {
628        AppRequest::Ping => "Ping",
629        AppRequest::HasDylib { .. } => "HasDylib",
630        AppRequest::Render { .. } => "Render",
631        AppRequest::Shutdown => "Shutdown",
632    }
633}
634
635async fn connect_with_timeout(addr: SocketAddr, timeout: Duration) -> io::Result<TcpStream> {
636    let connect = TcpStream::connect(addr).fuse();
637    let timeout_fut = Timer::after(timeout).fuse();
638
639    pin_mut!(connect);
640    pin_mut!(timeout_fut);
641
642    select! {
643        result = connect => result,
644        _ = timeout_fut => Err(io::Error::new(io::ErrorKind::TimedOut, "preview TCP connect timed out")),
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn protocol_match_requires_exact_preview_build() {
654        let protocol = PreviewProtocolInfo {
655            build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
656            waterui_core_fingerprint: "runtime-fingerprint".to_string(),
657            platform: PreviewRuntimePlatform::Macos,
658        };
659        assert!(protocol_is_compatible(
660            &protocol,
661            "runtime-fingerprint",
662            PreviewRuntimePlatform::Macos
663        ));
664
665        let stale = PreviewProtocolInfo {
666            build_commit: "stale-preview-build".to_string(),
667            ..protocol
668        };
669        assert!(!protocol_is_compatible(
670            &stale,
671            "runtime-fingerprint",
672            PreviewRuntimePlatform::Macos
673        ));
674    }
675
676    #[test]
677    fn rejection_names_both_halves_of_the_mismatch() {
678        let addr: SocketAddr = "127.0.0.1:9123".parse().unwrap();
679        let app = PreviewProtocolInfo {
680            build_commit: "app-protocol-build".to_string(),
681            waterui_core_fingerprint: "app-runtime".to_string(),
682            platform: PreviewRuntimePlatform::Macos,
683        };
684
685        let explanation = describe_incompatible_app(addr, &app, "cli-runtime");
686
687        assert!(explanation.contains("127.0.0.1:9123"), "{explanation}");
688        assert!(explanation.contains("app-protocol-build"), "{explanation}");
689        assert!(
690            explanation.contains(PREVIEW_PROTOCOL_COMMIT),
691            "{explanation}"
692        );
693        assert!(explanation.contains("app-runtime"), "{explanation}");
694        assert!(explanation.contains("cli-runtime"), "{explanation}");
695    }
696
697    #[test]
698    fn rejection_reports_only_the_half_that_differs() {
699        let addr: SocketAddr = "127.0.0.1:9123".parse().unwrap();
700        let app = PreviewProtocolInfo {
701            build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
702            waterui_core_fingerprint: "app-runtime".to_string(),
703            platform: PreviewRuntimePlatform::Macos,
704        };
705
706        let explanation = describe_incompatible_app(addr, &app, "cli-runtime");
707
708        assert!(!explanation.contains("preview protocol:"), "{explanation}");
709        assert!(
710            explanation.contains("runtime: app app-runtime"),
711            "{explanation}"
712        );
713    }
714}