use std::{
fmt::Write,
io::Read,
os::unix::{
net::{UnixListener, UnixStream},
process::CommandExt as _,
},
path,
process::{Command, Stdio},
time,
};
use anyhow::{anyhow, Context};
use nix::{
sys::signal::{self, Signal},
unistd::{ForkResult, Pid},
};
use ntest::timeout;
use regex::Regex;
mod support;
use crate::support::{
daemon::{AttachArgs, DaemonArgs},
tmpdir,
};
#[test]
#[timeout(30000)]
fn start() -> anyhow::Result<()> {
let tmp_dir = tmpdir::Dir::new("/tmp/shpool-test")?;
let mut child = Command::new(support::shpool_bin()?)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("--socket")
.arg(tmp_dir.path().join("shpool.socket"))
.arg("daemon")
.spawn()
.context("spawning daemon process")?;
std::thread::sleep(time::Duration::from_millis(500));
child.kill().context("killing child")?;
let mut stdout = child.stdout.take().context("missing stdout")?;
let mut stdout_str = String::from("");
stdout.read_to_string(&mut stdout_str).context("slurping stdout")?;
if !stdout_str.is_empty() {
println!("{stdout_str}");
return Err(anyhow!("unexpected stdout output"));
}
let mut stderr = child.stderr.take().context("missing stderr")?;
let mut stderr_str = String::from("");
stderr.read_to_string(&mut stderr_str).context("slurping stderr")?;
assert!(stderr_str.contains("STARTING DAEMON"));
Ok(())
}
#[test]
#[timeout(30000)]
#[cfg_attr(target_os = "macos", ignore)] fn systemd_activation() -> anyhow::Result<()> {
let tmp_dir = tmpdir::Dir::new("/tmp/shpool-test")?;
let sock_path = tmp_dir.path().join("shpool.socket");
let activation_sock = UnixListener::bind(&sock_path)?;
let (parent_stderr, child_stderr) =
nix::unistd::pipe().context("creating pipe to collect stderr")?;
let child_stderr_pipe = Stdio::from(child_stderr);
let mut cmd = Command::new(support::shpool_bin()?);
cmd.stdout(Stdio::piped())
.stderr(child_stderr_pipe)
.env("LISTEN_FDS", "1")
.env("LISTEN_FDNAMES", sock_path)
.arg("daemon");
let mut pid_buf = String::with_capacity(128);
let child_pid = match unsafe { nix::unistd::fork() } {
Ok(ForkResult::Parent { child, .. }) => child,
Ok(ForkResult::Child) => {
let fdarg = match unsafe { nix::unistd::dup2_raw(activation_sock, 3) } {
Ok(newfd) => newfd,
Err(e) => {
eprintln!("dup err: {e}");
std::process::exit(1)
}
};
let fdflags = nix::fcntl::fcntl(&fdarg, nix::fcntl::FcntlArg::F_GETFD)
.expect("getfd flags to work");
let mut newflags = nix::fcntl::FdFlag::from_bits(fdflags).unwrap();
newflags.remove(nix::fcntl::FdFlag::FD_CLOEXEC);
nix::fcntl::fcntl(&fdarg, nix::fcntl::FcntlArg::F_SETFD(newflags))
.expect("FD_CLOEXEC to be unset");
write!(&mut pid_buf, "{}", std::process::id()).expect("to be able to format the pid");
cmd.env("LISTEN_PID", pid_buf);
let err = cmd.exec();
eprintln!("exec err: {err:?}");
std::process::exit(1);
}
Err(e) => {
return Err(e).context("forking daemon proc");
}
};
std::thread::sleep(time::Duration::from_millis(500));
nix::sys::signal::kill(child_pid, Some(nix::sys::signal::Signal::SIGKILL))
.context("killing daemon")?;
nix::sys::wait::waitpid(child_pid, None).context("reaping daemon")?;
let mut stderr_buf: Vec<u8> = vec![0; 1024 * 8];
let len = nix::unistd::read(&parent_stderr, &mut stderr_buf[..]).context("reading stderr")?;
let stderr = String::from_utf8_lossy(&stderr_buf[..len]);
assert!(stderr.contains("using systemd activation socket"));
Ok(())
}
#[test]
#[timeout(30000)]
fn config() -> anyhow::Result<()> {
let tmp_dir = tmpdir::Dir::new("/tmp/shpool-test")?;
let mut child = Command::new(support::shpool_bin()?)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("--socket")
.arg(tmp_dir.path().join("shpool.socket"))
.arg("--config-file")
.arg(support::testdata_file("empty.toml"))
.arg("daemon")
.spawn()
.context("spawning daemon process")?;
std::thread::sleep(time::Duration::from_millis(500));
child.kill().context("killing child")?;
let mut stdout = child.stdout.take().context("missing stdout")?;
let mut stdout_str = String::from("");
stdout.read_to_string(&mut stdout_str).context("slurping stdout")?;
if !stdout_str.is_empty() {
println!("{stdout_str}");
return Err(anyhow!("unexpected stdout output"));
}
let mut stderr = child.stderr.take().context("missing stderr")?;
let mut stderr_str = String::from("");
stderr.read_to_string(&mut stderr_str).context("slurping stderr")?;
assert!(stderr_str.contains("STARTING DAEMON"));
Ok(())
}
#[test]
#[timeout(30000)]
fn hooks() -> anyhow::Result<()> {
let mut daemon_proc =
support::daemon::Proc::new_instrumented("norc.toml").context("starting daemon proc")?;
let sh1_detached_re = Regex::new("sh1.*disconnected")?;
{
let mut sh1_proc = daemon_proc
.attach(
"sh1",
AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() },
)
.context("starting attach proc")?;
let mut sh1_matcher = sh1_proc.line_matcher()?;
sh1_proc.run_cmd("echo hi")?;
sh1_matcher.scan_until_re("hi$")?;
let mut busy_proc = daemon_proc
.attach(
"sh1",
AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() },
)
.context("starting attach proc")?;
busy_proc.proc.wait()?;
}
daemon_proc.wait_until_list_matches(|listout| sh1_detached_re.is_match(listout))?;
let mut sh1_proc = daemon_proc
.attach("sh1", AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() })
.context("starting attach proc")?;
sh1_proc.run_cmd("exit")?;
support::wait_until(|| {
let hook_records = daemon_proc.hook_records.as_ref().unwrap().lock().unwrap();
Ok(!hook_records.shell_disconnects.is_empty())
})?;
let hook_records = daemon_proc.hook_records.as_ref().unwrap().lock().unwrap();
eprintln!("hook_records: {hook_records:?}");
assert_eq!(hook_records.new_sessions[0], "sh1");
assert_eq!(hook_records.reattaches[0], "sh1");
assert_eq!(hook_records.busys[0], "sh1");
assert_eq!(hook_records.client_disconnects[0], "sh1");
assert_eq!(hook_records.shell_disconnects[0], "sh1");
Ok(())
}
#[test]
#[timeout(30000)]
fn cleanup_socket() -> anyhow::Result<()> {
let mut daemon_proc = support::daemon::Proc::new(
"norc.toml",
DaemonArgs { listen_events: false, ..DaemonArgs::default() },
)
.context("starting daemon proc")?;
signal::kill(Pid::from_raw(daemon_proc.proc.as_ref().unwrap().id() as i32), Signal::SIGINT)?;
daemon_proc.proc_wait()?;
assert!(!path::Path::new(&daemon_proc.socket_path).exists());
Ok(())
}
#[test]
#[timeout(30000)]
fn stale_socket_autodaemonize() -> anyhow::Result<()> {
let tmp_dir = tmpdir::Dir::new("/tmp/shpool-test")?;
let socket_path = tmp_dir.path().join("shpool.socket");
{
let _listener = UnixListener::bind(&socket_path).context("binding stale socket")?;
}
assert!(socket_path.exists(), "stale socket file should persist after listener drop");
assert!(
matches!(
UnixStream::connect(&socket_path).map_err(|e| e.kind()),
Err(std::io::ErrorKind::ConnectionRefused)
),
"expected ConnectionRefused on stale socket"
);
let log_file = tmp_dir.path().join("shpool.log");
let out = Command::new(support::shpool_bin()?)
.arg("--daemonize")
.arg("--socket")
.arg(&socket_path)
.arg("--log-file")
.arg(&log_file)
.arg("--config-file")
.arg(support::testdata_file("norc.toml"))
.arg("list")
.output()
.context("running shpool list --daemonize")?;
Command::new("pkill").arg("-f").arg(socket_path.to_string_lossy().as_ref()).output().ok();
assert!(out.status.success(), "shpool list should succeed despite stale socket");
Ok(())
}
#[test]
#[timeout(30000)]
fn echo_sentinel() -> anyhow::Result<()> {
let output = Command::new(support::shpool_bin()?)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("SHPOOL__INTERNAL__PRINT_SENTINEL", "prompt")
.arg("daemon")
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("SHPOOL_PROMPT_SETUP_SENTINEL"));
Ok(())
}
#[test]
#[timeout(30000)]
fn allows_dynamic_log_adjustments() -> anyhow::Result<()> {
let mut daemon_proc =
support::daemon::Proc::new("norc.toml", DaemonArgs { verbosity: 0, ..Default::default() })
.context("starting daemon proc")?;
daemon_proc.set_log_level("trace")?;
loop {
let mut sh1 = daemon_proc.attach("sh1", AttachArgs::default())?;
sh1.run_cmd("echo hi")?;
let log_data = std::fs::read_to_string(&daemon_proc.log_file)?;
if log_data.contains("echo hi") {
break;
}
std::thread::sleep(time::Duration::from_millis(300));
}
Ok(())
}