diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index 96d86b2..8d0601e 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -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> { - 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 = 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, stderr: Vec } - 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();