diff --git a/crates/switchyard-server/src/cli.rs b/crates/switchyard-server/src/cli.rs index 231d4e2bc..4a68b599b 100644 --- a/crates/switchyard-server/src/cli.rs +++ b/crates/switchyard-server/src/cli.rs @@ -16,6 +16,13 @@ use switchyard_server::{ const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); const DEFAULT_PORT: u16 = 4000; +/// Default pidfile path used by `--detach` when `--pidfile` is omitted. +pub(crate) fn default_pidfile() -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push("switchyard-server.pid"); + dir +} + /// Command-line arguments accepted by the Rust server binary. #[derive(Debug, Parser)] #[command( @@ -59,6 +66,14 @@ pub(crate) struct ServerArgs { /// TLS private-key path in PEM format. #[arg(long, requires = "tls_cert")] tls_key: Option, + + /// Detach into a new session and run in the background (Unix `setsid`). + #[arg(long)] + pub(crate) detach: bool, + + /// Write the background process id to this file when `--detach` is set. + #[arg(long, value_name = "PATH", default_value_os_t = default_pidfile())] + pub(crate) pidfile: PathBuf, } impl ServerArgs { diff --git a/crates/switchyard-server/src/daemon.rs b/crates/switchyard-server/src/daemon.rs new file mode 100644 index 000000000..83528e54c --- /dev/null +++ b/crates/switchyard-server/src/daemon.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Detached execution for `switchyard-server`. +//! +//! The server is otherwise a foreground process that drains on `SIGTERM`/ +//! `SIGINT`. To run it as a managed background service that outlives the +//! launching terminal, call [`detach_into_background`] *before* the Tokio +//! runtime does significant work: it re-executes the current binary under the +//! system `setsid` in a new session with stdio disconnected and writes a +//! pidfile, so the original process exits and the child keeps serving. Spawning +//! before the async runtime boots avoids the hazard of `fork`/`setsid` after an +//! OS-thread/signal-handler runtime has initialised. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Re-exec the current binary under `setsid` in a detached background session. +/// +/// Returns `Ok(())` in the detached child (the caller should then boot the +/// server); the parent process exits successfully after spawning the child. +#[cfg(unix)] +pub(crate) fn detach_into_background(pidfile: &Path) -> std::io::Result<()> { + let current = std::env::current_exe()?; + // Use the system `setsid` to spawn a detached session (stable, no unstable + // std features). stdio is disconnected so the child is independent of the + // launching terminal. Drop `--detach` from the re-exec args so the child + // serves normally instead of recursing into another detach. + let mut child = Command::new("setsid"); + child.arg(¤t); + for arg in std::env::args().skip(1) { + if arg != "--detach" { + child.arg(arg); + } + } + child.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + + let handle = child.spawn()?; + write_pidfile(pidfile, handle.id())?; + // Parent exits; the detached child continues and serves in its own session. + std::process::exit(0); +} + +/// Write `pid` to `path`, creating parent directories as needed. +pub(crate) fn write_pidfile(path: &Path, pid: u32) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let mut file = std::fs::File::create(path)?; + writeln!(file, "{pid}")?; + file.flush()?; + Ok(()) +} diff --git a/crates/switchyard-server/src/main.rs b/crates/switchyard-server/src/main.rs index b9f7fdf0a..c15a4702a 100644 --- a/crates/switchyard-server/src/main.rs +++ b/crates/switchyard-server/src/main.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; mod cli; +mod daemon; #[tokio::main(flavor = "multi_thread")] async fn main() -> ExitCode { @@ -13,7 +14,17 @@ async fn main() -> ExitCode { eprintln!("failed to initialize observability: {error}"); return ExitCode::FAILURE; } - let exit_code = match cli::run(cli::ServerArgs::parse_args()).await { + let args = cli::ServerArgs::parse_args(); + // Detach must happen before the async runtime does significant work; the + // detached child re-parses args without `--detach` and serves normally. + if args.detach { + if let Err(error) = daemon::detach_into_background(&args.pidfile) { + eprintln!("failed to detach switchyard-server: {error}"); + return ExitCode::FAILURE; + } + // Unreachable: detach_into_background exits the parent process. + } + let exit_code = match cli::run(args).await { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("{error}");