Skip to main content

picoframe_core/
sidecar.rs

1//! Long-lived local server ("sidecar") lifecycle for picoframe apps.
2//!
3//! A sidecar is a persistent localhost HTTP server (e.g. a Bun process) spawned once at
4//! app startup, so substantial non-Rust business logic runs in-process without per-call
5//! subprocess cold-start. This module owns the full lifecycle:
6//!
7//! - **spawn** the bundled binary (Tauri `externalBin`, resolved next to the app exe) or a
8//!   dev command (`bun run server.ts`) when the compiled binary is absent under `tauri dev`;
9//! - **handshake** — the child binds an ephemeral `127.0.0.1` port and writes a `0600`
10//!   `{port,token,pid}` file we poll; the token is a per-spawn shared secret we generate;
11//! - **health** — poll `GET /health` (bearer auth) until ready or timeout;
12//! - **stream** — hold an SSE connection to `GET /events` and re-emit each progress record
13//!   as the Tauri event `"<event_prefix>/progress"`;
14//! - **supervise** — if the child exits unexpectedly and `restart` is set, respawn it;
15//! - **shutdown** — kill the child on app exit. (The child also self-exits if it sees the
16//!   parent pid disappear, covering the SIGKILL case that a parent-side kill cannot.)
17//!
18//! Transport is loopback TCP + a shared-secret bearer token rather than a Unix socket, so
19//! the same code path works on Windows, macOS and Linux.
20
21use crate::CliResult;
22use serde::Deserialize;
23use serde_json::Value;
24use std::io::{BufRead, BufReader};
25use std::path::PathBuf;
26use std::process::{Child, Command};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, Instant};
30use tauri::{AppHandle, Emitter, Runtime};
31
32/// Configuration for a supervised sidecar.
33#[derive(Clone)]
34pub struct SidecarOptions {
35    /// `externalBin` base name bundled next to the app executable, e.g.
36    /// `"picoframe-worker-sidecar"` (the platform triple/suffix is added at resolve time).
37    pub bin: String,
38    /// Fallback command used under `tauri dev` when the compiled binary is not found next to
39    /// the executable, e.g. `["bun", "run", "/abs/path/to/server.ts"]`. The first element is
40    /// the program. Ignored in release builds.
41    pub dev_command: Option<Vec<String>>,
42    /// Tauri event name prefix; each streamed progress record is emitted as
43    /// `"<event_prefix>/progress"`.
44    pub event_prefix: String,
45    /// How long to wait for the handshake file + a healthy `/health` before failing.
46    pub ready_timeout: Duration,
47    /// Respawn the child (and reconnect the event stream) if it exits unexpectedly.
48    pub restart: bool,
49}
50
51impl SidecarOptions {
52    /// Sensible defaults: no dev command, 10s readiness window, restart on crash.
53    pub fn new(bin: impl Into<String>, event_prefix: impl Into<String>) -> Self {
54        Self {
55            bin: bin.into(),
56            dev_command: None,
57            event_prefix: event_prefix.into(),
58            ready_timeout: Duration::from_secs(10),
59            restart: true,
60        }
61    }
62}
63
64#[derive(Deserialize)]
65struct Handshake {
66    port: u16,
67    token: String,
68}
69
70struct Connection {
71    port: u16,
72    token: String,
73    child: Child,
74}
75
76/// A running, supervised sidecar. Cheaply cloneable (`Arc` inside); dropping the last clone
77/// kills the child.
78#[derive(Clone)]
79pub struct Sidecar(Arc<Inner>);
80
81struct Inner {
82    conn: Mutex<Connection>,
83    http: reqwest::blocking::Client,
84    shutting_down: AtomicBool,
85}
86
87impl Sidecar {
88    /// Spawn the sidecar and block until it is healthy (or `ready_timeout` elapses), then
89    /// start the background event-stream bridge and crash supervisor. Returns an error if the
90    /// process cannot be spawned or never becomes healthy.
91    pub fn spawn<R: Runtime>(app: &AppHandle<R>, opts: SidecarOptions) -> Result<Sidecar, String> {
92        // A connect timeout (fail fast if the server is down) but NO total-request timeout: the
93        // `/events` SSE stream is long-lived and a `/command` job may run for minutes. Fast
94        // calls set their own per-request timeout instead (see `healthy`).
95        let http = reqwest::blocking::Client::builder()
96            .connect_timeout(Duration::from_secs(5))
97            .build()
98            .map_err(|e| format!("build http client: {e}"))?;
99
100        let conn = start_process(&opts, &http)?;
101        let sidecar = Sidecar(Arc::new(Inner {
102            conn: Mutex::new(conn),
103            http,
104            shutting_down: AtomicBool::new(false),
105        }));
106
107        sidecar.clone().supervise(app.clone(), opts);
108        Ok(sidecar)
109    }
110
111    /// Base URL of the running server, e.g. `http://127.0.0.1:54321`.
112    fn base_url(&self) -> String {
113        let conn = self.0.conn.lock().unwrap();
114        format!("http://127.0.0.1:{}", conn.port)
115    }
116
117    fn token(&self) -> String {
118        self.0.conn.lock().unwrap().token.clone()
119    }
120
121    /// Send a command to the sidecar and return its `CliResult`. Blocking — call from an
122    /// async Tauri command via `tauri::async_runtime::spawn_blocking`. A transport failure is
123    /// mapped to a failed `CliResult` so callers always get a uniform envelope.
124    pub fn request(&self, command: &str, args: Value) -> CliResult {
125        let url = format!("{}/command", self.base_url());
126        let res = self
127            .0
128            .http
129            .post(&url)
130            .bearer_auth(self.token())
131            .json(&serde_json::json!({ "command": command, "args": args }))
132            .send()
133            .and_then(|r| r.json::<CliResult>());
134        match res {
135            Ok(result) => result,
136            Err(e) => CliResult::err(format!("sidecar request failed: {e}")),
137        }
138    }
139
140    /// Whether the server currently answers `GET /health` with 200.
141    pub fn healthy(&self) -> bool {
142        let url = format!("{}/health", self.base_url());
143        self.0
144            .http
145            .get(&url)
146            .bearer_auth(self.token())
147            .timeout(Duration::from_secs(2))
148            .send()
149            .map(|r| r.status().is_success())
150            .unwrap_or(false)
151    }
152
153    /// Stop supervising and kill the child. Idempotent.
154    pub fn shutdown(&self) {
155        self.0.shutting_down.store(true, Ordering::SeqCst);
156        if let Ok(mut conn) = self.0.conn.lock() {
157            let _ = conn.child.kill();
158            let _ = conn.child.wait();
159        }
160    }
161
162    /// Background threads: (1) hold the SSE connection and re-emit events; (2) watch the child
163    /// and respawn it on unexpected exit when `restart` is set.
164    fn supervise<R: Runtime>(self, app: AppHandle<R>, opts: SidecarOptions) {
165        // Event-stream bridge: reconnect while the sidecar is alive.
166        let sse = self.clone();
167        let sse_app = app.clone();
168        let sse_prefix = opts.event_prefix.clone();
169        std::thread::spawn(move || {
170            while !sse.0.shutting_down.load(Ordering::SeqCst) {
171                stream_events(&sse, &sse_app, &sse_prefix);
172                // Dropped connection: pause before reconnecting so a hard-down server doesn't spin.
173                std::thread::sleep(Duration::from_millis(500));
174            }
175        });
176
177        // Crash supervisor: respawn on unexpected exit.
178        let sup = self.clone();
179        std::thread::spawn(move || loop {
180            std::thread::sleep(Duration::from_millis(500));
181            if sup.0.shutting_down.load(Ordering::SeqCst) {
182                return;
183            }
184            let exited = {
185                let mut conn = sup.0.conn.lock().unwrap();
186                matches!(conn.child.try_wait(), Ok(Some(_)))
187            };
188            if !exited {
189                continue;
190            }
191            if !opts.restart {
192                return;
193            }
194            match start_process(&opts, &sup.0.http) {
195                Ok(next) => {
196                    *sup.0.conn.lock().unwrap() = next;
197                    let _ = app.emit(&format!("{}/restarted", opts.event_prefix), ());
198                }
199                Err(e) => {
200                    // Back off and retry on the next loop; surface once.
201                    let _ = app.emit(&format!("{}/error", opts.event_prefix), e);
202                    std::thread::sleep(Duration::from_secs(2));
203                }
204            }
205        });
206    }
207}
208
209impl Drop for Inner {
210    fn drop(&mut self) {
211        self.shutting_down.store(true, Ordering::SeqCst);
212        if let Ok(mut conn) = self.conn.lock() {
213            let _ = conn.child.kill();
214            let _ = conn.child.wait();
215        }
216    }
217}
218
219/// Read the SSE stream and re-emit each `data:` progress record as a Tauri event. Returns when
220/// the connection drops (server restart, network error) so the caller can reconnect.
221fn stream_events<R: Runtime>(sidecar: &Sidecar, app: &AppHandle<R>, event_prefix: &str) {
222    let url = format!("{}/events", sidecar.base_url());
223    // No per-request timeout — this is a long-lived stream (the client has no total timeout).
224    let resp = sidecar.0.http.get(&url).bearer_auth(sidecar.token()).send();
225    let Ok(resp) = resp else { return };
226    if !resp.status().is_success() {
227        return;
228    }
229    let event = format!("{event_prefix}/progress");
230    let reader = BufReader::new(resp);
231    for line in reader.lines() {
232        if sidecar.0.shutting_down.load(Ordering::SeqCst) {
233            return;
234        }
235        let Ok(line) = line else { return };
236        if let Some(payload) = line.strip_prefix("data: ") {
237            if let Ok(value) = serde_json::from_str::<Value>(payload) {
238                let _ = app.emit(&event, value);
239            }
240        }
241    }
242}
243
244/// Spawn the child, wait for its handshake + health, and return a live connection.
245fn start_process(opts: &SidecarOptions, http: &reqwest::blocking::Client) -> Result<Connection, String> {
246    let token = random_token();
247    let handshake_path = handshake_path(&opts.bin);
248    // A stale handshake from a prior run would be read as this run's port; remove it first.
249    let _ = std::fs::remove_file(&handshake_path);
250
251    let mut cmd = resolve_command(opts)?;
252    cmd.env("PICOFRAME_SIDECAR_TOKEN", &token)
253        .env("PICOFRAME_SIDECAR_HANDSHAKE", &handshake_path)
254        .env("PICOFRAME_SIDECAR_PARENT_PID", std::process::id().to_string())
255        .env("PICOFRAME_SIDECAR_HOST", "127.0.0.1");
256
257    let mut child = cmd.spawn().map_err(|e| format!("spawn sidecar `{}`: {e}", opts.bin))?;
258
259    let deadline = Instant::now() + opts.ready_timeout;
260    let handshake = match wait_for_handshake(&handshake_path, deadline) {
261        Ok(h) => h,
262        Err(e) => {
263            let _ = child.kill();
264            return Err(e);
265        }
266    };
267    // The token in the handshake must match what we passed; a mismatch means a foreign process.
268    if handshake.token != token {
269        let _ = child.kill();
270        return Err("sidecar handshake token mismatch".to_string());
271    }
272
273    let conn = Connection { port: handshake.port, token, child };
274    let sidecar_url = format!("http://127.0.0.1:{}/health", conn.port);
275    if let Err(e) = wait_for_health(http, &sidecar_url, &conn.token, deadline) {
276        let mut conn = conn;
277        let _ = conn.child.kill();
278        return Err(e);
279    }
280    Ok(conn)
281}
282
283/// Build the `Command` to launch: the compiled `externalBin` next to the app exe, or the
284/// dev command when that binary is absent (debug builds only).
285fn resolve_command(opts: &SidecarOptions) -> Result<Command, String> {
286    if let Some(path) = resolve_bin(&opts.bin) {
287        return Ok(Command::new(path));
288    }
289    if cfg!(debug_assertions) {
290        if let Some(dev) = &opts.dev_command {
291            if let Some((program, args)) = dev.split_first() {
292                let mut cmd = Command::new(program);
293                cmd.args(args);
294                return Ok(cmd);
295            }
296        }
297    }
298    Err(format!(
299        "sidecar binary `{}` not found next to the executable (and no dev command available)",
300        opts.bin
301    ))
302}
303
304/// Resolve the bundled binary next to the current executable, as Tauri `externalBin` arranges.
305/// `PICOFRAME_SIDECAR_BIN` overrides the full path (handy for dev and tests).
306fn resolve_bin(bin: &str) -> Option<PathBuf> {
307    if let Ok(p) = std::env::var("PICOFRAME_SIDECAR_BIN") {
308        if !p.is_empty() {
309            let path = PathBuf::from(p);
310            return path.exists().then_some(path);
311        }
312    }
313    let exe = std::env::current_exe().ok()?;
314    let dir = exe.parent()?;
315    let candidate = dir.join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
316    candidate.exists().then_some(candidate)
317}
318
319fn handshake_path(bin: &str) -> PathBuf {
320    std::env::temp_dir().join(format!("picoframe-sidecar-{bin}-{}.json", std::process::id()))
321}
322
323fn wait_for_handshake(path: &PathBuf, deadline: Instant) -> Result<Handshake, String> {
324    loop {
325        if let Ok(text) = std::fs::read_to_string(path) {
326            if let Ok(h) = serde_json::from_str::<Handshake>(&text) {
327                return Ok(h);
328            }
329        }
330        if Instant::now() >= deadline {
331            return Err("timed out waiting for sidecar handshake".to_string());
332        }
333        std::thread::sleep(Duration::from_millis(50));
334    }
335}
336
337fn wait_for_health(
338    http: &reqwest::blocking::Client,
339    url: &str,
340    token: &str,
341    deadline: Instant,
342) -> Result<(), String> {
343    loop {
344        let ok = http
345            .get(url)
346            .bearer_auth(token)
347            .timeout(Duration::from_secs(1))
348            .send()
349            .map(|r| r.status().is_success())
350            .unwrap_or(false);
351        if ok {
352            return Ok(());
353        }
354        if Instant::now() >= deadline {
355            return Err("timed out waiting for sidecar to become healthy".to_string());
356        }
357        std::thread::sleep(Duration::from_millis(50));
358    }
359}
360
361/// 256 bits of randomness, hex-encoded, for the per-spawn bearer token.
362fn random_token() -> String {
363    let mut bytes = [0u8; 32];
364    getrandom::getrandom(&mut bytes).expect("os rng");
365    let mut s = String::with_capacity(64);
366    for b in bytes {
367        s.push_str(&format!("{b:02x}"));
368    }
369    s
370}