Skip to main content

agent_engine/daemon/
close.rs

1//! `ClientFrame::Close` (`synaps daemon close <ID|NAME> [--force]`): end one
2//! session from a control connection, without attaching to it.
3//!
4//! The daemon sends the session the engine command `close` with host
5//! authority (`from: None`), so the actor's own `/close` runs unchanged: the
6//! same blockers (and `force`), the same parked exemption (a parked session
7//! is never rebuilt), the same reply text. Host authority is no more power
8//! than `daemon stop`, which already ends every session from any same-uid
9//! shell, but this command is targeted (and the target can be a prefix), so
10//! a session with clients attached is refused unless the caller passes
11//! `attached` (`--attached`). `force` does not imply it.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::Duration;
15
16use tokio::sync::broadcast;
17
18use super::DaemonState;
19use crate::session::actor::CLOSE_COMMAND;
20use crate::session::wire::{DaemonFrame, RefuseReason, CLOSE_QUERY_ID_BASE};
21use crate::session::{EndReason, SessionCommand, SessionEventWire, SessionHandle, SessionId, SessionMeta, TransportError};
22
23/// How long a full command queue is retried before giving up (the actor
24/// drains it between commands).
25pub const CLOSE_SEND_RETRY: Duration = Duration::from_secs(2);
26/// How long the actor may take to answer `close`. The command waits in the
27/// queue behind whatever the actor is doing (an inline compaction, a
28/// preflight), so this is queue latency, not the end itself.
29pub const CLOSE_REPLY_BUDGET: Duration = Duration::from_secs(5);
30/// How long the session may take to end once it accepted: `finish`'s worst
31/// case plus a margin.
32pub const CLOSE_END_BUDGET: Duration =
33    Duration::from_secs(super::lifecycle::SESSION_END_BUDGET.as_secs() + 1);
34
35/// A reply id no other in-flight close uses.
36fn next_close_query_id() -> u64 {
37    static NEXT: AtomicU64 = AtomicU64::new(0);
38    CLOSE_QUERY_ID_BASE + NEXT.fetch_add(1, Ordering::Relaxed)
39}
40
41/// Serve `Close{session_id, force, attached}`. Always answers with one frame:
42/// `Closed`, `Refused{Busy|UnknownSession}` or `Error`.
43pub async fn close_session(state: &DaemonState, id: &SessionId, force: bool, attached: bool) -> DaemonFrame {
44    // The actor's reload fence swallows a host engine command (it only
45    // emits a notice), so no reply would ever come.
46    if state.reloading.load(Ordering::SeqCst) {
47        return DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading; retry in a moment".into() };
48    }
49    let Some(handle) = state.attach(id) else {
50        return DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: format!("no live session {id}") };
51    };
52    let sid = handle.id.clone();
53    if let Some(holder) = handle.meta().locked_by.clone() {
54        return close_placeholder(state, &handle, &holder).await;
55    }
56    // Someone is looking at it: closing would exit their TUI mid-draft.
57    // `force` is about work in flight, not people, so it does not cover this.
58    let presence = handle.presence();
59    if presence.clients > 0 && !attached {
60        let n = presence.clients;
61        let owner = presence
62            .input_owner
63            .map(|c| match presence.roster.iter().find(|i| i.id == c) {
64                Some(i) => format!(", input owned by {}", i.label()),
65                None => format!(", input owned by client #{}", c.0),
66            })
67            .unwrap_or_default();
68        return DaemonFrame::Refused {
69            reason: RefuseReason::Busy,
70            message: format!(
71                "session {sid} has {n} client{} attached{owner}; closing it exits their TUI and drops unsent input. \
72                 Close it anyway with --attached",
73                if n == 1 { "" } else { "s" }
74            ),
75        };
76    }
77
78    // Subscribe before sending: the reply can land before we would look.
79    let mut rx = handle.subscribe();
80    let query_id = next_close_query_id();
81    let cmd = || SessionCommand::EngineCommand {
82        id: query_id,
83        name: CLOSE_COMMAND.into(),
84        arg: if force { "force".into() } else { String::new() },
85    };
86    let deadline = tokio::time::Instant::now() + CLOSE_SEND_RETRY;
87    loop {
88        match handle.send(cmd()).await {
89            Ok(()) => break,
90            Err(TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
91                tokio::time::sleep(Duration::from_millis(20)).await;
92            }
93            Err(TransportError::Backpressure) => {
94                return DaemonFrame::Error {
95                    session_id: Some(sid),
96                    message: "close: the session's command queue stayed full; retry".into(),
97                };
98            }
99            Err(_) => {
100                state.remove(&sid);
101                return DaemonFrame::Closed { session_id: sid.clone(), text: format!("session {sid} had already ended") };
102            }
103        }
104    }
105
106    let reply = tokio::time::timeout(CLOSE_REPLY_BUDGET, async {
107        loop {
108            match rx.recv().await {
109                Ok(env) => match env.event {
110                    SessionEventWire::QueryResult { id, value } if id == query_id => {
111                        let text = value.get("text").and_then(|t| t.as_str()).unwrap_or_default().to_string();
112                        let ok = value.get("kind").and_then(|k| k.as_str()) != Some("error");
113                        return Reply::Answered { ok, text };
114                    }
115                    // An actor without `close` (echo) that ended anyway.
116                    SessionEventWire::Ended { .. } => return Reply::EndedSilently,
117                    _ => {}
118                },
119                Err(broadcast::error::RecvError::Lagged(_)) => continue,
120                Err(broadcast::error::RecvError::Closed) => return Reply::EndedSilently,
121            }
122        }
123    })
124    .await;
125    let text = match reply {
126        Ok(Reply::Answered { ok: false, text }) => {
127            return DaemonFrame::Refused { reason: RefuseReason::Busy, message: text };
128        }
129        Ok(Reply::Answered { ok: true, text }) => text,
130        Ok(Reply::EndedSilently) => format!("session {sid} ended"),
131        Err(_) => {
132            return DaemonFrame::Error {
133                session_id: Some(sid),
134                message: format!("close: no reply from the session in {} s", CLOSE_REPLY_BUDGET.as_secs()),
135            };
136        }
137    };
138
139    let ended = tokio::time::timeout(CLOSE_END_BUDGET, handle.closed()).await.is_ok();
140    // Idempotent with the host task's own removal; makes the very next
141    // `daemon sessions` exact.
142    state.remove(&sid);
143    let text = if ended { text } else { format!("{text} (still ending in the background)") };
144    DaemonFrame::Closed { session_id: sid, text }
145}
146
147enum Reply {
148    Answered { ok: bool, text: String },
149    EndedSilently,
150}
151
152/// A lock-held placeholder (F23) stands for a session that is live in
153/// another process; it holds no state and does not implement `close`.
154/// Ending it only drops it from this daemon's list.
155async fn close_placeholder(state: &DaemonState, handle: &SessionHandle, holder: &str) -> DaemonFrame {
156    let sid = handle.id.clone();
157    let _ = handle.send(SessionCommand::End { reason: EndReason::ClientQuit }).await;
158    let _ = tokio::time::timeout(CLOSE_REPLY_BUDGET, handle.closed()).await;
159    state.remove(&sid);
160    DaemonFrame::Closed {
161        session_id: sid.clone(),
162        text: format!(
163            "session {sid} is live in another process ({holder}); removed it from this daemon's list, it keeps running there"
164        ),
165    }
166}
167
168/// Why [`resolve_session`] found no single session.
169#[derive(Debug)]
170pub enum ResolveError {
171    NotFound,
172    /// Every session the query matched, so the caller can list them.
173    Ambiguous(Vec<SessionMeta>),
174    /// The query only matched as a prefix, and it is shorter than `min`.
175    TooShort { min: usize },
176}
177
178/// Shortest prefix [`resolve_session`] accepts: the date part of an id
179/// (`20261021`). Exact ids, journal ids and names are not prefixes.
180pub const MIN_PREFIX_LEN: usize = 8;
181
182/// Resolve what a user typed (`synaps daemon close X`) against a session
183/// listing, most specific first: exact id, exact journal id, exact name
184/// (two sessions with that name = ambiguous), then a prefix of exactly one
185/// session's id or journal id, at least [`MIN_PREFIX_LEN`] characters long.
186/// Never guesses between candidates.
187pub fn resolve_session(sessions: &[SessionMeta], query: &str) -> Result<SessionId, ResolveError> {
188    let q = query.trim();
189    if q.is_empty() {
190        return Err(ResolveError::NotFound);
191    }
192    if let Some(m) = sessions.iter().find(|m| m.id.as_str() == q) {
193        return Ok(m.id.clone());
194    }
195    if let Some(m) = sessions.iter().find(|m| m.journal_id == q) {
196        return Ok(m.id.clone());
197    }
198    let named: Vec<&SessionMeta> = sessions.iter().filter(|m| m.name.as_deref() == Some(q)).collect();
199    match named.as_slice() {
200        [one] => return Ok(one.id.clone()),
201        [] => {}
202        many => return Err(ResolveError::Ambiguous(many.iter().copied().cloned().collect())),
203    }
204    let prefixed: Vec<&SessionMeta> = sessions
205        .iter()
206        .filter(|m| m.id.as_str().starts_with(q) || (!m.journal_id.is_empty() && m.journal_id.starts_with(q)))
207        .collect();
208    if !prefixed.is_empty() && q.chars().count() < MIN_PREFIX_LEN {
209        return Err(ResolveError::TooShort { min: MIN_PREFIX_LEN });
210    }
211    match prefixed.as_slice() {
212        [one] => Ok(one.id.clone()),
213        [] => Err(ResolveError::NotFound),
214        many => Err(ResolveError::Ambiguous(many.iter().copied().cloned().collect())),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn meta(id: &str, journal: &str, name: Option<&str>) -> SessionMeta {
223        let mut m = crate::session::handle::echo::meta_for(&SessionId::from(id));
224        m.journal_id = journal.into();
225        m.name = name.map(Into::into);
226        m
227    }
228
229    fn ambiguous_ids(r: Result<SessionId, ResolveError>) -> Vec<String> {
230        match r {
231            Err(ResolveError::Ambiguous(ms)) => {
232                let mut ids: Vec<String> = ms.into_iter().map(|m| m.id.0).collect();
233                ids.sort();
234                ids
235            }
236            other => panic!("expected ambiguous, got {other:?}"),
237        }
238    }
239
240    fn sessions() -> Vec<SessionMeta> {
241        vec![
242            meta("alpha-1", "alpha-1", Some("work")),
243            meta("alpha-2", "20261020-120000-b9b9", Some("play")),
244            meta("gamma-3", "gamma-3", Some("work")),
245            meta("delta-4", "delta-4", None),
246            meta("20261021-093000-cafe", "20261021-093000-cafe", None),
247            meta("20261021-100000-beef", "20261021-100000-beef", None),
248        ]
249    }
250
251    #[test]
252    fn an_exact_id_wins_over_everything_else() {
253        let s = sessions();
254        assert_eq!(resolve_session(&s, "alpha-1").unwrap().as_str(), "alpha-1");
255        assert_eq!(resolve_session(&s, " delta-4 ").unwrap().as_str(), "delta-4");
256    }
257
258    #[test]
259    fn a_journal_id_resolves_to_its_session() {
260        assert_eq!(resolve_session(&sessions(), "20261020-120000-b9b9").unwrap().as_str(), "alpha-2");
261    }
262
263    #[test]
264    fn a_unique_name_resolves_and_a_shared_name_is_ambiguous() {
265        let s = sessions();
266        assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
267        assert_eq!(ambiguous_ids(resolve_session(&s, "work")), ["alpha-1", "gamma-3"]);
268    }
269
270    #[test]
271    fn a_unique_prefix_of_an_id_or_journal_id_resolves() {
272        let s = sessions();
273        assert_eq!(resolve_session(&s, "20261021-0930").unwrap().as_str(), "20261021-093000-cafe");
274        assert_eq!(resolve_session(&s, "20261020-1").unwrap().as_str(), "alpha-2");
275    }
276
277    #[test]
278    fn a_shared_prefix_is_ambiguous_and_lists_every_candidate() {
279        assert_eq!(ambiguous_ids(resolve_session(&sessions(), "20261021")), ["20261021-093000-cafe", "20261021-100000-beef"]);
280    }
281
282    #[test]
283    fn a_prefix_shorter_than_the_minimum_is_refused_even_when_unique() {
284        let s = sessions();
285        // `2` or `gam` would be a unique prefix, but one keystroke must not
286        // pick a session to end.
287        for q in ["2", "2026102", "gam", "alpha-"] {
288            assert!(matches!(resolve_session(&s, q), Err(ResolveError::TooShort { min: MIN_PREFIX_LEN })), "{q}");
289        }
290        // Exact ids and names are not prefixes: any length works.
291        assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
292        // A short query that matches nothing is still just not found.
293        assert!(matches!(resolve_session(&s, "zz"), Err(ResolveError::NotFound)));
294    }
295
296    #[test]
297    fn no_match_and_an_empty_query_are_not_found() {
298        let s = sessions();
299        assert!(matches!(resolve_session(&s, "zeta-zeta"), Err(ResolveError::NotFound)));
300        assert!(matches!(resolve_session(&s, "  "), Err(ResolveError::NotFound)));
301        assert!(matches!(resolve_session(&[], "alpha"), Err(ResolveError::NotFound)));
302    }
303}