diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcb339..0f4feb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - yyyy-mm-dd +### Fixed + +- Status bar is cleaned every time after execution is completed. + ## [v1.2.0] - 2026-03-18 ### Added diff --git a/crates/cli/src/apply.rs b/crates/cli/src/apply.rs index 850c278..38d9738 100644 --- a/crates/cli/src/apply.rs +++ b/crates/cli/src/apply.rs @@ -6,7 +6,7 @@ use itertools::{Either, Itertools}; use miette::{Diagnostic, IntoDiagnostic, Result}; use std::any::Any; use std::collections::HashSet; -use std::io::{Read, stderr}; +use std::io::Read; use std::sync::Arc; use std::sync::atomic::AtomicBool; use thiserror::Error; @@ -15,7 +15,7 @@ use wire_core::hive::executor::execute; use wire_core::hive::node::{Name, Node}; use wire_core::hive::plan::{Goal, plan_for_node}; use wire_core::hive::{Hive, HiveLocation}; -use wire_core::status::STATUS; +use wire_core::status::{UI_SENDER, UiMessage}; use wire_core::{SubCommandModifiers, errors::HiveLibError}; use crate::cli::{ApplyTarget, CommonVerbArgs, Partitions}; @@ -132,9 +132,9 @@ where ); } - STATUS - .lock() - .add_many(&partitioned_names.iter().collect::>()); + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::AddMany(partitioned_names.clone())); + } let mut set = hive .nodes @@ -178,10 +178,12 @@ where ); } - if !errors.is_empty() { - // clear the status bar if we are about to print error messages - STATUS.lock().clear(&mut stderr()); + // clear the status bar at the end of execution. + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::Clear); + } + if !errors.is_empty() { return Err(NodeErrors( errors .into_iter() diff --git a/crates/cli/src/tracing_setup.rs b/crates/cli/src/tracing_setup.rs index 8e5ac2d..c057a8c 100644 --- a/crates/cli/src/tracing_setup.rs +++ b/crates/cli/src/tracing_setup.rs @@ -1,14 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright 2024-2025 wire Contributors -use std::{ - collections::VecDeque, - io::{self, Stderr, Write, stderr}, - time::Duration, -}; +use std::io::{self, Write}; use clap_verbosity_flag::{LogLevel, Verbosity}; use owo_colors::{OwoColorize, Stream, Style}; +use tokio::sync::mpsc; use tracing::{Level, Subscriber}; use tracing_log::AsTrace; use tracing_subscriber::{ @@ -22,54 +19,28 @@ use tracing_subscriber::{ registry::LookupSpan, util::SubscriberInitExt, }; -use wire_core::{STDIN_CLOBBER_LOCK, status::STATUS}; - -/// The non-clobbering writer ensures that log lines are held while interactive -/// prompts are shown to the user. If logs where shown, they would "clobber" the -/// sudo / ssh prompt. -/// -/// Additionally, the `STDIN_CLOBBER_LOCK` is used to ensure that no two -/// interactive prompts are shown at the same time. -struct NonClobberingWriter { - queue: VecDeque>, - stderr: Stderr, -} - -impl NonClobberingWriter { - fn new() -> Self { - NonClobberingWriter { - queue: VecDeque::with_capacity(100), - stderr: stderr(), - } - } +use wire_core::status::{UI_SENDER, UiMessage}; - /// expects the caller to write the status line - fn dump_previous(&mut self) -> Result<(), io::Error> { - STATUS.lock().clear(&mut self.stderr); +/// Forwards log lines to the UI worker over `UI_SENDER`. +struct NonClobberingWriter; - for buf in self.queue.iter().rev() { - self.stderr.write(buf).map(|_| ())?; - } - - Ok(()) +impl NonClobberingWriter { + const fn new() -> Self { + NonClobberingWriter } } impl Write for NonClobberingWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if let 1.. = STDIN_CLOBBER_LOCK.available_permits() { - self.dump_previous().map(|()| 0)?; - - STATUS.lock().write_above_status(buf, &mut self.stderr) - } else { - self.queue.push_front(buf.to_vec()); - - Ok(buf.len()) + fn write(&mut self, buf: &[u8]) -> io::Result { + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::LogLine(buf.to_vec())); } + + Ok(buf.len()) } - fn flush(&mut self) -> std::io::Result<()> { - self.stderr.flush() + fn flush(&mut self) -> io::Result<()> { + Ok(()) } } @@ -231,36 +202,19 @@ where } } -async fn status_tick_worker() { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - let mut stderr = stderr(); - - loop { - interval.tick().await; - - if STDIN_CLOBBER_LOCK.available_permits() < 1 { - continue; - } - - let mut status = STATUS.lock(); - - status.clear(&mut stderr); - status.write_status(&mut stderr); - } -} - /// Set up logging for the application /// Uses `WireFieldFormat` if -v was never passed pub fn setup_logging(verbosity: &Verbosity, show_progress: bool) { let filter = verbosity.log_level_filter().as_trace(); let registry = tracing_subscriber::registry(); - STATUS.lock().show_progress(show_progress); + let (tx, rx) = mpsc::unbounded_channel(); + UI_SENDER + .set(tx) + .expect("expected setup_logging to the first and only .set() of `UI_SENDER`"); // spawn worker to tick the status bar - if show_progress { - tokio::spawn(status_tick_worker()); - } + tokio::spawn(wire_core::status::status_tick_worker(rx, show_progress)); if verbosity.is_present() { let layer = tracing_subscriber::fmt::layer() diff --git a/crates/core/src/commands/pty/mod.rs b/crates/core/src/commands/pty/mod.rs index bca4ca1..6005692 100644 --- a/crates/core/src/commands/pty/mod.rs +++ b/crates/core/src/commands/pty/mod.rs @@ -3,7 +3,7 @@ use crate::commands::pty::output::{WatchStdoutArguments, handle_pty_stdout}; use crate::hive::node::SharedTarget; -use crate::status::STATUS; +use crate::status::{UI_SENDER, UiMessage}; use aho_corasick::PatternID; use itertools::Itertools; use nix::sys::termios::{LocalFlags, SetArg, Termios, tcgetattr, tcsetattr}; @@ -12,7 +12,6 @@ use nix::unistd::write as posix_write; use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize}; use rand::distr::Alphabetic; use std::collections::VecDeque; -use std::io::stderr; use std::sync::{LazyLock, Mutex}; use std::{ io::{Read, Write}, @@ -262,14 +261,15 @@ async fn print_authenticate_warning>( "localhost (!)".to_string() }; - let _ = STATUS.lock().write_above_status( - &format!( - "{target_display} | Authenticate for \"sudo {}\":\n", - arguments.command_string.as_ref() - ) - .into_bytes(), - &mut stderr(), - ); + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::LogLine( + format!( + "{target_display} | Authenticate for \"sudo {}\":\n", + arguments.command_string.as_ref() + ) + .into_bytes(), + )); + } Ok(()) } diff --git a/crates/core/src/hive/executor.rs b/crates/core/src/hive/executor.rs index 5d22298..6e3ba30 100644 --- a/crates/core/src/hive/executor.rs +++ b/crates/core/src/hive/executor.rs @@ -1,4 +1,7 @@ -use crate::hive::node::Step; +use crate::{ + hive::node::Step, + status::{NodeStatus, UI_SENDER, UiMessage}, +}; use std::{assert_matches::debug_assert_matches, sync::Arc}; use tracing::{Instrument, Span, debug, error, event, instrument}; @@ -12,7 +15,6 @@ use crate::{ node::{Context, Derivation, ExecuteStep, Name}, plan::NodePlan, }, - status::STATUS, }; /// returns Err if the application should shut down. @@ -96,9 +98,12 @@ pub async fn execute(mut plan: NodePlan) -> Result<(), HiveLibError> { progress = format!("{}/{length}", position + 1) ); - STATUS - .lock() - .set_node_step(&plan.context.name, step.to_string()); + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::SetStatus( + plan.context.name.clone(), + NodeStatus::Running(step.to_string()), + )); + } if let Err(err) = step.execute(&mut plan.context).await.inspect_err(|_| { error!("Failed to execute `{step}`"); @@ -107,13 +112,23 @@ pub async fn execute(mut plan: NodePlan) -> Result<(), HiveLibError> { return Ok(()); } - STATUS.lock().mark_node_failed(&plan.context.name); + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::SetStatus( + plan.context.name.clone(), + NodeStatus::Failed, + )); + } return Err(err); } } - STATUS.lock().mark_node_succeeded(&plan.context.name); + if let Some(tx) = UI_SENDER.get() { + let _ = tx.send(UiMessage::SetStatus( + plan.context.name.clone(), + NodeStatus::Succeeded, + )); + } Ok(()) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f1782d5..a1b43a4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,14 +6,15 @@ #![feature(sync_nonpoison)] #![feature(nonpoison_mutex)] -use std::{ - io::{IsTerminal, stderr}, - sync::LazyLock, -}; +use std::{io::IsTerminal, sync::LazyLock}; -use tokio::sync::{AcquireError, Semaphore, SemaphorePermit}; +use tokio::sync::{AcquireError, Semaphore, SemaphorePermit, mpsc::UnboundedSender, oneshot}; -use crate::{errors::HiveLibError, hive::node::Name, status::STATUS}; +use crate::{ + errors::HiveLibError, + hive::node::Name, + status::{UI_SENDER, UiMessage}, +}; pub mod cache; pub mod commands; @@ -63,9 +64,33 @@ pub enum EvalGoal<'a> { pub static STDIN_CLOBBER_LOCK: LazyLock = LazyLock::new(|| Semaphore::new(1)); -pub async fn acquire_stdin_lock<'a>() -> Result, AcquireError> { +/// `SemaphorePermit` that sends a `UiMessage::Release` on drop +pub struct ClobberGuard<'a>( + #[allow(unused)] SemaphorePermit<'a>, + Option<&'a UnboundedSender>, +); + +impl Drop for ClobberGuard<'_> { + fn drop(&mut self) { + if let Some(tx) = self.1 { + let _ = tx.send(UiMessage::Release); + } + } +} + +pub async fn acquire_stdin_lock<'a>() -> Result, AcquireError> { let result = STDIN_CLOBBER_LOCK.acquire().await?; - STATUS.lock().wipe_out(&mut stderr()); + let (sender, rx) = oneshot::channel(); + let tx = UI_SENDER.get(); + + if let Some(tx) = tx { + let _ = tx.send(UiMessage::Takeover(sender)); + + // wait until takeover is confirmed + let _ = rx.await; + } + + let result = ClobberGuard(result, tx); Ok(result) } diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index fc37f07..77fb3fe 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -2,15 +2,21 @@ // Copyright 2024-2025 wire Contributors use owo_colors::OwoColorize; -use std::{fmt::Write, time::Instant}; +use std::{ + collections::VecDeque, + fmt::Write, + sync::OnceLock, + time::{Duration, Instant}, +}; use termion::{clear, cursor}; +use tokio::sync::{ + mpsc::{self, UnboundedReceiver}, + oneshot, +}; -use crate::{STDIN_CLOBBER_LOCK, hive::node::Name}; +use crate::hive::node::Name; -use std::{ - collections::HashMap, - sync::{LazyLock, nonpoison::Mutex}, -}; +use std::collections::HashMap; #[derive(Default)] pub enum NodeStatus { @@ -21,14 +27,31 @@ pub enum NodeStatus { Failed, } +pub enum UiMessage { + /// Initialise the status bar with many nodes at once. + AddMany(Vec), + SetStatus(Name, NodeStatus), + /// Takeover the terminal, blocking new messages from being printed until + /// `Release` is sent. + /// + /// Once the takeover request is completed, the oneshot channel will be + /// consumed. + Takeover(oneshot::Sender<()>), + /// Indicate that the takeover is no longer necessary + Release, + /// Clear the status line, mostly for when the program is about to end + Clear, + /// Writes above the status line + LogLine(Vec), +} + pub struct Status { statuses: HashMap, began: Instant, show_progress: bool, } -/// global status used for the progress bar in the cli crate -pub static STATUS: LazyLock> = LazyLock::new(|| Mutex::new(Status::new())); +pub static UI_SENDER: OnceLock> = OnceLock::new(); impl Status { fn new() -> Self { @@ -43,28 +66,6 @@ impl Status { self.show_progress = show_progress; } - pub fn add_many(&mut self, names: &[&Name]) { - self.statuses.extend( - names - .iter() - .map(|name| (name.0.to_string(), NodeStatus::Pending)), - ); - } - - pub fn set_node_step(&mut self, node: &Name, step: String) { - self.statuses - .insert(node.0.to_string(), NodeStatus::Running(step)); - } - - pub fn mark_node_failed(&mut self, node: &Name) { - self.statuses.insert(node.0.to_string(), NodeStatus::Failed); - } - - pub fn mark_node_succeeded(&mut self, node: &Name) { - self.statuses - .insert(node.0.to_string(), NodeStatus::Succeeded); - } - #[must_use] fn num_finished(&self) -> usize { self.statuses @@ -153,21 +154,77 @@ impl Status { let _ = write!(writer, "{}", self.get_msg()); } } +} - pub fn write_above_status( - &mut self, - buf: &[u8], - writer: &mut T, - ) -> std::io::Result { - if STDIN_CLOBBER_LOCK.available_permits() != 1 { - // skip - return Ok(0); +pub async fn status_tick_worker(mut rx: UnboundedReceiver, show_progress: bool) { + let mut status = Status::new(); + + status.show_progress(show_progress); + + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + let mut stderr = std::io::stderr(); + let mut log_queue: VecDeque> = VecDeque::with_capacity(100); + + // A single boolean represents the "taken over" state, where stdin is being + // accepted from the user. A "depth" is not used as it is expected the + // callers of `Takeover` respect the Semaphore. + // + // If there was ever multiple take overs at once (unlikely), this code would + // need to be updated to track multiple takeovers at once. + let mut taken_over = false; + + loop { + tokio::select! { + Some(msg) = rx.recv() => { + match msg { + UiMessage::AddMany(names) => { + status.statuses.extend( + names + .iter() + .map(|name| (name.0.to_string(), NodeStatus::Pending)), + ); + }, + UiMessage::SetStatus(name, value) => { + status.statuses.insert(name.0.to_string(), value); + }, + UiMessage::Takeover(tx) => { + taken_over = true; + status.wipe_out(&mut stderr); + let _ = tx.send(()); + }, + UiMessage::Release => { + taken_over = false; + for buf in log_queue.drain(..) { + let _ = std::io::Write::write_all(&mut stderr, &buf); + } + status.write_status(&mut stderr); + }, + UiMessage::Clear => { + status.clear(&mut stderr); + }, + UiMessage::LogLine(line) => { + if taken_over { + log_queue.push_back(line); + } else { + status.clear(&mut stderr); + for buf in log_queue.drain(..) { + let _ = std::io::Write::write_all(&mut stderr, &buf); + } + let _ = std::io::Write::write_all(&mut stderr, &line); + status.write_status(&mut stderr); + } + }, + } + } + + _ = ticker.tick() => { + if taken_over { + continue; + } + + status.clear(&mut stderr); + status.write_status(&mut stderr); + } } - - self.clear(writer); - let written = writer.write(buf)?; - self.write_status(writer); - - Ok(written) } } diff --git a/index.scip b/index.scip new file mode 100644 index 0000000000000000000000000000000000000000..6b9221caa725787694e08efd5d15f17daa2b0f82 GIT binary patch literal 959370 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