muxr_client/session/
delete.rs1use std::fs;
2use std::io;
3use std::path::Path;
4use std::time::Duration;
5use std::time::Instant;
6
7use muxr_core::ClientRequest;
8use muxr_core::ServerEvent;
9use muxr_core::SessionName;
10use muxr_core::SessionPaths;
11use muxr_transport::ClientConnection;
12use rootcause::prelude::ResultExt;
13use rootcause::report;
14
15use crate::session::list::SESSION_PROBE_TIMEOUT;
16use crate::session::list::SessionState;
17use crate::session::list::session_state;
18
19const SESSION_DELETE_TIMEOUT: Duration = Duration::from_secs(5);
21const SESSION_DELETE_POLL_INTERVAL: Duration = Duration::from_millis(10);
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SessionDeleteOutcome {
26 LiveDeleted,
27 LiveVanishedForced,
28 StoppedRemoved,
29 UnknownForced,
30}
31
32pub fn delete_session(session: &SessionName) -> rootcause::Result<SessionDeleteOutcome> {
39 let paths = SessionPaths::from_home(session)?;
40 self::delete_session_paths(&paths)
41}
42
43fn delete_session_paths(paths: &SessionPaths) -> rootcause::Result<SessionDeleteOutcome> {
44 match session_state(paths)? {
46 SessionState::Live => match self::delete_live_session(paths) {
47 Ok(()) => Ok(SessionDeleteOutcome::LiveDeleted),
48 Err(error) => {
49 if session_state(paths)? == SessionState::Live {
52 return Err(error);
53 }
54 self::remove_session_files(paths)?;
55 Ok(SessionDeleteOutcome::LiveVanishedForced)
56 }
57 },
58 SessionState::Stopped => {
59 self::remove_session_files(paths)?;
60 Ok(SessionDeleteOutcome::StoppedRemoved)
61 }
62 SessionState::Unknown => {
63 self::remove_session_files(paths)?;
64 Ok(SessionDeleteOutcome::UnknownForced)
65 }
66 }
67}
68
69fn delete_live_session(paths: &SessionPaths) -> rootcause::Result<()> {
70 tokio::runtime::Runtime::new()
71 .context("failed to build muxr tokio runtime")?
72 .block_on(self::delete_live_session_async(paths))?;
73 self::wait_for_session_files_removed(paths)
74}
75
76async fn delete_live_session_async(paths: &SessionPaths) -> rootcause::Result<()> {
77 let mut connection = tokio::time::timeout(SESSION_PROBE_TIMEOUT, ClientConnection::connect(&paths.socket))
78 .await
79 .map_err(|_| report!("timed out connecting live muxr session"))?
80 .context("failed to connect live muxr session")?;
81
82 tokio::time::timeout(
83 SESSION_PROBE_TIMEOUT,
84 connection.send_request(&ClientRequest::DeleteSession),
85 )
86 .await
87 .map_err(|_| report!("timed out sending muxr delete request"))?
88 .context("failed to send muxr delete request")?;
89
90 match tokio::time::timeout(SESSION_DELETE_TIMEOUT, connection.recv_event())
91 .await
92 .map_err(|_| report!("timed out waiting for muxr delete response"))?
93 .context("failed to read muxr delete response")?
94 {
95 Some(ServerEvent::Deleted) => Ok(()),
96 Some(ServerEvent::Error(error)) => Err(report!("muxr server rejected delete")
97 .attach(format!("code={}", error.code()))
98 .attach(format!("msg={}", error.msg()))),
99 Some(event) => Err(report!("unexpected muxr delete response").attach(format!("{event:?}"))),
100 None => Err(report!("muxr server closed before delete response")),
101 }
102}
103
104fn wait_for_session_files_removed(paths: &SessionPaths) -> rootcause::Result<()> {
105 let started_at = Instant::now();
106 loop {
107 if !paths.root.exists() && !paths.socket.exists() {
108 return Ok(());
109 }
110
111 if started_at.elapsed() > SESSION_DELETE_TIMEOUT {
112 return Err(report!("timed out waiting for muxr session deletion")
113 .attach(format!("root={}", paths.root.display()))
114 .attach(format!("socket={}", paths.socket.display())));
115 }
116
117 std::thread::sleep(SESSION_DELETE_POLL_INTERVAL);
118 }
119}
120
121fn remove_session_files(paths: &SessionPaths) -> rootcause::Result<()> {
122 self::remove_dir_if_exists(&paths.root)?;
123 self::remove_file_if_exists(&paths.socket)
124}
125
126fn remove_dir_if_exists(path: &Path) -> rootcause::Result<()> {
127 match fs::remove_dir_all(path) {
128 Ok(()) => Ok(()),
129 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
130 Err(error) => Err(error).context("failed to remove muxr session dir")?,
131 }
132}
133
134fn remove_file_if_exists(path: &Path) -> rootcause::Result<()> {
135 match fs::remove_file(path) {
136 Ok(()) => Ok(()),
137 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
138 Err(error) => Err(error).context("failed to remove muxr session file")?,
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use std::fs;
145
146 use test_that::prelude::*;
147
148 use super::*;
149
150 #[test]
151 fn test_delete_session_paths_when_session_is_stopped_removes_root() -> rootcause::Result<()> {
152 let root = tempfile::tempdir().context("failed to create muxr delete test root")?;
153 let session: SessionName = "dead".parse()?;
154 let paths = self::test_paths(root.path(), &session);
155 fs::create_dir_all(&paths.root).context("failed to create session root")?;
156
157 let outcome = self::delete_session_paths(&paths)?;
158
159 assert_that!(outcome, eq(SessionDeleteOutcome::StoppedRemoved));
160 assert_that!(paths.root.exists(), eq(false));
161 Ok(())
162 }
163
164 #[test]
165 fn test_delete_session_paths_when_session_is_stopped_keeps_centralized_logs() -> rootcause::Result<()> {
166 let root = tempfile::tempdir().context("failed to create muxr delete test root")?;
167 let session: SessionName = "dead".parse()?;
168 let timestamp = "20260611143012".parse()?;
169 let paths = self::test_paths(root.path(), &session);
170 fs::create_dir_all(&paths.root).context("failed to create session root")?;
171 fs::create_dir_all(paths.logs_root()?).context("failed to create logs root")?;
172 let log_path = paths.server_log_path(&session, ×tamp, 12345)?;
173 fs::write(&log_path, b"log").context("failed to create session log")?;
174
175 let outcome = self::delete_session_paths(&paths)?;
176
177 assert_that!(outcome, eq(SessionDeleteOutcome::StoppedRemoved));
178 assert_that!(paths.root.exists(), eq(false));
179 assert_that!(log_path.exists(), eq(true));
180 Ok(())
181 }
182
183 #[test]
184 fn test_delete_session_paths_when_session_is_unknown_force_removes_root_and_socket() -> rootcause::Result<()> {
185 let root = tempfile::tempdir().context("failed to create muxr delete test root")?;
186 let session: SessionName = "dead".parse()?;
187 let paths = self::test_paths(root.path(), &session);
188 fs::create_dir_all(&paths.root).context("failed to create session root")?;
189 let socket_parent = paths.socket.parent().ok_or_else(|| report!("expected socket parent"))?;
190 fs::create_dir_all(socket_parent).context("failed to create socket parent")?;
191 fs::write(&paths.socket, b"stale").context("failed to create stale socket")?;
192
193 let outcome = self::delete_session_paths(&paths)?;
194
195 assert_that!(outcome, eq(SessionDeleteOutcome::UnknownForced));
196 assert_that!(paths.root.exists(), eq(false));
197 assert_that!(paths.socket.exists(), eq(false));
198 Ok(())
199 }
200
201 #[test]
202 fn test_delete_session_paths_when_pid_exists_without_socket_force_removes_root() -> rootcause::Result<()> {
203 let root = tempfile::tempdir().context("failed to create muxr delete test root")?;
204 let session: SessionName = "dead".parse()?;
205 let paths = self::test_paths(root.path(), &session);
206 fs::create_dir_all(&paths.root).context("failed to create session root")?;
207 fs::write(&paths.pid, b"123").context("failed to create stale pid")?;
208
209 let outcome = self::delete_session_paths(&paths)?;
210
211 assert_that!(outcome, eq(SessionDeleteOutcome::UnknownForced));
212 assert_that!(paths.root.exists(), eq(false));
213 Ok(())
214 }
215
216 #[test]
217 fn test_delete_session_paths_when_live_session_vanishes_force_removes_files() -> rootcause::Result<()> {
218 let root = tempfile::tempdir().context("failed to create muxr delete test root")?;
219 let session: SessionName = "vanished".parse()?;
220 let paths = self::test_paths(root.path(), &session);
221 fs::create_dir_all(&paths.root).context("failed to create session root")?;
222 let socket_parent = paths.socket.parent().ok_or_else(|| report!("expected socket parent"))?;
223 fs::create_dir_all(socket_parent).context("failed to create socket parent")?;
224 let socket = paths.socket.clone();
225 let socket_for_server = paths.socket.clone();
226 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
227 let server = std::thread::spawn(move || -> rootcause::Result<()> {
228 tokio::runtime::Runtime::new()
229 .context("failed to create fake muxr runtime")?
230 .block_on(async move {
231 let listener = muxr_transport::ServerListener::bind(&socket_for_server)?;
232 ready_tx.send(()).map_err(|error| {
233 report!("failed to signal fake muxr server readiness").attach(format!("{error}"))
234 })?;
235 let mut connection = listener.accept().await?;
236 drop(listener);
237 self::remove_file_if_exists(&socket)?;
238 assert_that!(connection.recv_request().await?, eq(Some(ClientRequest::Ping)));
239 connection.send_event(&ServerEvent::Pong).await
240 })
241 });
242 ready_rx
243 .recv_timeout(SESSION_PROBE_TIMEOUT)
244 .map_err(|error| report!("timed out waiting for fake muxr server").attach(format!("{error}")))?;
245
246 let outcome = self::delete_session_paths(&paths)?;
247
248 server
249 .join()
250 .map_err(|error| report!("fake muxr session thread panicked").attach(format!("{error:?}")))??;
251 assert_that!(outcome, eq(SessionDeleteOutcome::LiveVanishedForced));
252 assert_that!(paths.root.exists(), eq(false));
253 assert_that!(paths.socket.exists(), eq(false));
254 Ok(())
255 }
256
257 fn test_paths(root: &Path, session: &SessionName) -> SessionPaths {
258 SessionPaths {
259 root: root.join("sessions").join(session.as_ref()),
260 socket: root.join("s").join("dead.sock"),
261 pid: root.join("sessions").join(session.as_ref()).join("server.pid"),
262 layout: root.join("sessions").join(session.as_ref()).join("layout.json"),
263 panes: root.join("sessions").join(session.as_ref()).join("panes"),
264 }
265 }
266}