Skip to main content

xianyun_deploy/registrycache/
process.rs

1//! zot process lifecycle: spawn, liveness check, and graceful stop.
2//!
3//! Ported from `internal/registrycache/process.go`.
4
5use std::fs::OpenOptions;
6use std::io;
7use std::os::unix::process::CommandExt;
8use std::process::{Command, Stdio};
9use std::time::{Duration, Instant};
10
11use thiserror::Error;
12
13use super::paths::{self, PathError};
14
15/// How long `stop_process` waits for `SIGTERM` before escalating to
16/// `SIGKILL`.
17const STOP_TIMEOUT: Duration = Duration::from_secs(5);
18
19/// Errors from spawning or controlling the zot process.
20#[derive(Debug, Error)]
21pub enum ProcessError {
22    /// Failed resolving a registry-cache path.
23    #[error(transparent)]
24    Path(#[from] PathError),
25    /// Failed creating the registry-cache directory.
26    #[error("creating registry cache dir: {0}")]
27    CreateDir(io::Error),
28    /// Failed opening the zot log file.
29    #[error("opening zot log: {0}")]
30    OpenLog(io::Error),
31    /// Failed spawning the zot binary.
32    #[error("spawning {0}: {1}")]
33    Spawn(String, io::Error),
34    /// Failed writing the PID file.
35    #[error("writing PID file: {0}")]
36    WritePid(io::Error),
37    /// Failed reading the PID file.
38    #[error("reading PID file: {0}")]
39    ReadPid(io::Error),
40    /// The PID file contents were not a valid PID.
41    #[error("parsing PID {0:?}: {1}")]
42    ParsePid(String, std::num::ParseIntError),
43    /// Failed removing the PID file.
44    #[error("removing PID file: {0}")]
45    RemovePid(io::Error),
46    /// `kill(pid, 0)` failed for a reason other than "no such process".
47    #[error("checking PID {0}: {1}")]
48    CheckPid(i32, io::Error),
49    /// Sending a signal to the process failed for a reason other than
50    /// "no such process".
51    #[error("sending {0} to {1}: {2}")]
52    Signal(&'static str, i32, io::Error),
53}
54
55/// Spawns zot in the background, writes the PID file, and returns.
56/// The caller does not wait for zot; zot survives as a detached child
57/// (own session via `setsid`). stdout and stderr are redirected to
58/// [`paths::log_path`].
59///
60/// Once this returns, the caller has no further control over the
61/// child's lifecycle beyond signalling it via [`stop_process`]: the
62/// child is reaped by a background thread so it never lingers as a
63/// zombie.
64pub fn spawn_process(binary: &str, args: &[String]) -> Result<i32, ProcessError> {
65    let log_path = paths::log_path()?;
66    let dir = paths::global_dir()?;
67
68    std::fs::create_dir_all(&dir).map_err(ProcessError::CreateDir)?;
69
70    let log_file = OpenOptions::new()
71        .create(true)
72        .append(true)
73        .open(&log_path)
74        .map_err(ProcessError::OpenLog)?;
75    let log_file_err = log_file.try_clone().map_err(ProcessError::OpenLog)?;
76
77    let mut cmd = Command::new(binary);
78    cmd.args(args)
79        .stdout(Stdio::from(log_file))
80        .stderr(Stdio::from(log_file_err));
81
82    // SAFETY: `setsid` is async-signal-safe and does not touch any
83    // memory shared with the parent; this mirrors Go's
84    // `SysProcAttr{Setsid: true}`, detaching the child into its own
85    // session so it survives the parent exiting.
86    unsafe {
87        cmd.pre_exec(|| {
88            if libc::setsid() == -1 {
89                return Err(io::Error::last_os_error());
90            }
91            Ok(())
92        });
93    }
94
95    let mut child = cmd
96        .spawn()
97        .map_err(|e| ProcessError::Spawn(binary.to_string(), e))?;
98
99    let pid = child.id().cast_signed();
100
101    let pid_path = paths::pid_path()?;
102    std::fs::write(&pid_path, format!("{pid}\n")).map_err(ProcessError::WritePid)?;
103
104    // Reap the child asynchronously so that when zot exits it doesn't
105    // linger as a zombie. If our process exits first, init adopts and
106    // reaps zot instead; this thread simply becomes a no-op.
107    std::thread::spawn(move || {
108        let _ = child.wait();
109    });
110
111    Ok(pid)
112}
113
114/// Returns true when the PID file exists and the named process is
115/// alive. Missing PID file or stale PID both yield false.
116pub fn is_running() -> Result<bool, ProcessError> {
117    let pid = match read_pid() {
118        Ok(pid) => pid,
119        Err(ProcessError::ReadPid(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
120        Err(e) => return Err(e),
121    };
122
123    // `kill -0` semantics: signal 0 doesn't actually send a signal, but
124    // the syscall still fails with ESRCH if the process doesn't exist.
125    // SAFETY: kill(2) with signal 0 is a pure liveness probe.
126    let rc = unsafe { libc::kill(pid, 0) };
127    if rc == 0 {
128        return Ok(true);
129    }
130
131    let err = io::Error::last_os_error();
132    match err.raw_os_error() {
133        Some(e) if e == libc::ESRCH => Ok(false),
134        // EPERM means the process exists but we can't signal it; treat
135        // as running.
136        Some(e) if e == libc::EPERM => Ok(true),
137        _ => Err(ProcessError::CheckPid(pid, err)),
138    }
139}
140
141fn kill_probe_dead(pid: i32) -> bool {
142    // SAFETY: liveness probe only.
143    let rc = unsafe { libc::kill(pid, 0) };
144    rc != 0 && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
145}
146
147/// Sends `SIGTERM` to the zot process, waits up to `STOP_TIMEOUT`
148/// for it to exit, escalates to `SIGKILL` if needed, then removes the
149/// PID file. No-op when zot isn't running. Idempotent.
150pub fn stop_process() -> Result<(), ProcessError> {
151    let pid = match read_pid() {
152        Ok(pid) => pid,
153        Err(ProcessError::ReadPid(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
154        Err(e) => return Err(e),
155    };
156
157    // SAFETY: sending SIGTERM to a PID we own (or that's gone) is safe.
158    let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
159    if rc != 0 {
160        let err = io::Error::last_os_error();
161        if err.raw_os_error() != Some(libc::ESRCH) {
162            return Err(ProcessError::Signal("SIGTERM", pid, err));
163        }
164    }
165
166    let deadline = Instant::now() + STOP_TIMEOUT;
167    while Instant::now() < deadline {
168        if kill_probe_dead(pid) {
169            break;
170        }
171
172        std::thread::sleep(Duration::from_millis(100));
173    }
174
175    if !kill_probe_dead(pid) {
176        // SAFETY: same as above.
177        let rc = unsafe { libc::kill(pid, libc::SIGKILL) };
178        if rc != 0 {
179            let err = io::Error::last_os_error();
180            if err.raw_os_error() != Some(libc::ESRCH) {
181                return Err(ProcessError::Signal("SIGKILL", pid, err));
182            }
183        }
184    }
185
186    let pid_path = paths::pid_path()?;
187    match std::fs::remove_file(&pid_path) {
188        Ok(()) => Ok(()),
189        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
190        Err(e) => Err(ProcessError::RemovePid(e)),
191    }
192}
193
194pub(super) fn read_pid() -> Result<i32, ProcessError> {
195    let pid_path = paths::pid_path()?;
196    let body = std::fs::read_to_string(&pid_path).map_err(ProcessError::ReadPid)?;
197
198    body.trim()
199        .parse::<i32>()
200        .map_err(|e| ProcessError::ParsePid(body, e))
201}
202
203#[cfg(test)]
204mod tests {
205    use std::os::unix::fs::PermissionsExt;
206    use std::path::Path;
207    use std::time::{Duration, Instant};
208
209    use super::*;
210    use crate::registrycache::test_support::with_home;
211
212    /// Creates a tiny shell script that sleeps for a while and writes
213    /// its PID to a "started" marker. Tests use it as a zot stand-in
214    /// so we can exercise spawn/kill/PID-file logic without the real
215    /// (~190 MB) zot binary.
216    fn write_stub_binary() -> tempfile::TempDir {
217        let dir = tempfile::tempdir().unwrap();
218        let bin = dir.path().join("zot-stub");
219
220        let script = format!(
221            "#!/bin/sh\n\
222            trap \"exit 0\" INT TERM\n\
223            echo woof > {}/started\n\
224            i=0; while [ \"$i\" -lt 60 ]; do sleep 1; i=$((i + 1)); done\n",
225            dir.path().display()
226        );
227
228        std::fs::write(&bin, script).unwrap();
229        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
230
231        dir
232    }
233
234    fn wait_for_process_started(bin: &Path) {
235        let dir = bin.parent().unwrap();
236        let started = dir.join("started");
237
238        let deadline = Instant::now() + Duration::from_secs(5);
239        while Instant::now() < deadline {
240            if started.exists() {
241                return;
242            }
243
244            std::thread::sleep(Duration::from_millis(10));
245        }
246
247        panic!("timed out waiting for stub process to start");
248    }
249
250    fn kill_by_pid(pid: i32) {
251        // SAFETY: best-effort cleanup signal to our own test-spawned
252        // child; ESRCH (already gone) is fine.
253        unsafe {
254            libc::kill(pid, libc::SIGINT);
255        }
256        std::thread::sleep(Duration::from_millis(50));
257        // SAFETY: same as above -- best-effort final cleanup signal to
258        // our own test-spawned child.
259        unsafe {
260            libc::kill(pid, libc::SIGKILL);
261        }
262    }
263
264    // Ported from internal/registrycache/process_test.go:59 (TestStartWritesPIDAndIsRunning)
265    #[test]
266    fn test_start_writes_pid_and_is_running() {
267        with_home(|_home| {
268            let stub_dir = write_stub_binary();
269            let stub = stub_dir.path().join("zot-stub");
270
271            let pid = spawn_process(stub.to_str().unwrap(), &["--whatever".to_string()]).unwrap();
272            assert!(pid > 0);
273
274            let pid_path = paths::pid_path().unwrap();
275            assert!(pid_path.exists());
276
277            let ok = is_running().unwrap();
278            assert!(ok);
279
280            kill_by_pid(pid);
281        });
282    }
283
284    // Ported from internal/registrycache/process_test.go:78 (TestStopKillsProcessAndRemovesPID)
285    #[test]
286    fn test_stop_kills_process_and_removes_pid() {
287        with_home(|_home| {
288            let stub_dir = write_stub_binary();
289            let stub = stub_dir.path().join("zot-stub");
290
291            spawn_process(stub.to_str().unwrap(), &[]).unwrap();
292            wait_for_process_started(&stub);
293
294            stop_process().unwrap();
295
296            let ok = is_running().unwrap();
297            assert!(!ok);
298
299            let pid_path = paths::pid_path().unwrap();
300            assert!(!pid_path.exists(), "PID file should be removed after stop");
301        });
302    }
303
304    // Ported from internal/registrycache/process_test.go:97 (TestIsRunningFalseWhenPIDFileMissing)
305    #[test]
306    fn test_is_running_false_when_pid_file_missing() {
307        with_home(|_home| {
308            let ok = is_running().unwrap();
309            assert!(!ok);
310        });
311    }
312
313    // Ported from internal/registrycache/process_test.go:106 (TestIsRunningFalseWhenPIDFileStale)
314    #[test]
315    fn test_is_running_false_when_pid_file_stale() {
316        with_home(|_home| {
317            let pid_path = paths::pid_path().unwrap();
318            std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
319            std::fs::write(&pid_path, "9999999\n").unwrap();
320
321            let ok = is_running().unwrap();
322            assert!(!ok);
323        });
324    }
325
326    // Ported from internal/registrycache/process_test.go:121 (TestStopWhenNotRunningIsNoop)
327    #[test]
328    fn test_stop_when_not_running_is_noop() {
329        with_home(|_home| {
330            stop_process().unwrap();
331        });
332    }
333}