use std::future::Future;
use std::io;
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context as TaskContext, Poll, Waker};
use std::time::Duration;
use anyhow::Context;
use tokio::signal::unix::{SignalKind, signal};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::events::{EventBus, Observer};
pub(crate) enum Frontend {
Cli(Option<Arc<dyn Observer>>),
Tui {
bus: EventBus,
detach: CancellationToken,
abandon: Arc<AtomicBool>,
report: Arc<dyn Fn(Report) + Send + Sync>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Report {
Line(String),
RunCreated(String),
RunStopped(u64),
}
impl Frontend {
pub(crate) fn open_bus(&self) -> BusGuard {
match self {
Self::Cli(observer) => {
let bus = EventBus::observed(EventBus::CAPACITY, observer.clone());
let renderer = tokio::spawn(super::progress::render(bus.subscribe()));
BusGuard {
bus,
renderer: Some(renderer),
cancel: None,
}
},
Self::Tui { bus, .. } => {
let cancel = CancellationToken::new();
let renderer = tokio::spawn(super::progress::render_until(
bus.subscribe(),
cancel.clone(),
));
BusGuard {
bus: bus.clone(),
renderer: Some(renderer),
cancel: Some(cancel),
}
},
}
}
pub(crate) fn interrupt(&self) -> Interrupt {
match self {
Self::Cli(_) => Interrupt::catch(),
Self::Tui { detach, .. } => {
let detach = detach.clone();
Interrupt::on(Box::pin(async move {
detach.cancelled_owned().await;
Ok(())
}))
},
}
}
pub(crate) fn provisioning_flag(&self) -> anyhow::Result<Flag> {
match self {
Self::Cli(_) => {
let mut sigint = signal(SignalKind::interrupt()).context("cannot catch Ctrl-C")?;
let interrupted = Arc::new(AtomicBool::new(false));
let seen = Arc::clone(&interrupted);
let watcher = tokio::spawn(async move {
if sigint.recv().await.is_some() {
seen.store(true, Ordering::SeqCst);
}
});
Ok(Flag {
interrupted,
watcher: Some(watcher),
})
},
Self::Tui { abandon, .. } => Ok(Flag {
interrupted: Arc::clone(abandon),
watcher: None,
}),
}
}
pub(crate) fn line(&self, line: &str) {
match self {
Self::Cli(_) => println!("{line}"),
Self::Tui { report, .. } => report(Report::Line(line.to_string())),
}
}
pub(crate) fn run_stopped(&self, step: u64) {
match self {
Self::Cli(_) => {},
Self::Tui { report, .. } => report(Report::RunStopped(step)),
}
}
pub(crate) fn run_created(&self, id: &str) {
match self {
Self::Cli(_) => {},
Self::Tui { report, .. } => report(Report::RunCreated(id.to_string())),
}
}
}
const RENDER_GRACE: Duration = Duration::from_secs(2);
pub(crate) struct BusGuard {
pub(crate) bus: EventBus,
renderer: Option<JoinHandle<()>>,
cancel: Option<CancellationToken>,
}
impl BusGuard {
pub(crate) async fn close(mut self) {
let renderer = self.renderer.take();
let cancel = self.cancel.take();
drop(self);
let Some(mut renderer) = renderer else {
return;
};
match cancel {
Some(cancel) => {
cancel.cancel();
if tokio::time::timeout(RENDER_GRACE, &mut renderer)
.await
.is_err()
{
renderer.abort();
renderer.await.ok();
}
},
None => {
renderer.await.ok();
},
}
}
}
impl Drop for BusGuard {
fn drop(&mut self) {
let Some(renderer) = self.renderer.take() else {
return;
};
match self.cancel.take() {
Some(cancel) => cancel.cancel(),
None => renderer.abort(),
}
}
}
pub(crate) struct Flag {
pub(crate) interrupted: Arc<AtomicBool>,
watcher: Option<JoinHandle<()>>,
}
impl Flag {
pub(crate) fn close(self) {
drop(self);
}
}
impl Drop for Flag {
fn drop(&mut self) {
if let Some(watcher) = self.watcher.take() {
watcher.abort();
}
}
}
type Signal = Pin<Box<dyn Future<Output = io::Result<()>> + Send>>;
pub(crate) enum Interrupt {
Listening(Signal),
Caught,
Off,
}
impl Interrupt {
fn on(signal: Signal) -> Self {
Self::Listening(signal)
}
fn catch() -> Self {
let mut signal: Signal = Box::pin(tokio::signal::ctrl_c());
let mut cx = TaskContext::from_waker(Waker::noop());
match signal.as_mut().poll(&mut cx) {
Poll::Pending => Self::Listening(signal),
Poll::Ready(result) => Self::after(result),
}
}
fn after(result: io::Result<()>) -> Self {
match result {
Ok(()) => Self::Caught,
Err(error) => {
tracing::warn!("cannot catch Ctrl-C: {error}");
Self::Off
},
}
}
pub(crate) fn caught(&self) -> bool {
matches!(self, Self::Caught)
}
pub(crate) async fn shield<T>(&mut self, flow: impl Future<Output = T>) -> T {
let mut flow = pin!(flow);
let result = match self {
Self::Listening(signal) => tokio::select! {
output = &mut flow => return output,
result = signal.as_mut() => result,
},
Self::Caught | Self::Off => return flow.await,
};
*self = Self::after(result);
flow.await
}
pub(crate) async fn race<T>(&mut self, flow: impl Future<Output = T>) -> Option<T> {
let mut flow = pin!(flow);
let result = match self {
Self::Listening(signal) => tokio::select! {
output = &mut flow => return Some(output),
result = signal.as_mut() => result,
},
Self::Caught => return None,
Self::Off => return Some(flow.await),
};
*self = Self::after(result);
if self.caught() {
None
} else {
Some(flow.await)
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::signal::unix::{SignalKind, signal};
use super::*;
use crate::events::{Event, Stage};
async fn ctrl_c() -> io::Result<()> {
let status = tokio::process::Command::new("kill")
.args(["-INT", &std::process::id().to_string()])
.status()
.await?;
if status.success() {
Ok(())
} else {
Err(io::Error::other("kill failed"))
}
}
#[tokio::test]
async fn ctrl_c_never_stops_a_shielded_flow_and_stops_a_raced_one()
-> Result<(), Box<dyn std::error::Error>> {
let _signals = crate::test_support::SIGNALS.lock().await;
let limit = Duration::from_secs(10);
let mut interrupt = Frontend::Cli(None).interrupt();
assert!(matches!(interrupt, Interrupt::Listening(_)));
let mut received = signal(SignalKind::interrupt())?;
let shielded = async {
ctrl_c().await?;
received.recv().await;
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, io::Error>("ended")
};
let ended = tokio::time::timeout(limit, interrupt.shield(shielded)).await??;
assert_eq!(ended, "ended");
assert!(interrupt.caught());
assert_eq!(interrupt.race(std::future::ready(())).await, None);
let mut interrupt = Frontend::Cli(None).interrupt();
let raced = async {
ctrl_c().await?;
std::future::pending::<()>().await;
Ok::<_, io::Error>(())
};
let raced = tokio::time::timeout(limit, interrupt.race(raced)).await?;
assert!(raced.is_none());
assert!(interrupt.caught());
Ok(())
}
fn tui(detach: &CancellationToken) -> Frontend {
Frontend::Tui {
bus: EventBus::with_capacity(8),
detach: detach.clone(),
abandon: Arc::new(AtomicBool::new(false)),
report: Arc::new(|_| {}),
}
}
#[tokio::test]
async fn a_detach_token_never_stops_a_shielded_flow_and_stops_a_raced_one()
-> Result<(), Box<dyn std::error::Error>> {
let limit = Duration::from_secs(10);
let token = CancellationToken::new();
let mut interrupt = tui(&token).interrupt();
assert!(matches!(interrupt, Interrupt::Listening(_)));
let shielded = async {
token.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
"ended"
};
assert_eq!(
tokio::time::timeout(limit, interrupt.shield(shielded)).await?,
"ended"
);
assert!(interrupt.caught());
assert_eq!(interrupt.race(std::future::ready(())).await, None);
let token = CancellationToken::new();
let mut interrupt = tui(&token).interrupt();
let raced = async {
token.cancel();
std::future::pending::<()>().await;
};
assert!(
tokio::time::timeout(limit, interrupt.race(raced))
.await?
.is_none()
);
assert!(interrupt.caught());
Ok(())
}
#[tokio::test]
async fn a_tui_front_end_shares_its_bus_flag_and_reports() -> anyhow::Result<()> {
let lines = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = Arc::clone(&lines);
let abandon = Arc::new(AtomicBool::new(false));
let front = Frontend::Tui {
bus: EventBus::with_capacity(8),
detach: CancellationToken::new(),
abandon: Arc::clone(&abandon),
report: Arc::new(move |report| {
if let Ok(mut lines) = seen.lock() {
lines.push(report);
}
}),
};
let Frontend::Tui { bus, .. } = &front else {
anyhow::bail!("not a TUI front end");
};
let mut events = bus.subscribe();
let guard = front.open_bus();
guard.bus.publish(Event::StageStarted {
stage: Stage::Split,
total: 2,
});
assert!(events.try_recv().is_ok(), "the task's own bus");
guard.close().await;
let flag = front.provisioning_flag()?;
abandon.store(true, Ordering::SeqCst);
assert!(flag.interrupted.load(Ordering::SeqCst));
flag.close();
front.line("split: 1 train, 1 eval, 0 excluded, 0 orphaned");
let reported = lines.lock().map(|lines| lines.clone()).unwrap_or_default();
assert_eq!(
reported,
[Report::Line(
"split: 1 train, 1 eval, 0 excluded, 0 orphaned".into()
)]
);
Ok(())
}
#[tokio::test]
async fn a_cli_bus_is_rendered_until_it_is_closed() -> Result<(), tokio::time::error::Elapsed> {
let guard = Frontend::Cli(None).open_bus();
let mut extra = guard.bus.subscribe();
guard.bus.publish(Event::StageStarted {
stage: Stage::Split,
total: 1,
});
assert!(extra.try_recv().is_ok());
tokio::time::timeout(Duration::from_secs(10), guard.close()).await
}
async fn eventually(done: impl Fn() -> bool) -> Result<(), tokio::time::error::Elapsed> {
tokio::time::timeout(Duration::from_secs(10), async {
while !done() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
}
#[tokio::test]
async fn a_cli_flag_dropped_without_close_stops_its_watcher() -> anyhow::Result<()> {
let _signals = crate::test_support::SIGNALS.lock().await;
let flag = Frontend::Cli(None).provisioning_flag()?;
let interrupted = Arc::clone(&flag.interrupted);
assert_eq!(
Arc::strong_count(&interrupted),
3,
"the flag and its watcher"
);
drop(flag);
eventually(|| Arc::strong_count(&interrupted) == 1).await?;
Ok(())
}
#[tokio::test]
async fn a_bus_guard_dropped_without_close_stops_its_renderer() -> anyhow::Result<()> {
let guard = Frontend::Cli(None).open_bus();
let bus = guard.bus.clone();
assert_eq!(bus.receiver_count(), 1, "the renderer");
drop(guard);
eventually(|| bus.receiver_count() == 0).await?;
let front = tui(&CancellationToken::new());
let Frontend::Tui { bus, .. } = &front else {
anyhow::bail!("not a TUI front end");
};
let guard = front.open_bus();
assert_eq!(bus.receiver_count(), 1, "the renderer");
drop(guard);
eventually(|| bus.receiver_count() == 0).await?;
Ok(())
}
#[tokio::test]
async fn a_cli_provisioning_flag_starts_clear() -> anyhow::Result<()> {
let flag = Frontend::Cli(None).provisioning_flag()?;
assert!(!flag.interrupted.load(Ordering::SeqCst));
flag.close();
Ok(())
}
}