magi-code 0.79.0

Repository-aware CLI coding agent for terminal work
Documentation
use std::{
    io::Write,
    marker::PhantomData,
    panic::{self, PanicHookInfo},
    rc::Rc,
    sync::{Arc, Mutex},
    thread,
};

type PanicHook = dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static;

#[derive(Default)]
struct BackgroundPanicReport {
    count: usize,
    first_location: Option<String>,
}

/// Outlives the app and its terminal guard, so deferred diagnostics are printed
/// only after terminal cleanup. Worker results still report failures in the UI.
pub(in crate::tui) struct TerminalPanicHook {
    previous: Arc<PanicHook>,
    background_panics: Arc<Mutex<Option<BackgroundPanicReport>>>,
    // Installation and teardown belong to the terminal owner.
    _owner_only: PhantomData<Rc<()>>,
}

impl TerminalPanicHook {
    pub(in crate::tui) fn install() -> Self {
        Self::install_with_restore(super::restore_terminal_best_effort)
    }

    fn install_with_restore(restore: impl Fn() + Send + Sync + 'static) -> Self {
        let owner = thread::current().id();
        let previous: Arc<PanicHook> = panic::take_hook().into();
        let background_panics = Arc::new(Mutex::new(Some(BackgroundPanicReport::default())));
        let hook_previous = Arc::clone(&previous);
        let hook_panics = Arc::clone(&background_panics);
        panic::set_hook(Box::new(move |info| {
            let active = {
                let mut panics = hook_panics.lock().unwrap_or_else(|e| e.into_inner());
                if let Some(report) = panics.as_mut() {
                    // An aborting build cannot reconcile worker failures: restore
                    // immediately on every thread before the process terminates.
                    if cfg!(panic = "unwind") && thread::current().id() != owner {
                        report.count = report.count.saturating_add(1);
                        if report.first_location.is_none() {
                            report.first_location = info.location().map(|location| {
                                let file: String = location.file().chars().take(256).collect();
                                format!("{file}:{}", location.line())
                            });
                        }
                        return;
                    }
                    true
                } else {
                    false
                }
            };
            if active {
                restore();
            }
            hook_previous(info);
        }));
        Self {
            previous,
            background_panics,
            _owner_only: PhantomData,
        }
    }
}

impl Drop for TerminalPanicHook {
    fn drop(&mut self) {
        let report = self
            .background_panics
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .take()
            .unwrap_or_default();
        // set_hook is forbidden while unwinding. The installed closure becomes
        // a passthrough in that case, without retaining terminal ownership.
        if !thread::panicking() {
            let previous = Arc::clone(&self.previous);
            panic::set_hook(Box::new(move |info| previous(info)));
        }
        if report.count > 0 {
            // Retain only bounded source metadata, never arbitrary panic payloads.
            let location = report
                .first_location
                .as_deref()
                .unwrap_or("unknown location");
            let _ = writeln!(
                std::io::stderr(),
                "Mission Control: {} background worker panic(s) occurred; first at {location}.",
                report.count
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::terminal::{
        RestoreProbeOps, TerminalOp, TerminalOps, test_guard_with_restore_probe,
    };
    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

    #[test]
    fn terminal_panic_lifecycle_subprocess() {
        const CHILD: &str = "MAGI_TEST_TERMINAL_PANIC_LIFECYCLE";
        let Ok(case) = std::env::var(CHILD) else {
            for case in ["worker", "owner", "error"] {
                let output = std::process::Command::new(std::env::current_exe().unwrap())
                    .args([
                        "--exact",
                        "tui::terminal::panic_hook::tests::terminal_panic_lifecycle_subprocess",
                        "--nocapture",
                    ])
                    .env(CHILD, case)
                    .output()
                    .unwrap();
                assert!(output.status.success(), "{case}: {output:?}");
                let stderr = String::from_utf8_lossy(&output.stderr);
                assert_eq!(
                    stderr.contains("1 background worker panic(s)"),
                    case == "worker",
                    "{stderr}"
                );
                if case == "worker" {
                    assert!(stderr.contains("panic_hook.rs:"), "{stderr}");
                    assert!(!stderr.contains("worker failure"), "{stderr}");
                }
            }
            return;
        };

        // Only the isolated child changes the process-wide panic hook. The
        // existing terminal mock records all input-mode and screen operations.
        let calls = Arc::new(Mutex::new(Vec::new()));
        let reports = Arc::new(AtomicUsize::new(0));
        let reported_before_restore = Arc::new(AtomicBool::new(false));
        let hook_reported_before_restore = Arc::clone(&reported_before_restore);
        let previous_reports = Arc::clone(&reports);
        let previous_calls = Arc::clone(&calls);
        panic::set_hook(Box::new(move |_| {
            let calls = previous_calls.lock().unwrap();
            previous_reports.fetch_add(1, Ordering::SeqCst);
            if calls.last() != Some(&"terminal restored") {
                hook_reported_before_restore.store(true, Ordering::SeqCst);
            }
        }));
        let restore_calls = Arc::clone(&calls);
        let result = panic::catch_unwind(|| -> Result<(), ()> {
            let _hook = TerminalPanicHook::install_with_restore(move || {
                let ops = RestoreProbeOps {
                    calls: restore_calls.clone(),
                };
                for op in [
                    TerminalOp::DisableEnhancedKeyboard,
                    TerminalOp::DisableInputAndLeaveAlternateScreen,
                    TerminalOp::DisableRawMode,
                ] {
                    ops.run(op).unwrap();
                }
            });
            let terminal = test_guard_with_restore_probe(Arc::clone(&calls)).unwrap();
            match case.as_str() {
                "worker" => {
                    let failure = thread::spawn(|| panic!("worker failure")).join();
                    assert!(
                        failure.is_err(),
                        "worker failure must remain available for reconciliation"
                    );
                    assert_eq!(reports.load(Ordering::SeqCst), 0);
                    assert_eq!(
                        *calls.lock().unwrap(),
                        [
                            "enable raw mode",
                            "enter alternate screen",
                            "enable enhanced keyboard"
                        ]
                    );
                    // Owner can continue with unchanged input modes, then exit.
                    drop(terminal);
                }
                "owner" => panic!("owner failure"),
                "error" => return Err(()),
                _ => unreachable!(),
            }
            Ok(())
        });
        assert_eq!(result.is_err(), case == "owner");
        if case == "error" {
            assert_eq!(result.unwrap(), Err(()));
        }
        assert_eq!(calls.lock().unwrap().last(), Some(&"terminal restored"));
        assert_eq!(reports.load(Ordering::SeqCst), usize::from(case == "owner"));
        let restored_calls = calls.lock().unwrap().len();
        // A subsequent panic delegates to the previous hook without touching
        // terminal state, including after owner-thread unwinding.
        let _ = thread::spawn(|| panic!("after TUI exit")).join();
        assert_eq!(
            reports.load(Ordering::SeqCst),
            1 + usize::from(case == "owner")
        );
        assert_eq!(calls.lock().unwrap().len(), restored_calls);
        assert!(!reported_before_restore.load(Ordering::SeqCst));
    }
}