Process Lifecycle
Ownership-safe ports, generation-guarded startup, process groups, and clean shutdown for Tauri v2 apps
A listener on a port is not proof that your app owns it. Track the child and process group created by this launch, and keep restart, readiness, and shutdown under one coordinator.
Choose a Port Without Killing Its Owner
Never discover PIDs with lsof and signal them merely because they occupy the preferred port. In strict mode, report the occupied port. With an explicit fallback policy, ask the kernel for an available loopback port:
use std::{io, net::{Ipv4Addr, TcpListener}};
fn choose_port(preferred: u16, strict: bool) -> io::Result<u16> {
let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, preferred)) {
Ok(listener) => listener,
Err(error) if error.kind() == io::ErrorKind::AddrInUse && !strict => {
TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?
}
Err(error) => return Err(error),
};
Ok(listener.local_addr()?.port())
}This is a preflight, not a reservation: the listener is dropped on return. Another process can bind before the sidecar does. Pass the effective port to the child and readiness probe, require the child to bind exactly that port, and retry only a positively identified bind race within a bounded retry budget. Do not retry arbitrary spawn/configuration failures or silently move a strict-mode port. A server that accepts an inherited listening socket can avoid this gap.
Own Every Spawn and Watcher
Use one serialized coordinator for launch/retry/shutdown. Each launch advances a generation. Workers capture that generation, prepare resources outside the state lock, and submit results back to the coordinator. Publication must atomically check both the generation and the shutdown flag before installing the child, watcher and effective URL. The same serialized decision authorizes navigation; a separate atomic read followed by navigate() is still racy.
If publication loses, dispose the candidate child/group and watcher immediately. On shutdown, mark the coordinator terminal before taking its Option<OwnedResources>; no worker may publish after that point. Taking the slot makes repeated teardown requests idempotent. Shutdown must also cancel and join/drain all in-flight launch workers, including their spawned-but-unpublished candidates, before permitting process exit. Owning only the published slot leaves that gap unprotected. Drop watchers and cancel pending probes as well as stopping the sidecar. A process launched by beforeDevCommand is not owned by this slot; do not kill it through runtime cleanup.
Process Group for Clean Shutdown
On Unix, create a dedicated group and save its PGID immediately after spawn:
#[cfg(unix)]
fn spawn_owned(command: &mut std::process::Command) -> std::io::Result<std::process::Child> {
use std::os::unix::process::CommandExt;
command.process_group(0).spawn()
}For process_group(0), the initial PGID is the child's PID. Descendants normally inherit it, but a child that creates a new session/group escapes this boundary. Windows requires a separate process-tree strategy, such as a Job Object; Child::kill() alone is not equivalent.
Every Exit Path and Auxiliary Windows
Closing an auxiliary Settings window must not stop the server. On its CloseRequested, prevent close and hide it if that is the product policy. Only the main window's Destroyed event should request application shutdown in a close-to-exit wrapper. Applications that remain in the Dock need a different policy.
Route main-window destruction, accepted RunEvent::ExitRequested, and RunEvent::Exit through the same idempotent cleanup owner. On ExitRequested, prevent exit while a background cleanup task finishes, then request exit again with a completion flag so the second request is allowed. Do not block the UI thread with polling sleeps. Keep a bounded final fallback for Exit; cancellation or a prevented quit must not accidentally tear down an app that continues running.
macOS quit paths (⌘Q, Dock Quit, Apple events) need native testing; do not assume every path first delivers a usable window-destroyed event. A Unix libc::atexit handler can be a best-effort backstop for normal native termination, but does not run after SIGKILL, _exit, or every crash. It must perform no PID discovery and avoid locks, allocation and Tauri APIs: use only a previously owned PGID. A terminal atomic sentinel alone is insufficient unless spawn/registration and shutdown are coordinated: a registration rejected after shutdown must immediately stop its newly spawned group. Clear the stored PGID once the group is gone, so later PID reuse cannot target an unrelated process.
Sidecar Kill Sequence
A reaped leader does not prove its descendants exited. Send SIGTERM to the owned group, reap the direct child with try_wait(), and poll group existence until a bounded deadline. Escalate to SIGKILL on the group, not only child.kill(). Signal errors other than ESRCH must surface in logs:
#[cfg(unix)]
fn group_exists(pgid: i32) -> std::io::Result<bool> {
if pgid <= 0 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid PGID"));
}
if unsafe { libc::kill(-pgid, 0) } == 0 { return Ok(true); }
let error = std::io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::ESRCH) => Ok(false),
Some(libc::EPERM) => Ok(true),
_ => Err(error),
}
}
#[cfg(unix)]
fn signal_group(pgid: i32, signal: i32) -> std::io::Result<()> {
if pgid <= 0 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid PGID"));
}
if unsafe { libc::kill(-pgid, signal) } == 0 { return Ok(()); }
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { Err(error) }
}
// Only accept a PGID saved immediately after this app's process_group(0) spawn.
#[cfg(unix)]
fn stop_owned_group(child: &mut std::process::Child, pgid: i32) -> std::io::Result<()> {
use std::time::{Duration, Instant};
for (signal, grace) in [(libc::SIGTERM, 500), (libc::SIGKILL, 500)] {
signal_group(pgid, signal)?;
let deadline = Instant::now() + Duration::from_millis(grace);
loop {
child.try_wait()?; // Reap our direct child; this is not group completion.
if !group_exists(pgid)? { return Ok(()); }
if Instant::now() >= deadline { break; }
std::thread::sleep(Duration::from_millis(20));
}
}
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "process group still present"))
}Run this bounded helper on a worker. It does not discover or adopt processes, guarantee cleanup of escaped descendants, or block indefinitely to reap an unresponsive child. Retain failed cleanup state for diagnostics/reaping; do not report a timeout as success. A positive signal-0 check includes permission-denied groups and can also persist while descendants are zombies.
Complete Lifecycle Sequence
Detecting Sidecar Death in the Readiness Loop
The readiness worker checks cancellation/generation and Child::try_wait() every tick, without holding a mutex over network I/O. A missing owned child or shutdown cancels the launch; an exited child returns its exit status immediately. Use separate readiness and probe classifications:
pub enum ReadyResult {
Ready,
Timeout,
Superseded,
SidecarExited { code: Option<i32> },
}
pub enum ProbeState {
HttpUnavailable,
StaleContent,
Ready,
}Probe the real destination route, require HTTP 200 and the current launch's marker, and check the same-origin module entries needed by the page. A stale marker is not ready. See the recipe's readiness contract. A 50 ms polling interval is a starting point, not a guarantee: bound each request and the entire startup deadline. Do not add a trailing “stability” sleep.
Ready still goes through generation-checked publication before navigation. Timeout and SidecarExited show the loading-screen error state with a log path. Superseded neither navigates nor emits an error over the newer launch.
Logging
Keep lifecycle decisions and sidecar stdout/stderr in separate files under an app-writable log directory. Clone the log file handle for the second stream (log_file.try_clone()?) before passing each handle to Stdio::from. Rotate or truncate sidecar output per launch; bound retained lifecycle logs. Record generation, effective port, owned PGID, exit status, signal errors and the last probe classification.
Verification and Sources
Exercise an occupied foreign port, strict/fallback behavior, rapid Retry, shutdown during spawn, auxiliary close, a descendant outliving its leader, and repeated relaunch. On macOS also test ⌘Q, Dock Quit and Apple-event quit; a docs build or Linux test cannot verify those paths.