use std::io::BufRead;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::super::util::Spinner;
pub fn run_make(kernel_dir: &Path, args: &[&str]) -> Result<()> {
const RUN_MAKE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
let child = std::process::Command::new("make")
.args(args)
.current_dir(kernel_dir)
.spawn()
.with_context(|| format!("spawn make {}", args.join(" ")))?;
poll_child_with_timeout(
child,
RUN_MAKE_TIMEOUT,
POLL_INTERVAL,
&format!("make {}", args.join(" ")),
)
}
pub(super) fn poll_child_with_timeout(
mut child: std::process::Child,
timeout: Duration,
poll_interval: Duration,
label: &str,
) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
anyhow::ensure!(status.success(), "{label} failed");
return Ok(());
}
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("{label} timed out after {timeout:?}; child killed");
}
std::thread::sleep(poll_interval);
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(e).with_context(|| format!("wait on {label}"));
}
}
}
}
pub(super) fn drain_lines_lossy(
mut reader: impl BufRead,
mut on_line: impl FnMut(&str),
) -> std::io::Result<Vec<String>> {
let mut captured = Vec::new();
let mut buf = Vec::new();
loop {
buf.clear();
let n = reader.read_until(b'\n', &mut buf)?;
if n == 0 {
break;
}
let mut slice: &[u8] = &buf;
if let Some(rest) = slice.strip_suffix(b"\n") {
slice = rest;
if let Some(rest) = slice.strip_suffix(b"\r") {
slice = rest;
}
}
let line = String::from_utf8_lossy(slice).into_owned();
on_line(&line);
captured.push(line);
}
Ok(captured)
}
pub fn run_make_with_output(
kernel_dir: &Path,
args: &[&str],
spinner: Option<&Spinner>,
) -> Result<()> {
let (read_fd, write_fd) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
.context("create pipe for merged make stdout+stderr")?;
let write_fd_err = write_fd
.try_clone()
.context("clone pipe write end for stderr")?;
let mut child = std::process::Command::new("make")
.args(args)
.current_dir(kernel_dir)
.stdout(std::process::Stdio::from(write_fd))
.stderr(std::process::Stdio::from(write_fd_err))
.spawn()
.with_context(|| format!("spawn make {}", args.join(" ")))?;
let reader = std::io::BufReader::new(std::fs::File::from(read_fd));
let captured = match drain_lines_lossy(reader, |line| {
if let Some(sp) = spinner {
sp.println(line);
}
}) {
Ok(v) => v,
Err(e) => {
child.kill().ok();
child.wait().ok();
return Err(e).context("read merged make stdout+stderr");
}
};
let status = child.wait()?;
if !status.success() {
for line in &captured {
eprintln!("{line}");
}
bail!("make {} failed", args.join(" "));
}
Ok(())
}
pub fn make_kernel_with_output(
kernel_dir: &Path,
spinner: Option<&Spinner>,
jobs_override: Option<usize>,
) -> Result<()> {
let nproc = jobs_override.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
});
let args = build_make_args(nproc);
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_make_with_output(kernel_dir, &arg_refs, spinner)
}
pub(super) fn build_make_args(nproc: usize) -> Vec<String> {
vec![format!("-j{nproc}"), "KCFLAGS=-Wno-error".into()]
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn make_in_path() -> bool {
let Ok(path) = std::env::var("PATH") else {
return false;
};
std::env::split_paths(&path).any(|p| p.join("make").is_file())
}
#[test]
fn drain_lines_lossy_eof_terminated_happy_path() {
let input: &[u8] = b"alpha\nbeta\ngamma\n";
let mut seen = Vec::new();
let captured = drain_lines_lossy(std::io::Cursor::new(input), |line| {
seen.push(line.to_string())
})
.unwrap();
assert_eq!(captured, vec!["alpha", "beta", "gamma"]);
assert_eq!(seen, captured);
}
#[test]
fn drain_lines_lossy_strips_crlf() {
let input: &[u8] = b"one\r\ntwo\r\nthree\r\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["one", "two", "three"]);
}
#[test]
fn drain_lines_lossy_non_utf8_bytes_survive_via_replacement() {
let input: &[u8] = b"valid\n\xffbroken\ntail\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["valid", "\u{FFFD}broken", "tail"]);
}
#[test]
fn drain_lines_lossy_empty_stream_yields_empty_vec() {
let input: &[u8] = b"";
let mut calls = 0usize;
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| calls += 1).unwrap();
assert!(captured.is_empty());
assert_eq!(calls, 0);
}
#[test]
fn drain_lines_lossy_single_line_without_trailing_newline() {
let input: &[u8] = b"no-newline";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["no-newline"]);
}
#[test]
fn drain_lines_lossy_lone_cr_at_eof_is_preserved() {
let input: &[u8] = b"foo\r";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["foo\r"]);
}
#[test]
fn drain_lines_lossy_interior_cr_is_preserved() {
let input: &[u8] = b"ab\rcd\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["ab\rcd"]);
}
#[test]
fn drain_lines_lossy_propagates_io_error_after_first_read() {
use std::io::{BufReader, ErrorKind, Read};
struct FlakyReader {
calls: usize,
}
impl Read for FlakyReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.calls += 1;
match self.calls {
1 => {
let data = b"line1\n";
let n = data.len().min(buf.len());
buf[..n].copy_from_slice(&data[..n]);
Ok(n)
}
_ => Err(std::io::Error::new(ErrorKind::BrokenPipe, "pipe closed")),
}
}
}
let err = drain_lines_lossy(BufReader::new(FlakyReader { calls: 0 }), |_| {})
.expect_err("flaky reader must surface Err");
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
}
#[test]
fn drain_lines_lossy_mixed_lf_and_crlf() {
let input: &[u8] = b"lf-line\ncrlf-line\r\nlf-again\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["lf-line", "crlf-line", "lf-again"]);
}
#[test]
fn drain_lines_lossy_empty_lines_lf() {
let input: &[u8] = b"a\n\nb\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["a", "", "b"]);
}
#[test]
fn drain_lines_lossy_empty_lines_crlf() {
let input: &[u8] = b"\r\n\r\n";
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_| {}).unwrap();
assert_eq!(captured, vec!["", ""]);
}
#[test]
fn drain_lines_lossy_callback_fires_once_per_line_in_order() {
let input: &[u8] = b"a\nb\nc\n";
let lens = std::cell::RefCell::new(Vec::<usize>::new());
let captured = drain_lines_lossy(std::io::Cursor::new(input), |_line| {
let mut v = lens.borrow_mut();
let current = v.len();
v.push(current);
})
.unwrap();
assert_eq!(captured, vec!["a", "b", "c"]);
assert_eq!(lens.into_inner(), vec![0, 1, 2]);
}
#[test]
fn run_make_with_output_surfaces_actionable_error_when_kernel_dir_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let missing = tmp.path().join("nonexistent_child");
let err = run_make_with_output(&missing, &["foo"], None)
.expect_err("nonexistent kernel_dir must surface a spawn failure");
let rendered = format!("{err:#}");
assert!(
rendered.contains("spawn make foo"),
"expected `spawn make foo` context layer, got: {rendered}"
);
let has_not_found = err.chain().any(|e| {
e.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
});
assert!(
has_not_found,
"expected underlying io::Error with ErrorKind::NotFound in anyhow chain, \
got: {rendered}"
);
}
#[test]
fn run_make_with_output_drains_high_volume_failing_make_without_deadlock() {
if !make_in_path() {
skip!("make not in PATH");
}
let dir = tempfile::TempDir::new().unwrap();
let stdout_chunk: String = "S".repeat(1024);
let stderr_chunk: String = "E".repeat(1024);
let mut recipe = String::new();
for _ in 0..100 {
recipe.push_str(&format!("\t@printf '%s\\n' '{stdout_chunk}'\n"));
recipe.push_str(&format!("\t@printf '%s\\n' '{stderr_chunk}' >&2\n"));
}
let makefile = format!("default:\n{recipe}\t@false\n");
std::fs::write(dir.path().join("Makefile"), makefile).unwrap();
let err = run_make_with_output(dir.path(), &["default"], None)
.expect_err("non-zero exit must surface as Err");
let rendered = format!("{err:#}");
assert!(
rendered.contains("make default failed"),
"expected `make default failed` wording from bail!, got: {rendered}"
);
}
#[test]
fn run_make_with_output_drains_stderr_only_high_volume_without_deadlock() {
if !make_in_path() {
skip!("make not in PATH");
}
let dir = tempfile::TempDir::new().unwrap();
let chunk: String = "X".repeat(1024);
let mut recipe = String::new();
for _ in 0..128 {
recipe.push_str(&format!("\t@printf '%s\\n' '{chunk}' >&2\n"));
}
let makefile = format!("default:\n{recipe}\t@false\n");
std::fs::write(dir.path().join("Makefile"), makefile).unwrap();
let err = run_make_with_output(dir.path(), &["default"], None)
.expect_err("non-zero exit must surface as Err");
let rendered = format!("{err:#}");
assert!(
rendered.contains("make default failed"),
"expected `make default failed` wording, got: {rendered}"
);
}
#[test]
fn run_make_with_output_releases_fds_on_spawn_failure() {
let proc_fd = std::path::Path::new("/proc/self/fd");
if !proc_fd.is_dir() {
skip!("/proc/self/fd not available");
}
let count_fds = || -> usize {
std::fs::read_dir(proc_fd)
.expect("read /proc/self/fd")
.filter_map(|e| e.ok())
.count()
};
let tmp = tempfile::TempDir::new().unwrap();
let missing = tmp.path().join("nonexistent_child");
let _ = run_make_with_output(&missing, &["foo"], None);
let before = count_fds();
const FD_LEAK_ITERATIONS: u32 = 128;
for _ in 0..FD_LEAK_ITERATIONS {
let _ = run_make_with_output(&missing, &["foo"], None);
}
let after = count_fds();
assert!(
after <= before,
"fd leak on spawn failure: {before} -> {after} \
({FD_LEAK_ITERATIONS} calls, expected no growth)"
);
}
fn spawn_sleeping_child(seconds: u64) -> (std::process::Child, u32) {
let child = std::process::Command::new("sh")
.arg("-c")
.arg(format!("sleep {seconds}"))
.spawn()
.expect("spawn sh -c sleep N");
let pid = child.id();
(child, pid)
}
fn pid_is_alive(pid: u32) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
kill(Pid::from_raw(pid as i32), None).is_ok()
}
#[test]
fn poll_child_with_timeout_bails_and_reaps_on_timeout() {
let (child, pid) = spawn_sleeping_child(60);
assert!(
pid_is_alive(pid),
"fixture precondition: spawned child pid {pid} must be \
alive before the helper runs",
);
let start = std::time::Instant::now();
let result = poll_child_with_timeout(
child,
Duration::from_millis(100),
Duration::from_millis(1),
"make wedged-target",
);
let elapsed = start.elapsed();
let err = result.expect_err("timed-out child must surface as Err");
let rendered = format!("{err:#}");
assert!(
rendered.contains("make wedged-target"),
"timeout bail must include the label parameter; got: {rendered}",
);
assert!(
rendered.contains("timed out after"),
"timeout bail must include the literal `timed out after` \
phrase so CI log scrapers can pattern-match wedged builds; \
got: {rendered}",
);
assert!(
elapsed < Duration::from_secs(5),
"helper must return within a small multiple of the \
configured timeout (100ms); took {elapsed:?} which \
suggests the deadline check is broken",
);
let zombie_check_deadline = std::time::Instant::now() + Duration::from_secs(1);
loop {
if !pid_is_alive(pid) {
break;
}
if std::time::Instant::now() >= zombie_check_deadline {
panic!(
"child pid {pid} still alive 1s after helper returned — \
timeout path leaked a zombie (missing child.wait() \
after child.kill()?)",
);
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn poll_child_with_timeout_succeeds_when_child_exits_clean() {
let child = std::process::Command::new("true")
.spawn()
.expect("spawn true");
let pid = child.id();
let result = poll_child_with_timeout(
child,
Duration::from_secs(5),
Duration::from_millis(1),
"make happy-target",
);
assert!(
result.is_ok(),
"child that exits 0 must surface as Ok; got: {result:?}",
);
let zombie_check_deadline = std::time::Instant::now() + Duration::from_secs(1);
loop {
if !pid_is_alive(pid) {
break;
}
if std::time::Instant::now() >= zombie_check_deadline {
panic!(
"child pid {pid} still alive 1s after Ok return — \
successful-exit path leaked a zombie",
);
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn poll_child_with_timeout_surfaces_nonzero_exit_as_err() {
let child = std::process::Command::new("false")
.spawn()
.expect("spawn false");
let result = poll_child_with_timeout(
child,
Duration::from_secs(5),
Duration::from_millis(1),
"make broken-target",
);
let err = result.expect_err("child that exits non-zero must surface as Err");
let rendered = format!("{err:#}");
assert!(
rendered.contains("make broken-target"),
"non-zero-exit bail must include the label; got: {rendered}",
);
assert!(
rendered.contains("failed"),
"non-zero-exit bail must use the `failed` wording so it is \
distinguishable from the timeout-path's `timed out after`; \
got: {rendered}",
);
assert!(
!rendered.contains("timed out"),
"non-zero-exit bail must NOT contain `timed out` — that \
phrase belongs to the deadline-fired path only; got: {rendered}",
);
}
#[test]
fn cli_build_make_args_single_core() {
let args = build_make_args(1);
assert_eq!(args, vec!["-j1", "KCFLAGS=-Wno-error"]);
}
#[test]
fn cli_build_make_args_multi_core() {
let args = build_make_args(16);
assert_eq!(args, vec!["-j16", "KCFLAGS=-Wno-error"]);
}
}