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, CLOSE_UNATTACHED_ARG};
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    // The check above is the friendly fast path; the actor decides (an
78    // attach already queued ahead of the close would race it).
79    close_in_actor(state, &handle, force, !attached).await
80}
81
82/// End `handle` only if no client is attached, decided inside the actor
83/// atomically with the close: an attach that reaches the actor first is
84/// never closed under. `Closed`, or `Refused`/`Error` with why.
85pub async fn close_if_unattached(state: &DaemonState, handle: &SessionHandle) -> DaemonFrame {
86    if state.reloading.load(Ordering::SeqCst) {
87        return DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading; retry in a moment".into() };
88    }
89    if let Some(holder) = handle.meta().locked_by.clone() {
90        return close_placeholder(state, handle, &holder).await;
91    }
92    close_in_actor(state, handle, false, true).await
93}
94
95/// Send the host `close` (`unattached`: refused while anyone is attached)
96/// and wait for the reply and the end.
97async fn close_in_actor(state: &DaemonState, handle: &SessionHandle, force: bool, unattached: bool) -> DaemonFrame {
98    let sid = handle.id.clone();
99    // Subscribe before sending: the reply can land before we would look.
100    let mut rx = handle.subscribe();
101    let query_id = next_close_query_id();
102    let arg = [force.then_some("force"), unattached.then_some(CLOSE_UNATTACHED_ARG)]
103        .into_iter()
104        .flatten()
105        .collect::<Vec<_>>()
106        .join(" ");
107    let cmd = || SessionCommand::EngineCommand { id: query_id, name: CLOSE_COMMAND.into(), arg: arg.clone() };
108    let deadline = tokio::time::Instant::now() + CLOSE_SEND_RETRY;
109    loop {
110        match handle.send(cmd()).await {
111            Ok(()) => break,
112            Err(TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
113                tokio::time::sleep(Duration::from_millis(20)).await;
114            }
115            Err(TransportError::Backpressure) => {
116                return DaemonFrame::Error {
117                    session_id: Some(sid),
118                    message: "close: the session's command queue stayed full; retry".into(),
119                };
120            }
121            Err(_) => {
122                state.remove(&sid);
123                return DaemonFrame::Closed { session_id: sid.clone(), text: format!("session {sid} had already ended") };
124            }
125        }
126    }
127
128    let reply = tokio::time::timeout(CLOSE_REPLY_BUDGET, async {
129        loop {
130            match rx.recv().await {
131                Ok(env) => match env.event {
132                    SessionEventWire::QueryResult { id, value } if id == query_id => {
133                        let text = value.get("text").and_then(|t| t.as_str()).unwrap_or_default().to_string();
134                        let ok = value.get("kind").and_then(|k| k.as_str()) != Some("error");
135                        return Reply::Answered { ok, text };
136                    }
137                    // An actor without `close` (echo) that ended anyway.
138                    SessionEventWire::Ended { .. } => return Reply::EndedSilently,
139                    _ => {}
140                },
141                Err(broadcast::error::RecvError::Lagged(_)) => continue,
142                Err(broadcast::error::RecvError::Closed) => return Reply::EndedSilently,
143            }
144        }
145    })
146    .await;
147    let text = match reply {
148        Ok(Reply::Answered { ok: false, text }) => {
149            return DaemonFrame::Refused { reason: RefuseReason::Busy, message: text };
150        }
151        Ok(Reply::Answered { ok: true, text }) => text,
152        Ok(Reply::EndedSilently) => format!("session {sid} ended"),
153        Err(_) => {
154            return DaemonFrame::Error {
155                session_id: Some(sid),
156                message: format!("close: no reply from the session in {} s", CLOSE_REPLY_BUDGET.as_secs()),
157            };
158        }
159    };
160
161    let ended = tokio::time::timeout(CLOSE_END_BUDGET, handle.closed()).await.is_ok();
162    // Idempotent with the host task's own removal; makes the very next
163    // `daemon sessions` exact.
164    state.remove(&sid);
165    let text = if ended { text } else { format!("{text} (still ending in the background)") };
166    DaemonFrame::Closed { session_id: sid, text }
167}
168
169enum Reply {
170    Answered { ok: bool, text: String },
171    EndedSilently,
172}
173
174/// A lock-held placeholder (F23) stands for a session that is live in
175/// another process; it holds no state and does not implement `close`.
176/// Ending it only drops it from this daemon's list.
177async fn close_placeholder(state: &DaemonState, handle: &SessionHandle, holder: &str) -> DaemonFrame {
178    let sid = handle.id.clone();
179    let _ = handle.send(SessionCommand::End { reason: EndReason::ClientQuit }).await;
180    let _ = tokio::time::timeout(CLOSE_REPLY_BUDGET, handle.closed()).await;
181    state.remove(&sid);
182    DaemonFrame::Closed {
183        session_id: sid.clone(),
184        text: format!(
185            "session {sid} is live in another process ({holder}); removed it from this daemon's list, it keeps running there"
186        ),
187    }
188}
189
190/// Why [`resolve_session`] found no single session.
191#[derive(Debug)]
192pub enum ResolveError {
193    NotFound,
194    /// Every session the query matched, so the caller can list them.
195    Ambiguous(Vec<SessionMeta>),
196    /// The query only matched as a prefix, and it is shorter than `min`.
197    TooShort { min: usize },
198}
199
200/// Shortest prefix [`resolve_session`] accepts: the date part of an id
201/// (`20261021`). Exact ids, journal ids and names are not prefixes.
202pub const MIN_PREFIX_LEN: usize = 8;
203
204/// Resolve what a user typed (`synaps daemon close X`) against a session
205/// listing, most specific first: exact id, exact journal id, exact name
206/// (two sessions with that name = ambiguous), then a prefix of exactly one
207/// session's id or journal id, at least [`MIN_PREFIX_LEN`] characters long.
208/// Never guesses between candidates.
209pub fn resolve_session(sessions: &[SessionMeta], query: &str) -> Result<SessionId, ResolveError> {
210    let q = query.trim();
211    if q.is_empty() {
212        return Err(ResolveError::NotFound);
213    }
214    if let Some(m) = sessions.iter().find(|m| m.id.as_str() == q) {
215        return Ok(m.id.clone());
216    }
217    if let Some(m) = sessions.iter().find(|m| m.journal_id == q) {
218        return Ok(m.id.clone());
219    }
220    let named: Vec<&SessionMeta> = sessions.iter().filter(|m| m.name.as_deref() == Some(q)).collect();
221    match named.as_slice() {
222        [one] => return Ok(one.id.clone()),
223        [] => {}
224        many => return Err(ResolveError::Ambiguous(many.iter().copied().cloned().collect())),
225    }
226    let prefixed: Vec<&SessionMeta> = sessions
227        .iter()
228        .filter(|m| m.id.as_str().starts_with(q) || (!m.journal_id.is_empty() && m.journal_id.starts_with(q)))
229        .collect();
230    if !prefixed.is_empty() && q.chars().count() < MIN_PREFIX_LEN {
231        return Err(ResolveError::TooShort { min: MIN_PREFIX_LEN });
232    }
233    match prefixed.as_slice() {
234        [one] => Ok(one.id.clone()),
235        [] => Err(ResolveError::NotFound),
236        many => Err(ResolveError::Ambiguous(many.iter().copied().cloned().collect())),
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn meta(id: &str, journal: &str, name: Option<&str>) -> SessionMeta {
245        let mut m = crate::session::handle::echo::meta_for(&SessionId::from(id));
246        m.journal_id = journal.into();
247        m.name = name.map(Into::into);
248        m
249    }
250
251    fn ambiguous_ids(r: Result<SessionId, ResolveError>) -> Vec<String> {
252        match r {
253            Err(ResolveError::Ambiguous(ms)) => {
254                let mut ids: Vec<String> = ms.into_iter().map(|m| m.id.0).collect();
255                ids.sort();
256                ids
257            }
258            other => panic!("expected ambiguous, got {other:?}"),
259        }
260    }
261
262    fn sessions() -> Vec<SessionMeta> {
263        vec![
264            meta("alpha-1", "alpha-1", Some("work")),
265            meta("alpha-2", "20261020-120000-b9b9", Some("play")),
266            meta("gamma-3", "gamma-3", Some("work")),
267            meta("delta-4", "delta-4", None),
268            meta("20261021-093000-cafe", "20261021-093000-cafe", None),
269            meta("20261021-100000-beef", "20261021-100000-beef", None),
270        ]
271    }
272
273    #[test]
274    fn an_exact_id_wins_over_everything_else() {
275        let s = sessions();
276        assert_eq!(resolve_session(&s, "alpha-1").unwrap().as_str(), "alpha-1");
277        assert_eq!(resolve_session(&s, " delta-4 ").unwrap().as_str(), "delta-4");
278    }
279
280    #[test]
281    fn a_journal_id_resolves_to_its_session() {
282        assert_eq!(resolve_session(&sessions(), "20261020-120000-b9b9").unwrap().as_str(), "alpha-2");
283    }
284
285    #[test]
286    fn a_unique_name_resolves_and_a_shared_name_is_ambiguous() {
287        let s = sessions();
288        assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
289        assert_eq!(ambiguous_ids(resolve_session(&s, "work")), ["alpha-1", "gamma-3"]);
290    }
291
292    #[test]
293    fn a_unique_prefix_of_an_id_or_journal_id_resolves() {
294        let s = sessions();
295        assert_eq!(resolve_session(&s, "20261021-0930").unwrap().as_str(), "20261021-093000-cafe");
296        assert_eq!(resolve_session(&s, "20261020-1").unwrap().as_str(), "alpha-2");
297    }
298
299    #[test]
300    fn a_shared_prefix_is_ambiguous_and_lists_every_candidate() {
301        assert_eq!(ambiguous_ids(resolve_session(&sessions(), "20261021")), ["20261021-093000-cafe", "20261021-100000-beef"]);
302    }
303
304    #[test]
305    fn a_prefix_shorter_than_the_minimum_is_refused_even_when_unique() {
306        let s = sessions();
307        // `2` or `gam` would be a unique prefix, but one keystroke must not
308        // pick a session to end.
309        for q in ["2", "2026102", "gam", "alpha-"] {
310            assert!(matches!(resolve_session(&s, q), Err(ResolveError::TooShort { min: MIN_PREFIX_LEN })), "{q}");
311        }
312        // Exact ids and names are not prefixes: any length works.
313        assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
314        // A short query that matches nothing is still just not found.
315        assert!(matches!(resolve_session(&s, "zz"), Err(ResolveError::NotFound)));
316    }
317
318    #[test]
319    fn no_match_and_an_empty_query_are_not_found() {
320        let s = sessions();
321        assert!(matches!(resolve_session(&s, "zeta-zeta"), Err(ResolveError::NotFound)));
322        assert!(matches!(resolve_session(&s, "  "), Err(ResolveError::NotFound)));
323        assert!(matches!(resolve_session(&[], "alpha"), Err(ResolveError::NotFound)));
324    }
325}