mod common;
use std::path::Path;
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use common::{drain_to_buffer, send_signal, spawn_camel_run};
fn write_fixture(dir: &Path) {
std::fs::write(
dir.join("Camel.toml"),
r#"[default]
routes = ["routes/*.yaml"]
log_level = "INFO"
watch = false
"#,
)
.expect("write Camel.toml");
}
struct Drained {
out_handle: thread::JoinHandle<()>,
err_handle: thread::JoinHandle<()>,
out_buf: Arc<Mutex<String>>,
err_buf: Arc<Mutex<String>>,
}
impl Drained {
fn captured(&self) -> String {
format!(
"stdout:\n{}\nstderr:\n{}",
self.out_buf.lock().expect("stdout buffer lock poisoned"),
self.err_buf.lock().expect("stderr buffer lock poisoned")
)
}
}
fn spawn_drained(child: &mut Child) -> Drained {
let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let stdout = child
.stdout
.take()
.expect("child stdout was configured as piped");
let stderr = child
.stderr
.take()
.expect("child stderr was configured as piped");
let out_handle = thread::spawn({
let buf = Arc::clone(&out_buf);
move || drain_to_buffer(stdout, buf)
});
let err_handle = thread::spawn({
let buf = Arc::clone(&err_buf);
move || drain_to_buffer(stderr, buf)
});
Drained {
out_handle,
err_handle,
out_buf,
err_buf,
}
}
fn wait_exit_code_bounded(child: &mut Child, timeout: Duration) -> i32 {
let start = Instant::now();
let step = Duration::from_millis(25);
loop {
match child.try_wait() {
Ok(Some(status)) => return status.code().unwrap_or(-1),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return -1;
}
thread::sleep(step);
}
Err(e) => panic!("try_wait failed: {e}"),
}
}
}
fn wait_for_marker_tight(
child: &mut Child,
buffers: &[Arc<Mutex<String>>],
marker: &str,
timeout: Duration,
) -> bool {
let start = Instant::now();
let step = Duration::from_millis(5);
loop {
if buffers
.iter()
.any(|buf| buf.lock().expect("buffer lock poisoned").contains(marker))
{
return true;
}
if start.elapsed() >= timeout {
return false;
}
if let Ok(Some(_)) = child.try_wait() {
return false;
}
thread::sleep(step);
}
}
#[test]
fn sigint_during_boot_shuts_down_gracefully() {
let dir = tempfile::tempdir().expect("tempdir");
write_fixture(dir.path());
let mut child = spawn_camel_run(dir.path());
let drained = spawn_drained(&mut child);
let booting = wait_for_marker_tight(
&mut child,
&[Arc::clone(&drained.out_buf), Arc::clone(&drained.err_buf)],
"trusts the current working directory",
Duration::from_secs(30),
);
assert!(
booting,
"camel run never reached mid-boot;\n{}",
drained.captured()
);
send_signal(&child, "-INT");
let exit_code = wait_exit_code_bounded(&mut child, Duration::from_secs(30));
let Drained {
out_handle,
err_handle,
out_buf,
err_buf,
} = drained;
let _ = out_handle.join();
let _ = err_handle.join();
let output = format!(
"stdout:\n{}\nstderr:\n{}",
out_buf.lock().expect("stdout buffer lock poisoned"),
err_buf.lock().expect("stderr buffer lock poisoned")
);
assert_eq!(
exit_code, 0,
"expected graceful shutdown (exit 0) after a mid-boot SIGINT; \
a default-disposition kill would surface as -1;\n{output}\n--- end ---"
);
assert!(
output.contains("Received Ctrl+C"),
"expected the shutdown select to consume the buffered SIGINT \
(missing `Received Ctrl+C`);\n{output}\n--- end ---"
);
}
#[test]
fn second_sigterm_during_teardown_force_exits() {
let dir = tempfile::tempdir().expect("tempdir");
write_fixture(dir.path());
let mut child = spawn_camel_run(dir.path());
let drained = spawn_drained(&mut child);
let booting = wait_for_marker_tight(
&mut child,
&[Arc::clone(&drained.out_buf), Arc::clone(&drained.err_buf)],
"trusts the current working directory",
Duration::from_secs(30),
);
assert!(
booting,
"camel run never reached mid-boot;\n{}",
drained.captured()
);
let pair = Command::new("sh")
.arg("-c")
.arg(format!(
"kill -INT {pid}; kill -TERM {pid}",
pid = child.id()
))
.status()
.expect("failed to spawn signal pair");
assert!(pair.success(), "signal pair returned non-zero: {pair:?}");
let exit_code = wait_exit_code_bounded(&mut child, Duration::from_secs(30));
let Drained {
out_handle,
err_handle,
out_buf,
err_buf,
} = drained;
let _ = out_handle.join();
let _ = err_handle.join();
let output = format!(
"stdout:\n{}\nstderr:\n{}",
out_buf.lock().expect("stdout buffer lock poisoned"),
err_buf.lock().expect("stderr buffer lock poisoned")
);
assert_eq!(
exit_code, 1,
"expected the second stop signal to force-exit with code 1; exit 0 \
means the force-exit arm never fired, -1 means a \
default-disposition kill;\n{output}\n--- end ---"
);
assert!(
output.contains("forcing exit"),
"expected a `Second ... — forcing exit` WARN;\n{output}\n--- end ---"
);
assert!(
output.contains("Received Ctrl+C") || output.contains("Received SIGTERM"),
"expected the graceful first-signal log before the force exit;\
\n{output}\n--- end ---"
);
}