use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use crate::error::{Error, Result};
#[cfg(windows)]
type Containment = Option<std::sync::Arc<crate::sandbox::windows::job::Job>>;
#[cfg(not(windows))]
type Containment = ();
pub(crate) const ORPHAN_REASON: &str =
"started by a previous process; its pid may since have been reused";
pub(crate) const MAX_LIVE_HANDLES: usize = 8;
pub(crate) const POLL_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HandleState {
Running,
Exited(Option<i32>),
Killed,
Orphaned(String),
}
impl HandleState {
pub(crate) fn is_over(&self) -> bool {
!matches!(self, HandleState::Running)
}
pub(crate) fn as_str(&self) -> &str {
match self {
HandleState::Running => "running",
HandleState::Exited(_) => "exited",
HandleState::Killed => "killed",
HandleState::Orphaned(_) => "orphaned",
}
}
}
struct Record {
line: String,
pids: Vec<u32>,
capture: PathBuf,
cursor: u64,
state: HandleState,
contained: Containment,
}
pub(crate) struct Handles {
inner: Mutex<HashMap<u64, Record>>,
next: AtomicU64,
dir: Option<tempfile::TempDir>,
cap: usize,
}
impl Handles {
pub(crate) fn new(cap: usize) -> Self {
Handles {
inner: Mutex::new(HashMap::new()),
next: AtomicU64::new(1),
dir: tempfile::tempdir().ok(),
cap,
}
}
pub(crate) fn live(&self) -> usize {
self.lock().values().filter(|r| !r.state.is_over()).count()
}
pub(crate) fn reserve(&self, line: &str) -> std::result::Result<(u64, PathBuf), String> {
let live = self.live();
if live >= self.cap {
return Err(format!(
"this run already has {live} live process handles and the cap is {}; \
kill one with shell_kill before starting another",
self.cap
));
}
let dir = self.dir.as_ref().ok_or_else(|| {
"no writable temporary directory is available for a handle's output".to_string()
})?;
let id = self.next.fetch_add(1, Ordering::SeqCst);
let capture = dir.path().join(format!("handle-{id}.out"));
#[cfg(windows)]
let contained = Some(std::sync::Arc::new(
crate::sandbox::windows::job::Job::for_handle().map_err(|e| {
format!(
"could not create the job object that would contain this handle ({e}); \
the line was not started, because a handle whose processes are not in a \
job is one whose kill cannot reach a re-parented grandchild"
)
})?,
));
#[cfg(not(windows))]
let contained = ();
self.lock().insert(
id,
Record {
line: line.to_string(),
pids: Vec::new(),
capture: capture.clone(),
cursor: 0,
state: HandleState::Running,
contained,
},
);
Ok((id, capture))
}
pub(crate) fn add_pid(&self, id: u64, pid: u32) {
if let Some(r) = self.lock().get_mut(&id) {
r.pids.push(pid);
}
}
#[cfg(windows)]
pub(crate) fn contain(&self, id: u64, child: &tokio::process::Child) -> Result<()> {
let job = self.lock().get(&id).and_then(|r| r.contained.clone());
match job {
Some(job) => job.adopt(child),
None => Err(Error::Sandbox {
reason: format!("process handle {id} was ended while its line was still starting"),
}),
}
}
pub(crate) fn pids(&self, id: u64) -> Vec<u32> {
self.lock()
.get(&id)
.map(|r| r.pids.clone())
.unwrap_or_default()
}
pub(crate) fn live_handles(&self) -> Vec<(u64, Vec<u32>)> {
let guard = self.lock();
let mut v: Vec<(u64, Vec<u32>)> = guard
.iter()
.filter(|(_, r)| !r.state.is_over())
.map(|(id, r)| (*id, r.pids.clone()))
.collect();
v.sort_by_key(|(id, _)| *id);
v
}
pub(crate) fn states(&self) -> Vec<(u64, HandleState)> {
let guard = self.lock();
let mut v: Vec<(u64, HandleState)> =
guard.iter().map(|(id, r)| (*id, r.state.clone())).collect();
v.sort_by_key(|(id, _)| *id);
v
}
pub(crate) fn line(&self, id: u64) -> Option<String> {
self.lock().get(&id).map(|r| r.line.clone())
}
pub(crate) fn state(&self, id: u64) -> Option<HandleState> {
self.lock().get(&id).map(|r| r.state.clone())
}
pub(crate) fn finished(&self, id: u64, code: Option<i32>) {
if let Some(r) = self.lock().get_mut(&id) {
if !r.state.is_over() {
r.state = HandleState::Exited(code);
}
}
}
pub(crate) fn poll(&self, id: u64) -> Result<(String, u64)> {
let mut guard = self.lock();
let r = guard
.get_mut(&id)
.ok_or_else(|| Error::Config(format!("no process handle {id} in this run")))?;
let mut f = match std::fs::File::open(&r.capture) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((String::new(), 0)),
Err(e) => return Err(Error::Io(e)),
};
let len = f.metadata().map_err(Error::Io)?.len();
if len <= r.cursor {
return Ok((String::new(), 0));
}
let available = len - r.cursor;
let skipped = available.saturating_sub(POLL_BYTES as u64);
f.seek(SeekFrom::Start(r.cursor + skipped))
.map_err(Error::Io)?;
let mut buf = Vec::with_capacity(available.min(POLL_BYTES as u64) as usize);
f.take(POLL_BYTES as u64)
.read_to_end(&mut buf)
.map_err(Error::Io)?;
r.cursor = len;
Ok((String::from_utf8_lossy(&buf).into_owned(), skipped))
}
#[cfg_attr(not(windows), allow(clippy::let_unit_value))]
pub(crate) fn kill(&self, id: u64) -> std::result::Result<HandleState, String> {
let (pids, was, contained) = {
let mut guard = self.lock();
let r = guard
.get_mut(&id)
.ok_or_else(|| format!("no process handle {id} in this run"))?;
let was = r.state.clone();
if let HandleState::Orphaned(reason) = &was {
return Err(format!(
"process handle {id} was started by a previous process and orphaned on \
resume ({reason}); it is not signalled, because the pid it recorded may \
since belong to something else"
));
}
if !was.is_over() {
r.state = HandleState::Killed;
}
let contained = std::mem::take(&mut r.contained);
(r.pids.clone(), was, contained)
};
if !was.is_over() {
#[cfg(windows)]
drop(contained);
#[cfg(not(windows))]
let () = contained;
for pid in pids.into_iter().rev() {
crate::sandbox::kill_tree_and_group(Some(pid));
}
}
Ok(was)
}
pub(crate) fn kill_all(&self) -> usize {
let live: Vec<u64> = {
let guard = self.lock();
guard
.iter()
.filter(|(_, r)| !r.state.is_over())
.map(|(id, _)| *id)
.collect()
};
let n = live.len();
for id in live {
let _ = self.kill(id);
}
n
}
pub(crate) fn adopt_orphan(&self, id: u64, line: &str) {
self.lock().insert(
id,
Record {
line: line.to_string(),
pids: Vec::new(),
capture: PathBuf::new(),
cursor: 0,
state: HandleState::Orphaned(ORPHAN_REASON.to_string()),
contained: Containment::default(),
},
);
self.next.fetch_max(id + 1, Ordering::SeqCst);
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Record>> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
}
impl Drop for Handles {
fn drop(&mut self) {
self.kill_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cap_refuses_rather_than_queues() {
let h = Handles::new(2);
let (a, _) = h.reserve("sleep 1").expect("first");
let (_b, _) = h.reserve("sleep 2").expect("second");
let err = h.reserve("sleep 3").expect_err("the third is over the cap");
assert!(err.contains("cap is 2"), "{err}");
h.finished(a, Some(0));
h.reserve("sleep 4").expect("after one ended");
}
#[test]
fn an_orphaned_handle_is_never_signalled() {
let h = Handles::new(4);
h.adopt_orphan(7, "npm run dev");
let err = h.kill(7).expect_err("an orphan is not killed");
assert!(err.contains("orphaned on resume"), "{err}");
assert!(err.contains("may since belong to something else"), "{err}");
assert_eq!(
h.state(7),
Some(HandleState::Orphaned(ORPHAN_REASON.to_string()))
);
}
#[test]
fn an_orphan_does_not_reuse_a_live_id() {
let h = Handles::new(4);
h.adopt_orphan(3, "old");
let (id, _) = h.reserve("new").expect("reserve");
assert!(
id > 3,
"a new handle must not take an orphan's id: got {id}"
);
}
#[test]
fn a_poll_returns_only_what_is_new() {
let h = Handles::new(4);
let (id, capture) = h.reserve("tail -f log").expect("reserve");
std::fs::write(&capture, b"first\n").expect("write");
let (text, skipped) = h.poll(id).expect("poll");
assert_eq!(text, "first\n");
assert_eq!(skipped, 0);
let (text, _) = h.poll(id).expect("poll again");
assert_eq!(text, "");
std::fs::write(&capture, b"first\nsecond\n").expect("append");
let (text, _) = h.poll(id).expect("third poll");
assert_eq!(text, "second\n");
}
#[test]
fn a_poll_before_any_output_is_empty_rather_than_an_error() {
let h = Handles::new(4);
let (id, _) = h.reserve("sleep 5").expect("reserve");
let (text, skipped) = h.poll(id).expect("a handle that has written nothing");
assert_eq!(text, "");
assert_eq!(skipped, 0);
}
#[test]
fn a_poll_over_the_window_keeps_the_end_and_reports_the_gap() {
let h = Handles::new(4);
let (id, capture) = h.reserve("noisy").expect("reserve");
let big = vec![b'x'; POLL_BYTES + 500];
let mut body = big.clone();
body.extend_from_slice(b"TAIL");
std::fs::write(&capture, &body).expect("write");
let (text, skipped) = h.poll(id).expect("poll");
assert!(text.ends_with("TAIL"), "the newest output is what is kept");
assert_eq!(skipped, 504, "the gap is reported, not hidden");
let (text, _) = h.poll(id).expect("second poll");
assert_eq!(text, "");
}
#[test]
fn killing_an_unknown_handle_says_so() {
let h = Handles::new(4);
let err = h.kill(99).expect_err("no such handle");
assert!(err.contains("no process handle 99"), "{err}");
}
}