use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::ssp::SyncState;
use crate::terminal::{ServerTerminal, DEFAULT_COLS, DEFAULT_ROWS};
use anyhow::Context;
use iroh::EndpointId;
use tokio::sync::{mpsc, watch, Mutex};
use tokio_util::sync::CancellationToken;
pub(crate) const REAP_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ClientId(u64);
impl ClientId {
pub fn next() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(1);
Self(NEXT.fetch_add(1, Ordering::Relaxed))
}
pub const fn get(self) -> u64 {
self.0
}
}
pub trait SessionHost: Send + 'static {
type State: SyncState + Send + 'static;
fn snapshot(&mut self) -> Self::State;
fn input(&mut self, bytes: &[u8]);
fn resize(&mut self, client: ClientId, rows: u16, cols: u16);
fn stamp_echo_ack(state: &mut Self::State, echo_ack: u64);
fn application_cursor(&self) -> bool {
false
}
fn alive(&self) -> bool;
fn attach_notify(&mut self, _changed: ChangeSignal) {}
fn client_detached(&mut self, _client: ClientId) {}
fn kill(&mut self) {}
fn shutdown(self)
where
Self: Sized,
{
}
}
pub struct PtyHost {
pub emu: ServerTerminal,
pub pty: crate::pty::Pty,
pub child_alive: bool,
}
impl PtyHost {
pub fn spawn(
command: &[String],
scrollback: usize,
) -> anyhow::Result<(Self, mpsc::Receiver<Vec<u8>>)> {
let (rows, cols) = (DEFAULT_ROWS, DEFAULT_COLS);
let emu = ServerTerminal::new(rows, cols, scrollback);
let (pty, pty_rx) = crate::pty::Pty::spawn(rows, cols, command, "xterm-256color")
.context("spawning shell")?;
Ok((
Self {
emu,
pty,
child_alive: true,
},
pty_rx,
))
}
}
impl SessionHost for PtyHost {
type State = crate::terminal::TerminalScreen;
fn snapshot(&mut self) -> Self::State {
self.emu.snapshot()
}
fn input(&mut self, bytes: &[u8]) {
if let Err(e) = self.pty.write_input(bytes) {
tracing::warn!(error = %e, "pty write failed");
}
}
fn resize(&mut self, _client: ClientId, rows: u16, cols: u16) {
if let Err(e) = self.pty.resize(rows, cols) {
tracing::warn!(error = %e, rows, cols, "pty resize failed");
}
self.emu.resize(rows, cols);
}
fn stamp_echo_ack(state: &mut Self::State, echo_ack: u64) {
state.set_echo_ack(echo_ack);
}
fn application_cursor(&self) -> bool {
self.emu.application_cursor()
}
fn alive(&self) -> bool {
self.child_alive
}
fn kill(&mut self) {
if let Err(e) = self.pty.kill() {
tracing::warn!(error = %e, "pty kill during teardown failed");
}
self.pty.kill_hard();
}
fn shutdown(self) {
self.pty.shutdown();
}
}
pub struct Session<H: SessionHost = PtyHost> {
pub host: H,
pub last_detach: Option<Instant>,
pub attached: u32,
}
#[derive(Clone, Debug)]
pub struct ChangeSignal(watch::Sender<u64>);
impl ChangeSignal {
fn new() -> Self {
Self(watch::Sender::new(0))
}
pub fn pulse(&self) {
self.0.send_modify(|v| *v = v.wrapping_add(1));
}
pub fn subscribe(&self) -> watch::Receiver<u64> {
self.0.subscribe()
}
}
impl Default for ChangeSignal {
fn default() -> Self {
Self::new()
}
}
pub struct SessionHandle<H: SessionHost = PtyHost> {
pub session: Mutex<Session<H>>,
pub changed: ChangeSignal,
}
impl<H: SessionHost> SessionHandle<H> {
pub fn new(mut host: H) -> SharedSession<H> {
let changed = ChangeSignal::new();
host.attach_notify(changed.clone());
Arc::new(Self {
session: Mutex::new(Session {
host,
last_detach: None,
attached: 0,
}),
changed,
})
}
}
pub type SharedSession<H = PtyHost> = Arc<SessionHandle<H>>;
pub type SessionStore<H = PtyHost> = Arc<Mutex<HashMap<EndpointId, SharedSession<H>>>>;
pub fn spawn_session(command: &[String], scrollback: usize) -> anyhow::Result<SharedSession> {
let (host, pty_rx) = PtyHost::spawn(command, scrollback)?;
let handle = SessionHandle::new(host);
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.host.child_alive = false;
if let Ok(Some(status)) = s.host.pty.try_wait() {
s.host.emu.set_exit_code(status.exit_code());
}
drop(s);
handle.changed.pulse();
break;
};
let mut s = handle.session.lock().await;
s.host.emu.process(&chunk);
let replies = s.host.emu.take_host_replies();
if !replies.is_empty() {
if let Err(e) = s.host.pty.write_input(&replies) {
tracing::debug!(error = %e, "pty host-reply write failed");
}
}
drop(s);
handle.changed.pulse();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachKind {
Created,
Reattached { detached_for: Option<Duration> },
Joined { viewers: u32 },
}
pub async fn attach(
store: &SessionStore,
peer: EndpointId,
command: &[String],
scrollback: usize,
max_sessions: usize,
) -> anyhow::Result<Option<(SharedSession, AttachKind)>> {
attach_with(store, peer, max_sessions, || {
spawn_session(command, scrollback)
})
.await
}
pub async fn attach_with<H: SessionHost>(
store: &SessionStore<H>,
key: EndpointId,
max_sessions: usize,
make: impl FnOnce() -> anyhow::Result<SharedSession<H>>,
) -> anyhow::Result<Option<(SharedSession<H>, AttachKind)>> {
let mut map = store.lock().await;
if let Some(h) = map.get(&key) {
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 = make()?;
handle.session.lock().await.attached = 1;
map.insert(key, handle.clone());
Ok(Some((handle, AttachKind::Created)))
}
pub async fn detach<H: SessionHost>(store: &SessionStore<H>, 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());
}
}
}
pub trait HostProvider<H: SessionHost>: Send + Sync + 'static {
fn attach(
&self,
peer: EndpointId,
) -> impl Future<Output = anyhow::Result<Option<(SharedSession<H>, AttachKind)>>> + Send;
fn detach(&self, peer: EndpointId) -> impl Future<Output = ()> + Send;
fn reap(&self, peer: EndpointId) -> impl Future<Output = ()> + Send;
fn store(&self) -> SessionStore<H>;
}
#[derive(Clone)]
pub struct PtyHosts {
store: SessionStore,
command: Arc<[String]>,
scrollback: usize,
max_sessions: usize,
}
impl PtyHosts {
pub fn new(command: Vec<String>, scrollback: usize, max_sessions: usize) -> Self {
Self {
store: SessionStore::default(),
command: command.into(),
scrollback,
max_sessions,
}
}
}
impl HostProvider<PtyHost> for PtyHosts {
async fn attach(
&self,
peer: EndpointId,
) -> anyhow::Result<Option<(SharedSession, AttachKind)>> {
attach(
&self.store,
peer,
&self.command,
self.scrollback,
self.max_sessions,
)
.await
}
async fn detach(&self, peer: EndpointId) {
detach(&self.store, peer).await;
}
async fn reap(&self, peer: EndpointId) {
reap(&self.store, peer).await;
}
fn store(&self) -> SessionStore {
self.store.clone()
}
}
pub struct SharedHost<H: SessionHost> {
store: SessionStore<H>,
make: Arc<dyn Fn() -> anyhow::Result<SharedSession<H>> + Send + Sync>,
}
impl<H: SessionHost> SharedHost<H> {
pub fn new(make: impl Fn() -> anyhow::Result<H> + Send + Sync + 'static) -> Self {
Self::new_with_handles(move || Ok(SessionHandle::new(make()?)))
}
pub fn new_with_handles(
make: impl Fn() -> anyhow::Result<SharedSession<H>> + Send + Sync + 'static,
) -> Self {
Self {
store: SessionStore::default(),
make: Arc::new(make),
}
}
fn key() -> EndpointId {
iroh::SecretKey::from_bytes(&[0u8; 32]).public()
}
}
impl<H: SessionHost> HostProvider<H> for SharedHost<H> {
async fn attach(
&self,
_peer: EndpointId,
) -> anyhow::Result<Option<(SharedSession<H>, AttachKind)>> {
let make = self.make.clone();
let Some((handle, kind)) = attach_with(&self.store, Self::key(), 1, || make()).await?
else {
return Ok(None);
};
let kind = match kind {
AttachKind::Reattached { detached_for: None } => {
let viewers = handle.session.lock().await.attached;
AttachKind::Joined { viewers }
}
other => other,
};
Ok(Some((handle, kind)))
}
async fn detach(&self, _peer: EndpointId) {
detach(&self.store, Self::key()).await;
}
async fn reap(&self, _peer: EndpointId) {
reap(&self.store, Self::key()).await;
}
fn store(&self) -> SessionStore<H> {
self.store.clone()
}
}
#[must_use = "hold the guard for the connection's lifetime, then disarm() on a normal return"]
pub(crate) struct AttachGuard<H: SessionHost, P: HostProvider<H>> {
provider: Arc<P>,
peer: EndpointId,
armed: bool,
_host: std::marker::PhantomData<fn() -> H>,
}
impl<H: SessionHost, P: HostProvider<H>> AttachGuard<H, P> {
pub(crate) fn new(provider: Arc<P>, peer: EndpointId) -> Self {
Self {
provider,
peer,
armed: true,
_host: std::marker::PhantomData,
}
}
pub(crate) fn disarm(mut self) {
self.armed = false;
}
}
impl<H: SessionHost, P: HostProvider<H>> Drop for AttachGuard<H, P> {
fn drop(&mut self) {
if !self.armed {
return;
}
let provider = self.provider.clone();
let peer = self.peer;
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
provider.detach(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<H: SessionHost>(store: &SessionStore<H>, peer: EndpointId) {
let removed = store.lock().await.remove(&peer);
if let Some(h) = removed {
teardown(h).await;
}
}
async fn teardown<H: SessionHost>(handle: SharedSession<H>) {
match Arc::try_unwrap(handle) {
Ok(h) => {
let Session { host, .. } = h.session.into_inner();
tokio::task::spawn_blocking(move || host.shutdown());
}
Err(h) => {
h.session.lock().await.host.kill();
}
}
}
pub async fn run_reaper<H: SessionHost>(
store: SessionStore<H>,
ttl: Duration,
interval: Duration,
shutdown: CancellationToken,
) {
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = shutdown.cancelled() => return,
}
sweep(&store, ttl).await;
}
}
pub(crate) async fn sweep<H: SessionHost>(store: &SessionStore<H>, ttl: Duration) {
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.host.alive() || detached_expired {
dead.push(*peer);
}
}
let doomed: Vec<SharedSession<H>> = dead.iter().filter_map(|peer| map.remove(peer)).collect();
drop(map); for h in doomed {
teardown(h).await;
}
}
#[cfg(test)]
pub(crate) mod test_host {
use super::*;
use crate::ssp::testkit::GridState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostCall {
Input(Vec<u8>),
Resize(ClientId, u16, u16),
Detached(ClientId),
Kill,
}
#[derive(Default)]
pub struct ScriptedHost {
pub state: GridState,
pub calls: Vec<HostCall>,
pub alive: bool,
pub notify: Option<ChangeSignal>,
}
impl ScriptedHost {
pub fn new() -> Self {
Self {
alive: true,
..Self::default()
}
}
pub fn set_exited(&mut self, code: u32) {
self.alive = false;
self.state.exit_code = Some(code);
if let Some(n) = &self.notify {
n.pulse();
}
}
}
impl SessionHost for ScriptedHost {
type State = GridState;
fn snapshot(&mut self) -> GridState {
self.state.clone()
}
fn stamp_echo_ack(state: &mut GridState, echo_ack: u64) {
state.echo_ack = echo_ack;
}
fn input(&mut self, bytes: &[u8]) {
self.state
.cells
.entry(0)
.or_default()
.extend_from_slice(bytes);
self.calls.push(HostCall::Input(bytes.to_vec()));
}
fn resize(&mut self, client: ClientId, rows: u16, cols: u16) {
self.state.rows = rows;
self.state.cols = cols;
self.calls.push(HostCall::Resize(client, rows, cols));
}
fn alive(&self) -> bool {
self.alive
}
fn attach_notify(&mut self, changed: ChangeSignal) {
self.notify = Some(changed);
}
fn client_detached(&mut self, client: ClientId) {
self.calls.push(HostCall::Detached(client));
}
fn kill(&mut self) {
self.alive = false;
self.calls.push(HostCall::Kill);
}
}
}
#[cfg(test)]
mod tests {
use super::test_host::{HostCall, ScriptedHost};
use super::*;
use crate::transport_iroh::generate_secret_key;
#[tokio::test]
async fn a_pulse_wakes_every_subscribed_viewer_not_just_one() {
let signal = ChangeSignal::new();
let mut a = signal.subscribe();
let mut b = signal.subscribe();
signal.pulse();
let both = async {
a.changed().await.expect("sender alive");
b.changed().await.expect("sender alive");
};
tokio::time::timeout(Duration::from_millis(100), both)
.await
.expect("both receivers wake within 100 ms");
}
#[tokio::test]
async fn a_pulse_between_snapshot_and_wait_is_not_lost() {
let signal = ChangeSignal::new();
let mut rx = signal.subscribe();
let _ = rx.borrow_and_update(); signal.pulse();
signal.pulse();
signal.pulse();
tokio::time::timeout(Duration::from_millis(100), rx.changed())
.await
.expect("the pulse after the mark wakes the loop")
.expect("sender alive");
let quiet = tokio::time::timeout(Duration::from_millis(50), rx.changed()).await;
assert!(quiet.is_err(), "the burst coalesced into one wake");
}
#[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, &["sh".to_owned()], 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, &["sh".to_owned()], 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.host.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, &["sh".to_owned()], 0, 64)
.await
.expect("attach A")
.expect("not at capacity");
let (_, _) = attach(&store, peer, &["sh".to_owned()], 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.host.pty.kill();
}
#[tokio::test]
async fn attach_guard_releases_the_attach_when_dropped_armed() {
let provider = Arc::new(PtyHosts::new(vec!["sh".to_owned()], 0, 64));
let peer = generate_secret_key().public();
let (h, _) = provider
.attach(peer)
.await
.expect("attach")
.expect("under cap");
assert_eq!(h.session.lock().await.attached, 1);
{
let _g = AttachGuard::new(provider.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.host.pty.kill();
}
#[tokio::test]
async fn attach_guard_is_a_noop_once_disarmed() {
let provider = Arc::new(PtyHosts::new(vec!["sh".to_owned()], 0, 64));
let peer = generate_secret_key().public();
let (h, _) = provider
.attach(peer)
.await
.expect("attach")
.expect("under cap");
assert_eq!(h.session.lock().await.attached, 1);
AttachGuard::new(provider.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.host.pty.kill();
}
#[tokio::test]
async fn attach_guard_releases_a_shared_host_attach_under_the_shared_key() {
let provider = Arc::new(SharedHost::new(|| Ok(ScriptedHost::new())));
let peer = generate_secret_key().public();
let (h, _) = provider
.attach(peer)
.await
.expect("attach")
.expect("under cap");
assert_eq!(h.session.lock().await.attached, 1);
{
let _g = AttachGuard::new(provider.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,
"the guard must release the shared entry, not look the peer up by its own id"
);
}
#[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, &["sh".to_owned()], 0, 2)
.await
.expect("attach p1")
.expect("under cap");
let (h2, _) = attach(&store, p2, &["sh".to_owned()], 0, 2)
.await
.expect("attach p2")
.expect("under cap");
let rejected = attach(&store, p3, &["sh".to_owned()], 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, &["sh".to_owned()], 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.host.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(&["sh".to_owned()], 0).expect("spawn session");
handle.session.lock().await.host.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"
);
}
#[tokio::test]
async fn pty_host_snapshot_input_and_exit_match_the_pre_trait_behaviour() {
let handle = spawn_session(
&[
"sh".to_owned(),
"-c".to_owned(),
"printf HELLO; exit 3".to_owned(),
],
0,
)
.expect("spawn");
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut s = handle.session.lock().await;
let snap = s.host.snapshot();
if snap.screen().contents().contains("HELLO") && !s.host.alive() {
assert_eq!(snap.exit_code(), Some(3), "exit code rides on the snapshot");
break;
}
drop(s);
assert!(Instant::now() < deadline, "timed out waiting for the child");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn scripted_host_attach_detach_and_reap_follow_the_pty_semantics() {
let store: SessionStore<ScriptedHost> = SessionStore::default();
let peer = generate_secret_key().public();
let (h, kind) = attach_with(&store, peer, 64, || {
Ok(SessionHandle::new(ScriptedHost::new()))
})
.await
.expect("attach")
.expect("under cap");
assert_eq!(kind, AttachKind::Created);
detach(&store, peer).await;
let (h2, kind) = attach_with(&store, peer, 64, || {
Ok(SessionHandle::new(ScriptedHost::new()))
})
.await
.expect("reattach")
.expect("under cap");
assert!(matches!(kind, AttachKind::Reattached { .. }));
assert!(Arc::ptr_eq(&h, &h2));
detach(&store, peer).await;
reap(&store, peer).await;
assert!(store.lock().await.is_empty(), "reap removes the entry");
}
#[tokio::test]
async fn shared_host_hands_every_peer_the_same_session() {
let built = Arc::new(std::sync::atomic::AtomicU32::new(0));
let b = built.clone();
let provider = SharedHost::new(move || {
b.fetch_add(1, Ordering::Relaxed);
Ok(ScriptedHost::new())
});
let p1 = generate_secret_key().public();
let p2 = generate_secret_key().public();
let (h1, k1) = provider.attach(p1).await.expect("attach p1").expect("cap");
let (h2, k2) = provider.attach(p2).await.expect("attach p2").expect("cap");
assert_eq!(k1, AttachKind::Created);
assert_eq!(
k2,
AttachKind::Joined { viewers: 2 },
"a second viewer joins, it does not 'reattach'"
);
assert!(Arc::ptr_eq(&h1, &h2), "both peers share one session");
assert_eq!(h1.session.lock().await.attached, 2);
assert_eq!(built.load(Ordering::Relaxed), 1, "the host is built once");
provider.detach(p1).await;
assert!(h1.session.lock().await.last_detach.is_none());
provider.detach(p2).await;
assert!(h1.session.lock().await.last_detach.is_some());
assert_eq!(provider.store().lock().await.len(), 1, "one store entry");
}
#[tokio::test]
async fn reaper_never_reaps_a_shared_host_with_a_viewer_attached() {
let provider = SharedHost::new(|| Ok(ScriptedHost::new()));
let p1 = generate_secret_key().public();
let (h, _) = provider.attach(p1).await.expect("attach").expect("cap");
let store = provider.store();
let shutdown = CancellationToken::new();
let task = tokio::spawn(run_reaper(
store.clone(),
Duration::from_millis(1),
Duration::from_millis(5),
shutdown.clone(),
));
tokio::time::sleep(Duration::from_millis(60)).await;
assert_eq!(store.lock().await.len(), 1, "attached host survives sweeps");
provider.detach(p1).await;
let mut reaped = false;
for _ in 0..200 {
if store.lock().await.is_empty() {
reaped = true;
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
assert!(
reaped,
"the reaper collects the host once every viewer left"
);
assert!(
h.session.lock().await.host.calls.contains(&HostCall::Kill),
"teardown with a live Arc elsewhere kills the host"
);
}
#[tokio::test]
async fn reaper_collects_a_scripted_host_once_it_exits() {
let provider = SharedHost::new(|| Ok(ScriptedHost::new()));
let peer = generate_secret_key().public();
let (h, _) = provider.attach(peer).await.expect("attach").expect("cap");
h.session.lock().await.host.set_exited(9);
assert_eq!(h.session.lock().await.host.snapshot().exit_code, Some(9));
drop(h);
let store = provider.store();
let shutdown = CancellationToken::new();
let task = tokio::spawn(run_reaper(
store.clone(),
Duration::from_secs(3600),
Duration::from_millis(5),
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(5)).await;
}
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
assert!(
reaped,
"an exited host is reaped regardless of TTL or attach count"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(128))]
#[test]
fn shared_host_refcount_and_reaping_invariants(
ops in proptest::collection::vec(0u8..5, 1..40),
) {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let provider = Arc::new(SharedHost::new(|| Ok(ScriptedHost::new())));
let peers: Vec<EndpointId> = (0..3).map(|_| generate_secret_key().public()).collect();
let mut attached: u32 = 0;
let mut alive_entry = false;
for (i, op) in ops.iter().enumerate() {
let peer = peers[i % peers.len()];
match op {
0 => {
provider.attach(peer).await.unwrap().unwrap();
attached += 1;
alive_entry = true;
}
1 => {
provider.detach(peer).await;
attached = attached.saturating_sub(1);
}
2 => {
provider.reap(peer).await;
attached = 0;
alive_entry = false;
}
4 => {
drop(AttachGuard::new(provider.clone(), peer));
for _ in 0..4 {
tokio::task::yield_now().await;
}
attached = attached.saturating_sub(1);
}
_ => {
sweep(&provider.store(), Duration::ZERO).await;
if attached == 0 {
alive_entry = false;
}
}
}
let store = provider.store();
let map = store.lock().await;
proptest::prop_assert_eq!(map.len(), usize::from(alive_entry), "entry presence");
if let Some(h) = map.values().next() {
let s = h.session.lock().await;
proptest::prop_assert_eq!(s.attached, attached, "refcount");
proptest::prop_assert!(
s.attached > 0 || s.last_detach.is_some(),
"a fully detached host always has the detach timer armed"
);
}
}
Ok(())
})?;
}
}
}