Skip to main content

muxr_client/session/
start.rs

1use std::fs;
2use std::os::unix::process::CommandExt;
3use std::path::Path;
4use std::path::PathBuf;
5use std::process::Command;
6use std::process::Stdio;
7
8use muxr_core::ServerRunnerArgs;
9use muxr_core::SessionName;
10use muxr_core::SessionPaths;
11use rootcause::prelude::ResultExt;
12use rootcause::report;
13
14pub struct SpawnedServer {
15    pub log_locator: ServerLogLocator,
16}
17
18pub struct ServerLogLocator {
19    pub file_pattern: String,
20    pub logs_dir: PathBuf,
21    pub pid: u32,
22}
23
24pub fn cleanup_stale_session_files(paths: &SessionPaths) -> rootcause::Result<()> {
25    // Structured rejections stop before cleanup; unusable attach failures may be stale incompatible servers.
26    self::remove_file_if_exists(&paths.socket)?;
27    self::remove_file_if_exists(&paths.pid)?;
28    Ok(())
29}
30
31pub fn spawn_server_process(
32    session: &SessionName,
33    paths: &SessionPaths,
34    server_executable: &Path,
35    external_layout: Option<&Path>,
36) -> rootcause::Result<SpawnedServer> {
37    // Existing sessions attach before this path; validate the runner only when spawning a missing/new server.
38    if !server_executable.is_file() {
39        return Err(report!("missing muxr server runner")
40            .attach(format!("expected={}", server_executable.display()))
41            .attach("run the muxr install/build step so muxr-server is installed next to muxr"));
42    }
43    let logs_dir = paths.logs_root()?;
44    let mut cmd = self::server_cmd(session, server_executable, external_layout);
45
46    let child = cmd.spawn().context("failed to spawn muxr server")?;
47    let pid = child.id();
48    drop(child);
49    Ok(SpawnedServer {
50        log_locator: ServerLogLocator {
51            file_pattern: SessionPaths::server_log_file_pattern(session, pid),
52            logs_dir,
53            pid,
54        },
55    })
56}
57
58fn server_cmd(session: &SessionName, server_executable: &Path, external_layout: Option<&Path>) -> Command {
59    let mut cmd = Command::new(server_executable);
60    let runner_args = ServerRunnerArgs {
61        external_layout: external_layout.map(Path::to_path_buf),
62        session: session.clone(),
63    };
64    cmd.args(runner_args.argv())
65        .stdin(Stdio::null())
66        .stdout(Stdio::null())
67        .stderr(Stdio::null())
68        .process_group(0);
69    cmd
70}
71
72fn remove_file_if_exists(path: &Path) -> rootcause::Result<()> {
73    match fs::remove_file(path) {
74        Ok(()) => Ok(()),
75        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
76        Err(error) => Err(error).context("failed to remove stale muxr file")?,
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::ffi::OsStr;
83    use std::os::unix::net::UnixListener;
84    use std::path::Path;
85
86    use muxr_core::EXTERNAL_LAYOUT_ARG;
87    use muxr_core::SessionPaths;
88    use test_that::prelude::*;
89
90    use super::*;
91
92    #[test]
93    fn test_cleanup_stale_session_files_when_running_pid_has_missing_socket_removes_pid() -> rootcause::Result<()> {
94        let tempdir = tempfile::tempdir()?;
95        let (_, paths) = self::session_paths(tempdir.path(), "work")?;
96        fs::create_dir_all(&paths.root)?;
97        fs::write(&paths.pid, std::process::id().to_string())?;
98
99        cleanup_stale_session_files(&paths)?;
100
101        assert_that!(paths.pid.exists(), eq(false));
102        assert_that!(paths.socket.exists(), eq(false));
103        Ok(())
104    }
105
106    #[test]
107    fn test_cleanup_stale_session_files_when_socket_path_exists_removes_socket_path() -> rootcause::Result<()> {
108        let tempdir = tempfile::tempdir()?;
109        let (_, paths) = self::session_paths(tempdir.path(), "work")?;
110        fs::create_dir_all(&paths.root)?;
111        let _listener = UnixListener::bind(&paths.socket)?;
112
113        cleanup_stale_session_files(&paths)?;
114
115        assert_that!(paths.socket.exists(), eq(false));
116        Ok(())
117    }
118
119    #[test]
120    fn test_server_cmd_uses_supplied_executable_for_server() -> rootcause::Result<()> {
121        let session: SessionName = "work".parse()?;
122        let cmd = server_cmd(&session, Path::new("/tmp/custom-muxr"), None);
123        let args: Vec<_> = cmd.get_args().collect();
124
125        assert_that!(cmd.get_program(), eq(OsStr::new("/tmp/custom-muxr")));
126        assert_that!(args.as_slice(), eq([OsStr::new("work")]));
127        Ok(())
128    }
129
130    #[test]
131    fn test_server_cmd_when_external_layout_is_supplied_passes_layout_to_server() -> rootcause::Result<()> {
132        let session: SessionName = "work".parse()?;
133        let layout = Path::new("../.config/muxr/layouts/work.json");
134        let cmd = server_cmd(&session, Path::new("/tmp/custom-muxr"), Some(layout));
135        let args: Vec<_> = cmd.get_args().collect();
136
137        assert_that!(
138            args.as_slice(),
139            eq([
140                OsStr::new("work"),
141                OsStr::new(EXTERNAL_LAYOUT_ARG),
142                OsStr::new("../.config/muxr/layouts/work.json")
143            ])
144        );
145        Ok(())
146    }
147
148    #[test]
149    fn test_spawn_server_process_when_runner_is_missing_returns_error() -> rootcause::Result<()> {
150        let session: SessionName = "work".parse()?;
151        let tempdir = tempfile::tempdir()?;
152        let missing_runner = tempdir.path().join("muxr-server");
153        let (_, paths) = self::session_paths(tempdir.path(), "work")?;
154
155        assert_that!(
156            spawn_server_process(&session, &paths, &missing_runner, None).map(|_| ()),
157            err(displays_as(contains_substring("missing muxr server runner")))
158        );
159        Ok(())
160    }
161
162    fn session_paths(base: &Path, raw: &str) -> rootcause::Result<(SessionName, SessionPaths)> {
163        let session = raw.parse()?;
164        let root = base.join("sessions").join(raw);
165
166        Ok((
167            session,
168            SessionPaths {
169                socket: root.join("server.sock"),
170                pid: root.join("server.pid"),
171                layout: root.join("layout.json"),
172                panes: root.join("panes"),
173                root,
174            },
175        ))
176    }
177}