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, CLOSE_UNATTACHED_ARG};
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 close_in_actor(state, &handle, force, !attached).await
80}
81
82pub 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
95async fn close_in_actor(state: &DaemonState, handle: &SessionHandle, force: bool, unattached: bool) -> DaemonFrame {
98 let sid = handle.id.clone();
99 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 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 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
174async 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#[derive(Debug)]
192pub enum ResolveError {
193 NotFound,
194 Ambiguous(Vec<SessionMeta>),
196 TooShort { min: usize },
198}
199
200pub const MIN_PREFIX_LEN: usize = 8;
203
204pub 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 for q in ["2", "2026102", "gam", "alpha-"] {
310 assert!(matches!(resolve_session(&s, q), Err(ResolveError::TooShort { min: MIN_PREFIX_LEN })), "{q}");
311 }
312 assert_eq!(resolve_session(&s, "play").unwrap().as_str(), "alpha-2");
314 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}