// A shell on a real pseudo-terminal, over an in-memory stream.
//
// Its own test binary on purpose, for the reason `reaping.rs` is: the reaper waits
// on any child, so in a binary shared with other tests it would collect
// processes they were waiting for.
use std::time::{Duration, Instant};
use nesinit::attach::{serve, spawn};
use nesinit::reap::{Waiters, reap_exited};
use nesprotocol::attach::{ExitStatus, Frame, FrameReader};
use nesprotocol::lifecycle::{AttachId, Exec, Winsize};
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
/// Reaping is process-wide, so these run one at a time.
static ONE_REAPER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A reaper like the guest's: the only thing that waits, handing exits to
/// whoever asked.
fn start_reaper(waiters: Waiters) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
for (pid, exit) in reap_exited() {
waiters.deliver(pid, exit);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
}
fn me() -> (u32, u32) {
// SAFETY: two calls with no arguments.
unsafe { (libc::getuid(), libc::getgid()) }
}
fn exec(argv: &[&str]) -> Exec {
let (uid, gid) = me();
Exec {
argv: argv.iter().map(|s| s.to_string()).collect(),
env: [("TERM".to_string(), "dumb".to_string())].into(),
cwd: None,
uid,
gid,
}
}
/// The host's end of the stream.
struct Host {
stream: DuplexStream,
frames: FrameReader,
}
impl Host {
async fn say(&mut self, frame: Frame) {
self.stream
.write_all(&frame.encode().unwrap())
.await
.unwrap();
}
async fn next(&mut self) -> Option {
let mut buf = [0u8; 4096];
loop {
if let Some(frame) = self.frames.next_frame().unwrap() {
return Some(frame);
}
let n = tokio::time::timeout(Duration::from_secs(10), self.stream.read(&mut buf))
.await
.expect("the guest went quiet")
.unwrap();
if n == 0 {
return None;
}
self.frames.push(&buf[..n]);
}
}
/// Everything until the exit, as (output, status).
async fn until_exit(&mut self) -> (Vec, ExitStatus) {
let mut out = Vec::new();
loop {
match self.next().await.expect("the stream ended before an exit") {
Frame::Data(bytes) => out.extend(bytes),
Frame::Exit(status) => return (out, status),
other => panic!("unexpected frame from the guest: {other:?}"),
}
}
}
}
/// Start a shell and serve it; return the host's end and the pid.
async fn attach(
waiters: &Waiters,
argv: &[&str],
size: Option,
) -> (Host, i32, tokio::task::JoinHandle>) {
let shell = spawn(waiters, &exec(argv), size).expect("the shell starts");
let pid = shell.pid();
let (guest_end, host_end) = tokio::io::duplex(1 << 16);
let id = AttachId::new("a-test");
let served = tokio::spawn(async move { serve(shell, &id, guest_end).await });
let mut host = Host {
stream: host_end,
frames: FrameReader::new(),
};
assert_eq!(
host.next().await,
Some(Frame::Hello("a-test".into())),
"the first frame must say which shell this is"
);
(host, pid, served)
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[tokio::test]
async fn a_person_at_a_terminal_types_and_sees_the_answer() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) =
attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await;
host.say(Frame::Data(b"echo hello-from-the-box\nexit 3\n".to_vec()))
.await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("hello-from-the-box"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(3));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A terminal that was resized tells the program on it, which is what makes a
/// full-screen tool fit the window it is in.
#[tokio::test]
async fn a_resize_reaches_the_program_on_the_terminal() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) =
attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await;
host.say(Frame::Resize {
cols: 100,
rows: 30,
})
.await;
host.say(Frame::Data(b"stty size\nexit 0\n".to_vec())).await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("30 100"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(0));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A command run for its output is a byte pipe: nothing rewrites its line
/// endings, and what it wrote to stderr is there too.
#[tokio::test]
async fn a_command_without_a_terminal_gets_its_bytes_back_unrewritten() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(
&waiters,
&["/bin/sh", "-c", "echo out; echo err >&2; exit 5"],
None,
)
.await;
let (out, status) = host.until_exit().await;
let out = text(&out);
assert!(out.contains("out\n") && out.contains("err\n"), "{out:?}");
assert!(
!out.contains('\r'),
"a terminal rewrote the line endings of a command's output: {out:?}"
);
assert_eq!(status, ExitStatus::Code(5));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A kill is not an exit code.
#[tokio::test]
async fn a_shell_that_is_killed_says_which_signal() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(&waiters, &["/bin/sh", "-c", "kill -9 $$"], None).await;
let (_, status) = host.until_exit().await;
assert_eq!(status, ExitStatus::Signal(9));
served.await.unwrap().unwrap();
reaper.abort();
}
/// The last thing a program printed is what says why it stopped, so it must
/// not be lost to the exit arriving first.
#[tokio::test]
async fn output_written_just_before_the_exit_is_not_lost() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(
&waiters,
&["/bin/sh", "-c", "printf 'the-last-line\\n'; exit 1"],
None,
)
.await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("the-last-line"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(1));
served.await.unwrap().unwrap();
reaper.abort();
}
/// The command line going away hangs the shell up, and what it started with it.
#[tokio::test]
async fn closing_the_stream_hangs_up_the_shell() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
drop(host);
served.await.unwrap().unwrap();
// Gone, and collected: signal zero asks whether the pid still exists.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
// SAFETY: an integer pid and the null signal.
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
break;
}
assert!(
Instant::now() < deadline,
"the shell outlived the connection that was its reason to exist"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}
/// An explicit hangup does the same.
#[tokio::test]
async fn a_hangup_frame_ends_the_shell() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
host.say(Frame::Hangup).await;
served.await.unwrap().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the shell outlived a hangup");
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}
/// Everything that can be refused before there is a connection is refused where
/// it can be sent up the control channel.
#[tokio::test]
async fn a_program_that_does_not_exist_is_refused_before_anything_connects() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let refused = spawn(&waiters, &exec(&["/nonexistent/program"]), None)
.err()
.expect("a missing program started");
assert!(
refused.reason.contains("/nonexistent/program"),
"{}",
refused.reason
);
assert!(spawn(&waiters, &exec(&[]), None).is_err());
reaper.abort();
}
/// A peer that speaks the wrong half of the protocol is not obeyed.
#[tokio::test]
async fn a_frame_only_the_guest_sends_is_refused_and_the_shell_hung_up() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
host.say(Frame::Exit(ExitStatus::Code(0))).await;
assert!(served.await.unwrap().is_err());
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the shell outlived a bad peer");
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}