//! Self-daemonization and single-instance locking for the workspace-server. //! //! The server is launched fire-and-forget by the sandbox orchestrator, which //! only ever holds a handle to the originally-spawned PID / process group. //! After a double-fork + `setsid()` the surviving daemon lives in a new //! session and process group, so a later process-group kill on the original //! pgid cannot reach it. //! //! The double-fork MUST run before the tokio runtime — or `tracing_subscriber` //! / the rustls provider — start any threads: forking a multi-threaded process //! leaves every lock held by a non-forking thread permanently locked in the //! child, which can deadlock it. use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; #[cfg(target_os = "linux")] use std::os::fd::{FromRawFd as _, OwnedFd}; use std::path::Path; use std::time::{Duration, Instant}; use std::{process, thread}; #[cfg(windows)] use windows::Win32::Foundation::HANDLE; use fs2::FileExt; use crate::util::is_lock_contended; #[cfg(unix)] use std::os::unix::io::{AsRawFd, RawFd}; /// stdout + stderr redirect target when no `--log-file` is given. #[cfg(unix)] pub const DEFAULT_LOG_PATH: &str = "/tmp/workspace-server.log"; #[cfg(windows)] pub const DEFAULT_LOG_PATH: &str = "C:\\Windows\\Temp\\workspace-server.log"; /// Single-instance lock file used when no `--pid-file` is given. #[cfg(unix)] pub const DEFAULT_PIDFILE_PATH: &str = "/tmp/workspace-server.pid"; #[cfg(windows)] pub const DEFAULT_PIDFILE_PATH: &str = "C:\\Windows\\Temp\\workspace-server.pid"; /// Readiness marker written once the server connection is established; the control /// plane polls it and may override the path with `--ready-file`. #[cfg(unix)] pub const DEFAULT_READY_PATH: &str = "/tmp/workspace-server.ready"; #[cfg(windows)] pub const DEFAULT_READY_PATH: &str = "C:\\Windows\\Temp\\workspace-server.ready"; /// How long a takeover waits for the gracefully-terminated predecessor to /// release the pidfile lock before escalating to a forceful kill. /// /// Intentionally far below the server's own SIGTERM drain budget /// (`KIGI_WORKSPACE_TERMINATION_GRACE_MS`, default 45s): a takeover only /// happens when the orchestrator has already declared the incumbent stale, /// so a bounded ready time for the replacement outranks completing the /// predecessor's drain. pub const TAKEOVER_GRACE: Duration = Duration::from_secs(2); /// How long a takeover waits for the lock after the forceful kill (process /// death releases the flock) before declining. const TAKEOVER_KILL_GRACE: Duration = Duration::from_secs(1); /// Poll interval while waiting for the predecessor to release the lock. const TAKEOVER_POLL: Duration = Duration::from_millis(50); /// Invocation fragment identifying a pidfile holder as a workspace-server. const WORKSPACE_SERVER_NAME_FRAGMENT: &str = "workspace-server"; /// Double-fork + `setsid()` into a new session, `chdir("/")`, and redirect /// stdio (stdin ← `/dev/null`, stdout+stderr appended to `log_path`). /// /// Must be called before any runtime/tracing/TLS threads start (see module docs). #[cfg(unix)] pub fn daemonize(log_path: &Path) -> io::Result<()> { // First fork: the launcher-tracked parent exits, orphaning the child. fork_and_exit_parent()?; // New session/process group, detaching the controlling terminal. Must // follow a fork — a process-group leader cannot call setsid(). // SAFETY: `setsid()` takes no pointers; it only changes session membership. if unsafe { libc::setsid() } == -1 { return Err(io::Error::last_os_error()); } // Second fork: a non-session-leader can never reacquire a controlling tty. fork_and_exit_parent()?; // Detach from the launch directory (callers capture cwd beforehand). // SAFETY: `c"/"` is a 'static, NUL-terminated string valid for the call. if unsafe { libc::chdir(c"/".as_ptr()) } == -1 { return Err(io::Error::last_os_error()); } redirect_stdio(log_path) } /// Windows daemonization: no fork/setsid (the launcher already backgrounds the /// server) — only redirect stdout+stderr to the log file. Must run before any /// stdout/stderr use (Rust caches the std handles on first access). The /// single-instance lock is taken separately via [`PidFile`]. #[cfg(windows)] pub fn daemonize(log_path: &Path) -> io::Result<()> { use std::os::windows::io::AsRawHandle; use windows::Win32::Foundation::HANDLE; use windows::Win32::System::Console::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetStdHandle}; if let Some(parent) = log_path.parent() { let _ = fs::create_dir_all(parent); } let log = daemon_file_options() .create(true) .append(true) .open(log_path)?; let handle = HANDLE(log.as_raw_handle()); // SAFETY: `handle` is a live file handle owned by `log`; SetStdHandle only // records it as the process stdout/stderr. `forget(log)` keeps it open for // the process lifetime (the std streams reference it now). unsafe { SetStdHandle(STD_OUTPUT_HANDLE, handle).map_err(io::Error::other)?; SetStdHandle(STD_ERROR_HANDLE, handle).map_err(io::Error::other)?; } std::mem::forget(log); Ok(()) } #[cfg(not(any(unix, windows)))] pub fn daemonize(_log_path: &Path) -> io::Result<()> { Err(io::Error::new( io::ErrorKind::Unsupported, "daemonize is only supported on Unix and Windows", )) } /// `fork()`; the parent exits 0, the child returns `Ok(())` to continue. #[cfg(unix)] fn fork_and_exit_parent() -> io::Result<()> { // SAFETY: only called pre-runtime while single-threaded, so the fork // cannot strand another thread's lock in the child. match unsafe { libc::fork() } { -1 => Err(io::Error::last_os_error()), 0 => Ok(()), _ => process::exit(0), } } /// `OpenOptions` for a daemon-owned file (log or pidfile). On Unix it adds /// `O_NOFOLLOW` + mode `0600` as symlink/permission defense-in-depth; the /// per-tenant sandbox namespace is the primary control. Shared with the /// preview-proxy log (`preview_supervisor`) so both daemon-owned files get the /// same posture. #[cfg(unix)] pub(crate) fn daemon_file_options() -> OpenOptions { use std::os::unix::fs::OpenOptionsExt; let mut opts = OpenOptions::new(); opts.custom_flags(libc::O_NOFOLLOW).mode(0o600); opts } #[cfg(not(unix))] pub(crate) fn daemon_file_options() -> OpenOptions { OpenOptions::new() } /// Open `/dev/null` (read) for stdin and `log_path` (created, append) for /// stdout + stderr. #[cfg(unix)] fn open_stdio_targets(log_path: &Path) -> io::Result<(File, File)> { if let Some(parent) = log_path.parent() { let _ = fs::create_dir_all(parent); } let stdin_src = OpenOptions::new().read(true).open("/dev/null")?; let log = daemon_file_options() .create(true) .append(true) .open(log_path)?; Ok((stdin_src, log)) } /// `dup2(source, target)`, mapping the `-1` sentinel to an `io::Error`. #[cfg(unix)] fn redirect_fd(target: RawFd, source: &File) -> io::Result<()> { // SAFETY: `source` is an open File and `target` a standard descriptor — // both valid for `dup2`. if unsafe { libc::dup2(source.as_raw_fd(), target) } == -1 { return Err(io::Error::last_os_error()); } Ok(()) } #[cfg(unix)] fn redirect_stdio(log_path: &Path) -> io::Result<()> { let (stdin_src, log) = open_stdio_targets(log_path)?; redirect_fd(libc::STDIN_FILENO, &stdin_src)?; redirect_fd(libc::STDOUT_FILENO, &log)?; redirect_fd(libc::STDERR_FILENO, &log)?; // `stdin_src` / `log` close here; fds 0/1/2 keep their dup'd copies. Ok(()) } /// Single-instance lock backed by an advisory `flock` on a pidfile, held for /// the daemon's lifetime. Dropping it closes the file, releasing the lock; the /// pidfile itself is left on disk for diagnostics. #[derive(Debug)] pub struct PidFile { _file: File, } impl PidFile { /// Take the exclusive lock and record the current PID. /// /// - `Ok(Some(_))` — lock acquired; hold the returned guard. /// - `Ok(None)` — another live process holds the lock (caller should /// no-op and exit cleanly). /// - `Err(_)` — an I/O error opening or locking the file. pub fn acquire(path: &Path) -> io::Result> { if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } let mut file = daemon_file_options() .read(true) .write(true) .create(true) .truncate(false) .open(path)?; match file.try_lock_exclusive() { Ok(()) => {} Err(e) if is_lock_contended(&e) => return Ok(None), Err(e) => return Err(e), } // PID contents are advisory diagnostics; the flock provides exclusion. // `set_len(0)` clears any stale (possibly longer) value first. file.set_len(0)?; file.write_all(process::id().to_string().as_bytes())?; file.flush()?; Ok(Some(Self { _file: file })) } /// Acquire the lock, taking over from a live predecessor workspace-server /// if one holds it: graceful termination (its normal drain runs), `grace` /// to release the lock, then a forceful kill (process death releases the /// flock). The lock is never bypassed — a guard is returned only with the /// flock held. /// /// `Ok(None)` means the caller should exit quietly: the holder is not an /// identifiable workspace-server, or the lock could not be won after the /// escalation (e.g. a concurrent newer spawn took it). pub fn acquire_or_take_over(path: &Path, grace: Duration) -> io::Result> { Self::acquire_or_take_over_matching(path, grace, WORKSPACE_SERVER_NAME_FRAGMENT) } /// [`Self::acquire_or_take_over`] with an injectable name fragment so /// tests can match their own predecessor processes. fn acquire_or_take_over_matching( path: &Path, grace: Duration, name_fragment: &str, ) -> io::Result> { if let Some(guard) = Self::acquire(path)? { return Ok(Some(guard)); } let Some(pid) = read_pidfile_pid(path) else { return Ok(None); }; if pid == process::id() { return Ok(None); } let Some(predecessor) = PredecessorTarget::open(pid, name_fragment) else { return Ok(None); }; // tracing is not initialized this early; in daemonized mode stderr is // already redirected to the log file, so eprintln! is the log channel. eprintln!("taking over from predecessor workspace-server (pid {pid})"); if let Err(e) = predecessor.signal(false) { eprintln!("failed to signal predecessor (pid {pid}): {e}"); } if let Some(guard) = Self::poll_acquire(path, grace)? { return Ok(Some(guard)); } eprintln!("predecessor (pid {pid}) did not release the pidfile lock in time; killing it"); if let Err(e) = predecessor.signal(true) { eprintln!("failed to kill predecessor (pid {pid}): {e}"); } if let Some(guard) = Self::poll_acquire(path, TAKEOVER_KILL_GRACE)? { return Ok(Some(guard)); } // The holder we signaled is dead yet the lock is still owned — a // concurrent newer spawn won it. Decline rather than double-run. eprintln!("pidfile lock is still held after killing pid {pid}; exiting"); Ok(None) } /// Retry [`Self::acquire`] until it succeeds or `budget` elapses. fn poll_acquire(path: &Path, budget: Duration) -> io::Result> { let deadline = Instant::now() + budget; loop { if let Some(guard) = Self::acquire(path)? { return Ok(Some(guard)); } if Instant::now() >= deadline { return Ok(None); } thread::sleep(TAKEOVER_POLL); } } } /// Advisory pid recorded in the pidfile by its holder; `None` if unreadable /// or not a positive integer. fn read_pidfile_pid(path: &Path) -> Option { fs::read_to_string(path) .ok()? .trim() .parse::() .ok() .filter(|&pid| pid > 0) } /// True if the basename of `name` (path separators `/` and `\` both count) /// contains `fragment`. Matching the basename rather than the whole path /// keeps a directory component like `/home/workspace-server-data/foo` from /// satisfying the kill gate. #[cfg(any(test, target_os = "linux", windows))] fn basename_contains(name: &str, fragment: &str) -> bool { name.rsplit(['/', '\\']).next().is_some_and(|base| { base.to_ascii_lowercase() .contains(&fragment.to_ascii_lowercase()) }) } /// True if `pid`'s argv0 basename (from `/proc//cmdline`) matches /// `fragment`. #[cfg(target_os = "linux")] fn process_name_matches(pid: u32, fragment: &str) -> bool { match fs::read(format!("/proc/{pid}/cmdline")) { Ok(cmdline) => cmdline .split(|&b| b == 0) .next() .is_some_and(|argv0| basename_contains(&String::from_utf8_lossy(argv0), fragment)), Err(_) => false, } } /// A pinned, verified handle to the predecessor process: `pidfd_open(2)` on /// Linux, an `OpenProcess` handle on Windows. /// /// Pinning happens **before** verification and every signal is delivered /// through the pin, closing the check-then-kill pid-reuse race by /// construction: a recycled pid is unreachable — at worst a signal lands on /// the already-dead pinned instance and is a no-op. #[cfg(target_os = "linux")] struct PredecessorTarget { pid: u32, /// `None` = pidfd unsupported on this kernel; plain-`kill` fallback mode /// (retains only the historical residual race). pidfd: Option, } #[cfg(target_os = "linux")] impl PredecessorTarget { /// Pin `pid` and verify its executable basename matches `fragment`. /// `None` if the process is gone, inaccessible, or not a match. fn open(pid: u32, fragment: &str) -> Option { // SAFETY: pidfd_open takes value arguments only; the returned fd is // fresh and exclusively owned here. let ret = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0u32) }; let pidfd = if ret >= 0 { // SAFETY: `ret` is a freshly returned, unowned fd. Some(unsafe { OwnedFd::from_raw_fd(ret as RawFd) }) } else { let e = io::Error::last_os_error(); if e.raw_os_error() == Some(libc::ESRCH) { return None; } // ENOSYS or seccomp-filtered: degrade to unpinned kill(). None }; // Verify after pinning: a pid recycled before the pin fails the name // match; recycled after, the pin targets the dead predecessor. if !process_name_matches(pid, fragment) { return None; } Some(Self { pid, pidfd }) } /// Deliver graceful (SIGTERM) or forceful (SIGKILL) termination to the /// pinned instance. Already-dead is `Ok`. fn signal(&self, forceful: bool) -> io::Result<()> { let signal = if forceful { libc::SIGKILL } else { libc::SIGTERM }; let ret = match &self.pidfd { // SAFETY: the pidfd is owned and open; the siginfo pointer is // documented-null (kernel builds a default), flags are zero. Some(fd) => unsafe { libc::syscall( libc::SYS_pidfd_send_signal, fd.as_raw_fd(), signal, std::ptr::null::(), 0u32, ) }, // SAFETY: kill() takes no pointers. None => unsafe { libc::kill(self.pid as libc::pid_t, signal) }.into(), }; if ret == 0 { return Ok(()); } match io::Error::last_os_error() { e if e.raw_os_error() == Some(libc::ESRCH) => Ok(()), e => Err(e), } } } #[cfg(windows)] struct PredecessorTarget { handle: HANDLE, } // SAFETY: the HANDLE is an owned kernel object reference; it is not tied to // the creating thread and is only used behind &self. #[cfg(windows)] unsafe impl Send for PredecessorTarget {} #[cfg(windows)] impl PredecessorTarget { /// Pin `pid` with query + terminate rights and verify the image basename /// matches `fragment` on the pinned handle. fn open(pid: u32, fragment: &str) -> Option { use windows::Win32::System::Threading::{ OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, QueryFullProcessImageNameW, }; use windows::core::PWSTR; // SAFETY: OpenProcess is FFI with value args; windows-rs returns Err // on absence/permission failure. let handle = unsafe { OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE, false, pid, ) } .ok()?; let target = Self { handle }; // QueryFullProcessImageNameW writes a NUL-terminated UTF-16 path into // the buffer; `size` is updated to the chars written (excluding NUL). let mut buf: Vec = vec![0; 1024]; let mut size: u32 = buf.len() as u32; // SAFETY: the handle is pinned by `target`; buf outlives the call; // size is in/out. let result = unsafe { QueryFullProcessImageNameW( target.handle, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut size, ) }; if result.is_err() { return None; } basename_contains(&String::from_utf16_lossy(&buf[..size as usize]), fragment) .then_some(target) } /// `TerminateProcess` on the pinned handle (Windows has no graceful /// signal for a detached process). Already-dead is `Ok`. fn signal(&self, _forceful: bool) -> io::Result<()> { use windows::Win32::System::Threading::TerminateProcess; // SAFETY: the handle is the pinned kernel object owned by self. match unsafe { TerminateProcess(self.handle, 0) } { Ok(()) => Ok(()), // Already exited: terminating a dead (but pinned) process fails // with access-style errors; the takeover treats that as done. Err(e) => Err(io::Error::other(format!("TerminateProcess: {e}"))), } } } #[cfg(windows)] impl Drop for PredecessorTarget { fn drop(&mut self) { use windows::Win32::Foundation::CloseHandle; // SAFETY: the handle is owned by self and closed exactly once. let _ = unsafe { CloseHandle(self.handle) }; } } #[cfg(not(any(target_os = "linux", windows)))] struct PredecessorTarget; #[cfg(not(any(target_os = "linux", windows)))] impl PredecessorTarget { /// Unsupported platform: never identify a predecessor (takeover declines /// rather than kill blind). fn open(_pid: u32, _fragment: &str) -> Option { None } fn signal(&self, _forceful: bool) -> io::Result<()> { Err(io::Error::new( io::ErrorKind::Unsupported, "process termination is only supported on Linux and Windows", )) } } #[cfg(test)] mod tests { // Used only by the linux-gated predecessor-takeover tests below. #[cfg(target_os = "linux")] use std::process::{Child, Command, Stdio}; use tempfile::TempDir; use super::*; #[test] fn pidfile_acquire_is_exclusive() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let first = PidFile::acquire(&path).unwrap(); assert!(first.is_some(), "first acquire should win the lock"); // A second open of the same path conflicts on the advisory flock, // even within the same process (flock is per open file description). let second = PidFile::acquire(&path).unwrap(); assert!(second.is_none(), "contended acquire must report None"); drop(first); // Dropping the guard closes the fd and releases the flock. Retry briefly: // under the parallel test runner a concurrent `fork`/`Command::spawn` can // transiently duplicate this flock'd fd, holding the lock until the child // `execve`s (the fd is `O_CLOEXEC`). That window is microseconds, so a // short bounded retry makes the release deterministic without weakening // the held-exclusion assertion above. let deadline = Instant::now() + Duration::from_secs(2); let third = loop { match PidFile::acquire(&path).unwrap() { Some(guard) => break Some(guard), None if Instant::now() < deadline => { thread::sleep(Duration::from_millis(5)); } none => break none, } }; assert!(third.is_some(), "acquire should succeed after release"); } #[test] fn pidfile_records_current_pid() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let guard = PidFile::acquire(&path).unwrap().unwrap(); let contents = fs::read_to_string(&path).unwrap(); assert_eq!(contents.trim().parse::().unwrap(), process::id()); drop(guard); } #[test] fn pidfile_persists_on_disk_after_drop() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let guard = PidFile::acquire(&path).unwrap().unwrap(); assert!(path.exists()); drop(guard); // The file is intentionally left behind for diagnostics; only the // lock is released (re-acquirable, covered by the exclusivity test). assert!(path.exists(), "pidfile should remain on disk after drop"); } #[test] fn pidfile_acquire_creates_parent_dir() { let dir = TempDir::new().unwrap(); let path = dir.path().join("nested/sub/ws.pid"); let guard = PidFile::acquire(&path).unwrap(); assert!(guard.is_some()); assert!(path.exists()); } #[test] fn pidfile_acquire_truncates_stale_longer_content() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); // A leftover value longer than our PID would leave trailing bytes if // `set_len(0)` were missing. fs::write(&path, "999999999999 stale junk\n").unwrap(); let guard = PidFile::acquire(&path).unwrap().unwrap(); let contents = fs::read_to_string(&path).unwrap(); assert_eq!( contents, process::id().to_string(), "stale content must be fully truncated, no trailing bytes" ); drop(guard); } #[test] fn contended_acquire_does_not_modify_pidfile() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let holder = PidFile::acquire(&path).unwrap().unwrap(); let before = fs::read_to_string(&path).unwrap(); let contended = PidFile::acquire(&path).unwrap(); assert!(contended.is_none()); let after = fs::read_to_string(&path).unwrap(); assert_eq!(before, after, "contended acquire must not rewrite the file"); drop(holder); } #[test] fn pidfile_acquire_errors_on_directory() { let dir = TempDir::new().unwrap(); let as_dir = dir.path().join("a_dir"); fs::create_dir(&as_dir).unwrap(); // Opening a directory for writing yields EISDIR — a real error that // must surface as `Err`, never be swallowed into `Ok(None)`. assert!( PidFile::acquire(&as_dir).is_err(), "acquiring a directory path must error, not report Ok(None)" ); } #[cfg(unix)] #[test] fn open_stdio_targets_opens_devnull_and_log() { use std::io::{Read, Write}; let dir = TempDir::new().unwrap(); let log_path = dir.path().join("logs/ws.log"); let (mut stdin_src, mut log) = open_stdio_targets(&log_path).unwrap(); assert!(log_path.exists(), "log file should be created"); log.write_all(b"hello").unwrap(); log.flush().unwrap(); assert_eq!(fs::read_to_string(&log_path).unwrap(), "hello"); // The stdin source is /dev/null: reads yield EOF immediately. let mut buf = [0u8; 4]; assert_eq!(stdin_src.read(&mut buf).unwrap(), 0); } #[cfg(unix)] #[test] fn open_stdio_targets_appends_to_existing_log() { use std::io::Write; let dir = TempDir::new().unwrap(); let log_path = dir.path().join("ws.log"); fs::write(&log_path, "prior\n").unwrap(); let (_stdin_src, mut log) = open_stdio_targets(&log_path).unwrap(); log.write_all(b"more\n").unwrap(); log.flush().unwrap(); assert_eq!(fs::read_to_string(&log_path).unwrap(), "prior\nmore\n"); } #[cfg(unix)] #[test] fn open_stdio_targets_errors_when_parent_is_a_file() { let dir = TempDir::new().unwrap(); let parent_file = dir.path().join("not_a_dir"); fs::write(&parent_file, "x").unwrap(); // `not_a_dir` is a regular file, so a log path under it is ENOTDIR. let log_path = parent_file.join("ws.log"); assert!( open_stdio_targets(&log_path).is_err(), "a log path whose parent is a file must error" ); } // O_NOFOLLOW makes a symlinked final component fail with ELOOP rather than // being followed — deterministic and uid-independent (no chmod, root-safe). #[cfg(unix)] #[test] fn open_stdio_targets_rejects_symlinked_log() { let dir = TempDir::new().unwrap(); let target = dir.path().join("real.log"); fs::write(&target, "").unwrap(); let link = dir.path().join("link.log"); std::os::unix::fs::symlink(&target, &link).unwrap(); let err = open_stdio_targets(&link).unwrap_err(); assert_eq!(err.raw_os_error(), Some(libc::ELOOP)); } #[cfg(unix)] #[test] fn pidfile_acquire_rejects_symlinked_path() { let dir = TempDir::new().unwrap(); let target = dir.path().join("real.pid"); let link = dir.path().join("link.pid"); std::os::unix::fs::symlink(&target, &link).unwrap(); let err = PidFile::acquire(&link).unwrap_err(); assert_eq!(err.raw_os_error(), Some(libc::ELOOP)); // The truncate-through-symlink primitive is blocked: O_CREAT did not // follow the link to create (and truncate) its target. assert!(!target.exists()); } #[cfg(unix)] #[test] fn pidfile_created_mode_is_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let _guard = PidFile::acquire(&path).unwrap().unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode(); // No group/other bits, regardless of umask (0600 & ~umask keeps them 0). assert_eq!( mode & 0o077, 0, "pidfile must not be group/other-accessible" ); } #[cfg(not(any(unix, windows)))] #[test] fn daemonize_unsupported_off_unix_and_windows() { let err = daemonize(Path::new("ignored")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::Unsupported); } #[test] fn take_over_uncontended_acquires_normally() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let guard = PidFile::acquire_or_take_over(&path, Duration::from_millis(100)).unwrap(); assert!(guard.is_some()); assert_eq!( fs::read_to_string(&path).unwrap(), process::id().to_string() ); } #[test] fn take_over_declines_unreadable_pidfile() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let _holder = PidFile::acquire(&path).unwrap().unwrap(); fs::write(&path, "not a pid").unwrap(); let taken = PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(100), "sleep") .unwrap(); assert!(taken.is_none(), "an unidentifiable holder must be declined"); } #[test] fn take_over_declines_own_pid() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); // The in-process holder wrote our own pid; a takeover must not // signal ourselves. let _holder = PidFile::acquire(&path).unwrap().unwrap(); let taken = PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(100), "").unwrap(); assert!(taken.is_none()); } /// Spawn a long-sleeping child to stand in for a predecessor process. #[cfg(target_os = "linux")] fn spawn_predecessor() -> Child { Command::new("sleep") .arg("300") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn sleep") } /// Wait (bounded) for a child to exit; returns true if it did. #[cfg(target_os = "linux")] fn wait_for_exit(child: &mut Child, budget: Duration) -> bool { let deadline = Instant::now() + budget; while Instant::now() < deadline { if child.try_wait().expect("try_wait").is_some() { return true; } thread::sleep(Duration::from_millis(10)); } false } #[cfg(target_os = "linux")] #[test] fn take_over_declines_non_matching_holder() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let _holder = PidFile::acquire(&path).unwrap().unwrap(); let mut child = spawn_predecessor(); fs::write(&path, child.id().to_string()).unwrap(); let taken = PidFile::acquire_or_take_over_matching( &path, Duration::from_millis(100), "definitely-not-this-process", ) .unwrap(); assert!(taken.is_none(), "a foreign holder must not be taken over"); assert!( child.try_wait().expect("try_wait").is_none(), "a foreign holder must not be killed" ); child.kill().expect("cleanup kill"); let _ = child.wait(); } #[cfg(target_os = "linux")] #[test] fn take_over_declines_when_lock_is_never_released() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); // The flock is held in-process for the whole test — after the child // named in the pidfile is dead, the lock is still owned by "someone // else" (a concurrent-spawn stand-in), so the takeover must decline // rather than run without single-instance protection. let _holder = PidFile::acquire(&path).unwrap().unwrap(); let mut child = spawn_predecessor(); let child_pid = child.id(); fs::write(&path, child_pid.to_string()).unwrap(); let taken = PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(300), "sleep") .unwrap(); assert!( wait_for_exit(&mut child, Duration::from_secs(2)), "the predecessor must be terminated" ); assert!( taken.is_none(), "a takeover that cannot win the lock must decline, never proceed lockless" ); assert_eq!( fs::read_to_string(&path).unwrap(), child_pid.to_string(), "a declined takeover must not rewrite the pidfile" ); } #[cfg(target_os = "linux")] #[test] fn take_over_escalates_to_sigkill_for_stuck_predecessor() { use std::os::unix::process::ExitStatusExt as _; let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); // A predecessor that ignores the graceful signal: only the SIGKILL // escalation can end it. It touches a marker once the trap is // installed so the test cannot signal it during bash startup. let trap_ready = dir.path().join("trap-ready"); let mut child = Command::new("bash") .arg("-c") .arg(format!( "trap '' TERM; touch {}; while true; do sleep 1; done", trap_ready.display() )) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn stubborn child"); let trap_deadline = Instant::now() + Duration::from_secs(5); while !trap_ready.exists() { assert!(Instant::now() < trap_deadline, "child never set its trap"); thread::sleep(Duration::from_millis(10)); } // Stand in for the stuck predecessor's flock: released only after the // graceful grace has expired, inside the post-kill window. let holder = PidFile::acquire(&path).unwrap().unwrap(); fs::write(&path, child.id().to_string()).unwrap(); let release = thread::spawn(move || { thread::sleep(Duration::from_millis(600)); drop(holder); }); let taken = PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(300), "bash") .unwrap(); release.join().expect("release thread"); let status = child.wait().expect("child wait"); assert_eq!( status.signal(), Some(libc::SIGKILL), "a SIGTERM-immune predecessor must be ended by the SIGKILL escalation" ); assert!(taken.is_some(), "the lock freed within the kill window"); assert_eq!( fs::read_to_string(&path).unwrap(), process::id().to_string() ); } #[cfg(target_os = "linux")] #[test] fn take_over_acquires_cleanly_when_predecessor_releases() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); let holder = PidFile::acquire(&path).unwrap().unwrap(); let mut child = spawn_predecessor(); fs::write(&path, child.id().to_string()).unwrap(); // Release the lock shortly after the takeover starts waiting, // simulating the predecessor finishing its drain within grace. let release = thread::spawn(move || { thread::sleep(Duration::from_millis(100)); drop(holder); }); let taken = PidFile::acquire_or_take_over_matching(&path, Duration::from_secs(5), "sleep").unwrap(); release.join().expect("release thread"); assert!( wait_for_exit(&mut child, Duration::from_secs(2)), "the predecessor must be terminated" ); assert!(taken.is_some()); assert_eq!( fs::read_to_string(&path).unwrap(), process::id().to_string() ); } #[cfg(target_os = "linux")] #[test] fn process_name_matches_own_argv0() { let pid = process::id(); // Derive the fragment from this process's real argv0 basename rather // than hardcoding a name: different test runners name the binary // differently (e.g. Cargo uses `kigi_workspace-`), so a // hardcoded fragment matches under one runner but not another. let cmdline = fs::read(format!("/proc/{pid}/cmdline")).expect("read own cmdline"); let argv0 = cmdline.split(|&b| b == 0).next().expect("argv0 present"); let basename = String::from_utf8_lossy(argv0) .rsplit(['/', '\\']) .next() .expect("basename") .to_owned(); assert!(!basename.is_empty(), "argv0 basename must not be empty"); assert!(process_name_matches(pid, &basename)); assert!(!process_name_matches(pid, "definitely-not-this-process")); } #[cfg(target_os = "linux")] #[test] fn predecessor_target_pins_verifies_and_signals() { let mut child = spawn_predecessor(); assert!( PredecessorTarget::open(child.id(), "not-a-match").is_none(), "a non-matching name must not produce a target" ); // /proc//cmdline can lag briefly after spawn under remote CI // executors; derive the fragment from the live cmdline (handles // busybox-as-sleep) and retry pin open instead of a one-shot expect. let fragment = { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Ok(cmdline) = fs::read(format!("/proc/{}/cmdline", child.id())) { let argv0 = cmdline.split(|&b| b == 0).next().unwrap_or_default(); let basename = String::from_utf8_lossy(argv0) .rsplit(['/', '\\']) .next() .unwrap_or("") .to_owned(); if !basename.is_empty() { break basename; } } if Instant::now() >= deadline { panic!("child cmdline never became readable"); } thread::sleep(Duration::from_millis(10)); } }; let target = { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(t) = PredecessorTarget::open(child.id(), &fragment) { break t; } if Instant::now() >= deadline { panic!("pin child (fragment={fragment:?})"); } thread::sleep(Duration::from_millis(10)); } }; target.signal(false).expect("graceful signal"); assert!( wait_for_exit(&mut child, Duration::from_secs(2)), "the pinned child must receive the signal" ); // Signalling the dead pinned instance is a no-op, never a stray kill. target .signal(true) .expect("signal to dead pinned instance is Ok"); } #[test] fn basename_contains_ignores_directory_components() { assert!(basename_contains( "/usr/local/bin/kigi-workspace-server", "workspace-server" )); assert!(basename_contains( "C:\\Program Files\\XAI-Workspace-Server.exe", "workspace-server" )); assert!( !basename_contains("/home/workspace-server-data/unrelated", "workspace-server"), "a matching directory component must not satisfy the kill gate" ); } #[test] fn read_pidfile_pid_parses_and_rejects() { let dir = TempDir::new().unwrap(); let path = dir.path().join("ws.pid"); fs::write(&path, "1234\n").unwrap(); assert_eq!(read_pidfile_pid(&path), Some(1234)); fs::write(&path, "0").unwrap(); assert_eq!(read_pidfile_pid(&path), None, "pid 0 is not a process"); fs::write(&path, "garbage").unwrap(); assert_eq!(read_pidfile_pid(&path), None); assert_eq!(read_pidfile_pid(&dir.path().join("missing")), None); } }