1use 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
23pub const CLOSE_SEND_RETRY: Duration = Duration::from_secs(2);
26pub const CLOSE_REPLY_BUDGET: Duration = Duration::from_secs(5);
30pub const CLOSE_END_BUDGET: Duration =
33 Duration::from_secs(super::lifecycle::SESSION_END_BUDGET.as_secs() + 1);
34
35fn 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
41pub async fn close_session(state: &DaemonState, id: &SessionId, force: bool, attached: bool) -> DaemonFrame {
44 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 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 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 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 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
152async 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#[derive(Debug)]
170pub enum ResolveError {
171 NotFound,
172 Ambiguous(Vec<SessionMeta>),
174 TooShort { min: usize },
176}
177
178pub const MIN_PREFIX_LEN: usize = 8;
181
182pub 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 for q in ["2", "2026102", "gam", "alpha-"] {
288 assert!(matches!(resolve_session(&s, q), Err(ResolveError::TooShort { min: MIN_PREFIX_LEN })), "{q}");
289 }
290 assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
292 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}