145 lines
4.8 KiB
Rust
145 lines
4.8 KiB
Rust
|
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||
|
|
// All rights reserved.
|
||
|
|
//
|
||
|
|
// Regression tests for session teardown (`webterm::reap_child`).
|
||
|
|
//
|
||
|
|
// Background: `Terminal::stopping` used to call `child.kill()` then a blocking
|
||
|
|
// `child.wait()` on the actix worker thread. When the signal was refused
|
||
|
|
// (sessions run as another uid, server without CAP_KILL) or only reached the
|
||
|
|
// shell (a non-interactive bash defers signals while a foreground command
|
||
|
|
// runs), the child survived and the worker hung forever — every other request
|
||
|
|
// to the server then timed out. These tests pin the fixed behaviour: the whole
|
||
|
|
// process group is signalled, SIGHUP escalates to SIGKILL, and the caller is
|
||
|
|
// never blocked.
|
||
|
|
|
||
|
|
use std::io::Read;
|
||
|
|
use std::time::{Duration, Instant};
|
||
|
|
|
||
|
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||
|
|
|
||
|
|
/// True while `pid` exists and is not a zombie (kill(pid, 0) also succeeds on
|
||
|
|
/// zombies, so read /proc directly).
|
||
|
|
fn alive(pid: i32) -> bool {
|
||
|
|
match std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||
|
|
Ok(s) => !s
|
||
|
|
.lines()
|
||
|
|
.any(|l| l.starts_with("State:") && l.contains("Z (zombie)")),
|
||
|
|
Err(_) => false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn wait_gone(pid: i32, timeout: Duration) -> bool {
|
||
|
|
let start = Instant::now();
|
||
|
|
while start.elapsed() < timeout {
|
||
|
|
if !alive(pid) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
std::thread::sleep(Duration::from_millis(50));
|
||
|
|
}
|
||
|
|
!alive(pid)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Spawn `script` under `sh -c` in a fresh pty. The script must print the pid
|
||
|
|
/// of its long-running grandchild as its first line; that pid is returned along
|
||
|
|
/// with the pty child handle.
|
||
|
|
fn spawn_in_pty(script: &str) -> (Box<dyn portable_pty::Child + Send>, i32) {
|
||
|
|
let pty = native_pty_system();
|
||
|
|
let pair = pty
|
||
|
|
.openpty(PtySize {
|
||
|
|
rows: 24,
|
||
|
|
cols: 80,
|
||
|
|
pixel_width: 0,
|
||
|
|
pixel_height: 0,
|
||
|
|
})
|
||
|
|
.expect("openpty");
|
||
|
|
|
||
|
|
let mut cmd = CommandBuilder::new("sh");
|
||
|
|
cmd.arg("-c");
|
||
|
|
cmd.arg(script);
|
||
|
|
let child = pair.slave.spawn_command(cmd).expect("spawn");
|
||
|
|
drop(pair.slave);
|
||
|
|
|
||
|
|
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||
|
|
let mut buf = Vec::new();
|
||
|
|
let mut byte = [0u8; 1];
|
||
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||
|
|
while Instant::now() < deadline {
|
||
|
|
match reader.read(&mut byte) {
|
||
|
|
Ok(1) if byte[0] == b'\n' => break,
|
||
|
|
Ok(1) => buf.push(byte[0]),
|
||
|
|
_ => break,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let grandchild: i32 = String::from_utf8_lossy(&buf)
|
||
|
|
.trim()
|
||
|
|
.parse()
|
||
|
|
.expect("grandchild pid line");
|
||
|
|
assert!(
|
||
|
|
alive(grandchild),
|
||
|
|
"grandchild {grandchild} should be running"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Keep the master alive for the duration of the test (mirrors the
|
||
|
|
// server's reader thread holding a cloned fd), so the kernel does not
|
||
|
|
// hang up the pty for us and the reaper alone has to do the work.
|
||
|
|
std::mem::forget(pair.master);
|
||
|
|
std::mem::forget(reader);
|
||
|
|
|
||
|
|
(child, grandchild)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn reap_child_returns_immediately_and_kills_whole_process_group() {
|
||
|
|
// sh waits on a foreground sleep: signalling sh alone would be deferred.
|
||
|
|
let (child, sleeper) = spawn_in_pty("sleep 30 & echo $!; wait");
|
||
|
|
let shell = child.process_id().expect("pid") as i32;
|
||
|
|
|
||
|
|
let t0 = Instant::now();
|
||
|
|
let reaper = webterm::reap_child(child);
|
||
|
|
assert!(
|
||
|
|
t0.elapsed() < Duration::from_millis(500),
|
||
|
|
"reap_child must not block the caller (took {:?})",
|
||
|
|
t0.elapsed()
|
||
|
|
);
|
||
|
|
|
||
|
|
reaper.join().expect("reaper thread");
|
||
|
|
assert!(
|
||
|
|
wait_gone(shell, Duration::from_secs(2)),
|
||
|
|
"shell {shell} survived"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
wait_gone(sleeper, Duration::from_secs(2)),
|
||
|
|
"grandchild {sleeper} was orphaned instead of killed with its group"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn reap_child_escalates_to_sigkill_when_sighup_is_ignored() {
|
||
|
|
// `trap '' HUP` makes sh ignore SIGHUP and the ignored disposition is
|
||
|
|
// inherited across exec, so sleep ignores it too — exactly the shape of a
|
||
|
|
// TUI that swallows HUP.
|
||
|
|
let (child, sleeper) = spawn_in_pty("trap '' HUP; sleep 30 & echo $!; wait");
|
||
|
|
let shell = child.process_id().expect("pid") as i32;
|
||
|
|
|
||
|
|
let t0 = Instant::now();
|
||
|
|
webterm::reap_child(child).join().expect("reaper thread");
|
||
|
|
let took = t0.elapsed();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
took >= Duration::from_secs(2),
|
||
|
|
"should have waited out the SIGHUP grace period (took {took:?})"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
took < Duration::from_secs(8),
|
||
|
|
"SIGKILL escalation should finish well inside the deadline (took {took:?})"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
wait_gone(shell, Duration::from_secs(2)),
|
||
|
|
"shell {shell} survived SIGKILL"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
wait_gone(sleeper, Duration::from_secs(2)),
|
||
|
|
"grandchild {sleeper} survived SIGKILL"
|
||
|
|
);
|
||
|
|
}
|