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}