Skip to content

Commit 0058ea6

Browse files
authored
Merge pull request #300 from bit8shift/fix/prevent-unsafe-PID-conversation-in-process-termination
fix: prevent unsafe PID conversion in process termination
2 parents a22542f + b77db7b commit 0058ea6

1 file changed

Lines changed: 114 additions & 6 deletions

File tree

libs/modkit/src/backends/local.rs

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const FORWARDER_DRAIN_TIMEOUT: Duration = Duration::from_millis(100);
2727

2828
/// Send graceful termination signal to a child process.
2929
///
30+
/// # Returns
31+
/// - `true` if signal was successfully sent
32+
/// - `false` if:
33+
/// - Process has no PID (already exited)
34+
/// - PID cannot be converted to i32 (extremely rare: PID > 2,147,483,647)
35+
/// - Signal delivery fails
36+
///
3037
/// On Unix: Sends SIGTERM which the process can handle for cleanup.
3138
/// On Windows: Returns false since there's no reliable graceful termination
3239
/// method for console applications.
@@ -35,16 +42,27 @@ fn send_terminate_signal(child: &Child) -> bool {
3542
use nix::sys::signal::{Signal, kill};
3643
use nix::unistd::Pid;
3744

38-
if let Some(pid) = child.id() {
39-
let pid_i32 = i32::try_from(pid).unwrap_or(0);
40-
kill(Pid::from_raw(pid_i32), Signal::SIGTERM).is_ok()
41-
} else {
42-
false
43-
}
45+
let Some(pid) = child.id() else {
46+
return false;
47+
};
48+
49+
let Ok(pid_i32) = i32::try_from(pid) else {
50+
tracing::warn!(
51+
pid = pid,
52+
"Failed to convert PID to i32, cannot send SIGTERM (PID exceeds i32::MAX: {})",
53+
i32::MAX
54+
);
55+
return false;
56+
};
57+
58+
kill(Pid::from_raw(pid_i32), Signal::SIGTERM).is_ok()
4459
}
4560

4661
/// Send graceful termination signal to a child process.
4762
///
63+
/// # Returns
64+
/// - `false` always on Windows (no reliable graceful termination available)
65+
///
4866
/// On Windows there's no reliable SIGTERM equivalent for console applications.
4967
/// We return false to indicate that graceful termination is not available,
5068
/// and the caller should proceed with force kill.
@@ -67,6 +85,16 @@ async fn stop_child_with_grace(
6785
let pid = child.id();
6886
let sent = send_terminate_signal(child);
6987

88+
// Log with module context if termination signal failed
89+
if !sent && pid.is_some() {
90+
tracing::debug!(
91+
module = %handle.module,
92+
instance_id = %handle.instance_id,
93+
pid = ?pid,
94+
"{context}: graceful termination not available, will force kill"
95+
);
96+
}
97+
7098
tracing::debug!(
7199
module = %handle.module,
72100
instance_id = %handle.instance_id,
@@ -509,4 +537,84 @@ mod tests {
509537
.expect("should list instances");
510538
assert_eq!(instances.len(), 0);
511539
}
540+
541+
mod send_terminate_signal_tests {
542+
#[cfg(unix)]
543+
use {super::send_terminate_signal, std::time::Duration};
544+
545+
#[cfg(unix)]
546+
#[tokio::test]
547+
async fn test_send_terminate_signal_to_valid_process() {
548+
// Spawn a long-running process using sh -c 'sleep 30'
549+
// This works on all Unix systems (Linux, macOS, BSD)
550+
let mut cmd = tokio::process::Command::new("/bin/sh");
551+
cmd.args(["-c", "sleep 30"]);
552+
553+
let mut child = cmd.spawn().expect("should spawn test process");
554+
555+
// Send termination signal
556+
let result = send_terminate_signal(&child);
557+
558+
// Should return true indicating signal was sent
559+
assert!(result, "Should successfully send SIGTERM to valid process");
560+
561+
// Wait briefly for graceful shutdown
562+
tokio::time::timeout(Duration::from_millis(100), child.wait())
563+
.await
564+
.expect("process should exit within timeout")
565+
.expect("wait should succeed");
566+
}
567+
568+
#[cfg(unix)]
569+
#[tokio::test]
570+
async fn test_send_terminate_signal_to_exited_process() {
571+
// Spawn a process that exits immediately using sh -c 'exit 0'
572+
// This works on all Unix systems (Linux, macOS, BSD)
573+
let mut cmd = tokio::process::Command::new("/bin/sh");
574+
cmd.args(["-c", "exit 0"]);
575+
let mut child = cmd.spawn().expect("should spawn test process");
576+
577+
// Wait for it to exit
578+
tokio::time::timeout(Duration::from_millis(100), child.wait())
579+
.await
580+
.expect("process should exit within timeout")
581+
.expect("wait should succeed");
582+
583+
// Try to send termination signal to exited process
584+
let result = send_terminate_signal(&child);
585+
586+
// Should return false because PID is no longer available
587+
assert!(!result, "Should return false for already-exited process");
588+
}
589+
590+
#[cfg(unix)]
591+
#[test]
592+
fn test_pid_conversion_edge_case_documentation() {
593+
// This test documents the edge case behavior for PIDs > i32::MAX
594+
// In practice, this is extremely rare as it would require:
595+
// 1. System uptime of weeks/months without reboot
596+
// 2. PID counter to wrap around multiple times
597+
// 3. Specific kernel configuration
598+
599+
// The maximum value a u32 PID can have
600+
let max_u32_pid: u32 = u32::MAX;
601+
602+
// This would fail to convert to i32
603+
let result = i32::try_from(max_u32_pid);
604+
assert!(result.is_err(), "u32::MAX should not fit in i32");
605+
606+
// Our code handles this by logging a warning and returning false
607+
// preventing the dangerous unwrap_or(0) that would signal PID 0
608+
}
609+
610+
#[cfg(unix)]
611+
#[test]
612+
fn test_pid_conversion_normal_range() {
613+
// Test that normal PIDs convert successfully
614+
let normal_pid: u32 = 12345;
615+
let result = i32::try_from(normal_pid);
616+
assert!(result.is_ok(), "Normal PID should convert to i32");
617+
assert_eq!(result.unwrap(), 12345);
618+
}
619+
}
512620
}

0 commit comments

Comments
 (0)