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}