From 4fee4f8c77289410f2b540f550a4260ccea8fae1 Mon Sep 17 00:00:00 2001 From: Hyeonggyu Kim Date: Sat, 22 Aug 2026 23:31:29 +0900 Subject: [PATCH 1/2] fix(server): add --detach and --pidfile for background/supervised mode Signed-off-by: Hyeonggyu Kim --- crates/switchyard-server/src/cli.rs | 15 +++++++ crates/switchyard-server/src/daemon.rs | 56 ++++++++++++++++++++++++++ crates/switchyard-server/src/main.rs | 13 +++++- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 crates/switchyard-server/src/daemon.rs 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}"); From 3d1a8805206d01a452fe2f8a9c57a826444e9aa0 Mon Sep 17 00:00:00 2001 From: Hyeonggyu Kim Date: Sat, 22 Aug 2026 23:52:47 +0900 Subject: [PATCH 2/2] fix(server): harden detached pidfile path and writes - default_pidfile now uses XDG_RUNTIME_DIR (fallback ~/.local/state/ switchyard) instead of the shared world-writable temp directory, avoiding a predictable path another user could pre-create. - write_pidfile rejects a symlink at the target and refuses to clobber an existing pidfile (create_new / O_EXCL), so a pre-placed symlink or another process's pidfile cannot be overwritten. Symlink check is Unix-only. - gate the --detach call in main.rs with cfg(unix) so the binary still compiles on non-Unix targets where detach_into_background is absent. Verified: cargo build clean; detach still yields a detached child serving /health; default pidfile resolves under XDG_RUNTIME_DIR; symlink pidfile is rejected (target left unwritten). Signed-off-by: Hyeonggyu Kim --- crates/switchyard-server/src/cli.rs | 20 ++++- crates/switchyard-server/src/daemon.rs | 107 ++++++++++++++++++++----- crates/switchyard-server/src/main.rs | 15 +++- 3 files changed, 117 insertions(+), 25 deletions(-) diff --git a/crates/switchyard-server/src/cli.rs b/crates/switchyard-server/src/cli.rs index 4a68b599b..26c3425aa 100644 --- a/crates/switchyard-server/src/cli.rs +++ b/crates/switchyard-server/src/cli.rs @@ -17,10 +17,24 @@ const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); const DEFAULT_PORT: u16 = 4000; /// Default pidfile path used by `--detach` when `--pidfile` is omitted. +/// +/// Uses a private runtime directory rather than the shared temporary +/// directory, so the path is not a predictable world-writable location another +/// user could pre-create (symlink / toctou) to hijack the recorded pid. pub(crate) fn default_pidfile() -> PathBuf { - let mut dir = std::env::temp_dir(); - dir.push("switchyard-server.pid"); - dir + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + let mut p = PathBuf::from(dir); + p.push("switchyard-server.pid"); + return p; + } + // Fall back to a per-user private state dir (~/.local/state/switchyard), + // mirroring the debug-log location used elsewhere in the project. + let mut p = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string())); + p.push(".local"); + p.push("state"); + p.push("switchyard"); + p.push("switchyard-server.pid"); + p } /// Command-line arguments accepted by the Rust server binary. diff --git a/crates/switchyard-server/src/daemon.rs b/crates/switchyard-server/src/daemon.rs index 83528e54c..b8543e437 100644 --- a/crates/switchyard-server/src/daemon.rs +++ b/crates/switchyard-server/src/daemon.rs @@ -7,15 +7,29 @@ //! `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. +//! system `setsid` in a new session with stdio disconnected, 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. +//! +//! The re-exec drops `--detach` so the child serves normally instead of +//! detaching again (which would re-exec repeatedly). The server process writes +//! its own real PID from inside the detached child, because `setsid` forks and +//! the spawned wrapper's PID is not the server's. use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +/// Internal env marker: present only on the detached child, signalling it to +/// record its own PID. A normal foreground run never sees it, so it never +/// writes a pidfile unexpectedly. +const DETACH_ENV: &str = "SWITCHYARD_SERVER_DETACHED"; + +/// Internal env carrying the pidfile path from parent to detached child, since +/// `--pidfile` is stripped from the re-executed arguments. +const PIDFILE_ENV: &str = "SWITCHYARD_SERVER_PIDFILE"; + /// Re-exec the current binary under `setsid` in a detached background session. /// /// Returns `Ok(())` in the detached child (the caller should then boot the @@ -23,33 +37,86 @@ use std::process::{Command, Stdio}; #[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); + // Re-exec with `--detach` (and `--pidfile`) removed so the child parses a + // normal foreground invocation; pass the pidfile path via the environment + // instead so the child can record its own real PID. + let mut command = Command::new("setsid"); + command.arg(¤t).args(detached_args()); + command + .env(DETACH_ENV, "1") + .env(PIDFILE_ENV, pidfile) + // Disconnect stdio so the child is independent of the launching + // terminal. + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // Parent spawns the setsid wrapper and exits; `setsid` forks the actual + // server session, which re-enters `main` without `--detach`. + let _ = command.spawn()?; + std::process::exit(0); +} + +/// Original CLI arguments with `--detach`/`--pidfile` stripped, so the +/// re-executed child behaves as a normal foreground server. The pidfile path +/// travels via [`PIDFILE_ENV`] instead. +fn detached_args() -> Vec { + let mut out = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--detach" || arg.starts_with("--detach=") { + continue; + } + if arg == "--pidfile" { + // Drop the flag and its value; the path is carried via env. + let _ = args.next(); + continue; } + if arg.starts_with("--pidfile=") { + continue; + } + out.push(arg); } - child.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + out +} - 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); +/// If this process is the detached child, return the pidfile path it should +/// record itself in. Returns `None` for a normal foreground run. +pub(crate) fn detached_pidfile() -> Option { + if std::env::var_os(DETACH_ENV).is_some() { + std::env::var_os(PIDFILE_ENV).map(PathBuf::from) + } else { + None + } } /// Write `pid` to `path`, creating parent directories as needed. +/// +/// Refuses to follow a symlink at `path` and refuses to overwrite an existing +/// pidfile, so a pre-placed symlink or another process's pidfile cannot be +/// clobbered. 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)?; + // Reject a symlink at the target so we never write through it. + #[cfg(unix)] + if let Ok(meta) = std::fs::symlink_metadata(path) { + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "pidfile path is a symlink", + )); + } + } + // create_new => O_CREAT|O_EXCL: fail if the file already exists, avoiding + // clobbering another process's pidfile. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; writeln!(file, "{pid}")?; file.flush()?; Ok(()) diff --git a/crates/switchyard-server/src/main.rs b/crates/switchyard-server/src/main.rs index c15a4702a..b2d4c3b87 100644 --- a/crates/switchyard-server/src/main.rs +++ b/crates/switchyard-server/src/main.rs @@ -15,8 +15,11 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } 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. + // Detach must happen before the async runtime does significant work. The + // parent re-execs under `setsid` and exits; this code only runs again in + // the detached child, which re-parses args without `--detach`. The call is + // Unix-only because `detach_into_background` is `#[cfg(unix)]`. + #[cfg(unix)] if args.detach { if let Err(error) = daemon::detach_into_background(&args.pidfile) { eprintln!("failed to detach switchyard-server: {error}"); @@ -24,6 +27,14 @@ async fn main() -> ExitCode { } // Unreachable: detach_into_background exits the parent process. } + // If this is the detached child, record its own real PID (the `setsid` + // parent cannot observe it). A normal foreground run skips this. + if let Some(pidfile) = daemon::detached_pidfile() { + if let Err(error) = daemon::write_pidfile(&pidfile, std::process::id()) { + eprintln!("failed to write pidfile {pidfile:?}: {error}"); + return ExitCode::FAILURE; + } + } let exit_code = match cli::run(args).await { Ok(()) => ExitCode::SUCCESS, Err(error) => {