1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
//! Regression test for the broken-pipe panic: piping a `roteiro` subcommand's
//! stdout to a reader that closes early (`roteiro query --kind … | head`) must
//! exit cleanly by SIGPIPE, not panic with a "failed printing to stdout: Broken
//! pipe" backtrace. `main` restores the default SIGPIPE disposition (`SIG_DFL`)
//! at startup so a closed stdout pipe terminates the process the Unix way; this
//! test exercises that on `query`, whose listing goes through `println!`.
//!
//! SIGPIPE is a Unix signal and the fix is a `#[cfg(unix)]` no-op elsewhere, so
//! this test is Unix-only. It is gated further to Linux + macOS because it names
//! the SIGPIPE signal number directly (13) to assert the termination signal, and
//! that value is only guaranteed on those targets — other Unixes may differ.
#![cfg(any(target_os = "linux", target_os = "macos"))]
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::process::{Command, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
// SIGPIPE is signal 13 on Linux and macOS — the platforms this test is gated to
// (see the module-level `cfg`). The `sigpipe` crate doesn't re-export the
// constant and `libc` isn't a direct dependency, so name it here.
const SIGPIPE: i32 = 13;
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn query_exits_cleanly_when_stdout_pipe_closes_early() {
let dir = std::env::temp_dir().join(format!("roteiro-brokenpipe-cli-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("src")).expect("mkdir");
// A couple of files so `query --kind file` has nodes to list — i.e. `main`
// reaches a real `println!` to stdout.
std::fs::write(dir.join("src/lib.rs"), "pub fn a() {}\npub fn b() {}\n").expect("write");
std::fs::write(dir.join("README.md"), "# Readme\n").expect("write");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
// Pipe stdout, then immediately close our read end: with no reader left on the
// pipe, the child's first stdout write returns EPIPE. Before the fix that
// panicked (exit 101, "failed printing to stdout: Broken pipe"); with SIGPIPE
// reset to SIG_DFL the kernel terminates the process by signal instead.
let mut child = Command::new(BIN)
.args(["query", "--kind", "file"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn roteiro");
// Drop the read end (close the pipe) before the child writes its listing.
drop(child.stdout.take());
let output = child.wait_with_output().expect("wait roteiro");
let stderr = String::from_utf8_lossy(&output.stderr);
// The regression: no Rust panic / broken-pipe backtrace on a closed pipe.
assert!(
!stderr.contains("panicked") && !stderr.contains("Broken pipe"),
"broken-pipe panic regressed: exit={:?} signal={:?}\nstderr:\n{stderr}",
output.status.code(),
output.status.signal(),
);
assert_ne!(
output.status.code(),
Some(101),
"process panicked (exit 101) instead of exiting on SIGPIPE\nstderr:\n{stderr}",
);
// Positively assert the intended mechanism: terminated by SIGPIPE. (If the
// child managed to buffer its whole short listing and exit 0 before the close
// was observed, that's also panic-free and acceptable — hence the fallback.)
assert!(
output.status.signal() == Some(SIGPIPE) || output.status.success(),
"expected SIGPIPE termination or clean exit, got exit={:?} signal={:?}\nstderr:\n{stderr}",
output.status.code(),
output.status.signal(),
);
}