max / makenotwork
6 files changed,
+74 insertions,
-9 deletions
| @@ -214,8 +214,8 @@ fn extract_sqlstate(err: &str) -> Option<String> { | |||
| 214 | 214 | /// `boot_smoke`: process exit info is the dominant signal. If the | |
| 215 | 215 | /// binary exited with a status during the smoke window, we map exit | |
| 216 | 216 | /// code 101 (Rust default for panic) to `BootPanic`, everything else | |
| 217 | - | /// to `BootExitedEarly`. If it never exited (stayed up), the caller | |
| 218 | - | /// constructs `PassNote::StayedUp` directly without consulting this. | |
| 217 | + | /// to `BootExitedEarly`. If it stayed up and served `/health`, the caller | |
| 218 | + | /// constructs `PassNote::HealthyProbe` directly without consulting this. | |
| 219 | 219 | pub fn classify_boot_smoke(exit_code: Option<i32>) -> GateFailure { | |
| 220 | 220 | match exit_code { | |
| 221 | 221 | Some(101) => GateFailure::BootPanic { exit_code: Some(101) }, |
| @@ -56,6 +56,13 @@ pub struct Config { | |||
| 56 | 56 | /// daemon config so the sando code stays project-agnostic. | |
| 57 | 57 | #[serde(default)] | |
| 58 | 58 | pub release_contents: Vec<ReleaseEntry>, | |
| 59 | + | /// Wall-clock ceiling (seconds) for the `cargo_test` and `migration_dry_run` | |
| 60 | + | /// gates. A hung suite (deadlocked test, wedged child) otherwise blocks the | |
| 61 | + | /// pipeline until a new `/rebuild` aborts it; past the ceiling the gate is | |
| 62 | + | /// killed and fails with `GateFailure::Timeout`. Default 2400s (40 min) — | |
| 63 | + | /// generous for a full release test suite, fatal only to a genuine hang. | |
| 64 | + | #[serde(default = "default_gate_timeout_secs")] | |
| 65 | + | pub gate_timeout_secs: u64, | |
| 59 | 66 | } | |
| 60 | 67 | ||
| 61 | 68 | /// A directory or file copied from the worktree into the staged release dir. | |
| @@ -76,6 +83,7 @@ pub struct ReleaseEntry { | |||
| 76 | 83 | fn default_bin_names() -> Vec<String> { vec!["server".into()] } | |
| 77 | 84 | fn default_logs_root() -> PathBuf { PathBuf::from("/srv/sando/logs") } | |
| 78 | 85 | fn default_boot_smoke_port() -> u16 { 18181 } | |
| 86 | + | fn default_gate_timeout_secs() -> u64 { 2400 } | |
| 79 | 87 | ||
| 80 | 88 | impl Config { | |
| 81 | 89 | /// Primary binary — the one the systemd unit's ExecStart points at. | |
| @@ -105,6 +113,7 @@ impl Config { | |||
| 105 | 113 | logs_root: PathBuf::from("/tmp/sando-test-logs"), | |
| 106 | 114 | release_contents: Vec::new(), | |
| 107 | 115 | cargo_target_dir: None, | |
| 116 | + | gate_timeout_secs: default_gate_timeout_secs(), | |
| 108 | 117 | } | |
| 109 | 118 | } | |
| 110 | 119 | } | |
| @@ -136,6 +145,19 @@ mod tests { | |||
| 136 | 145 | } | |
| 137 | 146 | ||
| 138 | 147 | #[test] | |
| 148 | + | fn gate_timeout_defaults_when_omitted() { | |
| 149 | + | let cfg: Config = toml::from_str(MINIMAL).unwrap(); | |
| 150 | + | assert_eq!(cfg.gate_timeout_secs, 2400, "omitting it keeps the 40-min ceiling"); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn gate_timeout_parses_when_present() { | |
| 155 | + | let raw = format!("{MINIMAL}\ngate_timeout_secs = 600\n"); | |
| 156 | + | let cfg: Config = toml::from_str(&raw).unwrap(); | |
| 157 | + | assert_eq!(cfg.gate_timeout_secs, 600); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 139 | 161 | fn build_host_is_required() { | |
| 140 | 162 | // No safe default: a config without build_host must not parse, so the | |
| 141 | 163 | // no-build-on-prod guard can never be silently skipped. |
| @@ -57,8 +57,22 @@ pub async fn run(ctx: &GateCtx, gate: &Gate) -> Result<GateOutcome> { | |||
| 57 | 57 | }); | |
| 58 | 58 | ||
| 59 | 59 | let outcome = match gate { | |
| 60 | + | // cargo_test bounds its own run internally (it kills the specific child). | |
| 60 | 61 | Gate::CargoTest => cargo_test(ctx, run_id).await, | |
| 61 | - | Gate::MigrationDryRun => migration_dry_run(ctx).await, | |
| 62 | + | // migration_dry_run's psql restore + sqlx migrate could wedge; bound the | |
| 63 | + | // whole gate here. Its bash restore sets kill_on_drop, so a timeout-drop | |
| 64 | + | // doesn't orphan it. | |
| 65 | + | Gate::MigrationDryRun => { | |
| 66 | + | let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs); | |
| 67 | + | match tokio::time::timeout(ceiling, migration_dry_run(ctx)).await { | |
| 68 | + | Ok(res) => res, | |
| 69 | + | Err(_elapsed) => Ok(GateOutcome::failed(GateFailure::Timeout { | |
| 70 | + | gate: GateKind::MigrationDryRun, | |
| 71 | + | after_s: ctx.cfg.gate_timeout_secs as u32, | |
| 72 | + | }) | |
| 73 | + | .with_log_ref(LogRef::new(&ctx.version, GateKind::MigrationDryRun))), | |
| 74 | + | } | |
| 75 | + | } | |
| 62 | 76 | Gate::BootSmoke => boot_smoke(ctx, run_id).await, | |
| 63 | 77 | Gate::BurnIn { hours } => burn_in(ctx, *hours).await, | |
| 64 | 78 | Gate::ManualConfirm => manual_confirm(ctx).await, | |
| @@ -165,8 +179,23 @@ async fn cargo_test(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> { | |||
| 165 | 179 | }).with_log_ref(log_ref)); | |
| 166 | 180 | } | |
| 167 | 181 | }; | |
| 168 | - | let (stdout_buf, stderr_buf, status) = | |
| 169 | - | stream_child_to_live_log(&mut child, ctx.events.clone(), run_id, log_path).await?; | |
| 182 | + | // Wall-clock ceiling: a hung suite would otherwise block the pipeline until a | |
| 183 | + | // new /rebuild aborts it. Past the ceiling, kill the child and fail Timeout. | |
| 184 | + | let ceiling = std::time::Duration::from_secs(ctx.cfg.gate_timeout_secs); | |
| 185 | + | let stream = stream_child_to_live_log(&mut child, ctx.events.clone(), run_id, log_path); | |
| 186 | + | let (stdout_buf, stderr_buf, status) = match tokio::time::timeout(ceiling, stream).await { | |
| 187 | + | Ok(res) => res?, | |
| 188 | + | Err(_elapsed) => { | |
| 189 | + | child.start_kill().ok(); | |
| 190 | + | let _ = child.wait().await; | |
| 191 | + | let after_s = started.elapsed().as_secs() as u32; | |
| 192 | + | return Ok(GateOutcome::failed(GateFailure::Timeout { | |
| 193 | + | gate: GateKind::CargoTest, | |
| 194 | + | after_s, | |
| 195 | + | }) | |
| 196 | + | .with_log_ref(log_ref)); | |
| 197 | + | } | |
| 198 | + | }; | |
| 170 | 199 | let duration_s = started.elapsed().as_secs() as u32; | |
| 171 | 200 | if status.success() { | |
| 172 | 201 | Ok(GateOutcome::passed(PassNote::TestsPassed { duration_s }).with_log_ref(log_ref)) | |
| @@ -403,6 +432,9 @@ async fn restore_dump(db_url: &str, dump: &str, log_buf: &mut Vec<u8>) -> Result | |||
| 403 | 432 | // locally on the Sando host (fw13), which has bash. | |
| 404 | 433 | let mut cmd = Command::new("bash"); | |
| 405 | 434 | cmd.arg("-c").arg(&shell); | |
| 435 | + | // kill_on_drop so the gate's wall-clock ceiling (dispatcher-level timeout on | |
| 436 | + | // migration_dry_run) can't orphan a wedged psql restore. | |
| 437 | + | cmd.kill_on_drop(true); | |
| 406 | 438 | if let Some(pw) = password { | |
| 407 | 439 | cmd.env("PGPASSWORD", pw); | |
| 408 | 440 | } |
| @@ -78,8 +78,6 @@ pub enum GateStatus { | |||
| 78 | 78 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 79 | 79 | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 80 | 80 | pub enum PassNote { | |
| 81 | - | /// `boot_smoke` — the binary stayed up for the smoke window. | |
| 82 | - | StayedUp { duration_s: u32 }, | |
| 83 | 81 | /// `boot_smoke` — the binary came up and served `GET /health` (readiness, | |
| 84 | 82 | /// not just liveness). `after_ms` is how long until the first good probe. | |
| 85 | 83 | HealthyProbe { after_ms: u32 }, | |
| @@ -101,7 +99,6 @@ pub enum PassNote { | |||
| 101 | 99 | impl PassNote { | |
| 102 | 100 | pub fn summary(&self) -> String { | |
| 103 | 101 | match self { | |
| 104 | - | PassNote::StayedUp { duration_s } => format!("stayed up for {duration_s}s"), | |
| 105 | 102 | PassNote::HealthyProbe { after_ms } => format!("served /health in {after_ms}ms"), | |
| 106 | 103 | PassNote::BurnInElapsed { hours } => format!("{hours} hours elapsed"), | |
| 107 | 104 | PassNote::Migrated { backup_path } => format!("restored {backup_path} + migrated"), | |
| @@ -350,6 +347,20 @@ mod tests { | |||
| 350 | 347 | } | |
| 351 | 348 | ||
| 352 | 349 | #[test] | |
| 350 | + | fn timeout_failure_renders_and_is_not_passed() { | |
| 351 | + | // Timeout is now a live failure (cargo_test / migration_dry_run ceilings), | |
| 352 | + | // not an aspirational variant — keep it mapped in summary() and serde. | |
| 353 | + | let o = GateOutcome::failed(GateFailure::Timeout { | |
| 354 | + | gate: GateKind::CargoTest, | |
| 355 | + | after_s: 2400, | |
| 356 | + | }); | |
| 357 | + | assert!(!o.is_passed()); | |
| 358 | + | let v: serde_json::Value = serde_json::to_value(&o).unwrap(); | |
| 359 | + | assert_eq!(v["status"]["failure"]["kind"], "timeout"); | |
| 360 | + | assert!(matches!(&o.status, GateStatus::Failed { failure } if failure.summary().contains("timed out"))); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 353 | 364 | fn blocked_is_not_passed() { | |
| 354 | 365 | let o = GateOutcome::blocked(GateBlocker::BurnInClockNotStarted); | |
| 355 | 366 | assert!(!o.is_passed()); |
| @@ -1166,6 +1166,7 @@ mod tests { | |||
| 1166 | 1166 | logs_root: PathBuf::from("/tmp/sando-logs"), | |
| 1167 | 1167 | release_contents: vec![], | |
| 1168 | 1168 | cargo_target_dir: None, | |
| 1169 | + | gate_timeout_secs: 2400, | |
| 1169 | 1170 | } | |
| 1170 | 1171 | } | |
| 1171 | 1172 |
| @@ -474,7 +474,6 @@ fn format_event_body(e: &Event) -> String { | |||
| 474 | 474 | ||
| 475 | 475 | fn pass_note_short(n: &PassNote) -> String { | |
| 476 | 476 | match n { | |
| 477 | - | PassNote::StayedUp { duration_s } => format!("up {duration_s}s"), | |
| 478 | 477 | PassNote::HealthyProbe { after_ms } => format!("/health {after_ms}ms"), | |
| 479 | 478 | PassNote::BurnInElapsed { hours } => format!("{hours}h elapsed"), | |
| 480 | 479 | PassNote::Migrated { backup_path } => { |