Fix: capture all script output + always log exit code

Two silent failure modes:

1. lines() drops any output chunk not terminated with \n — a script
   that crashes mid-line (or any final output without a newline) was
   silently swallowed. Switched to raw 4KB chunk reads which stream
   incrementally and capture everything.

2. A non-zero exit with no output (e.g. bash exit 127 'command not
   found') left the log completely empty. Now always appends
   '[hiy] exit code: N' after the process finishes so there is always
   at least one diagnostic line regardless of script output.

Exit code lookup:
  exit code: 0   -> success
  exit code: 1   -> script hit 'set -e' on a failing command
  exit code: 127 -> bash could not find the script or a command in it
  exit code: 126 -> script found but not executable (chmod +x missing)
  exit code: signal -> process killed by OS signal

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
Claude 2026-03-19 09:37:21 +00:00
parent c3f300e8ad
commit e1a01173ed
No known key found for this signature in database

View file

@ -1,5 +1,5 @@
use chrono::Utc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{AsyncReadExt, BufReader};
use tokio::process::Command;
use uuid::Uuid;
@ -136,28 +136,47 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
// Stream stdout and stderr concurrently into the deploy log.
// Read stdout/stderr in 4 KB chunks so we stream incrementally AND capture
// any partial last line that has no trailing newline (which lines() drops).
let db1 = state.db.clone();
let id1 = deploy_id.to_string();
let stdout_task = tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = append_log(&db1, &id1, &format!("{}\n", line)).await;
let mut reader = BufReader::new(stdout);
let mut buf = vec![0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
let _ = append_log(&db1, &id1, &chunk).await;
}
}
}
});
let db2 = state.db.clone();
let id2 = deploy_id.to_string();
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = append_log(&db2, &id2, &format!("{}\n", line)).await;
let mut reader = BufReader::new(stderr);
let mut buf = vec![0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
let _ = append_log(&db2, &id2, &chunk).await;
}
}
}
});
let exit_status = child.wait().await?;
let _ = tokio::join!(stdout_task, stderr_task);
// Always record the exit code — the one line that survives even silent failures.
let code = exit_status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into());
append_log(&state.db, deploy_id, &format!("\n[hiy] exit code: {}\n", code)).await?;
let final_status = if exit_status.success() { "success" } else { "failed" };
let finished = Utc::now().to_rfc3339();