use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::terminal::{ServerTerminal, DEFAULT_COLS, DEFAULT_ROWS};
use anyhow::Context;
use iroh::EndpointId;
use tokio::sync::{mpsc, Mutex, Notify};
use tokio_util::sync::CancellationToken;
pub(crate) const REAP_INTERVAL: Duration = Duration::from_secs(5);
pub struct Session {
pub emu: ServerTerminal,
pub pty: crate::pty::Pty,
pub child_alive: bool,
pub last_detach: Option<Instant>,
pub attached: u32,
}
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,
attached: 0,
}),
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() {
if let Err(e) = s.pty.write_input(&replies) {
tracing::debug!(error = %e, "pty host-reply write failed");
}
}
drop(s);
handle.changed.notify_one();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachKind {
Created,
Reattached { detached_for: Option<Duration> },
}
pub async fn attach(
store: &SessionStore,
peer: EndpointId,
shell: Option<&str>,
scrollback: usize,
max_sessions: usize,
) -> anyhow::Result<Option<(SharedSession, AttachKind)>> {
let mut map = store.lock().await;
if let Some(h) = map.get(&peer) {
let mut s = h.session.lock().await;
let detached_for = s.last_detach.map(|t| t.elapsed());
s.last_detach = None;
s.attached = s.attached.saturating_add(1);
drop(s);
return Ok(Some((h.clone(), AttachKind::Reattached { detached_for })));
}
if map.len() >= max_sessions {
return Ok(None);
}
let handle = spawn_session(shell, scrollback)?;
handle.session.lock().await.attached = 1;
map.insert(peer, handle.clone());
Ok(Some((handle, AttachKind::Created)))
}
pub async fn detach(store: &SessionStore, peer: EndpointId) {
if let Some(h) = store.lock().await.get(&peer) {
let mut s = h.session.lock().await;
s.attached = s.attached.saturating_sub(1);
if s.attached == 0 {
s.last_detach = Some(Instant::now());
}
}
}
#[must_use = "hold the guard for the connection's lifetime, then disarm() on a normal return"]
pub(crate) struct AttachGuard {
store: SessionStore,
peer: EndpointId,
armed: bool,
}
impl AttachGuard {
pub(crate) fn new(store: SessionStore, peer: EndpointId) -> Self {
Self {
store,
peer,
armed: true,
}
}
pub(crate) fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for AttachGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let store = self.store.clone();
let peer = self.peer;
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
detach(&store, peer).await;
tracing::warn!(
%peer,
"connection task unwound; released its session attach via the drop guard"
);
});
} else {
tracing::warn!(
%peer,
"connection task unwound with no tokio runtime in scope; session attach not \
decremented now (reaped later when the shell exits)"
);
}
}
}
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 mut s = h.session.lock().await;
if let Err(e) = s.pty.kill() {
tracing::warn!(error = %e, "pty kill during teardown failed");
}
s.pty.kill_hard();
}
}
}
pub(crate) async fn run_reaper(
store: SessionStore,
ttl: Duration,
interval: Duration,
shutdown: CancellationToken,
) {
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = shutdown.cancelled() => return,
}
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 attach_reports_created_then_reattached() {
let store = SessionStore::default();
let peer = generate_secret_key().public();
let (h1, kind) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("first attach")
.expect("not at capacity");
assert_eq!(kind, AttachKind::Created, "first attach creates a session");
detach(&store, peer).await;
let (h2, kind) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("reattach")
.expect("not at capacity");
assert!(
matches!(
kind,
AttachKind::Reattached {
detached_for: Some(_)
}
),
"reattach after a detach reports the detached duration, got {kind:?}"
);
assert!(
Arc::ptr_eq(&h1, &h2),
"reattach returns the very same session handle, not a new one"
);
let _ = h2.session.lock().await.pty.kill();
}
#[tokio::test]
async fn overlapping_detach_does_not_arm_reaper_until_last_client_leaves() {
let store = SessionStore::default();
let peer = generate_secret_key().public();
let (h, _) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("attach A")
.expect("not at capacity");
let (_, _) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("attach B")
.expect("not at capacity");
assert_eq!(
h.session.lock().await.attached,
2,
"both connections counted"
);
detach(&store, peer).await; {
let s = h.session.lock().await;
assert_eq!(s.attached, 1, "one client remains");
assert!(
s.last_detach.is_none(),
"detach timer must NOT be armed while a client is still attached"
);
}
detach(&store, peer).await; {
let s = h.session.lock().await;
assert_eq!(s.attached, 0);
assert!(
s.last_detach.is_some(),
"detach timer arms only once the last client leaves"
);
}
let _ = h.session.lock().await.pty.kill();
}
#[tokio::test]
async fn attach_guard_releases_the_attach_when_dropped_armed() {
let store = SessionStore::default();
let peer = generate_secret_key().public();
let (h, _) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("attach")
.expect("under cap");
assert_eq!(h.session.lock().await.attached, 1);
{
let _g = AttachGuard::new(store.clone(), peer);
}
let mut released = false;
for _ in 0..100 {
{
let s = h.session.lock().await;
if s.attached == 0 && s.last_detach.is_some() {
released = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
released,
"an armed guard's Drop must detach the session (attached->0, detach timer armed)"
);
let _ = h.session.lock().await.pty.kill();
}
#[tokio::test]
async fn attach_guard_is_a_noop_once_disarmed() {
let store = SessionStore::default();
let peer = generate_secret_key().public();
let (h, _) = attach(&store, peer, Some("sh"), 0, 64)
.await
.expect("attach")
.expect("under cap");
assert_eq!(h.session.lock().await.attached, 1);
AttachGuard::new(store.clone(), peer).disarm();
tokio::time::sleep(Duration::from_millis(20)).await;
let s = h.session.lock().await;
assert_eq!(
s.attached, 1,
"a disarmed guard must not release the attach"
);
assert!(
s.last_detach.is_none(),
"a disarmed guard must not arm the detach timer"
);
drop(s);
let _ = h.session.lock().await.pty.kill();
}
#[tokio::test]
async fn attach_enforces_session_cap_but_allows_reattach() {
let store = SessionStore::default();
let p1 = generate_secret_key().public();
let p2 = generate_secret_key().public();
let p3 = generate_secret_key().public();
let (h1, _) = attach(&store, p1, Some("sh"), 0, 2)
.await
.expect("attach p1")
.expect("under cap");
let (h2, _) = attach(&store, p2, Some("sh"), 0, 2)
.await
.expect("attach p2")
.expect("under cap");
let rejected = attach(&store, p3, Some("sh"), 0, 2)
.await
.expect("attach p3 ok-result");
assert!(
rejected.is_none(),
"a new peer beyond the cap must be refused"
);
detach(&store, p1).await;
let reattach = attach(&store, p1, Some("sh"), 0, 2)
.await
.expect("reattach p1")
.expect("reattach is allowed at capacity");
assert!(
matches!(reattach.1, AttachKind::Reattached { .. }),
"an existing peer reattaches at capacity, got {:?}",
reattach.1
);
for h in [h1, h2] {
let _ = h.session.lock().await.pty.kill();
}
}
#[tokio::test]
async fn reaper_collects_dead_session_at_injected_interval() {
let store = SessionStore::default();
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), 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"
);
}
}