@@ -156,7 +156,7 @@ fn handle_stream_activity(
156156 "Activity detected"
157157 ) ;
158158 let new_deadline = calculate_new_deadline ( timeouts. absolute_deadline , timeouts. activity ) ;
159-
159+
160160 if * current_deadline < timeouts. absolute_deadline && new_deadline != * current_deadline {
161161 debug ! ( old = ?* current_deadline, new = ?new_deadline, "Updating deadline" ) ;
162162 * current_deadline = new_deadline;
@@ -337,7 +337,7 @@ async fn run_command_loop(
337337 biased; // Prioritize checking exit status
338338
339339 // 1. Check for process exit
340- result = state. child. wait( ) , if can_check_exit => {
340+ result = async { state. child. wait( ) . await } , if can_check_exit => {
341341 state. exit_status = match result {
342342 Ok ( status) => {
343343 debug!( status = %status, "Process exited naturally" ) ;
@@ -495,6 +495,14 @@ pub async fn run_command_with_timeout(
495495 let mut std_cmd = std:: mem:: replace ( & mut command, StdCommand :: new ( "" ) ) ; // Take ownership temporarily
496496 unsafe {
497497 std_cmd. pre_exec ( || {
498+ #[ cfg( target_os = "macos" ) ]
499+ {
500+ // Disable SA_RESTART flag for SIGCHLD handler
501+ let mut sa: libc:: sigaction = std:: mem:: zeroed ( ) ;
502+ libc:: sigaction ( libc:: SIGCHLD , std:: ptr:: null ( ) , & mut sa) ;
503+ sa. sa_flags &= !libc:: SA_RESTART ;
504+ libc:: sigaction ( libc:: SIGCHLD , & sa, std:: ptr:: null_mut ( ) ) ;
505+ }
498506 // libc::setpgid(0, 0) makes the new process its own group leader.
499507 // Pass 0 for both pid and pgid to achieve this for the calling process.
500508 if libc:: setpgid ( 0 , 0 ) == 0 {
@@ -513,7 +521,6 @@ pub async fn run_command_with_timeout(
513521
514522 // Main execution loop
515523 run_command_loop ( & mut state, & timeout_config) . await ?;
516- let end_time = Instant :: now ( ) ;
517524
518525 // Drain remaining output after loop exit (natural exit or timeout break)
519526 debug ! ( "Command loop finished. Draining remaining output streams." ) ;
@@ -523,24 +530,25 @@ pub async fn run_command_with_timeout(
523530 & mut state. stdout_read_buffer ,
524531 "stdout" ,
525532 )
526- . await ?;
533+ . await ?;
527534 drain_reader (
528535 & mut state. stderr_reader ,
529536 & mut state. stderr_buffer ,
530537 & mut state. stderr_read_buffer ,
531538 "stderr" ,
532539 )
533- . await ?;
534- let duration = end_time. duration_since ( start_time) ;
540+ . await ?;
535541
536542 // Post-loop processing: Final wait if killed and status not yet obtained
537543 let final_exit_status = finalize_exit_status (
538544 & mut state. child ,
539545 state. exit_status , // Use status potentially set in loop
540546 state. timed_out ,
541547 )
542- . await ?;
548+ . await ?;
543549
550+ let end_time = Instant :: now ( ) ;
551+ let duration = end_time. duration_since ( start_time) ;
544552
545553 debug ! (
546554 duration = ?duration,
@@ -584,7 +592,7 @@ mod tests {
584592 fn run_async_test < F , Fut > ( test_fn : F )
585593 where
586594 F : FnOnce ( ) -> Fut ,
587- Fut : std:: future:: Future < Output = ( ) > ,
595+ Fut : std:: future:: Future < Output = ( ) > ,
588596 {
589597 setup_tracing ( ) ;
590598 let rt = Runtime :: new ( ) . unwrap ( ) ;
@@ -855,7 +863,7 @@ mod tests {
855863 fn test_min_timeout_greater_than_max_timeout ( ) {
856864 run_async_test ( || async {
857865 let cmd = StdCommand :: new ( "echo" ) ; // removed mut
858- // cmd.arg("test"); // Don't need args
866+ // cmd.arg("test"); // Don't need args
859867
860868 let min_timeout = Duration :: from_secs ( 2 ) ;
861869 let max_timeout = Duration :: from_secs ( 1 ) ; // Invalid config
@@ -876,7 +884,7 @@ mod tests {
876884 fn test_zero_activity_timeout ( ) {
877885 run_async_test ( || async {
878886 let cmd = StdCommand :: new ( "echo" ) ; // removed mut
879- // cmd.arg("test"); // Don't need args
887+ // cmd.arg("test"); // Don't need args
880888
881889 let min_timeout = Duration :: from_millis ( 100 ) ;
882890 let max_timeout = Duration :: from_secs ( 1 ) ;
@@ -924,8 +932,8 @@ mod tests {
924932 run_async_test ( || async {
925933 let mut cmd = StdCommand :: new ( "sh" ) ;
926934 // Continuously output numbers for ~2 seconds, sleeping shortly
927- cmd. arg ( "-c" )
928- . arg ( "i=0; while [ $i -lt 20 ]; do echo $i; i=$((i+1)); sleep 0.1; done" ) ;
935+ cmd. arg ( "-c" )
936+ . arg ( "i=0; while [ $i -lt 20 ]; do echo $i; i=$((i+1)); /bin/ sleep 0.1; done" ) ; // Use absolute sleep path
929937 let min_timeout = Duration :: from_millis ( 50 ) ;
930938 let max_timeout = Duration :: from_secs ( 10 ) ;
931939 let activity_timeout = Duration :: from_millis ( 500 ) ; // activity > sleep
@@ -947,10 +955,17 @@ mod tests {
947955 result. duration > Duration :: from_secs( 2 ) ,
948956 "Duration should be > 2s"
949957 ) ; // 20 * 0.1s
958+ #[ cfg( not( target_os = "macos" ) ) ]
950959 assert ! (
951960 result. duration < Duration :: from_secs( 3 ) ,
952961 "Duration should be < 3s"
953962 ) ;
963+ #[ cfg( target_os = "macos" ) ]
964+ assert ! (
965+ result. duration < Duration :: from_secs( 3 ) + Duration :: from_millis( 300 ) ,
966+ "Duration should account for macOS signal latency"
967+ ) ;
968+
954969 } ) ;
955970 }
956971
@@ -996,132 +1011,131 @@ mod tests {
9961011
9971012 // ----- tests for calculate_new_deadline -----
9981013 #[ test]
999- fn test_calculate_new_deadline_absolute_deadline_passed ( ) {
1000- let absolute_deadline = Instant :: now ( ) - Duration :: from_secs ( 1 ) ; // Already passed
1001- let activity_timeout = Duration :: from_secs ( 5 ) ;
1014+ fn test_calculate_new_deadline_absolute_deadline_passed ( ) {
1015+ let absolute_deadline = Instant :: now ( ) - Duration :: from_secs ( 1 ) ; // Already passed
1016+ let activity_timeout = Duration :: from_secs ( 5 ) ;
10021017
1003- let new_deadline = calculate_new_deadline ( absolute_deadline, activity_timeout) ;
1018+ let new_deadline = calculate_new_deadline ( absolute_deadline, activity_timeout) ;
10041019
1005- assert_eq ! (
1006- new_deadline, absolute_deadline,
1007- "New deadline should be the absolute deadline when it has already passed"
1008- ) ;
1009- }
1020+ assert_eq ! (
1021+ new_deadline, absolute_deadline,
1022+ "New deadline should be the absolute deadline when it has already passed"
1023+ ) ;
1024+ }
10101025
1011- #[ test]
1012- fn test_calculate_new_deadline_activity_timeout_before_absolute_deadline ( ) {
1013- let absolute_deadline = Instant :: now ( ) + Duration :: from_secs ( 10 ) ;
1014- let activity_timeout = Duration :: from_secs ( 5 ) ;
1026+ #[ test]
1027+ fn test_calculate_new_deadline_activity_timeout_before_absolute_deadline ( ) {
1028+ let absolute_deadline = Instant :: now ( ) + Duration :: from_secs ( 10 ) ;
1029+ let activity_timeout = Duration :: from_secs ( 5 ) ;
10151030
1016- let new_deadline = calculate_new_deadline ( absolute_deadline, activity_timeout) ;
1031+ let new_deadline = calculate_new_deadline ( absolute_deadline, activity_timeout) ;
10171032
1018- assert ! (
1019- new_deadline <= absolute_deadline,
1020- "New deadline should not exceed the absolute deadline"
1021- ) ;
1022- assert ! (
1023- new_deadline > Instant :: now( ) ,
1024- "New deadline should be in the future"
1025- ) ;
1026- }
1033+ assert ! (
1034+ new_deadline <= absolute_deadline,
1035+ "New deadline should not exceed the absolute deadline"
1036+ ) ;
1037+ assert ! (
1038+ new_deadline > Instant :: now( ) ,
1039+ "New deadline should be in the future"
1040+ ) ;
1041+ }
10271042 // ----- tests for handle_stream_activity -----
10281043 #[ test]
1029- fn test_handle_stream_activity_updates_deadline ( ) {
1030- let mut current_deadline = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
1031- let timeouts = TimeoutConfig {
1032- minimum : Duration :: from_secs ( 1 ) ,
1033- maximum : Duration :: from_secs ( 10 ) ,
1034- activity : Duration :: from_secs ( 3 ) ,
1035- start_time : Instant :: now ( ) ,
1036- absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 10 ) ,
1037- } ;
1038-
1039- handle_stream_activity ( 10 , "stdout" , & mut current_deadline, & timeouts) ;
1040-
1041- assert ! (
1042- current_deadline > Instant :: now( ) ,
1043- "Current deadline should be updated to a future time"
1044- ) ;
1045- assert ! (
1046- current_deadline <= timeouts. absolute_deadline,
1047- "Current deadline should not exceed the absolute deadline"
1048- ) ;
1049- }
1050-
1051- #[ test]
1052- fn test_handle_stream_activity_no_update_at_absolute_limit ( ) {
1053- let absolute_deadline = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
1054- let mut current_deadline = absolute_deadline; // Already at the absolute limit
1055- let timeouts = TimeoutConfig {
1056- minimum : Duration :: from_secs ( 1 ) ,
1057- maximum : Duration :: from_secs ( 10 ) ,
1058- activity : Duration :: from_secs ( 3 ) ,
1059- start_time : Instant :: now ( ) ,
1060- absolute_deadline,
1061- } ;
1062-
1063- handle_stream_activity ( 10 , "stderr" , & mut current_deadline, & timeouts) ;
1064-
1065- assert_eq ! (
1066- current_deadline, absolute_deadline,
1067- "Current deadline should remain unchanged when at the absolute limit"
1068- ) ;
1069- }
1070-
1071- // ----- tests for run_command_loop -----
1072- #[ test]
1073- fn test_run_command_loop_exits_on_process_finish ( ) {
1074- run_async_test ( || async {
1075- let mut cmd = StdCommand :: new ( "echo" ) ;
1076- cmd. arg ( "Test" ) ;
1077-
1044+ fn test_handle_stream_activity_updates_deadline ( ) {
1045+ let mut current_deadline = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
10781046 let timeouts = TimeoutConfig {
10791047 minimum : Duration :: from_secs ( 1 ) ,
1080- maximum : Duration :: from_secs ( 5 ) ,
1081- activity : Duration :: from_secs ( 2 ) ,
1048+ maximum : Duration :: from_secs ( 10 ) ,
1049+ activity : Duration :: from_secs ( 3 ) ,
10821050 start_time : Instant :: now ( ) ,
1083- absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 5 ) ,
1051+ absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 10 ) ,
10841052 } ;
10851053
1086- let mut state = spawn_command_and_setup_state ( & mut cmd, timeouts. absolute_deadline )
1087- . expect ( "Failed to spawn command" ) ;
1088-
1089- let result = run_command_loop ( & mut state, & timeouts) . await ;
1054+ handle_stream_activity ( 10 , "stdout" , & mut current_deadline, & timeouts) ;
10901055
1091- assert ! ( result. is_ok( ) , "Command loop should exit without errors" ) ;
10921056 assert ! (
1093- state . exit_status . is_some ( ) ,
1094- "Exit status should be set when process finishes naturally "
1057+ current_deadline > Instant :: now ( ) ,
1058+ "Current deadline should be updated to a future time "
10951059 ) ;
1096- } ) ;
1097- }
1098-
1099- #[ test]
1100- fn test_run_command_loop_exits_on_timeout ( ) {
1101- run_async_test ( || async {
1102- let mut cmd = StdCommand :: new ( "sleep" ) ;
1103- cmd. arg ( "5" ) ;
1060+ assert ! (
1061+ current_deadline <= timeouts. absolute_deadline,
1062+ "Current deadline should not exceed the absolute deadline"
1063+ ) ;
1064+ }
11041065
1066+ #[ test]
1067+ fn test_handle_stream_activity_no_update_at_absolute_limit ( ) {
1068+ let absolute_deadline = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
1069+ let mut current_deadline = absolute_deadline; // Already at the absolute limit
11051070 let timeouts = TimeoutConfig {
11061071 minimum : Duration :: from_secs ( 1 ) ,
1107- maximum : Duration :: from_secs ( 2 ) , // Short timeout
1108- activity : Duration :: from_secs ( 10 ) ,
1072+ maximum : Duration :: from_secs ( 10 ) ,
1073+ activity : Duration :: from_secs ( 3 ) ,
11091074 start_time : Instant :: now ( ) ,
1110- absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 2 ) ,
1075+ absolute_deadline,
11111076 } ;
11121077
1113- let mut state = spawn_command_and_setup_state ( & mut cmd, timeouts. absolute_deadline )
1114- . expect ( "Failed to spawn command" ) ;
1078+ handle_stream_activity ( 10 , "stderr" , & mut current_deadline, & timeouts) ;
11151079
1116- let result = run_command_loop ( & mut state, & timeouts) . await ;
1117-
1118- assert ! ( result. is_ok( ) , "Command loop should exit without errors" ) ;
1119- assert ! (
1120- state. exit_status. is_none( ) ,
1121- "Exit status should be None when process is killed due to timeout"
1080+ assert_eq ! (
1081+ current_deadline, absolute_deadline,
1082+ "Current deadline should remain unchanged when at the absolute limit"
11221083 ) ;
1123- assert ! ( state. timed_out, "State should indicate that the process timed out" ) ;
1124- } ) ;
1125- }
1084+ }
1085+
1086+ // ----- tests for run_command_loop -----
1087+ #[ test]
1088+ fn test_run_command_loop_exits_on_process_finish ( ) {
1089+ run_async_test ( || async {
1090+ let mut cmd = StdCommand :: new ( "echo" ) ;
1091+ cmd. arg ( "Test" ) ;
1092+
1093+ let timeouts = TimeoutConfig {
1094+ minimum : Duration :: from_secs ( 1 ) ,
1095+ maximum : Duration :: from_secs ( 5 ) ,
1096+ activity : Duration :: from_secs ( 2 ) ,
1097+ start_time : Instant :: now ( ) ,
1098+ absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 5 ) ,
1099+ } ;
1100+
1101+ let mut state = spawn_command_and_setup_state ( & mut cmd, timeouts. absolute_deadline )
1102+ . expect ( "Failed to spawn command" ) ;
11261103
1104+ let result = run_command_loop ( & mut state, & timeouts) . await ;
1105+
1106+ assert ! ( result. is_ok( ) , "Command loop should exit without errors" ) ;
1107+ assert ! (
1108+ state. exit_status. is_some( ) ,
1109+ "Exit status should be set when process finishes naturally"
1110+ ) ;
1111+ } ) ;
1112+ }
1113+
1114+ #[ test]
1115+ fn test_run_command_loop_exits_on_timeout ( ) {
1116+ run_async_test ( || async {
1117+ let mut cmd = StdCommand :: new ( "sleep" ) ;
1118+ cmd. arg ( "5" ) ;
1119+
1120+ let timeouts = TimeoutConfig {
1121+ minimum : Duration :: from_secs ( 1 ) ,
1122+ maximum : Duration :: from_secs ( 2 ) , // Short timeout
1123+ activity : Duration :: from_secs ( 10 ) ,
1124+ start_time : Instant :: now ( ) ,
1125+ absolute_deadline : Instant :: now ( ) + Duration :: from_secs ( 2 ) ,
1126+ } ;
1127+
1128+ let mut state = spawn_command_and_setup_state ( & mut cmd, timeouts. absolute_deadline )
1129+ . expect ( "Failed to spawn command" ) ;
1130+
1131+ let result = run_command_loop ( & mut state, & timeouts) . await ;
1132+
1133+ assert ! ( result. is_ok( ) , "Command loop should exit without errors" ) ;
1134+ assert ! (
1135+ state. exit_status. is_none( ) ,
1136+ "Exit status should be None when process is killed due to timeout"
1137+ ) ;
1138+ assert ! ( state. timed_out, "State should indicate that the process timed out" ) ;
1139+ } ) ;
1140+ }
11271141} // end tests mod
0 commit comments