diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 571b557..f69c178 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -1,139 +1,24 @@ use crate::context::{self, Context, ContextConfig}; use crate::deb::{Phase, enter_phase}; +use crate::interrupt::CleanupHookGuard; use crate::report::BuildView; use directories::ProjectDirs; -use std::any::Any; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; use tar::Archive; use xz2::read::XzDecoder; -// --------------------------------------------------------------------------- -// Process-global cleanup hooks -// -// On Ctrl-C, the SIGINT handler in `ui::deb` restores the terminal and then -// `libc::_exit(130)`s, skipping all destructors — including -// [`EphemeralContextGuard::drop`] — which leaks the freshly bootstrapped -// chroot under /tmp together with its bind-mounted /proc and any overlayfs -// mounts. To make interrupt-time cleanup possible anyway, resources register -// a self-contained cleanup hook here; the SIGINT handler drains and runs the -// registry right before exiting. -// --------------------------------------------------------------------------- - -/// A boxed, send-safe cleanup hook body -type CleanupFn = Box; - -/// A pending cleanup hook together with its registry id -struct CleanupHook { - id: u64, - f: CleanupFn, -} - -/// Registry of cleanup hooks waiting to run at interrupt time -static CLEANUP_HOOKS: Mutex> = Mutex::new(Vec::new()); - -/// Source of the registry ids used to deregister a specific hook -static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1); - -/// Register a hook to be run by [`run_cleanup_hooks`] (i.e. when the process -/// is interrupted), returning a guard whose drop deregisters the hook again -fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard { - let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed); - CLEANUP_HOOKS.lock().unwrap().push(CleanupHook { id, f }); - CleanupHookGuard(id) -} - -/// RAII handle to a registered cleanup hook: dropping it (or an explicit -/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so -/// the interrupt path can no longer run it -struct CleanupHookGuard(u64); - -impl CleanupHookGuard { - /// Registry id of the hook (used to filter the registry in tests) - #[cfg(test)] - fn id(&self) -> u64 { - self.0 - } - - /// Remove the hook from the registry; returns whether it was still pending - fn deregister(&mut self) -> bool { - deregister_cleanup_hook(self.0) - } -} - -impl Drop for CleanupHookGuard { - fn drop(&mut self) { - deregister_cleanup_hook(self.0); - } -} - -/// Remove a hook from the registry; returns whether it was still pending -fn deregister_cleanup_hook(id: u64) -> bool { - let mut hooks = CLEANUP_HOOKS.lock().unwrap(); - let len_before = hooks.len(); - hooks.retain(|hook| hook.id != id); - hooks.len() != len_before -} - -/// Drain and run every registered cleanup hook exactly once -/// -/// Called from the SIGINT handler right before the process exits. Draining -/// uses `try_lock` with a bounded retry instead of a blocking lock: if the -/// signal interrupted the main thread while it held [`CLEANUP_HOOKS`] (inside -/// register/deregister), blocking on the same non-recursive mutex from the -/// handler would deadlock the process. Timing out therefore skips cleanup -/// (leaking, as before this registry existed) rather than hanging. -pub(crate) fn run_cleanup_hooks() { - run_drained_hooks(drain_cleanup_hooks()); -} - -/// Take every pending hook out of the registry, waiting at most ~1s for the -/// registry lock (see [`run_cleanup_hooks`] for why this must not block forever) -fn drain_cleanup_hooks() -> Vec { - const RETRIES: usize = 200; - const RETRY_DELAY: Duration = Duration::from_millis(5); - - for _ in 0..RETRIES { - if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() { - return std::mem::take(&mut *hooks); - } - std::thread::sleep(RETRY_DELAY); - } - log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup"); - Vec::new() -} - -/// Run drained hooks one by one, isolating panics so that one failing hook -/// cannot skip the remaining ones -fn run_drained_hooks(hooks: Vec) { - for CleanupHook { id, f } in hooks { - // Hooks are arbitrary user code; assert unwind safety so they can be - // run inside a catching context - if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { - log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic)); - } - } -} - -/// Best-effort message extraction from a panic payload -fn panic_message(panic: &(dyn Any + Send)) -> String { - if let Some(s) = panic.downcast_ref::<&str>() { - (*s).to_string() - } else if let Some(s) = panic.downcast_ref::() { - s.clone() - } else { - "non-string panic payload".to_string() - } -} - // --------------------------------------------------------------------------- // Interrupt-time chroot cleanup +// +// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in +// [`EphemeralContextGuard::new_with_context`] right before exiting — the +// interrupt sequence skips all destructors, which would otherwise leak the +// freshly bootstrapped chroot under /tmp together with its bind-mounted +// /proc and any overlayfs mounts. // --------------------------------------------------------------------------- /// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side @@ -143,9 +28,9 @@ fn panic_message(panic: &(dyn Any + Send)) -> String { /// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go /// through the context manager, the ephemeral context's driver (whose /// `cleanup()` unmounts the tracked overlays) or the base context's command -/// builder: the signal may arrive while the interrupted thread holds any of -/// those mutexes, and re-locking them from the signal handler would deadlock. -/// Instead it only reads /proc/mounts and spawns umount/rm directly. +/// builder: interrupt-time hooks must be self-contained, and those +/// machineries may be mid-mutation on the interrupted thread. Instead it +/// only reads /proc/mounts and spawns umount/rm directly. /// /// It also differs from `drop` in that it removes the chroot regardless of /// the build result: the build was aborted, and leaving a still-mounted @@ -334,15 +219,14 @@ impl EphemeralContextGuard { // Register the interrupt-time cleanup hook before any heavy work: if // the user hits Ctrl-C during bootstrap or the build itself, the - // SIGINT handler unmounts and removes the chroot through this hook - // (see `sigint_cleanup_chroot`). This only works for a local base - // context: the hook must be self-contained (stored path + direct - // umount/rm subprocesses) and cannot go through `base_ctx`, whose - // driver mutex may be held by the interrupted thread. For remote or - // nested bases the chroot lives elsewhere, and leftovers stay - // handled by `pkh prune` as before. + // interrupt watchdog unmounts and removes the chroot through this + // hook (see `sigint_cleanup_chroot`). This only works for a local + // base context: the hook must be self-contained (stored path + + // direct umount/rm subprocesses) and cannot go through `base_ctx`. + // For remote or nested bases the chroot lives elsewhere, and + // leftovers stay handled by `pkh prune` as before. let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) { - Some(register_cleanup_hook(Box::new({ + Some(crate::interrupt::register_cleanup_hook(Box::new({ let chroot_path = chroot_path.clone(); move || sigint_cleanup_chroot(&chroot_path) }))) @@ -359,10 +243,18 @@ impl EphemeralContextGuard { Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view) .await { - // The guard (and its Drop) never materializes on this path, so - // stop tracking the chroot for interrupt cleanup; as before, a - // failed bootstrap leaves its partial directory in place. - drop(cleanup_hook); + // On a Ctrl+C the interrupt watchdog owns the tree: keep the + // hook registered (forgetting the guard) so it removes the + // partial directory, instead of the historical behavior of + // leaving it in place. Without an interrupt this is a plain + // bootstrap failure and the partial directory stays, as before. + if crate::interrupt::interrupted() + && let Some(hook) = cleanup_hook + { + std::mem::forget(hook); + } else { + drop(cleanup_hook); + } return Err(e); } @@ -437,6 +329,11 @@ impl EphemeralContextGuard { let poll_interval = 5; // Check every 5 seconds while ctx.exists(&lockfile_path)? { + // Stop waiting on a Ctrl+C: the interrupt watchdog removes the + // (yet empty) chroot and exits without waiting for the poll + if crate::interrupt::interrupted() { + return Err("Interrupted while waiting for the chroot tarball".into()); + } if wait_time >= timeout { log::warn!( "Lockfile {} exists and has been present for more than {} seconds. \ @@ -585,6 +482,11 @@ impl EphemeralContextGuard { // too expensive for multi-hundred-MB chroot tarballs) let mut count = 0usize; for entry in archive.entries()? { + // Bail on a Ctrl+C before the interrupt watchdog's rm -rf races + // this loop writing entries into the tree being removed + if crate::interrupt::interrupted() { + return Err("Interrupted while extracting the chroot".into()); + } let mut entry = entry?; entry.unpack_in(chroot_path)?; count += 1; @@ -702,6 +604,20 @@ impl EphemeralContextGuard { impl Drop for EphemeralContextGuard { fn drop(&mut self) { + // On Ctrl+C the interrupt watchdog owns the chroot teardown through + // the registered hook: duplicating it here would race the hook's + // umount/rm (mounts vanish under each other). Dropping this guard + // would normally deregister the hook, so while the watchdog runs it + // must be leaked instead to keep it registered (if it was already + // drained, forgetting is a harmless no-op). + if crate::interrupt::interrupted() { + context::manager().set_current_ephemeral(self.previous_context.clone()); + if let Some(hook) = self.cleanup_hook.take() { + std::mem::forget(hook); + } + return; + } + // Deregister the interrupt-time cleanup hook first: the normal // cleanup below takes care of the chroot, so the hook must not fire // afterwards. (If a SIGINT arrived mid-drop and the hook is already @@ -799,132 +715,8 @@ impl Drop for EphemeralContextGuard { } #[cfg(test)] -mod cleanup_registry_tests { +mod chroot_cleanup_tests { use super::*; - use std::sync::atomic::AtomicUsize; - - /// Serializes these tests: they drain the process-global registry, and - /// unrelated tests (e.g. live end-to-end builds) may hold registrations - /// concurrently that must be neither run nor lost. Poison-proof: a test - /// failing while holding the lock must not cascade into the others. - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn test_lock() -> std::sync::MutexGuard<'static, ()> { - TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - /// Drain the registry and take out only the hooks with the given ids, - /// putting everything else back so unrelated registrations (e.g. hooks of - /// live end-to-end builds running concurrently) stay pending - fn take_hooks(ids: &[u64]) -> Vec { - let drained = drain_cleanup_hooks(); - let mut mine = Vec::new(); - let mut others = Vec::new(); - for hook in drained { - if ids.contains(&hook.id) { - mine.push(hook); - } else { - others.push(hook); - } - } - CLEANUP_HOOKS.lock().unwrap().extend(others); - mine - } - - /// Register a hook that counts its invocations - fn counting_hook() -> (CleanupHookGuard, Arc) { - let counter = Arc::new(AtomicUsize::new(0)); - let seen = counter.clone(); - let guard = register_cleanup_hook(Box::new(move || { - seen.fetch_add(1, Ordering::SeqCst); - })); - (guard, counter) - } - - /// Hooks run in registration order, and draining means each hook runs - /// exactly once even across repeated cleanup passes. - #[test] - fn hooks_run_once_in_registration_order() { - let _serial = test_lock(); - - let log = Arc::new(Mutex::new(Vec::new())); - let mut guards = Vec::new(); - let mut ids = Vec::new(); - for name in ["hook-a", "hook-b", "hook-c"] { - let log = log.clone(); - // The returned guard must stay alive: dropping it deregisters - let guard = register_cleanup_hook(Box::new(move || log.lock().unwrap().push(name))); - ids.push(guard.id()); - guards.push(guard); - } - - // Only our own hooks are extracted; they run in registration order - let mine = take_hooks(&ids); - assert_eq!(mine.len(), ids.len()); - run_drained_hooks(mine); - assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]); - - // Draining removed them: a second pass runs nothing again - assert!(take_hooks(&ids).is_empty()); - assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]); - - drop(guards); - } - - /// A panicking hook is contained by the runner: it neither aborts the - /// process nor skips the hooks registered around it. - #[test] - fn panicking_hook_does_not_skip_the_others() { - let _serial = test_lock(); - // The hook below panics on purpose: do not record it as a test - // failure in the end-of-run matrix - let _quiet = crate::test_support::suppress_failure_recording(); - - let (before, ran_before) = counting_hook(); - let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded"))); - let (after, ran_after) = counting_hook(); - - let ids = [before.id(), boom.id(), after.id()]; - run_drained_hooks(take_hooks(&ids)); - - assert_eq!(ran_before.load(Ordering::SeqCst), 1); - assert_eq!(ran_after.load(Ordering::SeqCst), 1); - } - - /// Explicit deregistration removes the hook: it is no longer drained and - /// never runs; a second deregistration reports it as already gone. - #[test] - fn deregistered_hook_never_runs() { - let _serial = test_lock(); - - let (mut guard, ran) = counting_hook(); - - assert!(guard.deregister()); - assert!(!guard.deregister()); - - assert!(take_hooks(&[guard.id()]).is_empty()); - assert_eq!(ran.load(Ordering::SeqCst), 0); - } - - /// Dropping the registration guard deregisters the hook implicitly. - #[test] - fn dropping_the_guard_deregisters_the_hook() { - let _serial = test_lock(); - - let id; - let ran; - { - let (guard, counter) = counting_hook(); - id = guard.id(); - ran = counter; - drop(guard); - } - - assert!(take_hooks(&[id]).is_empty()); - assert_eq!(ran.load(Ordering::SeqCst), 0); - } /// /proc/mounts path fields use octal escapes for whitespace and /// backslashes; anything else must be kept verbatim. diff --git a/src/deb/local.rs b/src/deb/local.rs index ff60971..0adbb0b 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -725,8 +725,17 @@ fn install_build_dependencies( let status = cap(&mut cmd, sink).status()?; if !status.success() { view.suspend(); - if let Err(e) = - dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) + // Diagnosing a dependency failure the user interrupted themselves + // is wasted work + if !crate::interrupt::interrupted() + && let Err(e) = dose3_explain_dependencies( + package, + version, + arch, + build_root, + cross, + ctx.clone(), + ) { log::debug!("dose-builddebcheck diagnosis failed: {e}"); } diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 69c04e9..c5a06b5 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -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> = 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) { + 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 { + 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> = 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();