Files
depot/src/interrupts.rs
T
SFG545 a774c61e5a Implement interrupt handling for long-running commands and archive extraction
- Added an `interrupts` module to manage Ctrl-C signals and propagate them to child processes.
- Refactored archive extraction functions to check for interrupts and handle them gracefully.
- Introduced `copy_interruptibly` function to allow for interruptible copying of data.
- Updated various source files to utilize the new interrupt handling functions, ensuring that commands and archive extractions can be interrupted by the user.
- Added tests to verify the interrupt functionality during copying and tar extraction.
- Introduced a new Meson option for generating HTML CLI documentation.
- Enhanced the `PackageSpec` struct to support additional Rust LTO flags.
2026-03-15 14:48:01 -05:00

274 lines
8.1 KiB
Rust

use anyhow::{Context, Result, bail};
use signal_hook::consts::SIGINT;
use std::fs;
use std::io::{self, ErrorKind, Read, Write};
use std::path::Path;
use std::process::{Child, Command, ExitStatus};
use std::sync::Once;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
static INSTALL_HANDLER: Once = Once::new();
static INSTALL_ERROR: OnceLock<String> = OnceLock::new();
pub(crate) fn install() -> Result<()> {
INSTALL_HANDLER.call_once(|| {
if let Err(err) = unsafe {
signal_hook::low_level::register(SIGINT, || {
INTERRUPTED.store(true, Ordering::Relaxed);
})
} {
let _ = INSTALL_ERROR.set(err.to_string());
};
});
if let Some(err) = INSTALL_ERROR.get() {
bail!("Failed to register Ctrl-C handler: {}", err);
}
Ok(())
}
pub(crate) fn reset() {
INTERRUPTED.store(false, Ordering::Relaxed);
}
pub(crate) fn was_interrupted() -> bool {
INTERRUPTED.load(Ordering::Relaxed)
}
pub(crate) fn check() -> Result<()> {
install()?;
check_with(was_interrupted)
}
pub(crate) fn command_status(cmd: &mut Command) -> io::Result<ExitStatus> {
install().map_err(io::Error::other)?;
configure_child_process_group(cmd);
let mut child = cmd.spawn()?;
wait_for_child_with(&mut child, was_interrupted)
}
pub(crate) fn copy_interruptibly<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
) -> io::Result<u64> {
install().map_err(io::Error::other)?;
copy_interruptibly_with(reader, writer, was_interrupted)
}
pub(crate) fn unpack_tar_archive<R: Read>(
archive: &mut tar::Archive<R>,
dest: &Path,
) -> Result<()> {
install()?;
unpack_tar_archive_with(archive, dest, was_interrupted)
}
fn check_with(interrupted: impl Fn() -> bool) -> Result<()> {
if interrupted() {
bail!("Interrupted by Ctrl-C");
}
Ok(())
}
fn copy_interruptibly_with<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
interrupted: impl Fn() -> bool,
) -> io::Result<u64> {
let mut copied = 0u64;
let mut buf = [0u8; 64 * 1024];
loop {
if interrupted() {
return Err(io::Error::new(
ErrorKind::Interrupted,
"Interrupted by Ctrl-C",
));
}
let n = reader.read(&mut buf)?;
if n == 0 {
return Ok(copied);
}
writer.write_all(&buf[..n])?;
copied += n as u64;
}
}
fn unpack_tar_archive_with<R: Read>(
archive: &mut tar::Archive<R>,
dest: &Path,
interrupted: impl Fn() -> bool,
) -> Result<()> {
fs::create_dir_all(dest)
.with_context(|| format!("Failed to create extraction dir {}", dest.display()))?;
for entry in archive
.entries()
.context("Failed to iterate tar archive entries")?
{
check_with(&interrupted)?;
let mut entry = entry.context("Failed to read tar archive entry")?;
let path = entry
.path()
.context("Failed to inspect tar archive entry path")?
.into_owned();
if !entry
.unpack_in(dest)
.with_context(|| format!("Failed to unpack tar archive entry {}", path.display()))?
{
bail!(
"Refusing to extract unsafe tar archive entry outside destination: {}",
path.display()
);
}
}
Ok(())
}
#[cfg(unix)]
fn configure_child_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
// Put the child in its own process group so Ctrl-C can be forwarded to the
// whole build/extract pipeline without killing the depot parent process.
unsafe {
cmd.pre_exec(|| {
if nix::libc::setpgid(0, 0) != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(not(unix))]
fn configure_child_process_group(_cmd: &mut Command) {}
fn wait_for_child_with(
child: &mut Child,
interrupted: impl Fn() -> bool,
) -> io::Result<ExitStatus> {
let mut interrupted_at: Option<Instant> = None;
let mut sent_term = false;
let mut sent_kill = false;
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if interrupted() {
if interrupted_at.is_none() {
interrupted_at = Some(Instant::now());
signal_child_process_group(child, nix::libc::SIGINT);
} else if interrupted_at
.is_some_and(|started| started.elapsed() >= Duration::from_secs(2) && !sent_term)
{
sent_term = true;
signal_child_process_group(child, nix::libc::SIGTERM);
} else if interrupted_at
.is_some_and(|started| started.elapsed() >= Duration::from_secs(4) && !sent_kill)
{
sent_kill = true;
signal_child_process_group(child, nix::libc::SIGKILL);
}
}
thread::sleep(Duration::from_millis(50));
}
}
#[cfg(unix)]
fn signal_child_process_group(child: &Child, signal: i32) {
let pgid = -(child.id() as i32);
let rc = unsafe { nix::libc::kill(pgid, signal) };
if rc == 0 {
return;
}
let err = io::Error::last_os_error();
if err.raw_os_error() != Some(nix::libc::ESRCH) {
crate::log_warn!(
"Failed to forward signal {} to child process group {}: {}",
signal,
child.id(),
err
);
}
}
#[cfg(not(unix))]
fn signal_child_process_group(_child: &Child, _signal: i32) {}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::sync::Arc;
#[test]
fn copy_interruptibly_returns_interrupted_error() {
let mut reader = Cursor::new(b"hello".to_vec());
let mut writer = Vec::new();
let interrupted = AtomicBool::new(true);
let err = copy_interruptibly_with(&mut reader, &mut writer, || {
interrupted.load(Ordering::Relaxed)
})
.expect_err("copy should stop once interrupted");
assert_eq!(err.kind(), ErrorKind::Interrupted);
}
#[test]
fn unpack_tar_archive_stops_when_interrupted() {
let mut tar_data = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_data);
let mut header = tar::Header::new_gnu();
header.set_path("hello.txt").unwrap();
header.set_size(5);
header.set_mode(0o644);
header.set_cksum();
builder
.append(&header, Cursor::new(b"hello".to_vec()))
.unwrap();
builder.finish().unwrap();
}
let temp = tempfile::tempdir().unwrap();
let mut archive = tar::Archive::new(Cursor::new(tar_data));
let interrupted = AtomicBool::new(true);
let err = unpack_tar_archive_with(&mut archive, temp.path(), || {
interrupted.load(Ordering::Relaxed)
})
.expect_err("tar unpack should stop once interrupted");
assert!(err.to_string().contains("Interrupted by Ctrl-C"));
}
#[test]
#[cfg(unix)]
fn command_status_interrupts_child_process() {
use std::os::unix::process::ExitStatusExt;
let interrupted = Arc::new(AtomicBool::new(false));
let trigger_flag = interrupted.clone();
let trigger = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
trigger_flag.store(true, Ordering::Relaxed);
});
let start = Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 10");
configure_child_process_group(&mut cmd);
let mut child = cmd.spawn().expect("sleep command should spawn");
let status = wait_for_child_with(&mut child, || interrupted.load(Ordering::Relaxed))
.expect("sleep command should be interruptible");
trigger.join().unwrap();
assert!(start.elapsed() < Duration::from_secs(3));
assert_eq!(status.signal(), Some(SIGINT));
}
}