deb,ui: tear the live view down through the interrupt core
The ephemeral guard registers its chroot removal as an interrupt cleanup hook and, once interrupted, stands down from its own teardown so the two cannot race umount/rm; bootstrap bails out of tarball extraction and the lockfile wait, keeping the hook registered on the bootstrap error path so the watchdog can remove the partial tree. The live view registers an interrupt reporter that suspends the widget and returns the log-file hint, silences the tty rendering of the ^C keypress (the echoed "^C" can wrap near the right edge and shift the teardown erase by a row, leaving the first widget line on screen) and kills the shared draw target so late log records cannot repaint the cleared bars. Failure summaries stay quiet when interrupted: the captured errors are just the killed children's death throes, and the dose-builddebcheck diagnosis is skipped for a dependency failure the user interrupted themselves.
This commit is contained in:
+141
-68
@@ -11,14 +11,14 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
|
||||
use crossterm::style::Stylize;
|
||||
use directories::ProjectDirs;
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::context::{LineSink, Stream};
|
||||
@@ -84,6 +84,17 @@ impl DebUi {
|
||||
let enabled = is_stdout_tty();
|
||||
|
||||
let top = if enabled {
|
||||
// The tty renders a ^C keypress as the two visible characters
|
||||
// "^C"; on a terminal where the cursor sits near the right edge
|
||||
// that wraps to the next row, and the erase at teardown —
|
||||
// anchored to where indicatif last drew — ends up one row off,
|
||||
// leaving the first widget line on screen. Rendering control
|
||||
// characters raw instead (ECHOCTL off) makes the echo an
|
||||
// invisible byte that moves nothing; ECHO itself stays on, so
|
||||
// terminals showing a padlock while input is hidden are not
|
||||
// triggered. Restored by `suspend_shared`.
|
||||
suppress_control_char_echo();
|
||||
multi.set_draw_target(ProgressDrawTarget::stderr());
|
||||
let pb = multi.add(ProgressBar::new(0));
|
||||
pb.enable_steady_tick(Duration::from_millis(80));
|
||||
pb.set_style(spinner_style());
|
||||
@@ -110,7 +121,7 @@ impl DebUi {
|
||||
bar_total: 0,
|
||||
}),
|
||||
tee: Mutex::new(None),
|
||||
log_path: Mutex::new(log_path.clone()),
|
||||
log_path: Mutex::new(log_path),
|
||||
timestamp,
|
||||
enabled,
|
||||
suspended: AtomicBool::new(false),
|
||||
@@ -118,8 +129,13 @@ impl DebUi {
|
||||
}),
|
||||
};
|
||||
|
||||
// Ctrl+C: the signal wiring lives in the CLI; this view only
|
||||
// registers with `crate::interrupt` how to clear itself and where
|
||||
// the full log lives. The log path is read from the shared state at
|
||||
// interrupt time, so the rename in `open_log` stays visible to the
|
||||
// reporter.
|
||||
if ui.shared.enabled {
|
||||
install_sigint_hook(&log_path);
|
||||
set_interrupt_reporter(ui.shared.clone());
|
||||
}
|
||||
|
||||
ui
|
||||
@@ -128,7 +144,7 @@ impl DebUi {
|
||||
/// Identify the binary package being built; names the log file and the
|
||||
/// status bar
|
||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building {package} ({version}) for {series}/{arch}"
|
||||
));
|
||||
@@ -149,7 +165,6 @@ impl DebUi {
|
||||
};
|
||||
let _ = fs::rename(&old_path, &log_path);
|
||||
*self.shared.log_path.lock().unwrap() = log_path.clone();
|
||||
update_sigint_log_path(&log_path);
|
||||
|
||||
if let Some(dir) = log_path.parent() {
|
||||
let _ = fs::create_dir_all(dir);
|
||||
@@ -182,7 +197,7 @@ impl DebUi {
|
||||
st.bar_total = 0;
|
||||
st.last_draw = Instant::now();
|
||||
}
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_style(spinner_style());
|
||||
self.shared.top.set_message(label.to_string());
|
||||
drop_pane(&self.shared);
|
||||
@@ -197,19 +212,8 @@ impl DebUi {
|
||||
/// Release the widget from the terminal (e.g. before printing
|
||||
/// passthrough diagnostics or letting child cleanup commands write to
|
||||
/// the terminal); idempotent
|
||||
///
|
||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||
/// right after the clear, leaving stale copies of the widget on screen.
|
||||
fn suspend(&self) {
|
||||
if !self.shared.enabled {
|
||||
return;
|
||||
}
|
||||
if self.shared.suspended.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
self.shared.top.disable_steady_tick();
|
||||
drop_pane(&self.shared);
|
||||
self.shared.top.finish_and_clear();
|
||||
suspend_shared(&self.shared);
|
||||
}
|
||||
|
||||
/// Success outcome body: clear the widget and print the artifacts,
|
||||
@@ -226,9 +230,17 @@ impl DebUi {
|
||||
|
||||
/// Failure outcome body: clear the widget and print a summary (recent
|
||||
/// captured errors and the path to the full log)
|
||||
///
|
||||
/// On a Ctrl+C the interrupt watchdog owns the reporting — its captured
|
||||
/// errors are just the killed children's death throes, and the watchdog
|
||||
/// already points at the full log — so this prints nothing.
|
||||
fn failure_summary(&self) {
|
||||
self.suspend();
|
||||
|
||||
if crate::interrupt::interrupted() {
|
||||
return;
|
||||
}
|
||||
|
||||
let st = self.shared.state.lock().unwrap();
|
||||
if self.shared.enabled && !st.errors.is_empty() {
|
||||
eprintln!("Last captured errors:");
|
||||
@@ -256,7 +268,7 @@ impl DebUi {
|
||||
/// widget.
|
||||
impl crate::report::BuildView for DebUi {
|
||||
fn target(&self, target: BuildTarget<'_>) {
|
||||
if self.shared.enabled {
|
||||
if self.active() {
|
||||
self.shared.top.set_prefix(target.display.clone());
|
||||
}
|
||||
if target.tee_log {
|
||||
@@ -529,61 +541,90 @@ fn default_log_path(timestamp: &str) -> PathBuf {
|
||||
dir.join(format!("pkh-{timestamp}.log"))
|
||||
}
|
||||
|
||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
/// Register the interrupt reporter: release the widget from the terminal
|
||||
/// and return the log-file hint to print below the notice
|
||||
///
|
||||
/// The CLI watchdog runs this as the first step of the interrupt shutdown,
|
||||
/// so the widget disappears the moment Ctrl+C is hit. Registered only for
|
||||
/// enabled views: in `--verbose` mode or with piped output there is no
|
||||
/// widget and nothing to report.
|
||||
fn set_interrupt_reporter(shared: Arc<Shared>) {
|
||||
crate::interrupt::set_reporter(Box::new(move || interrupt_report(&shared)));
|
||||
}
|
||||
|
||||
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
|
||||
/// the log file before exiting
|
||||
fn install_sigint_hook(log_path: &Path) {
|
||||
update_sigint_log_path(log_path);
|
||||
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
|
||||
/// [`suspend_shared`] plus the log-file hint, in teardown order
|
||||
///
|
||||
/// Testable end to end: the reporter closure is private to the interrupt
|
||||
/// watchdog, but the drawing behavior is not.
|
||||
fn interrupt_report(shared: &Shared) -> Option<String> {
|
||||
suspend_shared(shared);
|
||||
let log_path = shared.log_path.lock().unwrap().clone();
|
||||
if log_path.exists() {
|
||||
Some(format!("Full log: {}", log_path.display()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// [`DebUi::suspend`] body, shared with the interrupt reporter
|
||||
///
|
||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||
/// right after the clear, leaving stale copies of the widget on screen.
|
||||
/// The tty echo suppressed at view start is restored here, before the
|
||||
/// erases: an echo from a keypress landing mid-teardown could not shift the
|
||||
/// cursor anymore. The draw target is finally killed: the interrupted flow
|
||||
/// keeps emitting log records while the cleanup hooks run, and every one of
|
||||
/// them would otherwise make the log bridge repaint the cleared bars from
|
||||
/// their cached frames.
|
||||
fn suspend_shared(shared: &Shared) {
|
||||
if !shared.enabled {
|
||||
return;
|
||||
}
|
||||
if shared.suspended.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
restore_tty_echo();
|
||||
shared.top.disable_steady_tick();
|
||||
drop_pane(shared);
|
||||
shared.top.finish_and_clear();
|
||||
shared.multi.set_draw_target(ProgressDrawTarget::hidden());
|
||||
}
|
||||
|
||||
// SAFETY: installing a signal handler; the handler itself is best-effort
|
||||
// (it performs non async-signal-safe operations, acceptable here because
|
||||
// it immediately exits afterwards).
|
||||
/// Termios snapshot taken when the echo is suppressed; `Some` only while the
|
||||
/// live view is on screen
|
||||
static SAVED_TTY_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);
|
||||
|
||||
/// Stop the tty from rendering control-character input (^C would show as a
|
||||
/// visible two-character "^C") while the live view is up: rendered echoes
|
||||
/// move the cursor without indicatif knowing, and the teardown erase ends
|
||||
/// up aimed past the widget
|
||||
///
|
||||
/// ECHO itself stays on — turning it off would trigger terminals'
|
||||
/// hidden-input padlock — so the only visible difference is that a ^C
|
||||
/// keypress echoes as a raw, cursor-invisible control byte. No-op without a
|
||||
/// tty on stdin.
|
||||
fn suppress_control_char_echo() {
|
||||
// SAFETY: tcgetattr on stdin with a valid, zero-initialized buffer
|
||||
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: reading the current attributes of stdin
|
||||
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 {
|
||||
return;
|
||||
}
|
||||
*SAVED_TTY_TERMIOS.lock().unwrap() = Some(termios);
|
||||
termios.c_lflag &= !libc::ECHOCTL;
|
||||
// SAFETY: applying the modified attributes to stdin
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
|
||||
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the sigint handler at the current log file location
|
||||
fn update_sigint_log_path(log_path: &Path) {
|
||||
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
|
||||
}
|
||||
|
||||
extern "C" fn on_sigint(_sig: libc::c_int) {
|
||||
// Best-effort cleanup: clear leftover widget lines and show the cursor
|
||||
let _ = execute!(
|
||||
std::io::stdout(),
|
||||
Clear(ClearType::FromCursorDown),
|
||||
cursor::Show
|
||||
);
|
||||
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
|
||||
&& let Some(path) = guard.as_ref()
|
||||
{
|
||||
eprintln!("\nInterrupted — full log: {}", path.display());
|
||||
}
|
||||
// Run the registered cleanup hooks (currently: unmount and remove the
|
||||
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
|
||||
// then exit with the conventional 130 status.
|
||||
//
|
||||
// Like the terminal restoration above, this is NOT strictly
|
||||
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
|
||||
// That is a deliberate tradeoff, no worse than the rest of this handler:
|
||||
// exiting immediately would skip all destructors and leak the chroot
|
||||
// together with its bind-mounted /proc and overlay mounts. The hooks are
|
||||
// self-contained (they only touch stored paths and spawn umount/rm
|
||||
// directly), so they cannot deadlock on a lock the interrupted thread
|
||||
// might have held; the hook registry itself is only ever taken with
|
||||
// try_lock plus a bounded retry for the same reason. Note that SIGINT
|
||||
// stays blocked for the duration of the handler, so a second Ctrl-C will
|
||||
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
|
||||
crate::deb::ephemeral::run_cleanup_hooks();
|
||||
// SAFETY: raw exit bypassing destructors, intended in a signal handler
|
||||
unsafe {
|
||||
libc::_exit(130);
|
||||
/// Restore the tty attributes saved by [`suppress_tty_echo`]
|
||||
fn restore_tty_echo() {
|
||||
if let Some(termios) = SAVED_TTY_TERMIOS.lock().unwrap().take() {
|
||||
// SAFETY: re-applying the snapshot taken at view start
|
||||
unsafe {
|
||||
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +743,38 @@ mod tests {
|
||||
assert!(ensure_pane(&shared).is_none());
|
||||
}
|
||||
|
||||
/// The interrupt report kills the shared draw target: during the cleanup
|
||||
/// hooks the interrupted flow keeps emitting log records, and every one
|
||||
/// of them would otherwise make the log bridge repaint the bars from
|
||||
/// their cached frames — resurrecting the widget that was just cleared.
|
||||
#[test]
|
||||
fn interrupt_report_disables_further_redraws() {
|
||||
let multi = MultiProgress::new();
|
||||
let shared = Shared {
|
||||
multi: multi.clone(),
|
||||
top: multi.add(ProgressBar::new(0)),
|
||||
pane: Mutex::new(None),
|
||||
state: Mutex::new(Pipeline {
|
||||
classifier: Box::new(GenericClassifier::new()),
|
||||
lines: VecDeque::new(),
|
||||
errors: Vec::new(),
|
||||
last_draw: Instant::now(),
|
||||
bar_total: 0,
|
||||
}),
|
||||
tee: Mutex::new(None),
|
||||
log_path: Mutex::new(std::env::temp_dir().join("pkh-interrupt-report-test.log")),
|
||||
timestamp: String::new(),
|
||||
enabled: true,
|
||||
suspended: AtomicBool::new(false),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
interrupt_report(&shared);
|
||||
|
||||
assert!(shared.suspended.load(Ordering::SeqCst));
|
||||
assert!(shared.multi.is_hidden());
|
||||
}
|
||||
|
||||
/// Best-effort ANSI escape stripper, enough for the assertions above
|
||||
fn strip_ansi(line: &str) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
Reference in New Issue
Block a user