Skip to content

Commit 7848a92

Browse files
author
超渡法師
committed
fix(cron): atomic write, kill child on timeout, add timeout test
- update_usercron_job: write to .toml.tmp then rename (atomic on POSIX) - check_disable_on_success: use spawn() + wait_with_output() to retain child handle; explicitly kill on timeout to prevent orphan processes - Add disable_on_success_kills_child_on_timeout test (sleep 999 + 1s timeout)
1 parent f8d3d16 commit 7848a92

1 file changed

Lines changed: 66 additions & 21 deletions

File tree

src/cron.rs

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)