Skip to main content

shell_tunnel/api/
sweep.rs

1//! Reclaiming what a client abandoned, with a trail of what went.
2//!
3//! Deliberately not in `handlers.rs`. That file holds async handlers, and
4//! `audit_e2e::the_async_handlers_record_without_blocking_the_runtime` holds it
5//! to a rule for exactly that reason: `AuditSink::record` opens, writes and
6//! flushes a file, so calling it from a runtime thread parks a worker that also
7//! serves `/health` and the accept loop. The sweep below *is* blocking and is
8//! meant to be — it runs inside a `spawn_blocking` body — but a blocking
9//! `record` sitting in the async-handler file would either trip that guard or,
10//! worse, force it to be loosened for everything else in there too. Same
11//! reasoning that keeps `sweep_expired_uploads` in `fs.rs` rather than here.
12
13use std::time::Duration;
14
15use crate::audit::{AuditEvent, AuditSink};
16use crate::session::SessionStore;
17
18/// Drop sessions idle past `ttl`, recording a terminal event for each.
19///
20/// The audit-aware half of [`SessionStore::sweep_idle`], and the same split
21/// [`crate::api::fs::sweep_expired_uploads`] uses: the store stays
22/// audit-agnostic because it has no `AuditSink`, and the recording happens here.
23///
24/// Sweeping silently would make an abandoned session indistinguishable from one
25/// its client deleted, which is the question the trail exists to answer — and
26/// the reason [`SessionStore::sweep_idle`] returns ids rather than a count.
27///
28/// **Blocking.** Call it inside a `spawn_blocking` body, as the periodic sweeper
29/// in `main.rs` does.
30pub fn sweep_expired_sessions(store: &SessionStore, audit: &AuditSink, ttl: Duration) -> usize {
31    let Ok(expired) = store.sweep_idle(ttl) else {
32        // A poisoned lock is reported by the routes that need the store; a sweep
33        // that cannot run this tick simply runs the next one.
34        return 0;
35    };
36    for id in &expired {
37        audit.record(AuditEvent::new("session.expired").with_session(id.as_u64()));
38    }
39    expired.len()
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::session::SessionState;
46
47    /// The count reported is what was actually removed.
48    #[test]
49    fn the_sweep_reports_what_it_removed() {
50        let store = SessionStore::new();
51        let busy = store.create().expect("create");
52        store.create().expect("create");
53        store
54            .update(&busy, |s| {
55                let _ = s.state.transition_to(SessionState::Active);
56            })
57            .expect("busy");
58
59        let swept = sweep_expired_sessions(&store, &AuditSink::Disabled, Duration::ZERO);
60
61        assert_eq!(swept, 1, "only the session with no command in it may go");
62        assert!(
63            store.contains(&busy).expect("lock"),
64            "a session running a command must survive"
65        );
66    }
67}