| 82 | 82 | | pub(crate) posts: Vec<Post>, |
| 83 | 83 | | step_names: Vec<String>, |
| 84 | 84 | | deadline: Instant, |
| 85 | + | /// The running step's `timeout-minutes`, as an instant: the nearest of |
| 86 | + | /// the step's own and those of the steps around it (a composite |
| 87 | + | /// action's step inside a `uses:` step). Every process a step starts |
| 88 | + | /// stops by it, as every one stops by the job's deadline. |
| 89 | + | step_deadline: Option<Instant>, |
| 85 | 90 | | debug: bool, |
| 86 | 91 | | /// What the last Node process left, for the step that ran it. |
| 87 | 92 | | pub(crate) last_node_outputs: BTreeMap<String, String>, |
| ⋯ |
| 229 | 234 | | self.remaining() |
| 230 | 235 | | } |
| 231 | 236 | | |
| 237 | + | /// What is left for the running step: its `timeout-minutes`, within |
| 238 | + | /// the job's. |
| 232 | 239 | | fn remaining(&self) -> Duration { |
| 240 | + | let until = self.step_deadline.map_or(self.deadline, |step| step.min(self.deadline)); |
| 241 | + | until.saturating_duration_since(Instant::now()) |
| 242 | + | } |
| 243 | + | |
| 244 | + | /// What is left of the job's own time, whatever the step's. |
| 245 | + | fn job_remaining(&self) -> Duration { |
| 233 | 246 | | self.deadline.saturating_duration_since(Instant::now()) |
| 234 | 247 | | } |
| 235 | 248 | | |
| ⋯ |
| 382 | 395 | | env.insert(name.clone(), expr::to_text(&value)); |
| 383 | 396 | | } |
| 384 | 397 | | } |
| 385 | | − | let timeout = step |
| 398 | + | let minutes = step |
| 386 | 399 | | .get("timeout-minutes") |
| 387 | 400 | | .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok()) |
| 388 | | − | .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok())) |
| 389 | | − | .map_or(Duration::from_secs(6 * 3600), |minutes| Duration::from_secs_f64(minutes * 60.0)); |
| 401 | + | .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok())); |
| 402 | + | let timeout = step_timeout(minutes); |
| 403 | + | // Every step stops at its own `timeout-minutes`, `run` or `uses`: a |
| 404 | + | // `uses:` step's action, and every process it starts, included. |
| 405 | + | let outer_deadline = self.step_deadline; |
| 406 | + | self.step_deadline = step_deadline(outer_deadline, Instant::now(), minutes); |
| 390 | 407 | | let continue_on_error = step |
| 391 | 408 | | .get("continue-on-error") |
| 392 | 409 | | .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok()) |
| ⋯ |
| 440 | 457 | | self.log.line("##[error]A step needs `run` or `uses`."); |
| 441 | 458 | | (false, BTreeMap::new()) |
| 442 | 459 | | }; |
| 460 | + | let own_deadline = self.step_deadline.filter(|_| self.step_deadline != outer_deadline); |
| 461 | + | self.step_deadline = outer_deadline; |
| 462 | + | let ok = if own_deadline.is_some_and(|until| Instant::now() >= until) { |
| 463 | + | self.log.line(&format!( |
| 464 | + | "##[error]The step ran past its timeout-minutes ({}) and was stopped.", |
| 465 | + | minutes.unwrap_or_default() |
| 466 | + | )); |
| 467 | + | false |
| 468 | + | } else { |
| 469 | + | ok |
| 470 | + | }; |
| 443 | 471 | | |
| 444 | 472 | | let outcome = if ok { "success" } else { "failure" }; |
| 445 | 473 | | let conclusion = if ok || continue_on_error { "success" } else { "failure" }; |
| ⋯ |
| 464 | 492 | | } |
| 465 | 493 | | } |
| 466 | 494 | | |
| 495 | + | /// How long a step's processes may run: its `timeout-minutes`, or six |
| 496 | + | /// hours (the job's own limit still applies). |
| 497 | + | fn step_timeout(minutes: Option<f64>) -> Duration { |
| 498 | + | match minutes { |
| 499 | + | Some(minutes) if minutes.is_finite() && minutes > 0.0 => Duration::from_secs_f64(minutes * 60.0), |
| 500 | + | Some(_) => Duration::ZERO, |
| 501 | + | None => Duration::from_secs(6 * 3600), |
| 502 | + | } |
| 503 | + | } |
| 504 | + | |
| 505 | + | /// When a step must end by: its `timeout-minutes` from `now`, or `outer` |
| 506 | + | /// (the deadline of the `uses:` step it runs inside, for a composite |
| 507 | + | /// action's step), whichever is nearer. None when neither sets one. |
| 508 | + | fn step_deadline(outer: Option<Instant>, now: Instant, minutes: Option<f64>) -> Option<Instant> { |
| 509 | + | let own = minutes.map(|minutes| now + step_timeout(Some(minutes))); |
| 510 | + | match (outer, own) { |
| 511 | + | (Some(outer), Some(own)) => Some(outer.min(own)), |
| 512 | + | (outer, own) => outer.or(own), |
| 513 | + | } |
| 514 | + | } |
| 515 | + | |
| 467 | 516 | | /// PowerShell on Windows: `pwsh` (PowerShell 7) if it is installed, as on |
| 468 | 517 | | /// GitHub's Windows runners, else Windows PowerShell. |
| 469 | 518 | | fn windows_powershell() -> String { |
| ⋯ |
| 560 | 609 | | posts: Vec::new(), |
| 561 | 610 | | step_names: Vec::new(), |
| 562 | 611 | | deadline: Instant::now() + Duration::from_secs(timeout * 60), |
| 612 | + | step_deadline: None, |
| 563 | 613 | | debug, |
| 564 | 614 | | last_node_outputs: BTreeMap::new(), |
| 565 | 615 | | last_node_state: BTreeMap::new(), |
| ⋯ |
| 657 | 707 | | for (index, step) in steps.iter().enumerate().filter(|_| containers_started) { |
| 658 | 708 | | job.step(&mut frame, step, index as u32 + 1, true, &defaults); |
| 659 | 709 | | job.log_docker_notes(); |
| 660 | | − | if job.remaining().is_zero() { |
| 710 | + | if job.job_remaining().is_zero() { |
| 661 | 711 | | job.log.line("##[error]The job ran past its time limit."); |
| 662 | 712 | | job.failed = true; |
| 663 | 713 | | break; |
| ⋯ |
| 745 | 795 | | run_job(&mut job); |
| 746 | 796 | | if job.failed { 1 } else { 0 } |
| 747 | 797 | | } |
| 798 | + | |
| 799 | + | #[cfg(test)] |
| 800 | + | mod tests { |
| 801 | + | use super::*; |
| 802 | + | |
| 803 | + | #[test] |
| 804 | + | fn a_steps_timeout_is_its_own_or_the_nearer_one_around_it() { |
| 805 | + | let now = Instant::now(); |
| 806 | + | // No timeout-minutes anywhere: only the job's deadline applies. |
| 807 | + | assert_eq!(step_deadline(None, now, None), None); |
| 808 | + | // A step's own, in minutes, fractions included. |
| 809 | + | assert_eq!(step_deadline(None, now, Some(1.5)), Some(now + Duration::from_secs(90))); |
| 810 | + | // A composite action's step inside a `uses:` step with its own: |
| 811 | + | // the nearer of the two. |
| 812 | + | let outer = now + Duration::from_secs(60); |
| 813 | + | assert_eq!(step_deadline(Some(outer), now, Some(10.0)), Some(outer)); |
| 814 | + | assert_eq!(step_deadline(Some(outer), now, Some(0.5)), Some(now + Duration::from_secs(30))); |
| 815 | + | assert_eq!(step_deadline(Some(outer), now, None), Some(outer)); |
| 816 | + | // Zero or less is a time already up. |
| 817 | + | assert_eq!(step_deadline(None, now, Some(0.0)), Some(now)); |
| 818 | + | assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600)); |
| 819 | + | assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO); |
| 820 | + | } |
| 821 | + | } |