Most "wrapper" apps do the same thing: launch a CLI binary in the background, pipe its logs into the UI, and tear it down when the user quits. It sounds trivial — until you actually build it.
I recently worked through this while building MoonProxy, a GUI desktop client that wraps the frp CLI (frpc). The Rust side has to own the full lifecycle of that child process: spawn it on demand, surface a ring buffer of its stdout/stderr to the frontend, detect "are we actually connected?" from log heuristics, stop it cleanly on window close, and restart it atomically when the binary updates itself on disk.
This post is the pattern that emerged. It's framework-aware (Tauri v2) but the core ideas apply to any Rust app that needs to babysit a long-running CLI.
The naive version (and why it bites you)
let mut child = Command::new("frpc")
.args(["-c", "frpc.toml"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
This compiles, runs, and is wrong in four ways you won't notice until production:
- You never read the pipes. When the OS pipe buffer fills (~64 KB on Linux), the child process blocks on its next write and silently stalls. The app shows "starting…" forever.
- No way to ask "is it healthy?" A spawned PID tells you the process exists, not that it's connected to the server.
-
On window close you call
child.kill(). That sends SIGKILL (orTerminateProcesson Windows). The server side never sees a clean disconnect — its connection table leaks until a keepalive timeout. - You can't swap the binary while it's running. File is locked on Windows; replacing it in-place on macOS is a race.
Let's fix each.
1. Drain the pipes into a ring buffer
A dedicated reader thread per stream, writing into a fixed-size ring buffer that the frontend can poll:
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;
pub struct LogRing {
buf: Mutex<VecDeque<String>>,
cap: usize,
}
impl LogRing {
pub fn push(&self, line: String) {
let mut g = self.buf.lock().unwrap();
if g.len() == self.cap { g.pop_front(); }
g.push_back(line);
}
pub fn snapshot(&self) -> Vec<String> {
self.buf.lock().unwrap().iter().cloned().collect()
}
}
fn spawn_reader<R: Read + Send + 'static>(stream: R, ring: Arc<LogRing>) {
std::thread::spawn(move || {
let reader = BufReader::new(stream);
for line in reader.lines().flatten() {
ring.push(line);
}
// stream closed = child exited
});
}
Key point: the ring is unbounded-consumer, bounded-memory. Logs from a chatty CLI never OOM the app; the oldest line just rolls off. The frontend gets a get_logs() command that snapshots the current contents — cheap to call on a timer.
2. Health = evidence, not just "process exists"
child.try_wait() returning None means "still running." It does not mean "doing useful work." For an frpc-style client the interesting state lives in its own log output: it prints something like [I] [proxy.go:xxx] start proxy success when a tunnel is actually up.
So model the connection as a small state machine driven by log lines:
#[derive(Clone, Copy, PartialEq)]
pub enum FrpcState { Stopped, Connecting, Connected, Error }
pub fn classify(line: &str, current: FrpcState) -> FrpcState {
if line.contains("login to server failed") || line.contains("token is incorrect") {
FrpcState::Error
} else if line.contains("start proxy success") && current == FrpcState::Connecting {
FrpcState::Connected
} else {
current
}
}
This matters because a running process with the wrong token will happily sit there retrying forever. You want the UI to show "Error: token rejected," not a fake green "Connected."
A 30-second fallback timer that flips Connecting → Error covers the case where the log never emits a decisive line (firewalled, DNS wedged, etc.).
3. Graceful shutdown: ask, then escalate
Don't go straight to kill(). On Unix, send SIGTERM and give it a beat to flush; on Windows, the equivalent is harder, so we lean on the CLI's own shutdown path if it has one.
pub async fn stop_gracefully(child: &mut Child, timeout: Duration) {
// Prefer a clean path
#[cfg(unix)]
{
use nix::sys::signal::{kill, Signal};
let _ = kill(
nix::unistd::Pid::from_raw(child.id() as i32),
Signal::SIGTERM,
);
}
#[cfg(windows)]
{
// Best-effort: on Windows we drop directly; for a well-behaved CLI
// that traps the console close event this still lets it clean up.
}
// Wait up to `timeout` for it to exit on its own
let start = Instant::now();
while start.elapsed() < timeout {
if let Ok(Some(_)) = child.try_wait() { return; }
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Escalate
let _ = child.kill();
let _ = child.wait();
}
In a Tauri app you also want to hook ExitRequested so that closing the window while a tunnel is up pops a confirm dialog (or, for "minimize to tray" mode, just hides the window and keeps frpc alive in the background — the whole reason the user installed a GUI client).
4. Atomic binary replacement
The problem: you want to update frpc from v0.69 to v0.70 without making the user reinstall the app, and without leaving a half-written file behind.
Pattern: download to a temp path → SHA256-verify → rename into place. Rename is atomic on the same filesystem.
// Pseudocode for the happy path
let tmp = bin_dir.join("frpc.new");
download_and_verify(&release_url, &expected_sha256, &tmp)?;
// Stop the running child FIRST (see §3), then:
fs::rename(&tmp, &final_path)?; // atomic on same fs
// Restart on next "start" click, or auto-restart
The two things that bite you here:
- Windows holds an exclusive lock on a running .exe. You cannot rename over it while the child is alive. The order is non-negotiable: stop → replace → start.
- SHA256 before rename, not after. A truncated or MITM'd binary that "looks like" frpc but isn't would otherwise get swapped in silently. Verify the bytes you downloaded against a digest fetched over a trusted channel (the GitHub Release API + a pinned algorithm), then promote.
A frpc_update.json next to the binary tracks current / pending versions, so a crash mid-update is recoverable on next launch rather than leaving the user with a broken install.
5. The part Tauri adds on top
All of the above is plain Rust. Tauri's job is to (a) ship the binary inside the app bundle via the sidecar mechanism — tauri.conf.json declares it and the build renames the per-target binary to the sidecar convention (frpc-x86_64-pc-windows-msvc.exe, etc.) — and (b) expose invoke_handler commands so the Vue frontend can call start_frpc, stop_frpc, get_state, get_logs, update_frpc over the IPC bridge.
The tempting mistake is to put lifecycle logic in the frontend and just have Rust be a thin Command::spawn wrapper. Don't. The frontend can be reloaded, the window can be closed, the process tree cares about neither — the Rust side is the single source of truth for "what is the child doing right now."
Recap
- Drain stdout/stderr into a bounded ring buffer, always.
- Treat "running" and "healthy" as different questions; derive health from the child's own output.
- Shutdown is SIGTERM-then-SIGKILL with a timeout, never the other way around.
- Binary updates are atomic rename after verify, and Windows forces a strict stop-before-replace order.
- In Tauri, the Rust core owns the state machine; the frontend is a view onto it.
The full implementation of this lives in src-tauri/src/ — process.rs for lifecycle, frpc_state.rs for the log classifier, proxy_relay.rs for the traffic-counting TCP middle layer, and frpc_update.rs for the verified-then-atomic update path. Comments and corrections welcome.
MoonProxy is MIT-licensed and on macOS + Windows — if you're shipping a Tauri app that wraps a CLI, the patterns above are field-tested.
Top comments (0)