diff --git a/Cargo.lock b/Cargo.lock index 40759436a1..90ee02f501 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8475,7 +8475,6 @@ dependencies = [ "futures", "httpmock", "indexmap", - "indicatif", "indoc", "js-semver", "oxc_resolver", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_global_install_multiple_fail/snapshots/command_env_global_install_multiple_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_global_install_multiple_fail/snapshots/command_env_global_install_multiple_fail.md index 4f97427e0f..726f569407 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_global_install_multiple_fail/snapshots/command_env_global_install_multiple_fail.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_global_install_multiple_fail/snapshots/command_env_global_install_multiple_fail.md @@ -8,7 +8,7 @@ VITE+ - The Unified Toolchain for the Web info: Installing 2 global packages with Node.js -⠿ Installing global packages (/2) npm error code E404 +⠿ Installing global packages (/2) npm error code E404 npm error 404 Not Found - GET http://127.0.0.1:/voidzero-nonexistent-pkg-xyz-23456 - npm error 404 npm error 404 The requested resource 'voidzero-nonexistent-pkg-xyz-23456@*' could not be found or you do not have permission to access it. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_fail/snapshots/command_env_install_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_fail/snapshots/command_env_install_fail.md index 2e34bda2f5..c5c22a8c76 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_fail/snapshots/command_env_install_fail.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_fail/snapshots/command_env_install_fail.md @@ -10,7 +10,7 @@ Install non-existent package VITE+ - The Unified Toolchain for the Web info: Installing 1 global package with Node.js -⠿ Installing global packages (/1) npm error code E404 +⠿ Installing global packages (/1) npm error code E404 npm error 404 Not Found - GET http://127.0.0.1:/voidzero-nonexistent-pkg-xyz-12345 - npm error 404 npm error 404 The requested resource 'voidzero-nonexistent-pkg-xyz-12345@*' could not be found or you do not have permission to access it. diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index c5ddcdb06a..557b384645 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -35,7 +35,6 @@ vt_pty_terminal_test_client = { workspace = true, features = ["testing"] } rustc-hash = { workspace = true } crossterm = { workspace = true } indexmap = { workspace = true } -indicatif = { workspace = true } indoc = { workspace = true } js-semver = { workspace = true } vp_error = { workspace = true } diff --git a/crates/vp_global_cli/src/commands/global/install.rs b/crates/vp_global_cli/src/commands/global/install.rs index 94f8bdfaca..16b405bf9f 100644 --- a/crates/vp_global_cli/src/commands/global/install.rs +++ b/crates/vp_global_cli/src/commands/global/install.rs @@ -5,17 +5,15 @@ use std::{ fs::{File, OpenOptions, TryLockError}, io::{Read, Write}, process::Stdio, - time::Duration, }; use console::style; use futures::{StreamExt, stream::FuturesUnordered}; use indexmap::IndexMap; -use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use tokio::process::Command; use uuid::Uuid; use vp_js_runtime::NodeProvider; -use vp_shared::{PrependOptions, ToolPathEnv, output}; +use vp_shared::{PrependOptions, ToolPathEnv, output, progress::Progress}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use crate::{ @@ -212,17 +210,10 @@ pub async fn install( node_version )); - let progress = ProgressBar::new(packages_count as u64); - if vp_shared::is_stderr_terminal() && std::env::var_os("CI").is_none() { - let style = ProgressStyle::with_template("{spinner:.cyan} {msg} ({pos}/{len})") - .unwrap_or_else(|_| ProgressStyle::default_spinner()) - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); - progress.set_style(style); - progress.set_message(format!("{} global packages", operation_progress)); - progress.enable_steady_tick(Duration::from_millis(80)); - } else { - progress.set_draw_target(ProgressDrawTarget::hidden()); - } + let progress = Progress::counter( + &format!("{} global packages", operation_progress), + packages_count as u64, + ); // We have to clone it because we will modify `packages` to storage package names let package_names = packages.keys().cloned().collect::>(); @@ -258,7 +249,9 @@ pub async fn install( match installs.next().await { Some((package_name, Ok((installed_package, lock_file)))) => { - progress.inc(1); + if let Some(progress) = &progress { + progress.bar().inc(1); + } packages.get_mut(&package_name).unwrap().install = Some(installed_package); install_locks.insert(package_name, lock_file); } @@ -271,7 +264,7 @@ pub async fn install( None => break, } } - progress.finish_and_clear(); + drop(progress); // 5. Finalize installed packages. for (index, (package_name, Package { spec, install })) in packages.into_iter().enumerate() { @@ -1396,6 +1389,8 @@ async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> #[cfg(test)] mod tests { + use std::time::Duration; + use vp_shared::env_vars; use super::*; diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 5e851fa2e8..d01e11d2f0 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -1,13 +1,12 @@ //! Managed global package utilities. -use std::{collections::HashMap, fs::File, io::Read, process::Stdio, time::Duration}; +use std::{collections::HashMap, fs::File, io::Read, process::Stdio}; use flate2::read::GzDecoder; use futures::{StreamExt, stream::FuturesUnordered}; -use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use tar::Archive; use tokio::process::Command; -use vp_shared::{PrependOptions, ToolPathEnv}; +use vp_shared::{PrependOptions, ToolPathEnv, progress::Progress}; use vt_path::{AbsolutePathBuf, current_dir}; use crate::{commands::env::config::resolve_version, error::Error}; @@ -92,17 +91,7 @@ pub(crate) async fn latest_package_versions( let mut package_specs = specs.iter(); let mut versions = HashMap::with_capacity(specs.len()); - let progress = ProgressBar::new(specs.len() as u64); - if vp_shared::is_stderr_terminal() && std::env::var_os("CI").is_none() { - let style = ProgressStyle::with_template("{spinner:.cyan} {msg} ({pos}/{len})") - .unwrap_or_else(|_| ProgressStyle::default_spinner()) - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); - progress.set_style(style); - progress.set_message("Checking latest package versions"); - progress.enable_steady_tick(Duration::from_millis(80)); - } else { - progress.set_draw_target(ProgressDrawTarget::hidden()); - } + let progress = Progress::counter("Checking latest package versions", specs.len() as u64); let mut queries = FuturesUnordered::new(); @@ -121,11 +110,13 @@ pub(crate) async fn latest_package_versions( } if let Some(version) = queries.next().await { - progress.inc(1); + if let Some(progress) = &progress { + progress.bar().inc(1); + } versions.insert(version.package_spec, version.version); } } - progress.finish_and_clear(); + drop(progress); Ok(versions) } diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index bfaef1910f..f1f4bde155 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -16,7 +16,7 @@ test-utils = ["dep:temp-env", "dep:tempfile"] [dependencies] directories = { workspace = true } -nix = { workspace = true, features = ["fs", "poll", "term"] } +nix = { workspace = true, features = ["fs", "poll", "signal", "term"] } console = { workspace = true } indicatif = { workspace = true } cow-utils = { workspace = true } diff --git a/crates/vp_shared/src/progress.rs b/crates/vp_shared/src/progress.rs index d5e0ae6095..f2df3f1ab6 100644 --- a/crates/vp_shared/src/progress.rs +++ b/crates/vp_shared/src/progress.rs @@ -1,12 +1,13 @@ //! A single progress row for an operation and its sequential downloads. +mod input; mod style; use std::{future::Future, time::Duration}; use indicatif::{ProgressBar, ProgressStyle}; -use self::style::Style; +use self::{input::InputBlock, style::Style}; tokio::task_local! { static ACTIVE_PROGRESS: ProgressBar; @@ -28,29 +29,48 @@ pub async fn with_spinner(message: Option<&str>, future: F) -> F::Out return future.await; } - let progress = - Progress::new(ProgressBar::new_spinner(), Style::Spinner.for_stderr(message), None); - progress.run(future).await + Progress::start(Style::Spinner.for_stderr(message)).run(future).await } -/// Clears a download on completion or cancellation, or restores its enclosing -/// operation's spinner. Keep this guard alive until the download finishes. +/// A progress row, cleared on completion or cancellation. A download nested in a +/// spinner restores that spinner instead. A row that is not nested blocks +/// keyboard input until it is dropped. Keep this guard alive until the work finishes. pub struct Progress { bar: ProgressBar, restore: Option<(ProgressStyle, Duration)>, + _input: Option, } impl Progress { + /// Animate a status with a completed count and elapsed time. Advance it + /// through [`Self::bar`]. Returns `None` when progress is hidden. + pub fn counter(message: &str, len: u64) -> Option { + if !enabled() { + return None; + } + let progress = Self::start(Style::Counter.for_stderr(message)); + progress.bar.set_length(len); + progress.bar.force_draw(); + Some(progress) + } + pub fn download(message: &str) -> Option { - let (bar, restore) = match ACTIVE_PROGRESS.try_with(Clone::clone) { + let style = Style::Download.for_stderr(message); + match ACTIVE_PROGRESS.try_with(Clone::clone) { Ok(bar) => { let restore = Some((bar.style(), bar.elapsed())); - (bar, restore) + Some(Self::new(bar, style, restore)) } - Err(_) if enabled() => (ProgressBar::new_spinner(), None), - Err(_) => return None, - }; - Some(Self::new(bar, Style::Download.for_stderr(message), restore)) + Err(_) if enabled() => Some(Self::start(style)), + Err(_) => None, + } + } + + /// Show a new row and block keyboard input until it is cleared. + fn start(style: ProgressStyle) -> Self { + let mut progress = Self::new(ProgressBar::new_spinner(), style, None); + progress._input = input::block(); + progress } fn new( @@ -63,7 +83,7 @@ impl Progress { bar.unset_length(); bar.enable_steady_tick(Duration::from_millis(100)); bar.force_draw(); - Self { bar, restore } + Self { bar, restore, _input: None } } pub fn bar(&self) -> &ProgressBar { @@ -100,6 +120,12 @@ mod tests { use super::*; + // napi futures hold `&Progress` across awaits, so it must stay `Send` and `Sync`. + const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); + }; + fn spinner(term: &InMemoryTerm) -> Progress { let draw_term = term.clone(); Progress::new( diff --git a/crates/vp_shared/src/progress/input.rs b/crates/vp_shared/src/progress/input.rs new file mode 100644 index 0000000000..b8a4674919 --- /dev/null +++ b/crates/vp_shared/src/progress/input.rs @@ -0,0 +1,403 @@ +//! Block keyboard input while a progress row is visible, like clack's `block()`. +//! The terminal switches to Node's raw mode except that Ctrl+C and Ctrl+Z keep +//! sending signals, so no reader thread is needed. Pending input is discarded +//! on release so it does not reach the shell. Signal handlers and a panic hook +//! restore the terminal on paths where `Drop` never runs. + +use std::sync::{ + Once, + atomic::{AtomicBool, Ordering}, +}; + +/// Only one row blocks input at a time, which keeps the saved state exclusive. +static ACTIVE: AtomicBool = AtomicBool::new(false); + +pub(super) struct InputBlock(sys::Guard); + +/// Returns `None` unless stdin is a terminal in its normal line-editing mode. +/// A terminal another program already switched to raw mode is left alone. +pub(super) fn block() -> Option { + if ACTIVE.swap(true, Ordering::SeqCst) { + return None; + } + let Some(guard) = sys::Guard::new() else { + ACTIVE.store(false, Ordering::SeqCst); + return None; + }; + install_panic_hook(); + Some(InputBlock(guard)) +} + +impl Drop for InputBlock { + fn drop(&mut self) { + self.0.release(); + ACTIVE.store(false, Ordering::SeqCst); + } +} + +/// Release builds abort on panic, so `Drop` never runs there. +fn install_panic_hook() { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + sys::restore_if_blocked(); + previous(info); + })); + }); +} + +#[cfg(unix)] +mod sys { + use std::{ + cell::UnsafeCell, + mem::MaybeUninit, + os::fd::RawFd, + sync::atomic::{AtomicBool, Ordering}, + }; + + use nix::{ + libc, + sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}, + }; + + struct Saved(UnsafeCell>); + + // SAFETY: only the guard that won `ACTIVE` writes this, before it sets + // `BLOCKED` and installs signal handlers. Every other access reads it. + unsafe impl Sync for Saved {} + + static SAVED: Saved = Saved(UnsafeCell::new(MaybeUninit::uninit())); + static BLOCKED: AtomicBool = AtomicBool::new(false); + + pub(super) struct Guard { + previous: Vec<(Signal, SigAction)>, + } + + impl Guard { + pub(super) fn new() -> Option { + if !block(libc::STDIN_FILENO) { + return None; + } + BLOCKED.store(true, Ordering::SeqCst); + Some(Self { previous: install_handlers() }) + } + + pub(super) fn release(&mut self) { + BLOCKED.store(false, Ordering::SeqCst); + restore(libc::STDIN_FILENO); + for (signal, previous) in self.previous.drain(..) { + // SAFETY: reinstalls the disposition captured by `install_handlers`. + let _ = unsafe { sigaction(signal, &previous) }; + } + } + } + + /// A background job that changes or flushes its controlling terminal is + /// stopped with SIGTTOU, so it leaves the terminal to the foreground job. + /// A terminal that is not the controlling one has no foreground job. + fn in_background(fd: RawFd) -> bool { + // SAFETY: both calls only read process state and are async-signal-safe. + let (foreground, own) = unsafe { (libc::tcgetpgrp(fd), libc::getpgrp()) }; + foreground != -1 && foreground != own + } + + /// Switch `fd` to raw input with signals kept, saving its current state. + pub(super) fn block(fd: RawFd) -> bool { + if in_background(fd) { + return false; + } + let mut termios = MaybeUninit::::uninit(); + // SAFETY: `tcgetattr` initializes `termios` when it succeeds, and the + // caller owns `SAVED` through `ACTIVE`. + unsafe { + if libc::tcgetattr(fd, termios.as_mut_ptr()) != 0 { + return false; + } + let original = termios.assume_init(); + let line_mode = libc::ECHO | libc::ICANON; + if original.c_lflag & line_mode != line_mode { + return false; + } + let mut raw = original; + raw.c_lflag &= !(libc::ECHO | libc::ICANON | libc::IEXTEN); + raw.c_iflag &= !(libc::IXON | libc::ICRNL | libc::BRKINT | libc::ISTRIP); + (*SAVED.0.get()).write(original); + libc::tcsetattr(fd, libc::TCSANOW, &raw const raw) == 0 + } + } + + /// Discard pending input and restore the saved state. Only async-signal-safe + /// calls, because the signal handlers use this too. + pub(super) fn restore(fd: RawFd) { + // The shell restored its own modes when it moved this job to the background. + if in_background(fd) { + return; + } + // SAFETY: callers only restore after `block` saved the state. + unsafe { + libc::tcflush(fd, libc::TCIFLUSH); + libc::tcsetattr(fd, libc::TCSANOW, (*SAVED.0.get()).as_ptr()); + } + } + + pub(super) fn restore_if_blocked() { + if BLOCKED.load(Ordering::SeqCst) { + restore(libc::STDIN_FILENO); + } + } + + fn install_handlers() -> Vec<(Signal, SigAction)> { + let action = SigAction::new( + SigHandler::Handler(restore_and_reraise), + SaFlags::empty(), + SigSet::empty(), + ); + // ISIG stays on, so Ctrl+\ sends SIGQUIT as well as Ctrl+C sending SIGINT. + [Signal::SIGINT, Signal::SIGQUIT, Signal::SIGTERM] + .into_iter() + .filter_map(|signal| { + // SAFETY: the handler only calls async-signal-safe functions. + let previous = unsafe { sigaction(signal, &action) }.ok()?; + if matches!(previous.handler(), SigHandler::SigDfl) { + return Some((signal, previous)); + } + // Keep a disposition someone else chose, such as an ignored SIGINT. + // SAFETY: reinstalls the disposition that was just replaced. + let _ = unsafe { sigaction(signal, &previous) }; + None + }) + .collect() + } + + extern "C" fn restore_and_reraise(signal: libc::c_int) { + restore(libc::STDIN_FILENO); + // SAFETY: `signal` and `raise` are async-signal-safe. The signal stays + // blocked until this handler returns, then the default action ends the + // process with the usual status. + unsafe { + libc::signal(signal, libc::SIG_DFL); + libc::raise(signal); + } + } +} + +#[cfg(windows)] +mod sys { + use std::{ + ffi::c_void, + sync::atomic::{AtomicU32, Ordering}, + }; + + type Handle = *mut c_void; + + // `(DWORD)-10` + const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; + const ENABLE_LINE_INPUT: u32 = 0x0002; + const ENABLE_ECHO_INPUT: u32 = 0x0004; + const LINE_MODE: u32 = ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT; + + /// The console mode before blocking, or 0 while input is not blocked. + static SAVED: AtomicU32 = AtomicU32::new(0); + + unsafe extern "system" { + fn GetStdHandle(std_handle: u32) -> Handle; + fn GetConsoleMode(handle: Handle, mode: *mut u32) -> i32; + fn SetConsoleMode(handle: Handle, mode: u32) -> i32; + fn FlushConsoleInputBuffer(handle: Handle) -> i32; + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + + pub(super) struct Guard(()); + + impl Guard { + /// Keeps `ENABLE_PROCESSED_INPUT`, so Ctrl+C still ends the process. + pub(super) fn new() -> Option { + let mut mode = 0; + // SAFETY: plain Win32 calls on this process's stdin handle. Getting + // the mode fails for pipes and redirected files. + unsafe { + if GetConsoleMode(stdin(), &raw mut mode) == 0 || mode & LINE_MODE != LINE_MODE { + return None; + } + SAVED.store(mode, Ordering::SeqCst); + if SetConsoleMode(stdin(), mode & !LINE_MODE) == 0 { + SAVED.store(0, Ordering::SeqCst); + return None; + } + SetConsoleCtrlHandler(Some(restore_on_ctrl), 1); + } + Some(Self(())) + } + + #[expect(clippy::unused_self, reason = "matches the Unix guard")] + pub(super) fn release(&self) { + restore_if_blocked(); + // SAFETY: removes the routine `new` registered. + unsafe { SetConsoleCtrlHandler(Some(restore_on_ctrl), 0) }; + } + } + + fn stdin() -> Handle { + // SAFETY: returns this process's stdin handle or an invalid handle. + unsafe { GetStdHandle(STD_INPUT_HANDLE) } + } + + pub(super) fn restore_if_blocked() { + let mode = SAVED.swap(0, Ordering::SeqCst); + if mode != 0 { + // SAFETY: plain Win32 calls on this process's stdin handle. + unsafe { + FlushConsoleInputBuffer(stdin()); + SetConsoleMode(stdin(), mode); + } + } + } + + /// Returns FALSE so the next handler still ends the process. + unsafe extern "system" fn restore_on_ctrl(_ctrl_type: u32) -> i32 { + restore_if_blocked(); + 0 + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::fd::{AsFd, AsRawFd}; + + use nix::{ + libc, + poll::{PollFd, PollFlags, PollTimeout, poll}, + pty::openpty, + sys::termios::{InputFlags, LocalFlags, tcgetattr}, + unistd::write, + }; + + use super::sys; + + #[test] + fn raw_input_keeps_signals_and_restores_the_terminal() { + let pty = openpty(None, None).unwrap(); + let fd = pty.slave.as_raw_fd(); + let pending = |timeout: PollTimeout| { + let mut fds = [PollFd::new(pty.slave.as_fd(), PollFlags::POLLIN)]; + poll(&mut fds, timeout).unwrap() > 0 + }; + let original = tcgetattr(&pty.slave).unwrap(); + + let not_a_terminal = std::fs::File::open("/dev/null").unwrap(); + assert!(!sys::block(not_a_terminal.as_raw_fd())); + + assert!(sys::block(fd)); + // A terminal already in raw mode is left alone. + assert!(!sys::block(fd)); + let raw = tcgetattr(&pty.slave).unwrap(); + assert!( + !raw.local_flags.intersects(LocalFlags::ECHO | LocalFlags::ICANON | LocalFlags::IEXTEN) + ); + assert!(raw.local_flags.contains(LocalFlags::ISIG)); + assert!(!raw.input_flags.contains(InputFlags::IXON)); + + write(&pty.master, b"ls\r").unwrap(); + // Input reaches the slave asynchronously, so wait before restoring. + assert!(pending(PollTimeout::from(1000u16))); + + sys::restore(fd); + let restored = tcgetattr(&pty.slave).unwrap(); + // BSD kernels set PENDIN whenever canonical mode returns. The queue is + // already empty, so it only clears itself on the next read. + assert_eq!(restored.local_flags - LocalFlags::PENDIN, original.local_flags); + assert_eq!(restored.input_flags, original.input_flags); + assert!(!pending(PollTimeout::ZERO)); + } + + #[test] + fn background_job_leaves_the_terminal_alone() { + let pty = openpty(None, None).unwrap(); + let slave = pty.slave.as_raw_fd(); + + // Only a new session can make the pty its controlling terminal, so fork. + // SAFETY: the children neither allocate nor take locks before `_exit`. + let status = unsafe { + let session = libc::fork(); + assert!(session >= 0, "fork failed"); + if session == 0 { + // A new session on the pty as stdin, whose group is the foreground job. + if libc::login_tty(slave) == -1 { + libc::_exit(3); + } + let fd = libc::STDIN_FILENO; + let job = libc::fork(); + if job == 0 { + // Another group in the session is a background job. + libc::setpgid(0, 0); + let blocked = sys::block(fd); + sys::restore(fd); + libc::_exit(i32::from(blocked)); + } + let mut status = 0; + libc::waitpid(job, &raw mut status, libc::WUNTRACED); + if libc::WIFSTOPPED(status) { + libc::kill(job, libc::SIGKILL); + libc::_exit(2); + } + libc::_exit(if libc::WIFEXITED(status) { libc::WEXITSTATUS(status) } else { 4 }); + } + let mut status = 0; + libc::waitpid(session, &raw mut status, 0); + status + }; + + // A session that died from a signal also reports exit status 0. + assert!(libc::WIFEXITED(status), "session ended abnormally: {status:#x}"); + assert_eq!( + libc::WEXITSTATUS(status), + 0, + "1: blocked input, 2: stopped by SIGTTOU, 3: no session, 4: job ended abnormally" + ); + } + + const SIGNAL_CHILD: &str = "VP_SHARED_INPUT_SIGNAL_CHILD"; + + /// Ends a process holding the input guard with each handled signal and + /// checks that the terminal is restored and the signal still ends it. A + /// separate process runs this same test, because the signal ends it and + /// forking cannot allocate safely in the multi-threaded test runner. + #[test] + fn signals_restore_the_terminal_and_still_end_the_process() { + use std::{os::unix::process::ExitStatusExt, process::Command}; + + if let Ok(signal) = std::env::var(SIGNAL_CHILD) { + let _guard = sys::Guard::new().expect("stdin is a terminal in line mode"); + let no_core = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + // SAFETY: SIGQUIT should not leave a core dump, and the signal is + // expected to end this process. + unsafe { + libc::setrlimit(libc::RLIMIT_CORE, &raw const no_core); + libc::raise(signal.parse().unwrap()); + } + unreachable!("signal {signal} did not end the process"); + } + + for signal in [libc::SIGINT, libc::SIGQUIT, libc::SIGTERM] { + let pty = openpty(None, None).unwrap(); + let original = tcgetattr(&pty.slave).unwrap(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "progress::input::tests::signals_restore_the_terminal_and_still_end_the_process"]) + .env(SIGNAL_CHILD, signal.to_string()) + .stdin(pty.slave.try_clone().unwrap()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + + assert_eq!(status.signal(), Some(signal), "{status}"); + let restored = tcgetattr(&pty.slave).unwrap(); + assert_eq!(restored.local_flags - LocalFlags::PENDIN, original.local_flags); + assert_eq!(restored.input_flags, original.input_flags); + } + } +} diff --git a/crates/vp_shared/src/progress/style.rs b/crates/vp_shared/src/progress/style.rs index 79aaedc23d..f00eb82770 100644 --- a/crates/vp_shared/src/progress/style.rs +++ b/crates/vp_shared/src/progress/style.rs @@ -9,6 +9,7 @@ use vt_str::{Str, format}; #[derive(Clone, Copy)] pub(super) enum Style { Spinner, + Counter, Download, } @@ -32,6 +33,11 @@ impl Style { let available = usize::from(width()).saturating_sub(1); let row = match self { Self::Spinner => spinner_row(state, &message, available), + Self::Counter => { + let count = + format!("{} ({}/{})", message, state.pos(), state.len().unwrap_or(0)); + spinner_row(state, &count, available) + } Self::Download => download_row(state, &message, available), }; write!(output, "{}", truncate_str(&row, available, "")).unwrap(); @@ -109,7 +115,7 @@ mod tests { #[test] fn redraws_stay_on_one_row_and_preserve_earlier_output() { - for style in [Style::Download, Style::Spinner] { + for style in [Style::Download, Style::Spinner, Style::Counter] { for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { for message in [ "Downloading node v26.8.0...", @@ -195,7 +201,7 @@ mod tests { #[test] fn layout_reads_the_current_width_on_each_draw() { - for style in [Style::Download, Style::Spinner] { + for style in [Style::Download, Style::Spinner, Style::Counter] { let width = Arc::new(AtomicU16::new(138)); let term = InMemoryTerm::new(5, 138); let progress = ProgressBar::with_draw_target(