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:
2026-09-22 10:43:58 +02:00
parent 84405a6762
commit adde0ee977
3 changed files with 206 additions and 332 deletions
+141 -68
View File
@@ -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();