use std::any::Any;
use std::collections::BTreeMap;
use std::future::Future;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::pin::Pin;
use std::task::{Context, Poll};
use crate::deploy::Outcome;
use crate::error::Error;
pub(super) struct CatchUnwind<F>(pub(super) Pin<Box<F>>);
impl<F: Future> Future for CatchUnwind<F> {
type Output = Result<F::Output, Box<dyn Any + Send>>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = &mut self.get_mut().0;
match catch_unwind(AssertUnwindSafe(|| inner.as_mut().poll(cx))) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(output)) => Poll::Ready(Ok(output)),
Err(payload) => Poll::Ready(Err(payload)),
}
}
}
pub(super) fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
(*text).to_owned()
} else if let Some(text) = payload.downcast_ref::<String>() {
text.clone()
} else {
"a panic with no message".to_owned()
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Said {
Note(String),
Complaint(String),
}
impl Said {
pub(super) fn text(&self) -> &str {
match self {
Self::Note(text) | Self::Complaint(text) => text,
}
}
}
pub(super) fn report(sheep: &str, outcome: &Result<Outcome, Error>) -> Option<Said> {
let sheep = &crate::shared::printable(sheep);
match outcome {
Ok(Outcome::UpToDate) => None,
Ok(Outcome::Deployed { sha }) => Some(Said::Note(format!("{sheep} deployed {sha}"))),
Ok(Outcome::RolledBack { to, why }) => Some(Said::Complaint(format!(
"{sheep} rolled back to {to}: {why}"
))),
Err(err) => Some(Said::Complaint(format!("{sheep}: {err}"))),
}
}
const RESAY: u32 = 120;
pub(super) struct Repeat {
line: String,
muted: u32,
}
pub(super) fn worth_saying(
previous: &mut BTreeMap<String, Repeat>,
sheep: &str,
line: &str,
) -> Option<u32> {
let mut swallowed = 0;
if let Some(seen) = previous.get_mut(sheep)
&& seen.line == line
{
seen.muted += 1;
if seen.muted < RESAY {
return None;
}
swallowed = seen.muted - 1;
}
previous.insert(
sheep.to_owned(),
Repeat {
line: line.to_owned(),
muted: 0,
},
);
Some(swallowed)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_panic_becomes_an_err_rather_than_ending_the_loop() {
let caught = CatchUnwind(Box::pin(async {
tokio::task::yield_now().await;
panic!("the deploy fell over");
}))
.await;
let payload = caught.expect_err("the panic must be caught");
assert_eq!(panic_message(payload.as_ref()), "the deploy fell over");
let fine = CatchUnwind(Box::pin(async { 7 })).await;
assert_eq!(fine.ok(), Some(7));
}
#[test]
fn a_panic_message_is_read_from_either_payload_shape() {
assert_eq!(panic_message(&"literal"), "literal");
assert_eq!(panic_message(&String::from("formatted")), "formatted");
assert_eq!(panic_message(&42_u8), "a panic with no message");
}
#[test]
fn a_line_that_repeats_is_said_once() {
let mut previous = BTreeMap::new();
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_some());
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_none());
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_none());
assert!(worth_saying(&mut previous, "koji", "koji: the remote is gone").is_some());
}
#[test]
fn every_repeated_line_is_muted_not_one_chosen_kind() {
let mut previous = BTreeMap::new();
let lines = [
"web: bad deploy configuration: /srv/deploy/web/deploy.toml: expected an equals",
"web: `git fetch origin` exited with status 128: could not resolve host",
"web: the build command exited with status 1",
];
for line in lines {
assert!(worth_saying(&mut previous, "web", line).is_some(), "{line}");
assert!(worth_saying(&mut previous, "web", line).is_none(), "{line}");
}
}
#[test]
fn a_line_that_changes_is_said_again() {
let mut previous = BTreeMap::new();
let broken = "web: the remote is gone";
assert!(worth_saying(&mut previous, "web", broken).is_some());
assert!(worth_saying(&mut previous, "web", broken).is_none());
assert!(worth_saying(&mut previous, "web", "web deployed abc1234").is_some());
assert!(worth_saying(&mut previous, "web", broken).is_some());
}
#[test]
fn a_line_said_again_counts_only_what_was_swallowed() {
let mut previous = BTreeMap::new();
let line = "web: the remote is gone";
assert_eq!(worth_saying(&mut previous, "web", line), Some(0), "new");
for _ in 1..RESAY {
assert_eq!(worth_saying(&mut previous, "web", line), None);
}
assert_eq!(
worth_saying(&mut previous, "web", line),
Some(RESAY - 1),
"the one being said is not swallowed"
);
}
#[test]
fn a_muted_line_is_said_again_eventually() {
let mut previous = BTreeMap::new();
let line = "web: the remote is gone";
assert!(worth_saying(&mut previous, "web", line).is_some());
let said = (0..RESAY * 2)
.filter(|_| worth_saying(&mut previous, "web", line).is_some())
.count();
assert_eq!(said, 2, "once every RESAY ticks, not once ever");
}
#[test]
fn each_outcome_says_what_it_did_and_where() {
assert_eq!(report("web", &Ok(Outcome::UpToDate)), None, "the heartbeat");
assert_eq!(
report(
"web",
&Ok(Outcome::Deployed {
sha: "a1b2c3".to_owned()
})
),
Some(Said::Note("web deployed a1b2c3".to_owned()))
);
assert_eq!(
report(
"web",
&Ok(Outcome::RolledBack {
to: "old".to_owned(),
why: "it did not come up".to_owned()
})
),
Some(Said::Complaint(
"web rolled back to old: it did not come up".to_owned()
))
);
let err = report("web", &Err(Error::Build { status: Some(1) }));
let Some(Said::Complaint(text)) = err else {
panic!("a failure is a complaint: {err:?}");
};
assert!(text.starts_with("web: "), "{text}");
}
}