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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! SIGINT handling for `batch-run`, which needs no AWS access — so unlike
//! the transfer commands (covered in `tests/e2e_ctrl_c.rs`) these run under
//! a plain `cargo test`.
//!
//! Unix-only: SIGINT delivery via the `nix` crate.
#![cfg(unix)]
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const STARTUP_DELAY: Duration = Duration::from_millis(1500);
const WAIT_TIMEOUT: Duration = Duration::from_secs(30);
fn s7cmd() -> Command {
Command::new(env!("CARGO_BIN_EXE_s7cmd"))
}
/// Poll for child exit up to `WAIT_TIMEOUT`. Returns `None` if it never
/// exits, so callers can assert "did not hang" without blocking a CI run
/// forever.
fn wait_bounded(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
let deadline = Instant::now() + WAIT_TIMEOUT;
while Instant::now() < deadline {
match child.try_wait().expect("try_wait failed") {
Some(status) => return Some(status),
None => std::thread::sleep(Duration::from_millis(50)),
}
}
None
}
/// Non-streaming `batch-run -` spends phase 1 in a blocking read of stdin
/// under the *default* SIGINT disposition (the handler is installed only
/// after the script has been read), so Ctrl-C there terminates the process
/// by signal — exactly the "I haven't started anything yet" intuition the
/// phase-1 comment describes. `code()` is `None` for a signal death;
/// the signal number is 2 (SIGINT).
#[test]
fn batch_run_sigint_while_reading_stdin_dies_by_signal() {
use std::os::unix::process::ExitStatusExt;
let mut child = s7cmd()
.args(["batch-run", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn s7cmd");
std::thread::sleep(STARTUP_DELAY);
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
let status = wait_bounded(&mut child).unwrap_or_else(|| {
let _ = child.kill();
panic!("batch-run did not exit within {WAIT_TIMEOUT:?} of SIGINT during phase-1 read");
});
assert_eq!(
status.signal(),
Some(2),
"phase-1 SIGINT must terminate by signal; got {status:?}"
);
}
/// Streaming `batch-run --streaming -` sitting idle on stdin: SIGINT sets
/// the shared interrupt flag and trips the reader's `ctrl_c` select arm, so
/// once stdin also reaches EOF the run finishes cleanly with exit 0 (no
/// line ever executed, so nothing is "failed" or "skipped").
///
/// The stdin close after the signal is load-bearing, not incidental:
/// streaming mode reads via `tokio::io::stdin()`, which tokio implements as
/// an uncancellable blocking read on a helper thread. Tokio documents that
/// this "can make shutdown of the runtime hang until the user presses
/// enter" — so a `--streaming -` run whose stdin pipe never closes does NOT
/// exit on SIGINT alone. This test therefore pins the reachable contract
/// (signal + EOF → prompt, clean exit); the pipe-stays-open hang is a
/// separate defect in the product, not something to assert as correct here.
#[test]
fn batch_run_streaming_sigint_then_eof_exits_0() {
let mut child = s7cmd()
.args(["batch-run", "--streaming", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn s7cmd");
std::thread::sleep(STARTUP_DELAY);
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
// Let the signal be observed, then release the blocking stdin read.
std::thread::sleep(Duration::from_millis(300));
drop(child.stdin.take());
let status = wait_bounded(&mut child).unwrap_or_else(|| {
let _ = child.kill();
panic!("streaming batch-run did not exit within {WAIT_TIMEOUT:?} of SIGINT + EOF");
});
assert_eq!(
status.code(),
Some(0),
"interrupted idle streaming batch must exit 0; got {status:?}"
);
}
/// The same streaming path with real work queued: lines are written before
/// the signal, stdin is closed after it, and the run still exits cleanly.
/// Every line is a `--dry-run` so no S3 endpoint is contacted.
#[test]
fn batch_run_streaming_sigint_with_queued_lines_exits_cleanly() {
let mut child = s7cmd()
.args(["batch-run", "--streaming", "--disable-color-tracing", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn s7cmd");
{
let stdin = child.stdin.as_mut().expect("stdin piped");
for i in 0..3 {
writeln!(stdin, "create-bucket --dry-run s3://sigint-stream-{i}").expect("write line");
}
stdin.flush().expect("flush");
}
std::thread::sleep(STARTUP_DELAY);
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
std::thread::sleep(Duration::from_millis(300));
drop(child.stdin.take());
let status = wait_bounded(&mut child).unwrap_or_else(|| {
let _ = child.kill();
panic!("streaming batch-run did not exit within {WAIT_TIMEOUT:?} of SIGINT + EOF");
});
assert_eq!(
status.code(),
Some(0),
"dry-run lines all succeed, so an interrupt after them exits 0; got {status:?}"
);
}