use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use crate::terminal::{ServerTerminal, DEFAULT_COLS, DEFAULT_ROWS};
use crate::transport_iroh::ratelimit::FailureLimiter;
use crate::transport_iroh::MonoClock;
use anyhow::Context;
use iroh::EndpointId;
use tokio::sync::{mpsc, Mutex, Notify};
use tokio_util::sync::CancellationToken;
pub const REAP_INTERVAL: Duration = Duration::from_secs(5);
pub type AuthLimiter = Arc<StdMutex<FailureLimiter<EndpointId>>>;
pub struct Session {
pub emu: ServerTerminal,
pub pty: crate::pty::Pty,
pub child_alive: bool,
pub last_detach: Option<Instant>,
}
pub struct SessionHandle {
pub session: Mutex<Session>,
pub changed: Notify,
}
pub type SharedSession = Arc<SessionHandle>;
pub type SessionStore = Arc<Mutex<HashMap<EndpointId, SharedSession>>>;
pub fn spawn_session(shell: Option<&str>, scrollback: usize) -> anyhow::Result<SharedSession> {
let (rows, cols) = (DEFAULT_ROWS, DEFAULT_COLS);
let emu = ServerTerminal::new(rows, cols, scrollback);
let (pty, pty_rx) =
crate::pty::Pty::spawn(rows, cols, shell, "xterm-256color").context("spawning shell")?;
let handle = Arc::new(SessionHandle {
session: Mutex::new(Session {
emu,
pty,
child_alive: true,
last_detach: None,
}),
changed: Notify::new(),
});
tokio::spawn(drain(handle.clone(), pty_rx));
Ok(handle)
}
async fn drain(handle: SharedSession, mut pty_rx: mpsc::Receiver<Vec<u8>>) {
loop {
let Some(chunk) = pty_rx.recv().await else {
let mut s = handle.session.lock().await;
s.child_alive = false;
if let Ok(Some(status)) = s.pty.try_wait() {
s.emu.set_exit_code(status.exit_code());
}
drop(s);
handle.changed.notify_one();
break;
};
let mut s = handle.session.lock().await;
s.emu.process(&chunk);
let replies = s.emu.take_host_replies();
if !replies.is_empty() {
let _ = s.pty.write_input(&replies);
}
drop(s);
handle.changed.notify_one();
}
}
pub async fn attach(
store: &SessionStore,
peer: EndpointId,
shell: Option<&str>,
scrollback: usize,
) -> anyhow::Result<SharedSession> {
let mut map = store.lock().await;
if let Some(h) = map.get(&peer) {
h.session.lock().await.last_detach = None;
return Ok(h.clone());
}
let handle = spawn_session(shell, scrollback)?;
map.insert(peer, handle.clone());
Ok(handle)
}
pub async fn detach(store: &SessionStore, peer: EndpointId) {
if let Some(h) = store.lock().await.get(&peer) {
h.session.lock().await.last_detach = Some(Instant::now());
}
}
pub async fn reap(store: &SessionStore, peer: EndpointId) {
let removed = store.lock().await.remove(&peer);
if let Some(h) = removed {
teardown(h).await;
}
}
async fn teardown(handle: SharedSession) {
match Arc::try_unwrap(handle) {
Ok(h) => {
let Session { pty, .. } = h.session.into_inner();
tokio::task::spawn_blocking(move || pty.shutdown());
}
Err(h) => {
let _ = h.session.lock().await.pty.kill();
}
}
}
pub async fn run_reaper(
store: SessionStore,
ttl: Duration,
limiter: AuthLimiter,
clock: MonoClock,
interval: Duration,
shutdown: CancellationToken,
) {
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = shutdown.cancelled() => return,
}
#[expect(
clippy::expect_used,
reason = "a poisoned auth-limiter mutex is a bug, not input"
)]
limiter
.lock()
.expect("auth limiter mutex poisoned")
.gc(clock.now_ms());
let mut map = store.lock().await;
let mut dead = Vec::new();
for (peer, h) in map.iter() {
let s = h.session.lock().await;
let detached_expired = s.last_detach.is_some_and(|t| t.elapsed() >= ttl);
if !s.child_alive || detached_expired {
dead.push(*peer);
}
}
let doomed: Vec<SharedSession> = dead.iter().filter_map(|peer| map.remove(peer)).collect();
drop(map); for h in doomed {
teardown(h).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport_iroh::generate_secret_key;
#[tokio::test]
async fn reaper_collects_dead_session_at_injected_interval() {
let store = SessionStore::default();
let limiter: AuthLimiter = Arc::new(StdMutex::new(FailureLimiter::new(1000, 3)));
let clock = MonoClock::new();
let peer = generate_secret_key().public();
let handle = spawn_session(Some("sh"), 0).expect("spawn session");
handle.session.lock().await.child_alive = false;
store.lock().await.insert(peer, handle);
assert_eq!(
store.lock().await.len(),
1,
"session is present before the sweep"
);
let shutdown = CancellationToken::new();
let task = tokio::spawn(run_reaper(
store.clone(),
Duration::from_secs(3600), limiter,
clock,
Duration::from_millis(10),
shutdown.clone(),
));
let mut reaped = false;
for _ in 0..200 {
if store.lock().await.is_empty() {
reaped = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
shutdown.cancel();
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reaper must exit promptly after cancellation")
.expect("reaper task should not panic");
assert!(
reaped,
"the reaper must collect the dead session at the injected interval"
);
}
}