@@ -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