pub mod app;
pub mod field;
#[cfg(test)]
pub mod frames;
pub mod input;
pub mod link;
pub mod pane;
pub mod source;
pub mod tail;
pub mod term;
pub mod theme;
pub mod view;
pub mod viewport;
use std::io::IsTerminal;
use std::path::Path;
use std::time::{Duration, Instant};
use futures_util::future::BoxFuture;
use futures_util::stream::FuturesUnordered;
use futures_util::{Stream, StreamExt};
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::Rect;
use shep_core::paths::ShepPaths;
use tokio::sync::mpsc;
use self::app::{App, Control, Effect, Msg, RowKey, Sent};
use self::source::Shepherd;
use self::theme::Palette;
use crate::cli::LookoutArgs;
use crate::exit::ExitCode;
use crate::output::Streams;
use crate::style::{StyleLevel, StyleSource};
pub const HEARTBEAT: Duration = Duration::from_secs(1);
pub const MIN_REDRAW: Duration = Duration::from_millis(33);
pub async fn lookout(
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &LookoutArgs,
style: (StyleLevel, StyleSource),
) -> ExitCode {
if !std::io::stdout().is_terminal() {
return streams.fail(
ExitCode::Usage,
"lookout needs a terminal; stdout is not one",
);
}
let mut shepherd = source::UnixShepherd::new(&paths.socket);
let opened = match shepherd.link().await {
Ok(opened) => opened,
Err(err) => {
let code = err.exit_code();
return streams.fail(code, &err.to_string());
}
};
if let Err(code) =
crate::refuse_version_skew(streams, opened.0.client(), crate::VersionGuard::Enforce)
{
return code;
}
let palette = Palette::detect(
std::env::var_os("NO_COLOR").as_deref(),
std::env::var_os("TERM").as_deref(),
std::env::var_os("COLORTERM").as_deref(),
);
let control = resolve_control(args.read_only, &paths.kv);
let mut app = App::new(
palette,
control,
paths.home.to_string_lossy().into_owned(),
Instant::now(),
);
app.set_style(style);
term::install_panic_hook();
let _guard = term::RestoreGuard::new();
let out = match term::enter() {
Ok(out) => out,
Err(err) => {
return streams.fail(
ExitCode::Failure,
&format!("could not put the terminal into raw mode: {err}"),
);
}
};
let terminal = match Terminal::new(CrosstermBackend::new(out)) {
Ok(terminal) => terminal,
Err(err) => {
return streams.fail(
ExitCode::Failure,
&format!("could not open the terminal: {err}"),
);
}
};
let (msg_tx, msg_rx) = mpsc::channel(1024);
let (poll_tx, poll_rx) = mpsc::channel(8);
let (request_tx, request_rx) = mpsc::channel(2);
let link = tokio::spawn(link::run_link(
shepherd,
opened,
msg_tx,
link::Channels {
polls: poll_rx,
requests: request_rx,
},
link::FLOCK_POLL,
));
let events = crossterm::event::EventStream::new();
let _ = run_ui(
app,
terminal,
events,
msg_rx,
poll_tx,
request_tx,
paths.home.clone(),
paths.daemon_config.clone(),
paths.socket.clone(),
source::LocalReader::new(),
)
.await;
link.abort();
ExitCode::Success
}
#[must_use]
pub fn resolve_control(read_only: bool, kv: &Path) -> Control {
if read_only {
return Control::ReadOnly;
}
match shep_core::kv::get(kv, "lookout.allow_control") {
Ok(Some(value)) if value == "false" => Control::ReadOnly,
_ => Control::Allowed,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run_ui<B: Backend, S, L>(
mut app: App,
mut terminal: Terminal<B>,
events: S,
mut msgs: mpsc::Receiver<Msg>,
polls: mpsc::Sender<()>,
requests: mpsc::Sender<self::app::Sent>,
home: std::path::PathBuf,
daemon_config: std::path::PathBuf,
socket_default: std::path::PathBuf,
mut local: L,
) -> Terminal<B>
where
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
L: source::Local,
{
let mut events = events;
let mut heartbeat = tokio::time::interval(HEARTBEAT);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut sigterm = crate::shutdown::Terminate::install().ok();
let mut keys_done = false;
let mut link_done = false;
let mut dirty = true;
let mut feed_dirty = false;
let mut lambs_dirty = false;
let mut inflight: FuturesUnordered<BoxFuture<'static, Msg>> = FuturesUnordered::new();
let mut last_draw: Option<Instant> = None;
loop {
let may_draw = last_draw.is_none_or(|at| at.elapsed() >= MIN_REDRAW);
if feed_dirty && may_draw {
let tail = match app.selected_row() {
None => tail::Tail::default(),
Some(row) => {
let (out, err) = (row.info.out_file.clone(), row.info.err_file.clone());
local.tail(out.as_deref().map(Path::new), err.as_deref().map(Path::new))
}
};
let _ = app.update(Msg::Bleats { tail });
feed_dirty = false;
dirty = true;
}
if lambs_dirty && may_draw {
let height = terminal.size().map_or(0, |size| size.height);
if view::panes_for(height).detail
&& let Some(RowKey::Sheep(id)) = app.selected()
{
let _ = requests.try_send(Sent::Lambs { id });
}
lambs_dirty = false;
}
if dirty && may_draw {
if let Ok(size) = terminal.size() {
let area = Rect::new(0, 0, size.width, size.height);
app.note_body_rows(view::body_rows(area));
}
let _ = terminal.draw(|frame| view::draw(&app, frame));
dirty = false;
last_draw = Some(Instant::now());
}
let msg = tokio::select! {
biased;
() = async {
match sigterm.as_mut() {
Some(signal) => {
signal.recv().await;
}
None => std::future::pending().await,
}
} => break,
event = events.next(), if !keys_done => match event {
Some(Ok(crossterm::event::Event::Resize(..))) => Some(Msg::Resize),
Some(Ok(event)) => input::map_key(&event, app.mode()).map(Msg::Key),
Some(Err(_)) | None => {
keys_done = true;
None
}
},
msg = msgs.recv(), if !link_done => match msg {
Some(msg) => Some(msg),
None => {
link_done = true;
None
}
},
done = inflight.next(), if !inflight.is_empty() => done,
_ = heartbeat.tick() => {
let _ = app.update(Msg::Host { sample: local.host() });
Some(Msg::Tick { now: Instant::now() })
}
};
let Some(msg) = msg else { continue };
match app.update(msg) {
Effect::Quit => break,
Effect::PollNow => {
let _ = polls.try_send(());
lambs_dirty = true;
dirty = true;
}
Effect::RefreshFeed => {
feed_dirty = true;
dirty = true;
}
Effect::RefreshSelected => {
feed_dirty = true;
lambs_dirty = true;
dirty = true;
}
Effect::Send(sent) => {
if let Err(err) = requests.try_send(sent) {
let (mpsc::error::TrySendError::Full(sent)
| mpsc::error::TrySendError::Closed(sent)) = err;
let _ = app.update(Msg::Unsent { sent });
}
dirty = true;
}
Effect::LoadSettings => {
let path = daemon_config.clone();
let socket_default = socket_default.clone();
let style = app.style();
let handle = tokio::task::spawn_blocking(move || {
crate::commands::settings::load_settings(&path, &socket_default, style)
});
inflight.push(Box::pin(async move {
let result = handle
.await
.map_err(|err| err.to_string())
.and_then(|inner| inner.map_err(|err| err.to_string()));
Msg::Settings { result }
}));
dirty = true;
}
Effect::WriteSetting(edit, _authority) => {
let path = daemon_config.clone();
let for_msg = edit.clone();
let handle = tokio::task::spawn_blocking(move || {
crate::commands::settings::apply_setting(&path, &edit)
});
inflight.push(Box::pin(async move {
let result = handle
.await
.map_err(|err| err.to_string())
.and_then(|inner| inner.map_err(|err| err.to_string()));
Msg::SettingWritten {
edit: for_msg,
result,
}
}));
dirty = true;
}
Effect::LoadDogPane { name, adopted_path } => {
let home = home.clone();
let handle = tokio::task::spawn_blocking(move || {
let schema = match (crate::dog::builtin_schema(&name), adopted_path.as_deref())
{
(Some(schema), _) => Some(schema),
(None, Some(path)) => {
match crate::commands::dogs::ask_schema(
path,
&home,
&name,
crate::commands::dogs::VERSION_BUDGET,
) {
crate::commands::dogs::DogSchema::Published(schema) => Some(schema),
crate::commands::dogs::DogSchema::Silent
| crate::commands::dogs::DogSchema::Unreadable => None,
}
}
(None, None) => None,
};
let result = schema.ok_or_else(|| {
format!("{name} publishes no schema; edit dogs.toml with $EDITOR")
});
Msg::DogPane {
name,
adopted_path,
result,
}
});
inflight.push(Box::pin(async move {
handle.await.unwrap_or_else(|err| Msg::DogPane {
name: String::new(),
adopted_path: None,
result: Err(format!("the schema probe failed: {err}")),
})
}));
dirty = true;
}
Effect::WriteDog(edit, _authority) => {
let path = daemon_config.clone();
let for_msg = edit.clone();
let handle = tokio::task::spawn_blocking(move || {
if edit.enable {
crate::commands::dogs::enable_in_config(&path, &edit.name)
.map_err(|err| enable_refusal_message(&err, &edit.name))
} else {
crate::commands::dogs::disable_in_config(&path, &edit.name)
.map_err(|err| err.to_string())
}
});
inflight.push(Box::pin(async move {
let result = handle
.await
.map_err(|err| err.to_string())
.and_then(|inner| inner);
Msg::DogWritten {
edit: for_msg,
result,
}
}));
dirty = true;
}
Effect::None => dirty = true,
}
}
terminal
}
fn enable_refusal_message(err: &crate::commands::dogs::EnableRefusal, name: &str) -> String {
use crate::commands::dogs::EnableRefusal;
match err {
EnableRefusal::Config(err) => err.to_string(),
EnableRefusal::UnknownDog { .. } => {
format!("{name} is not a dog shep knows about")
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use futures_util::stream;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use shep_core::protocol::{BusEvent, ProcessInfo};
use shep_core::status::ProcStatus;
use crate::lookout::app::{App, Control, KeyPress, Sent};
use crate::lookout::source::{HostSample, Local};
use crate::lookout::tail::Tail;
use crate::lookout::theme::Palette;
#[derive(Clone, Default)]
struct FakeLocal {
sample: Option<HostSample>,
hosts: Arc<AtomicUsize>,
tails: Arc<AtomicUsize>,
}
impl Local for FakeLocal {
fn host(&mut self) -> Option<HostSample> {
self.hosts.fetch_add(1, Ordering::Relaxed);
self.sample
}
fn tail(&mut self, _out: Option<&Path>, _err: Option<&Path>) -> Tail {
self.tails.fetch_add(1, Ordering::Relaxed);
Tail::default()
}
}
#[tokio::test(start_paused = true)]
async fn the_loop_draws_and_quits_on_a_keypress() {
let (msg_tx, msg_rx) = mpsc::channel(16);
let (poll_tx, _poll_rx) = mpsc::channel(1);
let (request_tx, _request_rx) = mpsc::channel(2);
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
let keys = stream::iter(vec![Ok(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
),
))]);
drop(msg_tx);
let done = tokio::time::timeout(
Duration::from_secs(10),
run_ui(
app,
terminal,
keys,
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
FakeLocal::default(),
),
)
.await;
let terminal = done.expect("the loop left on `q` within ten seconds");
let frame = crate::lookout::frames::render_text(terminal.backend().buffer());
assert!(frame.contains("shep lookout"), "it drew at least once");
}
#[tokio::test(start_paused = true)]
async fn a_drop_forwards_a_poll_request_to_the_link_task() {
let (msg_tx, msg_rx) = mpsc::channel(16);
let (poll_tx, mut poll_rx) = mpsc::channel(4);
let (request_tx, _request_rx) = mpsc::channel(2);
msg_tx
.send(Msg::Event(BusEvent::Dropped { count: 4 }))
.await
.unwrap();
msg_tx.send(Msg::Key(KeyPress::Quit)).await.unwrap();
drop(msg_tx);
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(10),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
FakeLocal::default(),
),
)
.await
.expect("the loop left within ten seconds");
assert_eq!(poll_rx.try_recv(), Ok(()), "the poll request was forwarded");
}
#[test]
fn control_is_allowed_when_nothing_says_otherwise() {
let dir = tempfile::tempdir().unwrap();
let kv = dir.path().join("kv.json");
assert_eq!(resolve_control(false, &kv), Control::Allowed);
}
#[test]
fn the_flag_and_the_key_can_each_ask_for_read_only() {
let dir = tempfile::tempdir().unwrap();
let kv = dir.path().join("kv.json");
assert_eq!(resolve_control(true, &kv), Control::ReadOnly);
shep_core::kv::set(&kv, "lookout.allow_control", "false").unwrap();
assert_eq!(resolve_control(false, &kv), Control::ReadOnly);
}
#[test]
fn an_unreadable_store_leaves_control_allowed() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
resolve_control(false, &dir.path().join("missing.json")),
Control::Allowed
);
}
#[tokio::test(start_paused = true)]
async fn the_heartbeat_asks_the_local_reader_for_a_host_sample() {
let (msg_tx, msg_rx) = mpsc::channel(64);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, _request_rx) = mpsc::channel(2);
let local = FakeLocal::default();
let hosts = Arc::clone(&local.hosts);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(1_500)).await;
let _ = msg_tx.send(Msg::Key(KeyPress::Quit)).await;
});
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(10),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
local,
),
)
.await
.expect("the loop left within ten seconds");
assert!(
hosts.load(Ordering::Relaxed) >= 1,
"the heartbeat fired and never sampled the host"
);
}
#[tokio::test]
async fn a_heartbeat_puts_the_host_strip_on_the_frame() {
let (msg_tx, msg_rx) = mpsc::channel(64);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, _request_rx) = mpsc::channel(2);
let local = FakeLocal {
sample: Some(crate::lookout::view::fixtures::sample()),
..FakeLocal::default()
};
tokio::spawn(async move {
tokio::time::sleep(MIN_REDRAW * 3).await;
let _ = msg_tx.send(Msg::Resize).await;
tokio::time::sleep(MIN_REDRAW).await;
let _ = msg_tx.send(Msg::Key(KeyPress::Quit)).await;
});
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
let terminal = tokio::time::timeout(
Duration::from_secs(10),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
local,
),
)
.await
.expect("the loop left within ten seconds");
let frame = crate::lookout::frames::render_text(terminal.backend().buffer());
assert!(
frame.contains("host load ██░░░░░░░░ 2.31 4.10 3.88 / 10 cores"),
"the strip drew the sample the heartbeat took: {frame}"
);
assert!(
!frame.contains("not read yet"),
"and not the pre-heartbeat sentence"
);
}
#[tokio::test]
async fn a_burst_of_selection_moves_costs_one_read_and_not_one_per_key() {
let (msg_tx, msg_rx) = mpsc::channel(64);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, _request_rx) = mpsc::channel(2);
let local = FakeLocal::default();
let tails = Arc::clone(&local.tails);
let at = Instant::now();
msg_tx
.send(Msg::Snapshot {
rows: (0..8)
.map(|id| {
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online).build()
})
.collect(),
at,
})
.await
.unwrap();
for _ in 0..20 {
msg_tx.send(Msg::Key(KeyPress::SelectDown)).await.unwrap();
}
tokio::spawn(async move {
tokio::time::sleep(MIN_REDRAW * 3).await;
let _ = msg_tx.send(Msg::Resize).await;
tokio::time::sleep(MIN_REDRAW).await;
let _ = msg_tx.send(Msg::Key(KeyPress::Quit)).await;
});
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(5),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
local,
),
)
.await
.expect("the loop left within five seconds");
assert_eq!(
tails.load(Ordering::Relaxed),
1,
"a snapshot and twenty selection moves must coalesce into one read"
);
}
#[tokio::test]
async fn a_burst_of_selection_moves_costs_one_lamb_request() {
let (msg_tx, msg_rx) = mpsc::channel(64);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, mut request_rx) = mpsc::channel(2);
let local = FakeLocal::default();
let at = Instant::now();
msg_tx
.send(Msg::Snapshot {
rows: (0..8)
.map(|id| {
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online).build()
})
.collect(),
at,
})
.await
.unwrap();
for _ in 0..20 {
msg_tx.send(Msg::Key(KeyPress::SelectDown)).await.unwrap();
}
tokio::spawn(async move {
tokio::time::sleep(MIN_REDRAW * 3).await;
let _ = msg_tx.send(Msg::Resize).await;
tokio::time::sleep(MIN_REDRAW).await;
let _ = msg_tx.send(Msg::Key(KeyPress::Quit)).await;
});
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(5),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
local,
),
)
.await
.expect("the loop left within five seconds");
let mut asked = 0;
while let Ok(sent) = request_rx.try_recv() {
assert!(matches!(sent, Sent::Lambs { .. }));
asked += 1;
}
assert_eq!(asked, 1, "twenty moves, one Describe");
}
#[tokio::test]
async fn no_lambs_are_requested_when_the_detail_pane_is_not_drawn() {
let (msg_tx, msg_rx) = mpsc::channel(64);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, mut request_rx) = mpsc::channel(2);
let local = FakeLocal::default();
let at = Instant::now();
msg_tx
.send(Msg::Snapshot {
rows: (0..8)
.map(|id| {
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online).build()
})
.collect(),
at,
})
.await
.unwrap();
for _ in 0..20 {
msg_tx.send(Msg::Key(KeyPress::SelectDown)).await.unwrap();
}
tokio::spawn(async move {
tokio::time::sleep(MIN_REDRAW * 3).await;
let _ = msg_tx.send(Msg::Resize).await;
tokio::time::sleep(MIN_REDRAW).await;
let _ = msg_tx.send(Msg::Key(KeyPress::Quit)).await;
});
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/tmp/shep".to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 20)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(5),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
PathBuf::from("/tmp/shep-lookout-tests"),
PathBuf::from("/tmp/shep-lookout-tests/shep.toml"),
PathBuf::from("/tmp/shep-lookout-tests/run/shep.sock"),
local,
),
)
.await
.expect("the loop left within five seconds");
assert!(
request_rx.try_recv().is_err(),
"no lamb request when the detail pane is not drawn"
);
}
#[cfg(unix)]
#[tokio::test]
async fn the_loop_keeps_running_while_a_settings_write_is_stuck() {
use nix::fcntl::{Flock, FlockArg};
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("shep.toml");
std::fs::write(&config, "[daemon]\nlog_level = \"info\"\n").unwrap();
let socket_default = dir.path().join("run/shep.sock");
let lock_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(dir.path().join("shep.toml.lock"))
.unwrap();
let held = Flock::lock(lock_file, FlockArg::LockExclusive).unwrap();
let (msg_tx, msg_rx) = mpsc::channel(16);
let (poll_tx, _poll_rx) = mpsc::channel(4);
let (request_tx, _request_rx) = mpsc::channel(4);
let snapshot = crate::commands::settings::load_settings(
&config,
&socket_default,
(StyleLevel::Full, StyleSource::Default),
)
.unwrap();
msg_tx
.send(Msg::Settings {
result: Ok(snapshot),
})
.await
.unwrap();
msg_tx.send(Msg::Key(KeyPress::Cycle)).await.unwrap();
msg_tx.send(Msg::Key(KeyPress::Confirm)).await.unwrap();
msg_tx.send(Msg::Key(KeyPress::Quit)).await.unwrap();
drop(msg_tx);
let app = App::new(
Palette::detect(None, None, None),
Control::Allowed,
dir.path().display().to_string(),
Instant::now(),
);
let terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(5),
run_ui(
app,
terminal,
stream::empty(),
msg_rx,
poll_tx,
request_tx,
dir.path().to_path_buf(),
config.clone(),
socket_default,
FakeLocal::default(),
),
)
.await
.expect("the loop answered `q` with a write still in flight");
const WRITE_ATTEMPTS: usize = 3_000;
const WRITE_POLL: Duration = Duration::from_millis(10);
drop(held);
let mut written = false;
for _ in 0..WRITE_ATTEMPTS {
if std::fs::read_to_string(&config).unwrap().contains("debug") {
written = true;
break;
}
std::thread::sleep(WRITE_POLL);
}
assert!(written, "the write that was in flight still landed");
}
}