@@ -751,15 +751,16 @@ async fn check_disable_on_success(
751751 marker : & str ,
752752) -> DisableOnSuccessResult {
753753 let timeout_secs = job. disable_on_success_timeout_secs . max ( 1 ) ;
754- let mut child = shell_command ( command) ;
754+ let mut cmd = shell_command ( command) ;
755755 if let Some ( dir) = non_empty_opt ( job. disable_on_success_working_dir . as_deref ( ) ) {
756- child . current_dir ( dir) ;
756+ cmd . current_dir ( dir) ;
757757 }
758+ cmd. stdout ( std:: process:: Stdio :: piped ( ) ) ;
759+ cmd. stderr ( std:: process:: Stdio :: piped ( ) ) ;
758760
759- let output = match timeout ( std:: time:: Duration :: from_secs ( timeout_secs) , child. output ( ) ) . await
760- {
761- Ok ( Ok ( output) ) => output,
762- Ok ( Err ( e) ) => {
761+ let mut child = match cmd. spawn ( ) {
762+ Ok ( child) => child,
763+ Err ( e) => {
763764 warn ! (
764765 id = job. id. as_deref( ) . unwrap_or( "" ) ,
765766 command,
@@ -768,27 +769,56 @@ async fn check_disable_on_success(
768769 ) ;
769770 return DisableOnSuccessResult :: NotAchieved ( "command failed to start" ) ;
770771 }
771- Err ( _) => {
772+ } ;
773+
774+ let deadline = tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( timeout_secs) ) ;
775+ tokio:: pin!( deadline) ;
776+
777+ tokio:: select! {
778+ status = child. wait( ) => {
779+ let status = match status {
780+ Ok ( s) => s,
781+ Err ( e) => {
782+ warn!(
783+ id = job. id. as_deref( ) . unwrap_or( "" ) ,
784+ command,
785+ error = %e,
786+ "disable_on_success command wait failed"
787+ ) ;
788+ return DisableOnSuccessResult :: NotAchieved ( "command failed to start" ) ;
789+ }
790+ } ;
791+ if !status. success( ) {
792+ return DisableOnSuccessResult :: NotAchieved ( "command exited non-zero" ) ;
793+ }
794+ // Read stdout/stderr after process exits.
795+ let mut stdout_buf = Vec :: new( ) ;
796+ let mut stderr_buf = Vec :: new( ) ;
797+ if let Some ( mut out) = child. stdout. take( ) {
798+ let _ = tokio:: io:: AsyncReadExt :: read_to_end( & mut out, & mut stdout_buf) . await ;
799+ }
800+ if let Some ( mut err) = child. stderr. take( ) {
801+ let _ = tokio:: io:: AsyncReadExt :: read_to_end( & mut err, & mut stderr_buf) . await ;
802+ }
803+ let stdout = String :: from_utf8_lossy( & stdout_buf) ;
804+ let stderr = String :: from_utf8_lossy( & stderr_buf) ;
805+ if stdout. contains( marker) || stderr. contains( marker) {
806+ DisableOnSuccessResult :: Achieved
807+ } else {
808+ DisableOnSuccessResult :: NotAchieved ( "success marker not found" )
809+ }
810+ }
811+ _ = & mut deadline => {
812+ // Timeout — kill the child to avoid orphan processes.
813+ let _ = child. kill( ) . await ;
772814 warn!(
773815 id = job. id. as_deref( ) . unwrap_or( "" ) ,
774816 command,
775817 timeout_secs,
776818 "disable_on_success command timed out"
777819 ) ;
778- return DisableOnSuccessResult :: NotAchieved ( "command timed out" ) ;
820+ DisableOnSuccessResult :: NotAchieved ( "command timed out" )
779821 }
780- } ;
781-
782- if !output. status . success ( ) {
783- return DisableOnSuccessResult :: NotAchieved ( "command exited non-zero" ) ;
784- }
785-
786- let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
787- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
788- if stdout. contains ( marker) || stderr. contains ( marker) {
789- DisableOnSuccessResult :: Achieved
790- } else {
791- DisableOnSuccessResult :: NotAchieved ( "success marker not found" )
792822 }
793823}
794824
@@ -839,7 +869,10 @@ fn update_usercron_job(
839869 anyhow:: bail!( "usercron job id {:?} not found" , id) ;
840870 }
841871
842- std:: fs:: write ( path, doc. to_string ( ) ) ?;
872+ // Atomic write: write to temp file then rename to avoid corruption on crash.
873+ let tmp = path. with_extension ( "toml.tmp" ) ;
874+ std:: fs:: write ( & tmp, doc. to_string ( ) ) ?;
875+ std:: fs:: rename ( & tmp, path) ?;
843876 Ok ( ( ) )
844877}
845878
@@ -1451,6 +1484,18 @@ message = "a"
14511484 ) ) ;
14521485 }
14531486
1487+ #[ tokio:: test]
1488+ async fn disable_on_success_kills_child_on_timeout ( ) {
1489+ let mut job = test_cron_job ( ) ;
1490+ job. disable_on_success_timeout_secs = 1 ;
1491+
1492+ let result = check_disable_on_success ( & job, "sleep 999" , "SUCCESS" ) . await ;
1493+ assert ! ( matches!(
1494+ result,
1495+ DisableOnSuccessResult :: NotAchieved ( "command timed out" )
1496+ ) ) ;
1497+ }
1498+
14541499 fn test_cron_job ( ) -> CronJobConfig {
14551500 CronJobConfig {
14561501 id : Some ( "goal" . into ( ) ) ,
0 commit comments