use std::panic::AssertUnwindSafe;
use color_eyre::eyre::Result;
use futures::FutureExt;
use futures::stream::StreamExt;
use ratatui::prelude::Backend;
use tokio::sync::mpsc;
use tokio::task::JoinSet;
use crate::application::Application;
use crate::command::{Action, RuntimeCommandParts};
use crate::subscription::SubscriptionManager;
use super::app_input::AppInputs;
pub(super) struct RuntimeCore<App: Application> {
pub(super) app: App,
pub(super) msg_tx: mpsc::UnboundedSender<App::Message>,
pub(super) app_inputs: AppInputs<App::Message>,
pub(super) quit_tx: mpsc::UnboundedSender<()>,
pub(super) quit_rx: mpsc::UnboundedReceiver<()>,
pub(super) subscription_manager: SubscriptionManager<App::Message>,
pub(super) command_tasks: JoinSet<()>,
}
impl<App: Application> RuntimeCore<App> {
#[must_use]
pub(super) fn new(flags: App::Flags) -> Self {
let (msg_tx, msg_rx) = mpsc::unbounded_channel();
let app_inputs = AppInputs::new(msg_rx);
let (quit_tx, quit_rx) = mpsc::unbounded_channel();
let subscription_manager = SubscriptionManager::new(msg_tx.clone());
let (app, init_cmd) = App::new(flags);
let mut core = Self {
app,
msg_tx,
app_inputs,
quit_tx,
quit_rx,
subscription_manager,
command_tasks: JoinSet::new(),
};
core.enqueue_command(init_cmd.into_runtime_parts());
core
}
pub(super) fn enqueue_command(&mut self, parts: RuntimeCommandParts<App::Message>) {
if let Some(stream) = parts.into_stream() {
let msg_tx = self.msg_tx.clone();
let quit_tx = self.quit_tx.clone();
while self.command_tasks.try_join_next().is_some() {}
tracing::trace!(target: "tears::runtime", "command spawned");
self.command_tasks.spawn(async move {
let result = AssertUnwindSafe(async move {
futures::pin_mut!(stream);
while let Some(action) = stream.next().await {
match action {
Action::Message(msg) => {
let _ = msg_tx.send(msg);
}
Action::Quit => {
let _ = quit_tx.send(());
break;
}
}
}
})
.catch_unwind()
.await;
if result.is_err() {
tracing::error!(target: "tears::runtime", "command task panicked");
}
});
}
}
pub(super) fn initialize_subscriptions(&mut self) {
let subscriptions = self.app.subscriptions();
self.subscription_manager.update(subscriptions);
}
pub(super) fn render<B: Backend>(
&self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<(), <B as Backend>::Error> {
terminal.draw(|frame| {
self.app.view(frame);
})?;
Ok(())
}
pub(super) fn shutdown(&mut self) {
self.subscription_manager.shutdown();
self.command_tasks.abort_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::pending;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use tokio::sync::oneshot;
use tokio::time::{Duration, advance, timeout};
use crate::command::{Command, RetryPolicy};
use crate::runtime::AppInput;
use crate::subscription::Subscription;
use crate::subscription::time::Timer;
use crate::test_support::{TestApp, TestMessage, wait_until};
#[test]
fn test_new() {
let core = RuntimeCore::<TestApp>::new(42);
assert_eq!(core.app.counter, 42);
}
#[test]
fn test_new_with_zero() {
let core = RuntimeCore::<TestApp>::new(0);
assert_eq!(core.app.counter, 0);
}
#[test]
fn test_new_initializes_channels() {
let core = RuntimeCore::<TestApp>::new(0);
assert_eq!(core.app.counter, 0);
}
struct AppWithInitCommand {
initialized: bool,
}
impl Application for AppWithInitCommand {
type Message = bool;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Self::Message>) {
let cmd = Command::future(async { true });
(Self { initialized: false }, cmd)
}
fn update(&mut self, msg: Self::Message) -> Command<Self::Message> {
self.initialized = msg;
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![]
}
}
#[tokio::test]
async fn test_processes_init_command() {
let mut core = RuntimeCore::<AppWithInitCommand>::new(());
let input = timeout(Duration::from_secs(1), core.app_inputs.next())
.await
.expect("init command should send a message before the timeout");
assert_eq!(input, Some(AppInput::Shared(true)));
assert!(!core.app.initialized);
}
#[test]
fn test_enqueue_command_none() {
let mut core = RuntimeCore::<TestApp>::new(0);
core.enqueue_command(Command::none().into_runtime_parts());
}
#[tokio::test]
async fn test_enqueue_command_with_message() {
let mut core = RuntimeCore::<TestApp>::new(0);
let cmd = Command::future(async { TestMessage::Increment });
core.enqueue_command(cmd.into_runtime_parts());
let input = timeout(Duration::from_secs(1), core.app_inputs.next())
.await
.expect("command should send a message before the timeout");
assert!(matches!(
input,
Some(AppInput::Shared(TestMessage::Increment))
));
}
#[tokio::test(start_paused = true)]
async fn test_enqueue_command_delivers_timeout_message() {
let mut core = RuntimeCore::<TestApp>::new(0);
let (started_tx, started_rx) = oneshot::channel();
let command = Command::future(async move {
let _ = started_tx.send(());
pending::<TestMessage>().await
})
.timeout(Duration::from_secs(1), || TestMessage::Increment);
core.enqueue_command(command.into_runtime_parts());
timeout(Duration::from_secs(1), started_rx)
.await
.expect("command should start before the timeout")
.expect("command should signal its first poll");
advance(Duration::from_secs(1)).await;
let input = timeout(Duration::from_secs(1), core.app_inputs.next())
.await
.expect("command should send its timeout message");
assert!(matches!(
input,
Some(AppInput::Shared(TestMessage::Increment))
));
}
#[tokio::test]
async fn test_enqueue_command_delivers_retry_final_message() {
let mut core = RuntimeCore::<TestApp>::new(0);
let mut attempts = 0;
let command = Command::retry(
RetryPolicy::try_new(2).expect("valid policy"),
move |_| {
attempts += 1;
let attempt = attempts;
async move {
if attempt == 2 {
Ok(TestMessage::Increment)
} else {
Err("transient")
}
}
},
|result| result.expect("second attempt should succeed"),
);
core.enqueue_command(command.into_runtime_parts());
let input = timeout(Duration::from_secs(1), core.app_inputs.next())
.await
.expect("retry should send its final message before the timeout");
assert!(matches!(
input,
Some(AppInput::Shared(TestMessage::Increment))
));
}
#[tokio::test]
async fn test_enqueue_command_with_quit() {
let mut core = RuntimeCore::<TestApp>::new(0);
let cmd = Command::effect(Action::Quit);
core.enqueue_command(cmd.into_runtime_parts());
timeout(Duration::from_secs(1), core.quit_rx.recv())
.await
.expect("quit command should send a quit signal before the timeout")
.expect("quit channel should remain open");
}
#[test]
fn test_multiple_cores() {
let core1 = RuntimeCore::<TestApp>::new(1);
let core2 = RuntimeCore::<TestApp>::new(2);
assert_eq!(core1.app.counter, 1);
assert_eq!(core2.app.counter, 2);
}
struct AppWithStringFlags {
name: String,
}
impl Application for AppWithStringFlags {
type Message = ();
type Flags = String;
fn new(name: String) -> (Self, Command<Self::Message>) {
(Self { name }, Command::none())
}
fn update(&mut self, _msg: Self::Message) -> Command<Self::Message> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![]
}
}
#[test]
fn test_with_string_flags() {
let core = RuntimeCore::<AppWithStringFlags>::new("test".to_string());
assert_eq!(core.app.name, "test");
}
#[test]
fn test_with_empty_string_flags() {
let core = RuntimeCore::<AppWithStringFlags>::new(String::new());
assert_eq!(core.app.name, "");
}
#[tokio::test]
async fn test_initialize_subscriptions() {
struct AppWithSubs;
impl Application for AppWithSubs {
type Message = ();
type Flags = ();
fn new((): ()) -> (Self, Command<()>) {
(Self, Command::none())
}
fn update(&mut self, (): ()) -> Command<()> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<()>> {
vec![
Subscription::new(
Timer::try_new(100).expect("timer interval must be non-zero"),
)
.map(|_| ()),
]
}
}
let mut core = RuntimeCore::<AppWithSubs>::new(());
core.initialize_subscriptions();
}
#[test]
fn test_initialize_subscriptions_empty() {
let mut core = RuntimeCore::<TestApp>::new(0);
core.initialize_subscriptions();
}
#[test]
fn test_render() -> Result<()> {
let core = RuntimeCore::<TestApp>::new(0);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
assert!(core.render(&mut terminal).is_ok());
Ok(())
}
#[test]
fn test_render_multiple_times() -> Result<()> {
let core = RuntimeCore::<TestApp>::new(0);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
assert!(core.render(&mut terminal).is_ok());
assert!(core.render(&mut terminal).is_ok());
assert!(core.render(&mut terminal).is_ok());
Ok(())
}
#[test]
fn test_shutdown() {
let mut core = RuntimeCore::<TestApp>::new(0);
core.shutdown();
}
#[tokio::test]
async fn test_shutdown_after_initialize_subscriptions() {
struct AppWithSubs;
impl Application for AppWithSubs {
type Message = ();
type Flags = ();
fn new((): ()) -> (Self, Command<()>) {
(Self, Command::none())
}
fn update(&mut self, (): ()) -> Command<()> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<()>> {
vec![
Subscription::new(
Timer::try_new(100).expect("timer interval must be non-zero"),
)
.map(|_| ()),
]
}
}
let mut core = RuntimeCore::<AppWithSubs>::new(());
core.initialize_subscriptions();
core.shutdown();
}
#[tokio::test]
async fn test_dropping_core_aborts_command_tasks() {
struct AbortGuard(Arc<AtomicBool>);
impl Drop for AbortGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let aborted = Arc::new(AtomicBool::new(false));
{
let mut core = RuntimeCore::<TestApp>::new(0);
let guard = AbortGuard(aborted.clone());
let (started_tx, started_rx) = oneshot::channel();
core.enqueue_command(
Command::future(async move {
let _guard = guard;
let _ = started_tx.send(());
pending::<TestMessage>().await
})
.into_runtime_parts(),
);
timeout(Duration::from_secs(1), started_rx)
.await
.expect("command task should start before the timeout")
.expect("command task should signal that it started");
assert!(
!aborted.load(Ordering::SeqCst),
"command task should still be running before the core is dropped"
);
}
wait_until(
|| aborted.load(Ordering::SeqCst),
"dropping the core should abort running command tasks",
)
.await;
}
}