From 91cbb227d8258a758d2d0798637a158c64612d4d Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:14:40 +0900 Subject: [PATCH 1/2] fix(test): tolerate EPIPE when a CLI rejects stdin early [DEV-150] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zip_profile_rejects_standard_input_as_usage` intermittently failed (observed on `main` run 30050968242, passing on the portable profile and failing on the native one in the same job). The product behaviour is correct: `oxarchive package validate -` refuses `-` as a usage error and exits 2 *without* reading stdin, closing the pipe. The `run_stdin` helper then raced that exit — `write_all` of the JAR body returns `BrokenPipe` (EPIPE) whenever the child exits before the bytes land in the pipe buffer, and `.expect("write stdin")` turned that into a panic. Treat `BrokenPipe` as an expected outcome of feeding a process that may reject its arguments without consuming input; every other write error still panics. Stdin is scoped so it is closed before `wait_with_output`, so children that *do* read stdin (e.g. `deb_validates_from_standard_input`) still see EOF. Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide-cli/tests/common/mod.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/libarchive_oxide-cli/tests/common/mod.rs b/libarchive_oxide-cli/tests/common/mod.rs index ec6432e..45aa198 100644 --- a/libarchive_oxide-cli/tests/common/mod.rs +++ b/libarchive_oxide-cli/tests/common/mod.rs @@ -91,8 +91,15 @@ pub(crate) fn run(bin_name: &str, args: &[&str]) -> Output { } /// Runs `bin` in `cwd`, feeding `stdin_bytes` to stdin, returning the captured [`Output`]. +/// +/// A binary that rejects its arguments — e.g. `-` refused as a usage error — exits +/// *without* reading stdin and closes the pipe, so the parent's write races the child's +/// exit: it succeeds if the bytes fit the pipe buffer first, and fails with `BrokenPipe` +/// (EPIPE) otherwise. That race is inherent to the behavior under test, so a broken pipe +/// is an expected outcome here rather than a test failure; every other write error still +/// fails loudly. Stdin is always closed before waiting so the child sees EOF. pub(crate) fn run_stdin(bin_name: &str, args: &[&str], cwd: &Path, stdin_bytes: &[u8]) -> Output { - use std::io::Write; + use std::io::{ErrorKind, Write}; let mut child = Command::new(bin(bin_name)) .args(args) .current_dir(cwd) @@ -101,12 +108,14 @@ pub(crate) fn run_stdin(bin_name: &str, args: &[&str], cwd: &Path, stdin_bytes: .stderr(Stdio::piped()) .spawn() .expect("spawn bin"); - child - .stdin - .take() - .expect("stdin") - .write_all(stdin_bytes) - .expect("write stdin"); + { + let mut stdin = child.stdin.take().expect("stdin"); + match stdin.write_all(stdin_bytes) { + Ok(()) => {}, + Err(error) if error.kind() == ErrorKind::BrokenPipe => {}, + Err(error) => panic!("write stdin: {error}"), + } + } child.wait_with_output().expect("wait") } From 531c3c5df100838a3c31840a80de3fb5ae8d6a6b Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:29:35 +0900 Subject: [PATCH 2/2] fix(test): widen the async deadlock watchdog budget and trim repeats [DEV-151] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `async_xz_never_deadlocks_under_slow_source` (added by DEV-124) aborted on the `big-endian (s390x, qemu)` and `test (macos-latest)` jobs: watchdog: `async_xz_never_deadlocks_under_slow_source` did not complete within 30s; aborting the deadlocked test That was not a deadlock — it was the watchdog mistaking *slowness* for one. Driving the reader one byte per poll for 50 iterations honestly exceeds 30s on an emulated s390x (10-50x slower than a native host) and on a loaded macOS runner, so the guard meant to kill flakes became a flake itself. Detection determinism comes from the park/unpark driver, not the repeat count: the executor has nothing else to poll, so a lost wakeup parks forever on the very first iteration. Trim the loop to 8 repeats (still exercising the NeedInput/Output interleaving) and raise the budget to 240s via a documented `WATCHDOG_BUDGET` constant — a real hang still aborts fast and attributably, well inside the job's `timeout-minutes`, while honest slowness no longer trips it. The suite now runs in 0.30s natively. Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide/tests/async_stream_v2.rs | 31 +++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/libarchive_oxide/tests/async_stream_v2.rs b/libarchive_oxide/tests/async_stream_v2.rs index f02b054..61e9b70 100644 --- a/libarchive_oxide/tests/async_stream_v2.rs +++ b/libarchive_oxide/tests/async_stream_v2.rs @@ -195,21 +195,36 @@ fn park_block_on(future: F) -> F::Output { } } +/// Wall-clock budget for [`arm_watchdog`], deliberately generous. +/// +/// A lost wakeup parks the [`park_block_on`] driver *immediately* — it has nothing else +/// to poll — so a real deadlock trips this watchdog long before the budget is reached; +/// the budget's only job is to exceed the honest runtime of the slowest runner. Emulated +/// big-endian s390x under qemu and a loaded macOS runner are an order of magnitude +/// slower than a native `x86_64` host, and a budget tight enough to matter there turns +/// *slowness* into a false "deadlock" abort. It still sits well inside the job's +/// `timeout-minutes`, so a genuine hang fails fast and attributably rather than +/// consuming the whole job budget. +const WATCHDOG_BUDGET: Duration = Duration::from_secs(240); + /// Arms a watchdog thread that aborts the process if `label` has not signaled -/// completion within ~30s, so a deadlocked poll fails loudly instead of hanging -/// the test runner indefinitely. +/// completion within [`WATCHDOG_BUDGET`], so a deadlocked poll fails loudly instead of +/// hanging the test runner indefinitely. fn arm_watchdog(label: &'static str) -> Arc { let done = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&done); thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(30); + let deadline = Instant::now() + WATCHDOG_BUDGET; while Instant::now() < deadline { if flag.load(Ordering::SeqCst) { return; } thread::sleep(Duration::from_millis(50)); } - eprintln!("watchdog: `{label}` did not complete within 30s; aborting the deadlocked test"); + eprintln!( + "watchdog: `{label}` did not complete within {}s; aborting the deadlocked test", + WATCHDOG_BUDGET.as_secs() + ); std::process::abort(); }); done @@ -228,7 +243,13 @@ fn async_xz_never_deadlocks_under_slow_source() { let expected = collect_sync(fixture()); let archive = sync_filtered_fixture(FilterId::Xz); guarded("async_xz_never_deadlocks_under_slow_source", async { - for iteration in 0..50 { + // The park/unpark driver makes a lost wakeup deterministic: the executor has + // nothing else to poll, so a missed wake parks forever on the very first + // iteration. Repeats only re-exercise the NeedInput/Output interleaving, so a + // handful is as strong a regression guard as a large count — and a large count + // pushed the honest runtime past the watchdog budget on emulated (qemu s390x) + // and loaded runners, turning slowness into a false deadlock report. + for iteration in 0..8 { assert_eq!( collect_futures(archive.clone()).await, expected,