use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::{pid_liveness, PidCandidate, SessionsRegistry};
const WATCH_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Eq)]
enum PidStatus {
Gone,
Alive { start_token: Option<String> },
}
fn probe_pid(pid: u32) -> PidStatus {
if pid_liveness::process_exists(pid) {
PidStatus::Alive {
start_token: pid_liveness::process_start_token(pid),
}
} else {
PidStatus::Gone
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Action {
End { session_id: String },
Confirm {
session_id: String,
pid_start: String,
},
}
impl Action {
fn apply(self, registry: &SessionsRegistry, now: DateTime<Utc>) {
match self {
Self::End { session_id } => {
tracing::debug!(%session_id, reason = "pid_liveness", "session_process_ended");
registry.end(&session_id, Some("pid liveness watcher"), None);
}
Self::Confirm {
session_id,
pid_start,
} => {
registry.confirm_pid_liveness(&session_id, now, &pid_start);
}
}
}
}
fn plan(
candidates: &[PidCandidate],
confirmed: &mut HashSet<u32>,
probe: impl Fn(u32) -> PidStatus,
) -> Vec<Action> {
let candidate_pids: HashSet<u32> = candidates.iter().map(|c| c.pid).collect();
confirmed.retain(|pid| candidate_pids.contains(pid));
let mut status: HashMap<u32, PidStatus> = HashMap::new();
for c in candidates {
status.entry(c.pid).or_insert_with(|| probe(c.pid));
}
let mut most_recently_seen: HashMap<u32, DateTime<Utc>> = HashMap::new();
for c in candidates {
most_recently_seen
.entry(c.pid)
.and_modify(|t| *t = (*t).max(c.last_seen))
.or_insert(c.last_seen);
}
let mut actions = Vec::new();
for c in candidates {
match &status[&c.pid] {
PidStatus::Gone => {
if confirmed.remove(&c.pid) {
actions.push(Action::End {
session_id: c.session_id.clone(),
});
}
}
PidStatus::Alive { start_token } => {
match (c.pid_start.as_deref(), start_token.as_deref()) {
(Some(stored), Some(current)) if stored != current => {
confirmed.remove(&c.pid);
actions.push(Action::End {
session_id: c.session_id.clone(),
});
}
_ => {
confirmed.insert(c.pid);
let is_most_recently_seen =
most_recently_seen.get(&c.pid) == Some(&c.last_seen);
if c.prompted && !c.streamed && is_most_recently_seen {
if let Some(token) = start_token {
actions.push(Action::Confirm {
session_id: c.session_id.clone(),
pid_start: token.clone(),
});
}
}
}
}
}
}
}
actions
}
pub(crate) fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
let mut confirmed: HashSet<u32> = HashSet::new();
loop {
let candidates = registry.pid_liveness_candidates();
let mut owned = std::mem::take(&mut confirmed);
let (returned, actions) = tokio::task::spawn_blocking(move || {
let actions = plan(&candidates, &mut owned, probe_pid);
(owned, actions)
})
.await
.unwrap_or_else(|_| (HashSet::new(), Vec::new()));
confirmed = returned;
let now = Utc::now();
for action in actions {
action.apply(®istry, now);
}
tokio::select! {
() = token.cancelled() => break,
() = tokio::time::sleep(WATCH_INTERVAL) => {}
}
}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::sessions::{Agent, ObserveRequest, SessionEvent, SessionState};
fn candidate(
session_id: &str,
pid: u32,
pid_start: Option<&str>,
prompted: bool,
) -> PidCandidate {
PidCandidate {
session_id: session_id.to_string(),
pid,
pid_start: pid_start.map(str::to_string),
prompted,
streamed: false,
last_seen: Utc::now(),
}
}
fn probe_of(statuses: &HashMap<u32, PidStatus>) -> impl Fn(u32) -> PidStatus {
let statuses = statuses.clone();
move |pid| statuses.get(&pid).cloned().unwrap_or(PidStatus::Gone)
}
#[test]
fn a_pid_never_seen_alive_is_never_ended_when_it_is_gone() {
let candidates = vec![candidate("s1", 100, None, true)];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(100, PidStatus::Gone)]));
assert!(plan(&candidates, &mut confirmed, probe).is_empty());
}
#[test]
fn a_confirmed_pid_going_gone_ends_its_session() {
let candidates = vec![candidate("s1", 100, Some("tok"), true)];
let mut confirmed = HashSet::from([100]);
let probe = probe_of(&HashMap::from([(100, PidStatus::Gone)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::End {
session_id: "s1".to_string()
}]
);
assert!(!confirmed.contains(&100));
}
#[test]
fn an_alive_prompted_newest_pid_is_confirmed() {
let candidates = vec![candidate("s1", 100, Some("tok"), true)];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::Confirm {
session_id: "s1".to_string(),
pid_start: "tok".to_string()
}]
);
assert!(confirmed.contains(&100));
}
#[test]
fn an_unprompted_alive_pid_is_tracked_but_not_confirmed() {
let candidates = vec![candidate("s1", 100, Some("tok"), false)];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert!(actions.is_empty());
assert!(confirmed.contains(&100));
}
#[test]
fn a_streamed_session_is_tracked_but_never_confirmed() {
let mut streamed = candidate("s1", 100, Some("tok"), true);
streamed.streamed = true;
let alive = HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("tok".to_string()),
},
)]);
let mut confirmed = HashSet::new();
let actions = plan(&[streamed.clone()], &mut confirmed, probe_of(&alive));
assert!(actions.is_empty());
assert!(confirmed.contains(&100));
let gone = HashMap::from([(100, PidStatus::Gone)]);
let actions = plan(&[streamed], &mut confirmed, probe_of(&gone));
assert_eq!(
actions,
vec![Action::End {
session_id: "s1".to_string()
}]
);
}
#[test]
fn only_the_most_recently_active_session_under_a_shared_pid_is_confirmed() {
let mut old = candidate("old", 100, Some("tok"), true);
old.last_seen = Utc::now() - chrono::Duration::seconds(100);
let new = candidate("new", 100, Some("tok"), true);
let candidates = vec![old, new];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::Confirm {
session_id: "new".to_string(),
pid_start: "tok".to_string()
}]
);
}
#[test]
fn a_resumed_older_session_id_reclaims_the_exemption_by_last_seen() {
let old = candidate("old", 100, Some("tok"), true);
let mut new = candidate("new", 100, Some("tok"), true);
new.last_seen = Utc::now() - chrono::Duration::seconds(100);
let candidates = vec![old, new];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::Confirm {
session_id: "old".to_string(),
pid_start: "tok".to_string()
}]
);
}
#[test]
fn a_mismatched_token_ends_the_session_even_though_the_pid_is_alive() {
let candidates = vec![candidate("s1", 100, Some("old-tok"), true)];
let mut confirmed = HashSet::from([100]);
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("new-tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::End {
session_id: "s1".to_string()
}]
);
assert!(!confirmed.contains(&100));
}
#[test]
fn a_pid_with_no_stored_token_captures_the_freshly_probed_one() {
let candidates = vec![candidate("s1", 100, None, true)];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive {
start_token: Some("first-tok".to_string()),
},
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert_eq!(
actions,
vec![Action::Confirm {
session_id: "s1".to_string(),
pid_start: "first-tok".to_string()
}]
);
}
#[test]
fn an_unreadable_token_on_an_alive_pid_tracks_but_does_not_confirm() {
let candidates = vec![candidate("s1", 100, None, true)];
let mut confirmed = HashSet::new();
let probe = probe_of(&HashMap::from([(
100,
PidStatus::Alive { start_token: None },
)]));
let actions = plan(&candidates, &mut confirmed, probe);
assert!(actions.is_empty());
assert!(confirmed.contains(&100));
}
#[test]
fn a_distinct_pid_is_probed_only_once_for_several_candidates() {
let candidates = vec![
candidate("s1", 100, None, true),
candidate("s2", 100, None, false),
];
let mut confirmed = HashSet::new();
let calls = std::sync::Arc::new(std::sync::Mutex::new(0));
let calls_clone = calls.clone();
let probe = move |pid| {
*calls_clone.lock().unwrap() += 1;
assert_eq!(pid, 100);
PidStatus::Alive {
start_token: Some("tok".to_string()),
}
};
plan(&candidates, &mut confirmed, probe);
assert_eq!(*calls.lock().unwrap(), 1);
}
fn observe_owned_by(registry: &SessionsRegistry, session_id: &str, pid: u32) {
registry.observe(ObserveRequest {
agent_id: None,
session_id: session_id.to_string(),
cwd: None,
transcript_path: None,
event: SessionEvent::UserPromptSubmit,
repo: None,
model: None,
agent: Agent::Claude,
pid: Some(pid),
});
}
#[test]
fn an_end_action_ends_the_session_and_logs_why() {
let registry = SessionsRegistry::new();
observe_owned_by(®istry, "s1", 100);
let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
Action::End {
session_id: "s1".to_string(),
}
.apply(®istry, Utc::now());
});
assert!(logs.contains("session_process_ended"), "{logs}");
assert!(logs.contains("pid_liveness"), "{logs}");
assert_eq!(registry.list()[0].state, SessionState::Ended);
}
#[test]
fn a_confirm_action_captures_the_pid_identity_token() {
let registry = SessionsRegistry::new();
observe_owned_by(®istry, "s1", 100);
Action::Confirm {
session_id: "s1".to_string(),
pid_start: "tok".to_string(),
}
.apply(®istry, Utc::now());
let candidates = registry.pid_liveness_candidates();
assert_eq!(candidates[0].pid_start.as_deref(), Some("tok"));
}
#[test]
fn a_pid_no_longer_among_candidates_is_forgotten() {
let mut confirmed = HashSet::from([100, 200]);
let probe = probe_of(&HashMap::new());
let candidates = vec![candidate("s2", 200, Some("tok"), false)];
plan(&candidates, &mut confirmed, probe);
assert!(!confirmed.contains(&100));
}
}