Skip to main content

lmcpp/server/process/
kill.rs

1//! Server Process - Kill
2//! =====================
3//!
4//! Cross-platform helpers for terminating *server instances* started by this
5//! crate.  The public surface consists of two primary entry points:
6//!
7//! * [`kill_by_hostname`] – shut down **exactly one** instance that was launched
8//!   with `--host <hostname>` (or `-h <hostname>`).
9//! * [`kill_all_servers`] – blanket kill for **all** running copies of the same
10//!   executable.
11//!
12//! Internally the module follows a three-step escalation ladder:
13//!
14//! 1. **PID-file fast path** – If the server created a PID-file we trust it and
15//!    attempt a *polite* signal (`SIGTERM`, `CTRL_BREAK`, or the platform
16//!    equivalent) against the recorded PID.  
17//! 2. **Command-line scan fallback** – When the PID-file is missing or stale we
18//!    search the process list for an argument pair matching `--host <hostname>`
19//!    or `-h <hostname>` (same logic used by the launcher).  
20//! 3. **Force-kill escalation** – If the target is still alive after the
21//!    configurable *grace period* (`POLITE_WAIT`) we deliver an unconditional
22//!    kill (`SIGKILL` / `TerminateProcess`).  
23//!
24//! The helper returns **as soon as every targeted PID is gone** or the
25//! force-kill timeout elapses, whichever happens first.  Any stubborn PIDs are
26//! propagated back in a [`ProcessError::TerminationTimeout`] so that higher
27//! layers can decide whether to retry, log, or treat it as fatal.
28//!
29//! ## Error semantics
30//!
31//! *All* public functions return [`crate::server::process::error::Result`].  The
32//! errors are designed to be actionable; for instance, [`ProcessError::PermissionDenied`]
33//! will not be silently swallowed.
34//!
35//! ## Constants
36//!
37//! Timing knobs such as `POLITE_WAIT`, `POLL_INTERVAL_MS`, and
38//! `FORCE_KILL_TIMEOUT_SECS` live in the *parent* module so that they are shared
39//! across all process-control utilities.  They are referenced here but not
40//! re-exported.
41//!
42//! ## Thread safety
43//!
44//! The functions perform blocking I/O (`std::fs`, `nix`, Win32 API) and sleep
45//! loops.  They are therefore *synchronous* and expected to run on a dedicated
46//! management thread or as part of a CLI command—not in an async runtime.
47
48use core::str;
49use std::{
50    path::Path,
51    time::{Duration, Instant},
52};
53
54use super::{error::*, pid::*, *};
55
56pub fn kill_by_client(pidfile_path: &Path, host: &str) -> Result<()> {
57    // ── 1 ▪ PID-file fast path ────────────────────────────────────────────
58    let pid: Option<u32> = std::fs::read_to_string(pidfile_path)
59        .ok()
60        .and_then(|s| s.trim().parse().ok());
61    if let Some(pid) = pid {
62        match pid_alive(pid) {
63            // a) recognised & alive ⇒ killfs
64            Ok(true) => {
65                crate::info!("Killing server at {host} (PID {pid}) via pid-file");
66                let res = kill_pids(&[pid], POLITE_WAIT);
67
68                if res.is_ok() {
69                    if let Err(e) = std::fs::remove_file(pidfile_path) {
70                        crate::warn!("Failed to remove pid-file for host {host}: {e}");
71                    }
72                }
73                return res;
74            }
75            // b) recognised & dead ⇒ stale file → delete & fall through
76            Ok(false) => match std::fs::remove_file(pidfile_path) {
77                Ok(_) => (),
78                Err(e) => crate::warn!("Failed to remove stale pid-file for host {host}: {e}"),
79            },
80            // c) could not check (permissions, EACCES, etc.) ⇒ warn & fall back
81            Err(e) => {
82                crate::warn!("pid_alive({pid}) failed: {e}. Falling back to argv scan…");
83            }
84        }
85    } else {
86        if let Err(e) = std::fs::remove_file(pidfile_path) {
87            crate::warn!("Failed to remove malformed pid-file for host {host}: {e}");
88        }
89    };
90
91    // ── 2 ▪ Fallback: argv scan for --host / -h ───────────────────────────
92    let patterns: &[&[&str]] = &[&["--host", host], &["-h", host]];
93    if let Some(pid) = get_server_pid_by_cmd_args(patterns) {
94        crate::info!("Killing server at {host} (PID {pid}) via argv scan");
95        match kill_pids(&[pid], POLITE_WAIT) {
96            Ok(()) => {
97                match std::fs::remove_file(pidfile_path) {
98                    Ok(_) => (),
99                    Err(e) => {
100                        crate::warn!("Failed to remove stale pid-file for host {host}: {e}")
101                    }
102                }
103                return Ok(());
104            }
105            Err(e) => {
106                crate::warn!("Failed to kill server at {host} (PID {pid}): {e}");
107            }
108        }
109    }
110
111    // ── 3 ▪ Nothing matched ───────────────────────────────────────────────
112    Err(ProcessError::NoSuchProcess {
113        query: format!("host={host}"),
114    })
115}
116
117pub fn kill_all_servers(executable_name: &str) -> Result<()> {
118    crate::info!("Killing all {executable_name} processes");
119
120    // ── 1 ▪ kill-by-path / name (old behaviour) ────────────────
121    let pids = get_all_server_pids(executable_name);
122    let mut errors = Vec::new();
123
124    if !pids.is_empty() {
125        if let Err(e) = kill_pids(&pids, POLITE_WAIT) {
126            errors.push(e);
127        }
128    }
129
130    // ── 2 ▪ sweep pid-files we just invalidated ────────────────
131    for pid in pids {
132        // If the process is gone (or we just killed it), delete the file.
133        match pid_alive(pid) {
134            Ok(false) => crate::info!("PID {pid} shut down"),
135            Ok(true) => crate::warn!("PID {pid} still alive, but we tried to kill it"),
136            Err(e) => crate::warn!("Could not probe PID {pid}: {e}"),
137        }
138    }
139
140    // ── 3 ▪ summarise result ───────────────────────────────────
141    if errors.is_empty() {
142        Ok(())
143    } else {
144        // bubble up the first error but log the rest
145        for e in &errors[1..] {
146            crate::warn!("Additional error while killing servers: {e}");
147        }
148        Err(errors.remove(0))
149    }
150}
151
152fn kill_pids(pids: &[u32], polite_wait: Duration) -> Result<()> {
153    let mut seen = std::collections::HashSet::with_capacity(pids.len());
154    let uniq: Vec<u32> = pids.iter().copied().filter(|p| seen.insert(*p)).collect();
155    if uniq.is_empty() {
156        return Ok(());
157    }
158    let start = Instant::now();
159    // phase 1 ▪ TERM / taskkill /T
160    for pid in &uniq {
161        match pid_alive(*pid) {
162            Ok(true) => match kill_pid(*pid) {
163                Ok(()) => crate::info!("Sent TERM to PID {}", pid),
164                Err(e) => crate::error!("Failed to send TERM to PID {}: {}", pid, e),
165            },
166            Ok(false) => (),
167            Err(e) => crate::error!("Failed to check PID {}: {}", pid, e),
168        }
169    }
170
171    // phase 2 ▪ wait a little, bailing early if all gone
172    let polite_deadline = Instant::now() + polite_wait;
173    let mut probe_failures: Vec<(u32, ProcessError)> = Vec::new();
174
175    while Instant::now() < polite_deadline {
176        let all_dead = pids.iter().all(|&pid| match pid_alive(pid) {
177            Ok(alive) => !alive,
178            Err(e) => {
179                // record only once; keep treating PID as "alive"
180                if !probe_failures.iter().any(|(p, _)| *p == pid) {
181                    probe_failures.push((pid, e));
182                }
183                false
184            }
185        });
186
187        if all_dead {
188            break;
189        }
190        std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
191    }
192
193    // phase 3 ▪ KILL / taskkill /F
194    for &pid in pids {
195        if let Err(e) = force_kill_pid(pid) {
196            crate::error!("Failed to force-kill PID {pid}: {e}");
197        }
198    }
199
200    if !probe_failures.is_empty() {
201        for (pid, err) in &probe_failures {
202            crate::warn!("Never obtained status for PID {pid}: {err}");
203        }
204    }
205    let force_kill_deadline = Instant::now() + Duration::from_secs(FORCE_KILL_TIMEOUT_SECS);
206    while Instant::now() < force_kill_deadline {
207        if pids
208            .iter()
209            .all(|&pid| matches!(pid_alive(pid), Ok(false) | Err(_)))
210        {
211            break;
212        }
213        std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
214    }
215
216    #[cfg(target_os = "macos")]
217    for &pid in &uniq {
218        use nix::sys::wait::{waitpid, WaitPidFlag};
219        let _ = nix::unistd::Pid::from_raw(pid as i32);
220        // Try to reap our own children; ignore errors & non‑children.
221        let _ = waitpid(
222            nix::unistd::Pid::from_raw(pid as i32),
223            Some(WaitPidFlag::WNOHANG),
224        );
225    }
226
227    // return any stubborn PIDs so callers can escalate or log
228    let leftovers: Vec<u32> = pids
229        .iter()
230        .copied()
231        .filter(|&pid| match pid_alive(pid) {
232            Ok(alive) => alive,
233            Err(_) => true, // treat unknowns as alive
234        })
235        .collect();
236
237    let elapsed = start.elapsed();
238    if leftovers.is_empty() {
239        Ok(())
240    } else {
241        Err(ProcessError::TerminationTimeout {
242            operation: "kill_pids",
243            elapsed,
244            leftovers,
245        })
246    }
247}
248
249#[cfg(unix)]
250pub fn kill_pid(pid: u32) -> Result<()> {
251    use nix::{
252        errno::Errno,
253        sys::signal::{kill, Signal},
254        unistd::Pid,
255    };
256    match kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
257        Ok(_) | Err(Errno::ESRCH) => Ok(()), // gone already → success
258
259        Err(Errno::EPERM) => Err(ProcessError::PermissionDenied {
260            action: "send SIGTERM",
261            source: "operation not permitted".into(),
262        }),
263
264        Err(e) => Err(ProcessError::CommandFailed {
265            action: "send SIGTERM",
266            source: e.into(),
267        }),
268    }
269}
270
271#[cfg(unix)]
272fn force_kill_pid(pid: u32) -> Result<()> {
273    use nix::{
274        errno::Errno,
275        sys::signal::{kill, Signal},
276        unistd::Pid,
277    };
278    match kill(Pid::from_raw(pid as i32), Signal::SIGKILL) {
279        Ok(_) | Err(Errno::ESRCH) => Ok(()),
280
281        Err(Errno::EPERM) => Err(ProcessError::PermissionDenied {
282            action: "send SIGKILL",
283            source: "operation not permitted".into(),
284        }),
285
286        Err(e) => Err(ProcessError::CommandFailed {
287            action: "send SIGKILL",
288            source: e.into(),
289        }),
290    }
291}
292
293#[cfg(windows)]
294pub fn kill_pid(pid: u32) -> Result<()> {
295    use windows::Win32::{
296        Foundation::CloseHandle,
297        System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE},
298    };
299
300    unsafe {
301        // Open the process with termination rights
302        let handle = OpenProcess(PROCESS_TERMINATE, false, pid).map_err(|e| {
303            ProcessError::CommandFailed {
304                action: "OpenProcess",
305                source: Box::new(e),
306            }
307        })?;
308
309        if handle.is_invalid() {
310            // Process already gone
311            return Ok(());
312        }
313
314        // Terminate the process (this is already "forceful" on Windows)
315        let result = TerminateProcess(handle, 1);
316        let _ = CloseHandle(handle);
317
318        result.map_err(|e| ProcessError::CommandFailed {
319            action: "TerminateProcess",
320            source: Box::new(e),
321        })
322    }
323}
324
325#[cfg(windows)]
326pub fn force_kill_pid(pid: u32) -> Result<()> {
327    use windows::Win32::{
328        Foundation::CloseHandle,
329        System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE},
330    };
331    fn win32_error(action: &'static str) -> ProcessError {
332        // windows::core::Error already wraps GetLastError + FormatMessageW.
333        let err = windows::core::Error::from_win32();
334        match err.code().0 {
335            5 => ProcessError::PermissionDenied {
336                /* ERROR_ACCESS_DENIED */
337                action,
338                source: Box::new(err),
339            },
340            _ => ProcessError::CommandFailed {
341                action,
342                source: Box::new(err),
343            },
344        }
345    }
346
347    unsafe {
348        let h = OpenProcess(PROCESS_TERMINATE, false, pid).map_err(|e| {
349            ProcessError::CommandFailed {
350                action: "force-kill (OpenProcess)",
351                source: e.into(),
352            }
353        })?;
354        if h.is_invalid() {
355            let err = windows::core::Error::from_win32();
356            return match err.code().0 {
357                87 => Ok(()), // already gone
358                _ => Err(win32_error("force-kill (OpenProcess)")),
359            };
360        }
361        match TerminateProcess(h, 1) {
362            Ok(_) => CloseHandle(h).map_err(|e| ProcessError::CommandFailed {
363                action: "force-kill (CloseHandle)",
364                source: e.into(),
365            }),
366            Err(_) => {
367                let _ = CloseHandle(h);
368                Err(win32_error("force-kill (TerminateProcess)"))
369            }
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use std::time::Duration;
377
378    use serial_test::serial;
379    use tempfile::tempdir;
380
381    use super::*;
382    use crate::server::process::tests_helpers::*;
383
384    // ─────────────────────────────────────────────────────────────────────
385    //  kill_pids – unchanged edge-case matrix
386    // ─────────────────────────────────────────────────────────────────────
387    #[test]
388    #[serial]
389    fn kill_pids_scenarios() {
390        use ProcessError::*;
391
392        // 1 ▪ empty slice is always Ok
393        assert!(kill_pids(&[], Duration::from_millis(10)).is_ok());
394
395        // 2 ▪ “all-dead” slice: spawn a short-lived child, wait for it to exit,
396        //     then point kill_pids at that *real* but now-dead PID.
397        let dead_pid = {
398            let mut child = short_cmd().spawn().unwrap();
399            let pid = child.id();
400            let _ = child.wait();
401            pid
402        };
403        match kill_pids(&[dead_pid], Duration::from_millis(200)) {
404            Ok(()) | Err(TerminationTimeout { .. }) => {} // both are fine
405            Err(e) => panic!("unexpected error on all-dead slice: {e:?}"),
406        }
407
408        // 3 ▪ Live-process variants ------------------------------------------------
409        fn spawn_and_kill(wait: Duration, duplicate: bool) {
410            let mut child = long_cmd().spawn().unwrap();
411            let pid = child.id();
412            let pids = if duplicate { vec![pid, pid] } else { vec![pid] };
413
414            // Accept success or the timeout error – either means *we tried*.
415            match kill_pids(&pids, wait) {
416                Ok(()) | Err(TerminationTimeout { .. }) => {}
417                Err(e) => panic!("kill_pids failed unexpectedly: {e:?}"),
418            }
419
420            let _ = child.wait();
421            assert!(
422                !pid_alive(pid).unwrap_or(true),
423                "child {pid} still alive after kill_pids(wait={wait:?}, dup={duplicate})"
424            );
425        }
426
427        // duplicate-PID case
428        spawn_and_kill(Duration::from_secs(2), true);
429
430        // polite-wait vs. force-kill
431        for &d in &[Duration::from_secs(2), Duration::from_secs(0)] {
432            spawn_and_kill(d, false);
433        }
434
435        // 4 ▪ Mixed live / dead ----------------------------------------------------
436        let mut child_live = long_cmd().spawn().unwrap();
437        let pid_live = child_live.id();
438
439        let mut child_dead = long_cmd().spawn().unwrap();
440        let pid_dead = child_dead.id();
441        kill_pid(pid_dead).unwrap(); // terminate it immediately
442        let _ = child_dead.wait(); // reap
443
444        match kill_pids(&[pid_dead, pid_live], Duration::from_millis(500)) {
445            Ok(()) | Err(TerminationTimeout { .. }) => {}
446            Err(e) => panic!("mixed kill failed unexpectedly: {e:?}"),
447        }
448        let _ = child_live.wait();
449        assert!(
450            !pid_alive(pid_live).unwrap_or(true),
451            "live child {pid_live} survived mixed-status kill"
452        );
453    }
454
455    // ─────────────────────────────────────────────────────────────────────
456    //  kill_by_client – permutations covering pid-file & argv scan paths
457    // ─────────────────────────────────────────────────────────────────────
458    #[test]
459    #[serial]
460    fn kill_by_client_scenarios() {
461        use sanitize_filename::sanitize; // kept local to honour “no extra imports” request
462
463        struct Case<'a> {
464            name: &'a str,
465            pidfile_raw: Option<&'a str>, // None ⇒ no pid-file
466            spawn_child: bool,            // spawn a long_cmd() child?
467            expect_ok: bool,              // expect Ok(())
468            pf_removed: bool,             // should pid-file be gone?
469            argv_scan: bool,              // child must advertise --host
470        }
471
472        let cases = [
473            Case {
474                name: "no_match",
475                pidfile_raw: None,
476                spawn_child: false,
477                expect_ok: false,
478                pf_removed: false,
479                argv_scan: false,
480            },
481            Case {
482                name: "corrupt_pidfile",
483                pidfile_raw: Some("not-a-number"),
484                spawn_child: false,
485                expect_ok: false,
486                pf_removed: true,
487                argv_scan: false,
488            },
489            Case {
490                name: "stale_pidfile",
491                pidfile_raw: Some("999999"), // dead PID
492                spawn_child: false,
493                expect_ok: false,
494                pf_removed: true,
495                argv_scan: false,
496            },
497            Case {
498                name: "pidfile_happy",
499                pidfile_raw: None, // will be filled with real PID below
500                spawn_child: true,
501                expect_ok: true,
502                pf_removed: true,
503                argv_scan: false,
504            },
505            Case {
506                name: "argv_scan",
507                pidfile_raw: None,
508                spawn_child: true,
509                expect_ok: true,
510                pf_removed: false,
511                argv_scan: true,
512            },
513        ];
514
515        for Case {
516            name,
517            pidfile_raw,
518            spawn_child,
519            expect_ok,
520            pf_removed,
521            argv_scan,
522        } in cases
523        {
524            let td = tempdir().unwrap();
525            let host = name; // host string kept short to stay well below 240 chars
526
527            // Construct the pid-file path *exactly* the way production code does.
528            // We mimic the “*_unix_*” layout for simplicity; the exact flavour
529            // (unix / http / tcp) is irrelevant to the termination logic.
530            let pid_id = sanitize(format!("{TEST_EXE}_unix_{host}").to_ascii_lowercase());
531            let pidfile_path = td.path().join(format!("{pid_id}.pid"));
532
533            // maybe spawn a child
534            let child = if spawn_child {
535                #[cfg(unix)]
536                {
537                    let mut c = std::process::Command::new("sh");
538                    c.args(["-c", "sleep 30"]);
539                    if argv_scan {
540                        c.arg("--host").arg(host);
541                    }
542                    Some(c.spawn().unwrap())
543                }
544                #[cfg(windows)]
545                {
546                    let mut c = std::process::Command::new("cmd");
547                    c.args(["/C", "timeout", "/T", "30", "/NOBREAK"]);
548                    if argv_scan {
549                        c.arg("--host").arg(host);
550                    }
551                    Some(c.spawn().unwrap())
552                }
553            } else {
554                None
555            };
556
557            // create / tweak pid-file if required
558            if let Some(contents) = pidfile_raw {
559                std::fs::write(&pidfile_path, contents.as_bytes()).unwrap();
560            } else if spawn_child && !argv_scan {
561                // happy-path pid-file with live PID
562                let pid = child.as_ref().unwrap().id();
563                std::fs::write(&pidfile_path, pid.to_string()).unwrap();
564            }
565
566            // -------- exercise ------------------------------------------------
567            let result = kill_by_client(&pidfile_path, host);
568
569            // -------- assertions ---------------------------------------------
570            if expect_ok {
571                result.unwrap();
572            } else {
573                matches!(
574                    result.expect_err("should fail"),
575                    ProcessError::NoSuchProcess { .. }
576                );
577            }
578
579            // pid-file may have been removed by kill_by_client
580            let expect_exists = pidfile_raw.is_some() && !pf_removed;
581            assert_eq!(
582                pidfile_path.exists(),
583                expect_exists,
584                "[{name}] pid-file existence mismatch (expected {expect_exists})"
585            );
586
587            if let Some(mut ch) = child {
588                let _ = ch.wait();
589                assert!(
590                    !pid_alive(ch.id()).unwrap_or(true),
591                    "[{name}] child process not killed"
592                );
593            }
594        }
595    }
596}