Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e7d7349
docs(e2e): docker build test matrix from a real-world Dockerfile corpus
AprilNEA Jul 21, 2026
92298d3
test(e2e): docker build suite harness + D2 stage-graph scenario
AprilNEA Jul 21, 2026
2865fe8
test(e2e): D3 cache-semantics scenario for the docker build suite
AprilNEA Jul 21, 2026
918cfbb
test(e2e): D1 large-context scenario; wire docker_build into xtask pr…
AprilNEA Jul 21, 2026
0a99151
docs(e2e): record docker_build suite status — red on ABX-494 (FEX wed…
AprilNEA Jul 21, 2026
0360c5a
docs(xtask): add egress_throughput and docker_build rows to the prebu…
AprilNEA Jul 22, 2026
62abf37
test(e2e): D4 session (secret/ssh) + D5 bind-mount scenarios for dock…
AprilNEA Jul 22, 2026
95eefc6
test(e2e): D9 output-streaming + D10 exporter scenarios for docker build
AprilNEA Jul 22, 2026
da91d80
test(e2e): hash-compare the D4 secret; pace D9 under BuildKit's log r…
AprilNEA Jul 22, 2026
3abcee8
test(e2e): fix D3/D4 assertions found by first green-guest run
AprilNEA Jul 22, 2026
5fa8850
fix(e2e): drain command pipes during run_with_timeout; carry output t…
AprilNEA Jul 22, 2026
c80c639
docs(e2e): docker_build suite green on boot 0.6.10; record BuildKit r…
AprilNEA Jul 22, 2026
75168f6
test(e2e): D6 cross-platform, D7 concurrent, D8 cancellation scenarios
AprilNEA Jul 22, 2026
0cd5ad1
test(e2e): Tier X external suite — pinned postgres/next.js/caddy real…
AprilNEA Jul 22, 2026
1ace168
test(e2e): allow dependency build scripts in the pinned next.js build
AprilNEA Jul 22, 2026
bc50554
docs(e2e): record 2026-07-22 docker build bench baseline
AprilNEA Jul 22, 2026
e6cce2d
docs(e2e): add Colima comparison to the build bench baseline (ABX-496)
AprilNEA Jul 22, 2026
c725c1a
docs(plans): add post-ABX-496 bench re-run — parity on real builds, r…
AprilNEA Jul 22, 2026
51200f4
fix(e2e): kill the whole process group on timeout; harden D4 and the …
AprilNEA Jul 31, 2026
43c7d0c
Merge remote-tracking branch 'origin/master' into test/docker-build-e2e
AprilNEA Jul 31, 2026
580fb4c
fix(e2e): kill the process group on the success path too; derive the …
AprilNEA Jul 31, 2026
92b1cde
fix(xtask): guard every backend-pinned e2e target, not just scenario …
AprilNEA Aug 1, 2026
239a334
Merge remote-tracking branch 'origin/master' into test/docker-build-e2e
AprilNEA Aug 14, 2026
c6eff42
fix(xtask): let an unset --backend adopt the target's pin
AprilNEA Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 155 additions & 6 deletions tests/e2e/src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! via `DOCKER_HOST`, so the developer's Docker context and any host
//! daemon stay untouched (see tests/e2e/README.md on isolation).

use std::io::Read as _;
use std::os::unix::process::CommandExt as _;
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread;
Expand Down Expand Up @@ -140,23 +142,170 @@ pub fn ensure_image(data_dir: &Path, image: &str) -> Result<()> {
Err(last_err.expect("loop ran at least once")).context("docker pull (3 attempts)")
}

/// SIGKILLs the process group led by `pgid`, which `process_group(0)` made
/// equal to the spawned child's pid. Safe to call after the leader has been
/// reaped: POSIX forbids reusing a pid while it is still the group id of an
/// existing group, so this either reaches that group or nothing at all
/// (`ESRCH`), never an unrelated one.
fn kill_process_group(pgid: i32) {
// SAFETY: `killpg` takes no pointers and cannot fail unsoundly; a stale
// or empty group yields ESRCH, which we ignore.
unsafe { libc::killpg(pgid, libc::SIGKILL) };
}

/// Runs a command, killing it once `timeout` passes.
///
/// Both pipes are drained on background threads for the whole run: an
/// undrained pipe fills at ~64 KiB and blocks the child, which turns any
/// chatty command (a `--progress=plain` build, a large pull) into a bogus
/// timeout. On a real timeout the error carries the output tail, so a
/// killed command still leaves forensics.
///
/// The child leads its own process group and **both** exit paths signal the
/// whole group before joining. Killing just the direct child is not enough:
/// `docker build` runs the build in a `docker-buildx` grandchild that
/// inherits these pipes, so the write end stays open and the drain-thread
/// joins block past the deadline — indefinitely if that descendant is itself
/// wedged, which is exactly what this suite exists to catch. The same holds
/// when the command *succeeds* while leaving a descendant behind, so the
/// group kill is not conditional on timing out: once the direct child is
/// gone, nothing else may hold pipes this function is about to join on.
pub fn run_with_timeout(command: &mut Command, timeout: Duration) -> Result<std::process::Output> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()?;
// Captured before any reap: `Child::id` is not meaningful afterwards.
let pgid = i32::try_from(child.id()).expect("pid fits in i32");
let drain = |pipe: Option<Box<dyn std::io::Read + Send>>| {
thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
})
};
let stdout_thread = drain(
child
.stdout
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
);
let stderr_thread = drain(
child
.stderr
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
);

let start = Instant::now();
while start.elapsed() < timeout {
if child.try_wait()?.is_some() {
return child
.wait_with_output()
.context("collecting command output");
if let Some(status) = child.try_wait()? {
kill_process_group(pgid);
let stdout = stdout_thread.join().unwrap_or_default();
let stderr = stderr_thread.join().unwrap_or_default();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
thread::sleep(Duration::from_millis(100));
}

let _ = child.kill();
kill_process_group(pgid);
let _ = child.wait();
Err(anyhow!("command timed out after {}s", timeout.as_secs()))
// The whole group is gone, so every inherited write end is closed and the
// drain threads see EOF.
let stdout = stdout_thread.join().unwrap_or_default();
let stderr = stderr_thread.join().unwrap_or_default();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout),
String::from_utf8_lossy(&stderr)
);
let mut tail_start = combined.len().saturating_sub(2000);
while !combined.is_char_boundary(tail_start) {
tail_start += 1;
}
Err(anyhow!(
"command timed out after {}s; output tail:\n{}",
timeout.as_secs(),
&combined[tail_start..]
))
}

#[cfg(test)]
mod tests {
use super::*;

/// A command whose output exceeds the OS pipe buffer must complete: an
/// undrained pipe blocks the child at ~64 KiB and the old implementation
/// turned every chatty command into a bogus timeout (caught by the
/// docker_build D9 streaming scenario).
#[test]
fn chatty_command_is_drained_not_deadlocked() {
let output = run_with_timeout(
Command::new("sh").args([
"-c",
"dd if=/dev/zero bs=1024 count=256 2>/dev/null | base64",
]),
Duration::from_secs(20),
)
.expect("chatty command must not time out");
assert!(output.status.success());
assert!(output.stdout.len() > 64 * 1024);
}

/// The success path has the same hazard as the timeout path: a command
/// can exit promptly while leaving a descendant holding the inherited
/// pipes, and the drain-thread joins would then block on that descendant
/// with no deadline left to enforce. Returns in ~0s once the group is
/// killed, ~30s if the success branch stops doing so.
#[test]
fn success_returns_promptly_despite_surviving_descendant() {
let start = Instant::now();
let output = run_with_timeout(
Command::new("sh").args(["-c", "echo done; sleep 30 & exit 0"]),
Duration::from_secs(60),
)
.expect("command must succeed");
let elapsed = start.elapsed();
assert!(output.status.success());
assert!(
elapsed < Duration::from_secs(10),
"success took {elapsed:?}; a descendant outlived the command and \
held the pipes open"
);
}

/// A genuine timeout must surface the output tail for forensics, and
/// must return at the deadline even though the shell leaves a `sleep`
/// descendant holding the inherited pipes. Killing only the direct child
/// leaves that write end open, so the drain-thread joins block until the
/// descendant exits — ~30s here, unbounded when the survivor is a wedged
/// `docker-buildx`, which is the shape this suite hits for real.
///
/// `sleep 30 & wait` is deliberate: with a plain `sleep 30` the shell
/// `exec`s it as the last command, so there is no grandchild and the bug
/// hides. Backgrounding forces the shell to stay alive as a real parent.
/// The elapsed bound is the regression; the tail is the original contract.
#[test]
fn timeout_returns_at_deadline_despite_surviving_descendant() {
let start = Instant::now();
let error = run_with_timeout(
Command::new("sh").args(["-c", "echo tail-marker; sleep 30 & wait"]),
Duration::from_secs(1),
)
.expect_err("command must time out");
let elapsed = start.elapsed();
assert!(error.to_string().contains("tail-marker"));
assert!(
elapsed < Duration::from_secs(10),
"timeout took {elapsed:?}; the `sleep` descendant outlived the \
kill and held the pipes open"
);
}
}
Loading
Loading