Skip to main content

frust_devtools/
service.rs

1//! [`Service::start`] and [`ServiceHandle`] — what a shell actually touches.
2
3use std::io;
4use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5use std::sync::Arc;
6use std::time::Duration;
7
8use frust_devtools_protocol::{FrameStats, format_discovery_line};
9use tokio::net::TcpListener;
10use tokio::runtime::Builder;
11use tokio::sync::watch;
12
13use crate::backend::{AppInfo, DevtoolsBackend};
14use crate::dispatch::SessionCtx;
15use crate::frame_stats::{self, FrameStatsBus};
16use crate::hop::spawn_backend_thread;
17use crate::server::accept_loop;
18use crate::token;
19
20/// Tuning knobs. [`ServiceConfig::default`] is what [`Service::start`] uses;
21/// [`Service::start_with_config`] exists for tests and for a shell with an
22/// unusual budget.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ServiceConfig {
25    /// How long a single backend call may take before the waiting client is
26    /// answered with an error instead. This is the promise that a wedged UI
27    /// thread costs a client an error, never a hang — see `crate::hop`'s
28    /// blocking model.
29    pub backend_timeout: Duration,
30    /// Per-client frame-stats queue depth (drop-oldest beyond it).
31    pub frame_stats_capacity: usize,
32    /// Simultaneous clients; further connections are closed immediately.
33    pub max_clients: usize,
34    /// How many backend calls may queue ahead of the one in flight.
35    pub backend_queue_depth: usize,
36    /// Whether a client must present this process's token at `handshake`
37    /// before any other method is dispatched. **Default `true`, and a shell
38    /// should leave it that way**: loopback is not a trust boundary on a
39    /// device, where any co-resident app can reach the port (see
40    /// `crate::token`'s module doc). Switching it off is for an in-process
41    /// test or a host with a stronger boundary of its own, never for a shipped
42    /// build.
43    pub require_token: bool,
44}
45
46impl Default for ServiceConfig {
47    fn default() -> Self {
48        Self {
49            // Comfortably longer than a slow frame, far shorter than a human's
50            // patience: a client learns "the app is wedged" in about a second.
51            backend_timeout: Duration::from_millis(1_000),
52            frame_stats_capacity: frame_stats::DEFAULT_CAPACITY,
53            // A devtools client, a CI driver, and room for a stale connection
54            // the OS has not reaped yet.
55            max_clients: 4,
56            backend_queue_depth: 16,
57            require_token: true,
58        }
59    }
60}
61
62/// Starts the in-app devtools service. A namespace, not a value — the running
63/// service is owned through its [`ServiceHandle`].
64pub struct Service;
65
66impl Service {
67    /// Binds an ephemeral loopback port and starts serving, with
68    /// [`ServiceConfig::default`].
69    ///
70    /// # Errors
71    ///
72    /// The `io::Error` from binding `127.0.0.1:0` or from building the
73    /// internal runtime. A shell should treat a failure here as "no devtools
74    /// this run" and carry on — the service is never load-bearing for the app.
75    pub fn start<B: DevtoolsBackend>(backend: B, app: AppInfo) -> io::Result<ServiceHandle> {
76        Service::start_with_config(backend, app, ServiceConfig::default())
77    }
78
79    /// [`Service::start`] with explicit tuning.
80    ///
81    /// # Errors
82    ///
83    /// See [`Service::start`].
84    pub fn start_with_config<B: DevtoolsBackend>(
85        backend: B,
86        app: AppInfo,
87        config: ServiceConfig,
88    ) -> io::Result<ServiceHandle> {
89        // Captured here, on the caller's thread (the shell's, at setup time),
90        // so `handshake` is answerable later without touching the backend at
91        // all — including while the UI thread is wedged.
92        let handshake = backend.handshake_info(&app);
93
94        // One current-thread runtime: this service is entirely IO-bound, and a
95        // worker pool inside an app process would be a cost the shell never
96        // asked for. `enable_io` needs tokio's `net` feature; `enable_time`
97        // backs the backend timeout and the shutdown grace window.
98        let runtime = Builder::new_current_thread()
99            .enable_io()
100            .enable_time()
101            .thread_name("frust-devtools")
102            .build()?;
103
104        // Loopback only, ephemeral port. Never `0.0.0.0`: this port answers
105        // `input_*` and dumps the widget tree, so exposing it on a network
106        // interface would hand any peer on the LAN control of the app (see the
107        // crate doc's trust model).
108        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
109        let listener = runtime.block_on(TcpListener::bind(addr))?;
110        let port = listener.local_addr()?.port();
111
112        // One token per process, minted here and never regenerated: a client
113        // that read the discovery line once can reconnect for the app's whole
114        // lifetime (see `crate::token` for the entropy source and its limits).
115        let token = config.require_token.then(token::generate);
116
117        // The one discovery contract, formatted by the protocol crate itself so
118        // the formatter and `parse_discovery_line` cannot drift. `log` (not
119        // `println!`) because that is the only sink that reaches logcat/oslog on
120        // a device, which is where tooling greps for it — and, with auth on, the
121        // only place the token appears at all.
122        log::info!("{}", format_discovery_line(port, token.as_deref()));
123
124        let bus = Arc::new(FrameStatsBus::new(config.frame_stats_capacity));
125        let backend_client =
126            spawn_backend_thread(backend, config.backend_queue_depth, config.backend_timeout);
127        let ctx = Arc::new(SessionCtx {
128            handshake,
129            backend: backend_client,
130            bus: Arc::clone(&bus),
131            token: token.clone(),
132        });
133
134        let (shutdown_tx, shutdown_rx) = watch::channel(false);
135        let max_clients = config.max_clients;
136        let driver = std::thread::Builder::new()
137            .name("frust-devtools".to_string())
138            .spawn(move || {
139                runtime.block_on(accept_loop(listener, ctx, shutdown_rx, max_clients));
140                // Dropping the runtime here (rather than leaking it) drops the
141                // last `SessionCtx`, which closes the backend channel and lets
142                // the backend thread finish.
143            })?;
144
145        Ok(ServiceHandle {
146            port,
147            token,
148            bus,
149            shutdown_tx,
150            driver: Some(driver),
151        })
152    }
153}
154
155/// The running service. Dropping it shuts the service down, so a shell can
156/// simply hold it for as long as devtools should be available.
157pub struct ServiceHandle {
158    port: u16,
159    token: Option<String>,
160    bus: Arc<FrameStatsBus>,
161    shutdown_tx: watch::Sender<bool>,
162    driver: Option<std::thread::JoinHandle<()>>,
163}
164
165impl ServiceHandle {
166    /// The bound loopback port — always ephemeral, so this is the only way to
167    /// know it besides the discovery line.
168    pub fn port(&self) -> u16 {
169        self.port
170    }
171
172    /// This process's handshake token, or `None` when the service was started
173    /// with [`ServiceConfig::require_token`] off.
174    ///
175    /// The **in-process** counterpart of reading it off the discovery line, and
176    /// no weaker: the caller is the process that owns the secret. Handing it
177    /// anywhere else — a response body, a file, another process — defeats the
178    /// whole mechanism, which relies on the token reaching only readers of this
179    /// app's log stream.
180    pub fn token(&self) -> Option<&str> {
181        self.token.as_deref()
182    }
183
184    /// Publishes one frame's stats to every subscribed client.
185    ///
186    /// **Safe to call from the frame thread**: it writes one value into a
187    /// preallocated bounded ring and returns — it never waits on a client, on
188    /// the network, or on a full queue, and it cannot fail. With no
189    /// subscribers it is very nearly free. A client that cannot keep up loses
190    /// the oldest queued frames (`crate::frame_stats`), which is the whole
191    /// reason this cannot stall the producer — `docs/REVIEW_FOCUS.md` rates a
192    /// parking call on the UI thread critical.
193    pub fn publish_frame_stats(&self, stats: FrameStats) {
194        self.bus.publish(stats);
195    }
196
197    /// Stops accepting, closes live connections, and joins the service thread.
198    ///
199    /// The backend thread is deliberately **not** joined: it may be parked
200    /// inside a shell call on a frozen UI thread, and shutdown must not be
201    /// hostage to that. It exits on its own once the last connection drops the
202    /// channel.
203    pub fn shutdown(mut self) {
204        self.shutdown_inner();
205    }
206
207    fn shutdown_inner(&mut self) {
208        // A receiver-less send is not a failure here — it means the accept
209        // loop already exited.
210        let _ = self.shutdown_tx.send(true);
211        if let Some(driver) = self.driver.take()
212            && driver.join().is_err()
213        {
214            log::error!("frust-devtools: the service thread panicked");
215        }
216    }
217}
218
219impl Drop for ServiceHandle {
220    fn drop(&mut self) {
221        self.shutdown_inner();
222    }
223}
224
225impl std::fmt::Debug for ServiceHandle {
226    /// Hand-written, and deliberately **not** derived: the token must never
227    /// reach a log line a `{:?}` produces (it belongs on the discovery line and
228    /// nowhere else), and a derive would leak it the moment a field is added.
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("ServiceHandle")
231            .field("port", &self.port)
232            .finish_non_exhaustive()
233    }
234}