Skip to main content

cc_switch/daemon/
pidfile.rs

1//! Pidfile primitives and process-liveness checks for the daemon supervisor.
2//!
3//! The pidfile is an atomic single-writer lock. `acquire` uses `O_CREAT|O_EXCL`
4//! so two concurrent daemons racing for the same upstream cannot both win. The
5//! caller is responsible for preflight: if a stale pidfile exists for a dead
6//! PID, it must be removed *before* `acquire` is called.
7
8use anyhow::{Context, Result};
9use std::io::{ErrorKind, Write};
10use std::path::PathBuf;
11
12pub struct Pidfile {
13    path: PathBuf,
14}
15
16impl Pidfile {
17    pub fn new(path: PathBuf) -> Self {
18        Self { path }
19    }
20
21    /// Atomically create the pidfile and write our PID into it. Fails if the
22    /// file already exists (intentional — caller must preflight stale files).
23    pub fn acquire(&self) -> Result<()> {
24        let mut options = std::fs::OpenOptions::new();
25        options.write(true).create_new(true);
26
27        #[cfg(unix)]
28        {
29            use std::os::unix::fs::OpenOptionsExt;
30            options.mode(0o600);
31        }
32
33        let mut file = options
34            .open(&self.path)
35            .with_context(|| format!("failed to create pidfile at {}", self.path.display()))?;
36        let pid = std::process::id();
37        file.write_all(format!("{pid}\n").as_bytes())
38            .with_context(|| format!("failed to write pid to {}", self.path.display()))?;
39        file.sync_all()
40            .with_context(|| format!("failed to fsync pidfile {}", self.path.display()))?;
41        Ok(())
42    }
43
44    /// Best-effort removal. Returns Ok if the file was already missing.
45    pub fn release(&self) -> Result<()> {
46        match std::fs::remove_file(&self.path) {
47            Ok(()) => Ok(()),
48            Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
49            Err(err) => Err(err)
50                .with_context(|| format!("failed to remove pidfile {}", self.path.display())),
51        }
52    }
53
54    /// Returns the PID stored in the pidfile, or `Ok(None)` if the file is
55    /// missing. Unparseable contents return `Err`.
56    pub fn read(&self) -> Result<Option<u32>> {
57        let raw = match std::fs::read_to_string(&self.path) {
58            Ok(text) => text,
59            Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
60            Err(err) => {
61                return Err(err)
62                    .with_context(|| format!("failed to read pidfile {}", self.path.display()));
63            }
64        };
65        let trimmed = raw.trim();
66        trimmed.parse::<u32>().map(Some).map_err(|err| {
67            anyhow::anyhow!(
68                "pidfile {} contains unparseable content {:?}: {err}",
69                self.path.display(),
70                trimmed
71            )
72        })
73    }
74}
75
76/// Returns true if a process with this PID is running and visible to us.
77///
78/// Unix: `kill(pid, 0)` — 0 means alive, ESRCH means gone, EPERM means alive
79/// but owned by another uid (still "alive" for our purposes).
80///
81/// Non-Unix builds always return `Ok(false)` — the daemon command path is
82/// gated behind `#[cfg(unix)]`, so this branch is unreachable in practice but
83/// keeps the module compilable on Windows.
84#[cfg(unix)]
85pub fn process_alive(pid: u32) -> Result<bool> {
86    // SAFETY: kill(pid, 0) is signal-free; it only performs the permission /
87    // existence check and never delivers a signal.
88    let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
89    if ret == 0 {
90        return Ok(true);
91    }
92    let err = std::io::Error::last_os_error();
93    match err.raw_os_error() {
94        Some(libc::ESRCH) => Ok(false),
95        Some(libc::EPERM) => Ok(true),
96        _ => Err(err).with_context(|| format!("kill({pid}, 0) failed")),
97    }
98}
99
100#[cfg(not(unix))]
101pub fn process_alive(_pid: u32) -> Result<bool> {
102    Ok(false)
103}
104
105/// Returns the executable name for the PID if it can be determined cheaply.
106///
107/// Linux: reads `/proc/<pid>/comm`.
108/// Other Unix (macOS, BSDs): shells out to `ps -p <pid> -o comm=`, then takes
109/// the basename — macOS `ps` returns the full path of the executable.
110/// Non-Unix: returns `None`.
111///
112/// Used by the daemon preflight to tell "our prior daemon process" apart from
113/// "PID was recycled and now belongs to /usr/bin/grep" or similar.
114#[cfg(target_os = "linux")]
115pub fn process_name(pid: u32) -> Option<String> {
116    let raw = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
117    let trimmed = raw.trim();
118    if trimmed.is_empty() {
119        None
120    } else {
121        Some(trimmed.to_string())
122    }
123}
124
125#[cfg(all(unix, not(target_os = "linux")))]
126pub fn process_name(pid: u32) -> Option<String> {
127    use std::process::Command;
128    let output = Command::new("ps")
129        .args(["-p", &pid.to_string(), "-o", "comm="])
130        .output()
131        .ok()?;
132    if !output.status.success() {
133        return None;
134    }
135    let stdout = String::from_utf8(output.stdout).ok()?;
136    let trimmed = stdout.trim();
137    if trimmed.is_empty() {
138        return None;
139    }
140    let basename = std::path::Path::new(trimmed)
141        .file_name()
142        .and_then(|name| name.to_str())
143        .unwrap_or(trimmed)
144        .to_string();
145    Some(basename)
146}
147
148#[cfg(not(unix))]
149pub fn process_name(_pid: u32) -> Option<String> {
150    None
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{Pidfile, process_alive};
156    use tempfile::TempDir;
157
158    fn make_path() -> (TempDir, std::path::PathBuf) {
159        let dir = TempDir::new().expect("tempdir");
160        let path = dir.path().join("ccs-proxy.pid");
161        (dir, path)
162    }
163
164    #[test]
165    fn acquire_writes_pid_to_file() {
166        let (_dir, path) = make_path();
167        let pidfile = Pidfile::new(path.clone());
168        pidfile.acquire().expect("acquire");
169
170        let raw = std::fs::read_to_string(&path).expect("read pidfile");
171        let parsed: u32 = raw.trim().parse().expect("parse pid");
172        assert_eq!(parsed, std::process::id());
173    }
174
175    #[test]
176    fn acquire_errors_when_file_already_exists() {
177        let (_dir, path) = make_path();
178        let pidfile = Pidfile::new(path);
179        pidfile.acquire().expect("first acquire");
180        let second = pidfile.acquire();
181        assert!(second.is_err(), "second acquire must fail");
182    }
183
184    #[test]
185    fn release_removes_file() {
186        let (_dir, path) = make_path();
187        let pidfile = Pidfile::new(path.clone());
188        pidfile.acquire().expect("acquire");
189        pidfile.release().expect("release");
190        assert!(!path.exists(), "pidfile should be gone after release");
191        pidfile.release().expect("release is idempotent");
192    }
193
194    #[test]
195    fn read_missing_file_returns_none() {
196        let (_dir, path) = make_path();
197        let pidfile = Pidfile::new(path);
198        assert!(pidfile.read().expect("read").is_none());
199    }
200
201    #[test]
202    fn read_unparseable_returns_err() {
203        let (_dir, path) = make_path();
204        std::fs::write(&path, "hello\n").expect("write garbage");
205        let pidfile = Pidfile::new(path);
206        assert!(pidfile.read().is_err());
207    }
208
209    #[test]
210    fn read_returns_pid_for_valid_file() {
211        let (_dir, path) = make_path();
212        let pidfile = Pidfile::new(path);
213        pidfile.acquire().expect("acquire");
214        let pid = pidfile.read().expect("read");
215        assert_eq!(pid, Some(std::process::id()));
216    }
217
218    #[cfg(unix)]
219    #[test]
220    fn process_alive_for_self() {
221        let alive = process_alive(std::process::id()).expect("query self");
222        assert!(alive);
223    }
224
225    #[cfg(unix)]
226    #[test]
227    fn process_alive_for_pid_1() {
228        // PID 1 is init/launchd; it always exists on Unix and is owned by
229        // root, so kill(1, 0) from a non-root caller returns EPERM. That
230        // still counts as "alive" — exercises the EPERM branch.
231        let alive = process_alive(1).expect("query pid 1");
232        assert!(alive, "PID 1 must be reported alive on Unix");
233    }
234
235    #[test]
236    fn process_alive_for_high_unused_pid() {
237        let alive = process_alive(0xFFFF_FFFE).expect("query unused pid");
238        assert!(!alive);
239    }
240
241    #[cfg(unix)]
242    #[test]
243    fn acquire_sets_unix_0600_perms() {
244        use std::os::unix::fs::PermissionsExt;
245        let (_dir, path) = make_path();
246        let pidfile = Pidfile::new(path.clone());
247        pidfile.acquire().expect("acquire");
248        let meta = std::fs::metadata(&path).expect("stat");
249        let mode = meta.permissions().mode() & 0o777;
250        assert_eq!(mode, 0o600, "pidfile must be 0600, got {mode:o}");
251    }
252}