magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use anyhow::{Context, Result, bail};
use std::{
    process::{Command, ExitStatus},
    sync::{
        OnceLock,
        atomic::{AtomicBool, Ordering},
    },
    thread,
    time::{Duration, Instant},
};

static UPDATE_CANCELED: AtomicBool = AtomicBool::new(false);
static SIGNAL_HANDLER: OnceLock<Result<(), String>> = OnceLock::new();

pub(super) fn install_cancel_handler() -> Result<()> {
    SIGNAL_HANDLER
        .get_or_init(|| {
            ctrlc::set_handler(|| UPDATE_CANCELED.store(true, Ordering::SeqCst))
                .map_err(|error| error.to_string())
        })
        .as_ref()
        .map_err(|error| anyhow::anyhow!("cannot install update cancellation handler: {error}"))?;
    UPDATE_CANCELED.store(false, Ordering::SeqCst);
    Ok(())
}

pub(super) fn check_canceled() -> Result<()> {
    if UPDATE_CANCELED.load(Ordering::SeqCst) {
        bail!("Cargo update canceled");
    }
    Ok(())
}

/// Inherited output cannot fill an internal pipe. Only one Cargo process runs.
pub(super) fn run_cargo(command: &mut Command, timeout: Duration) -> Result<ExitStatus> {
    check_canceled()?;
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        command.process_group(0);
    }
    let mut child = command
        .spawn()
        .context("cannot start Cargo; install a working Rust toolchain from https://rustup.rs")?;
    let deadline = Instant::now() + timeout;
    loop {
        match child.try_wait() {
            Ok(Some(status)) => return Ok(status),
            Ok(None) if Instant::now() < deadline && !UPDATE_CANCELED.load(Ordering::SeqCst) => {
                thread::sleep(Duration::from_millis(100))
            }
            result => {
                let reason = if result.is_err() {
                    "cannot wait for Cargo"
                } else if UPDATE_CANCELED.load(Ordering::SeqCst) {
                    "Cargo update canceled"
                } else {
                    "Cargo update timed out"
                };
                #[cfg(windows)]
                let cleanup = cleanup_windows_child(&mut child, terminate_windows_build_tree);
                #[cfg(not(windows))]
                let cleanup = crate::tools::process::terminate_child_tree_and_wait(&mut child)
                    .and_then(|outcome| match outcome.cleanup_warning {
                        Some(warning) => Err(anyhow::anyhow!(warning)),
                        None => Ok(()),
                    });
                cleanup.with_context(|| reason)?;
                result.context("cannot wait for Cargo")?;
                bail!("{reason}; review the install records and retry manually");
            }
        }
    }
}

// Tree cleanup may fail (missing taskkill, denied access, timeout, wait failure).
// Always kill and poll the owned Cargo handle before propagating that failure.
#[cfg(any(windows, test))]
fn cleanup_windows_child(
    child: &mut std::process::Child,
    terminate_tree: impl FnOnce(u32) -> Result<()>,
) -> Result<()> {
    let tree = terminate_tree(child.id());
    let direct = kill_direct_child_and_wait(child);
    match (tree, direct) {
        (Ok(()), Ok(())) => Ok(()),
        (Err(tree), Ok(())) => Err(tree).context("Cargo stopped, but build-tree cleanup failed"),
        (Ok(()), Err(direct)) => Err(direct),
        (Err(tree), Err(direct)) => {
            Err(direct).context(format!("build-tree cleanup failed: {tree:#}"))
        }
    }
}

#[cfg(any(windows, test))]
fn kill_direct_child_and_wait(child: &mut std::process::Child) -> Result<()> {
    // Do not let a failed try_wait skip the kill attempt. A racing natural exit
    // can make kill fail; an observed exit below is still successful cleanup.
    let kill = child.kill();
    let deadline = Instant::now() + Duration::from_secs(1);
    loop {
        let wait = child.try_wait();
        if matches!(wait, Ok(Some(_))) {
            return Ok(());
        }
        if Instant::now() >= deadline {
            bail!("direct child exit could not be confirmed; kill: {kill:?}; wait: {wait:?}");
        }
        thread::sleep(Duration::from_millis(20));
    }
}

#[cfg(windows)]
fn terminate_windows_build_tree(pid: u32) -> Result<()> {
    use std::process::Stdio;
    let mut taskkill = Command::new(super::windows::system_directory()?.join("taskkill.exe"))
        .args(["/PID", &pid.to_string(), "/T", "/F"])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .context("cannot stop Cargo build tree")?;
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        let status = match taskkill.try_wait() {
            Ok(status) => status,
            Err(error) => {
                let cleanup = kill_direct_child_and_wait(&mut taskkill);
                return Err(error)
                    .context(format!("cannot wait for taskkill; cleanup: {cleanup:?}"));
            }
        };
        if let Some(status) = status {
            if !status.success() {
                bail!("cannot stop Cargo build tree; stop Cargo and rustc manually");
            }
            return Ok(());
        }
        if Instant::now() >= deadline {
            let cleanup = kill_direct_child_and_wait(&mut taskkill);
            bail!(
                "timed out stopping Cargo build tree; stop rustc manually; taskkill cleanup: {cleanup:?}"
            );
        }
        thread::sleep(Duration::from_millis(20));
    }
}

#[cfg(all(test, any(unix, windows)))]
mod tests {
    use super::*;

    #[test]
    fn tree_cleanup_failure_still_kills_and_reaps_direct_child() {
        #[cfg(unix)]
        let mut child = Command::new("/bin/sleep").arg("30").spawn().unwrap();
        #[cfg(windows)]
        let mut child = Command::new(
            super::super::windows::system_directory()
                .unwrap()
                .join("WindowsPowerShell/v1.0/powershell.exe"),
        )
        .args([
            "-NoProfile",
            "-NonInteractive",
            "-Command",
            "[Threading.Thread]::Sleep(30000)",
        ])
        .spawn()
        .unwrap();
        let started = Instant::now();
        let error =
            cleanup_windows_child(&mut child, |_| bail!("injected tree failure")).unwrap_err();
        assert!(format!("{error:#}").contains("injected tree failure"));
        assert!(child.try_wait().unwrap().is_some());
        assert!(started.elapsed() < Duration::from_secs(3));
    }

    #[cfg(unix)]
    #[test]
    fn cargo_process_failure_and_timeout_are_not_reported_as_success() {
        let mut failure = Command::new("/bin/sh");
        failure.args(["-c", "exit 7"]);
        assert_eq!(
            run_cargo(&mut failure, Duration::from_secs(1))
                .unwrap()
                .code(),
            Some(7)
        );
        let mut slow = Command::new("/bin/sh");
        slow.args(["-c", "sleep 30"]);
        let error = run_cargo(&mut slow, Duration::from_millis(20)).unwrap_err();
        assert!(error.to_string().contains("timed out"), "{error:#}");
    }
}