use std::io;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use muxio_tokio_rpc_ipc_client::RpcCallPrebuffered;
use term_session_muxio_service_definitions::{Attach, ListChannels, ShutdownGateway, Spawn};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_term-session"))
}
fn mock_bin() -> PathBuf {
term_session_mock::get_mock_bin()
}
fn unique_gateway(tag: &str) -> String {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("term-wm/dtest-{tag}-{id}")
}
fn spawn_daemon(gateway: &str, selfcheck: bool) -> (Child, Option<PathBuf>) {
let marker = if selfcheck {
let path = std::env::temp_dir().join(format!(
"term-session-selfcheck-{}.txt",
gateway.replace('/', "-")
));
let _ = std::fs::remove_file(&path);
Some(path)
} else {
None
};
let mut cmd = Command::new(bin());
cmd.env("TERM_WM_GATEWAY", gateway).arg("--daemon");
if let Some(ref m) = marker {
cmd.arg("--daemon-selfcheck").arg(m);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = cmd.spawn().expect("spawn daemon");
(child, marker)
}
async fn wait_connectable(gateway: &str) -> Arc<muxio_tokio_rpc_ipc_client::RpcIpcClient> {
let start = Instant::now();
loop {
match muxio_tokio_rpc_ipc_client::RpcIpcClient::new(gateway).await {
Ok(c) => return c,
Err(_) if start.elapsed() < Duration::from_secs(20) => {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(e) => panic!("gateway {gateway} not reachable after 20s: {e}"),
}
}
}
#[tokio::test]
async fn daemon_detaches_and_reports_proof() {
let gateway = unique_gateway("detach");
let (mut child, marker) = spawn_daemon(&gateway, true);
let marker = marker.expect("marker requested");
let start = Instant::now();
let proof = loop {
if let Ok(content) = std::fs::read_to_string(&marker) {
break content.trim().to_string();
}
assert!(
start.elapsed() < Duration::from_secs(8),
"daemon never wrote selfcheck marker"
);
tokio::time::sleep(Duration::from_millis(50)).await;
};
#[cfg(windows)]
assert_eq!(proof, "windows-no-console", "marker: {proof}");
#[cfg(unix)]
assert_eq!(proof, "unix-session-leader", "marker: {proof}");
let client = wait_connectable(&gateway).await;
ShutdownGateway::call(&*client, ()).await.unwrap();
let _ = child.wait();
}
#[tokio::test]
async fn daemon_survives_all_clients_disconnecting() {
let gateway = unique_gateway("survive");
let (mut child, _marker) = spawn_daemon(&gateway, false);
let client = wait_connectable(&gateway).await;
let channel = "test/daemon_survive";
Attach::call(
&*client,
(
channel.to_string(),
"t".to_string(),
std::process::id() as u64,
),
)
.await
.unwrap();
Spawn::call(
&*client,
(
Some(vec![
mock_bin().to_string_lossy().to_string(),
"sleep".into(),
"60000".into(),
]),
80u16,
24u16,
),
)
.await
.unwrap();
drop(client);
let client2 = wait_connectable(&gateway).await;
Attach::call(
&*client2,
(
channel.to_string(),
"t".to_string(),
std::process::id() as u64,
),
)
.await
.unwrap();
Spawn::call(&*client2, (None, 80u16, 24u16)).await.unwrap();
ShutdownGateway::call(&*client2, ()).await.unwrap();
let _ = child.wait();
}
#[tokio::test]
async fn daemon_survives_parent_death() {
let gateway = unique_gateway("parent_death");
let channel = "test/daemon_parent_death";
let mock = mock_bin().to_string_lossy().to_string();
let mut attach = Command::new(bin())
.env("TERM_WM_GATEWAY", &gateway)
.args([
"attach",
"--channel",
channel,
"--",
&mock,
"sleep",
"60000",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn attach");
tokio::time::sleep(Duration::from_millis(2000)).await;
let _ = attach.kill();
let _ = attach.wait();
let client = wait_connectable(&gateway).await;
Attach::call(
&*client,
(
channel.to_string(),
"t".to_string(),
std::process::id() as u64,
),
)
.await
.unwrap();
let (id, _, _) = Spawn::call(&*client, (None, 80u16, 24u16)).await.unwrap();
assert_eq!(id, 1, "session from the orphaned daemon must persist");
ShutdownGateway::call(&*client, ()).await.unwrap();
tokio::time::sleep(Duration::from_millis(1000)).await;
}
#[tokio::test]
async fn daemon_does_not_inherit_parent_handles() {
use term_session::auto_spawn::connect_or_spawn_server;
let gateway = unique_gateway("no_inherit");
unsafe {
std::env::set_var("TERM_WM_GATEWAY", &gateway);
}
#[cfg(windows)]
let (read_end, write_end) = create_inheritable_pipe();
#[cfg(unix)]
let (read_end, write_end) = create_cloexec_pipe();
#[cfg(not(any(unix, windows)))]
panic!("handle-inheritance test not supported on this platform");
connect_or_spawn_server(Some(&bin())).expect("auto-spawn daemon");
close_write_end(write_end);
assert_eof_on_read_end(read_end, Duration::from_secs(5))
.expect("daemon inherited the parent's pipe write end");
close_read_end(read_end);
let client = wait_connectable(&gateway).await;
ShutdownGateway::call(&*client, ()).await.unwrap();
}
#[cfg(windows)]
fn create_inheritable_pipe() -> (
windows_sys::Win32::Foundation::HANDLE,
windows_sys::Win32::Foundation::HANDLE,
) {
use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
use windows_sys::Win32::System::Pipes::CreatePipe;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: std::ptr::null_mut(),
bInheritHandle: 1,
};
let mut read = std::ptr::null_mut();
let mut write = std::ptr::null_mut();
let ok = unsafe { CreatePipe(&mut read, &mut write, &sa, 0) };
assert_ne!(ok, 0, "CreatePipe failed: {}", io::Error::last_os_error());
(read, write)
}
#[cfg(unix)]
fn create_cloexec_pipe() -> (libc::c_int, libc::c_int) {
use std::os::unix::io::RawFd;
let mut fds = [0 as RawFd; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed");
for &fd in &fds {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
assert!(flags >= 0, "F_GETFD failed");
let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
assert_eq!(rc, 0, "F_SETFD failed");
}
(fds[0], fds[1])
}
#[cfg(windows)]
fn assert_eof_on_read_end(
read: windows_sys::Win32::Foundation::HANDLE,
timeout: Duration,
) -> io::Result<()> {
use windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE;
use windows_sys::Win32::System::Pipes::PeekNamedPipe;
let start = Instant::now();
loop {
let mut total_avail: u32 = 0;
let ok = unsafe {
PeekNamedPipe(
read,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
&mut total_avail,
std::ptr::null_mut(),
)
};
if ok == 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) {
return Ok(());
}
return Err(err);
}
if start.elapsed() >= timeout {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"read end never reached EOF (daemon inherited the write handle)",
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[cfg(unix)]
fn assert_eof_on_read_end(fd: libc::c_int, timeout: Duration) -> io::Result<()> {
let start = Instant::now();
loop {
let mut poll_fds = [libc::pollfd {
fd,
events: libc::POLLIN | libc::POLLHUP,
revents: 0,
}];
let n = unsafe { libc::poll(poll_fds.as_mut_ptr(), 1, 50) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n > 0 {
let mut buf = [0u8; 64];
loop {
let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if r < 0 {
if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(io::Error::last_os_error());
}
if r == 0 {
return Ok(());
}
if (r as usize) < buf.len() {
break;
}
}
}
if start.elapsed() >= timeout {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"read end never reached EOF (child inherited the write fd)",
));
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[cfg(windows)]
fn close_read_end(read: windows_sys::Win32::Foundation::HANDLE) {
unsafe {
let _ = windows_sys::Win32::Foundation::CloseHandle(read);
}
}
#[cfg(windows)]
fn close_write_end(write: windows_sys::Win32::Foundation::HANDLE) {
unsafe {
let _ = windows_sys::Win32::Foundation::CloseHandle(write);
}
}
#[cfg(unix)]
fn close_read_end(read: libc::c_int) {
unsafe {
let _ = libc::close(read);
}
}
#[cfg(unix)]
fn close_write_end(write: libc::c_int) {
unsafe {
let _ = libc::close(write);
}
}
#[tokio::test]
async fn cli_kill_client_detaches_one_client() {
let gateway = unique_gateway("kill_client");
let channel = "test/kill_client";
let (mut child, _marker) = spawn_daemon(&gateway, false);
let c1 = wait_connectable(&gateway).await;
let c2 = wait_connectable(&gateway).await;
Attach::call(
&*c1,
(
channel.to_string(),
"one".to_string(),
std::process::id() as u64,
),
)
.await
.unwrap();
Attach::call(
&*c2,
(
channel.to_string(),
"two".to_string(),
std::process::id() as u64,
),
)
.await
.unwrap();
Spawn::call(
&*c1,
(
Some(vec![
mock_bin().to_string_lossy().to_string(),
"sleep".into(),
"60000".into(),
]),
80u16,
24u16,
),
)
.await
.unwrap();
Spawn::call(&*c2, (None, 80u16, 24u16)).await.unwrap();
let resp = ListChannels::call(&*c1, ()).await.unwrap();
let ch = resp
.channels
.iter()
.find(|c| c.name == channel)
.expect("channel listed");
assert_eq!(ch.clients.len(), 2, "two clients attached");
let target = ch.clients[0].conn_id;
let out = Command::new(bin())
.env("TERM_WM_GATEWAY", &gateway)
.args(["kill-client", channel, &target.to_string()])
.output()
.expect("run kill-client");
assert!(
out.status.success(),
"kill-client failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let resp = ListChannels::call(&*c1, ()).await.unwrap();
let ch = resp
.channels
.iter()
.find(|c| c.name == channel)
.expect("channel listed");
assert_eq!(
ch.clients.len(),
1,
"one client should remain after kill-client"
);
ShutdownGateway::call(&*c1, ()).await.unwrap();
let _ = child.wait();
}