fix: use std::process::Command+spawn_blocking+temp file to avoid all pipe/async hang issues

This commit is contained in:
M1 2026-03-18 13:08:08 +04:00
parent 289ec8e038
commit 5e76b2212f
1 changed files with 44 additions and 59 deletions

View File

@ -177,8 +177,9 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
}
}
/// Run an HTTP check via curl subprocess — the only reliable way to enforce
/// a hard timeout including TLS handshake on hosts that stall at that phase.
/// Run an HTTP check via curl subprocess writing output to a temp file.
/// Using a temp file instead of a pipe avoids async read_to_end hanging
/// when curl exits without closing its inherited stdout properly.
async fn run_curl(
url: &str,
method: &str,
@ -186,79 +187,63 @@ async fn run_curl(
body: Option<&str>,
timeout_secs: f64,
) -> Result<(u16, HashMap<String, String>, String), String> {
let mut cmd = tokio::process::Command::new("curl");
cmd.arg("--silent")
.arg("--show-error")
.arg("--include") // include response headers in output
.arg("--max-time").arg(format!("{:.1}", timeout_secs))
.arg("--connect-timeout").arg(format!("{:.1}", timeout_secs))
.arg("-X").arg(method)
.arg("--user-agent").arg("PingQL-Monitor/0.1")
.arg("--location") // follow redirects
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true); // kill if future is dropped
use std::process::Stdio;
// Write output to a temp file so we just wait for the process to exit
let tmp = format!("/tmp/pingql-curl-{}.txt", std::process::id());
let mut args: Vec<String> = vec![
"--silent".into(),
"--show-error".into(),
"--include".into(),
"--max-time".into(), format!("{:.1}", timeout_secs),
"--connect-timeout".into(), format!("{:.1}", timeout_secs),
"-X".into(), method.to_string(),
"--user-agent".into(), "PingQL-Monitor/0.1".into(),
"--location".into(),
"--output".into(), tmp.clone(),
];
if let Some(hdrs) = headers {
for (k, v) in hdrs {
cmd.arg("-H").arg(format!("{k}: {v}"));
args.push("-H".into());
args.push(format!("{k}: {v}"));
}
}
if let Some(b) = body {
cmd.arg("--data-raw").arg(b);
args.push("--data-raw".into());
args.push(b.to_string());
}
args.push(url.to_string());
cmd.arg(url);
// Run curl synchronously in a blocking thread — simple and reliable
let args_owned = args.clone();
let tmp_owned = tmp.clone();
let result = tokio::task::spawn_blocking(move || {
let status = std::process::Command::new("curl")
.args(&args_owned)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.status();
let output = std::fs::read(&tmp_owned).unwrap_or_default();
let _ = std::fs::remove_file(&tmp_owned);
(status, output)
}).await.map_err(|e| format!("spawn_blocking error: {e}"))?;
let mut child = cmd.spawn().map_err(|e| format!("curl spawn error: {e}"))?;
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
// Read stdout and stderr concurrently, then wait for process exit
let (stdout_bytes, stderr_bytes, status) = tokio::time::timeout(
std::time::Duration::from_secs_f64(timeout_secs + 2.0),
async {
let stdout_fut = async {
if let Some(mut out) = stdout_handle {
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut out, &mut buf).await.ok();
buf
} else { Vec::new() }
};
let stderr_fut = async {
if let Some(mut err) = stderr_handle {
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut err, &mut buf).await.ok();
buf
} else { Vec::new() }
};
let (out, err) = tokio::join!(stdout_fut, stderr_fut);
let status = child.wait().await;
(out, err, status)
}
).await.map_err(|_| format!("timed out after {:.0}s", timeout_secs))?;
let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(-1);
let (status_result, raw_bytes) = result;
let exit_code = status_result.ok().and_then(|s| s.code()).unwrap_or(-1);
if exit_code != 0 {
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
let msg = match exit_code {
return Err(match exit_code {
28 => format!("timed out after {:.0}s", timeout_secs),
6 => "DNS lookup failed".to_string(),
7 => "connection refused".to_string(),
_ => stderr.trim().to_string(),
};
return Err(msg);
_ => format!("curl error (exit {})", exit_code),
});
}
struct FakeOutput { stdout: Vec<u8>, stderr: Vec<u8> }
let output = FakeOutput { stdout: stdout_bytes, stderr: stderr_bytes };
// Parse curl output: headers then blank line then body
let raw = String::from_utf8_lossy(&output.stdout).to_string();
let raw = String::from_utf8_lossy(&raw_bytes).to_string();
let mut parts = raw.splitn(2, "\r\n\r\n");
let header_block = parts.next().unwrap_or("");
let body_str = parts.next().unwrap_or("").to_string();