Skip to main content

muxr_client/session/
delete.rs

1use 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
19// Live delete can race with one bounded server write to an attached client; keep the cleanup wait above that path.
20const SESSION_DELETE_TIMEOUT: Duration = Duration::from_secs(5);
21const SESSION_DELETE_POLL_INTERVAL: Duration = Duration::from_millis(10);
22
23/// Result of deleting one selected muxr session.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SessionDeleteOutcome {
26    LiveDeleted,
27    LiveVanishedForced,
28    StoppedRemoved,
29    UnknownForced,
30}
31
32/// Delete a muxr session selected from the local picker.
33///
34/// # Errors
35/// - The session paths cannot be resolved.
36/// - A live session accepts delete but does not stop and remove its files in time.
37/// - The session files cannot be removed.
38pub 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    // A reachable server gets a protocol delete so pane processes close before state files disappear.
45    match session_state(paths)? {
46        SessionState::Live => match self::delete_live_session(paths) {
47            Ok(()) => Ok(SessionDeleteOutcome::LiveDeleted),
48            Err(error) => {
49                // The live probe and delete request are separate socket operations; a server can exit between them.
50                // Once the selected server is no longer reachable, deletion becomes local stopped-session cleanup.
51                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, &timestamp, 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}