#![cfg(unix)]
use std::{
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
const READY_MARKER: &str = "Starting node";
const SHUTDOWN_MARKER: &str = "Initiating graceful shutdown";
fn freenet_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_freenet"))
}
fn reserve_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
struct NodeProcess {
child: Child,
log_dir: PathBuf,
}
impl Drop for NodeProcess {
fn drop(&mut self) {
if self.child.kill().is_ok() {
let _reaped = self.child.wait().is_ok();
}
}
}
impl NodeProcess {
fn spawn(dir: &Path) -> anyhow::Result<Self> {
let ws_port = reserve_port()?;
let network_port = reserve_port()?;
let child = Command::new(freenet_bin())
.arg("network")
.env("FREENET_TELEMETRY_ENABLED", "false")
.env("RUST_LOG", "info")
.env_remove("FREENET_LOG_TO_STDERR")
.env_remove("FREENET_DISABLE_LOGS")
.env_remove("FREENET_LOG_FORMAT")
.args(["--ws-api-address", "127.0.0.1"])
.args(["--ws-api-port", &ws_port.to_string()])
.args(["--network-address", "127.0.0.1"])
.args(["--network-port", &network_port.to_string()])
.args(["--public-network-address", "127.0.0.1"])
.args(["--public-network-port", &network_port.to_string()])
.arg("--is-gateway")
.arg("--skip-load-from-network")
.arg("--ignore-protocol-checking")
.arg("--disable-auto-update")
.args(["--location", "0.5"])
.args(["--shutdown-drain-secs", "1"])
.args(["--config-dir", &dir.to_string_lossy()])
.args(["--data-dir", &dir.to_string_lossy()])
.args(["--log-dir", &dir.to_string_lossy()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(Self {
child,
log_dir: dir.to_path_buf(),
})
}
fn pid(&self) -> i32 {
self.child.id() as i32
}
fn logs(&self) -> String {
let mut out = String::new();
if let Ok(entries) = std::fs::read_dir(&self.log_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "log") {
out.push_str(&std::fs::read_to_string(&path).unwrap_or_default());
}
}
}
out
}
fn wait_until_ready(&mut self, timeout: Duration) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.child.try_wait()? {
anyhow::bail!(
"freenet exited ({status:?}) before reaching {READY_MARKER:?}. Logs:\n{}",
self.logs()
);
}
if self.logs().contains(READY_MARKER) {
return Ok(());
}
if Instant::now() >= deadline {
anyhow::bail!(
"freenet did not log {READY_MARKER:?} within {timeout:?}. Logs:\n{}",
self.logs()
);
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn send_sigterm(&self) -> anyhow::Result<()> {
if unsafe { libc::kill(self.pid(), libc::SIGTERM) } != 0 {
anyhow::bail!("kill(SIGTERM) failed: {}", std::io::Error::last_os_error());
}
Ok(())
}
fn wait_for_exit(&mut self, timeout: Duration) -> anyhow::Result<std::process::ExitStatus> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.child.try_wait()? {
return Ok(status);
}
if Instant::now() >= deadline {
anyhow::bail!(
"freenet did not exit within {timeout:?} of SIGTERM. Logs:\n{}",
self.logs()
);
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
#[test]
fn graceful_shutdown_on_sigterm_exits_zero() -> anyhow::Result<()> {
use std::os::unix::process::ExitStatusExt;
let dir = tempfile::tempdir()?;
let mut node = NodeProcess::spawn(dir.path())?;
node.wait_until_ready(Duration::from_secs(120))?;
node.send_sigterm()?;
let status = node.wait_for_exit(Duration::from_secs(45))?;
assert!(
status.signal().is_none(),
"node was killed by signal {:?} instead of handling SIGTERM — the readiness \
gate raced. Logs:\n{}",
status.signal(),
node.logs()
);
assert_eq!(
status.code(),
Some(0),
"a graceful shutdown must exit 0 (#5227). Any other code is classified as a \
CRASH by rollback::classify_stop, so systemd logs status=N/FAILURE for a clean \
`systemctl stop` and the post-update probation counts it toward an \
auto-rollback. Logs:\n{}",
node.logs()
);
let logs = node.logs();
assert!(
logs.contains(SHUTDOWN_MARKER),
"exit 0 must come from the signal-handling path — {SHUTDOWN_MARKER:?} is absent, \
so the node exited cleanly for some other reason and this test proves nothing. \
Logs:\n{logs}"
);
Ok(())
}
#[test]
fn graceful_classification_is_by_type_not_by_message() {
#[derive(Debug, thiserror::Error)]
#[error("Graceful shutdown")]
struct GracefulLookalike;
assert!(freenet::listener_exit_is_graceful(
&freenet::EventLoopExitReason::GracefulShutdown.into()
));
assert!(!freenet::listener_exit_is_graceful(
&freenet::EventLoopExitReason::UnexpectedStreamEnd.into()
));
assert!(!freenet::listener_exit_is_graceful(&anyhow::Error::new(
GracefulLookalike
)));
assert!(!freenet::listener_exit_is_graceful(&anyhow::anyhow!(
"Graceful shutdown"
)));
}