Skip to main content

skiff_cli/session/
paths.rs

1//! Session directory layout under `~/.cache/skiff/sessions/`.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9use crate::paths::cache_dir;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionMeta {
13    pub pid: u32,
14    pub source: String,
15    pub transport: String,
16    pub created_at: f64,
17    #[serde(default)]
18    pub idle_secs: u64,
19    /// Unix timestamp of last IPC activity (for idle remaining).
20    #[serde(default)]
21    pub last_activity_at: f64,
22}
23
24pub fn sessions_dir() -> PathBuf {
25    cache_dir().join("sessions")
26}
27
28pub fn session_meta_path(name: &str) -> PathBuf {
29    sessions_dir().join(format!("{name}.json"))
30}
31
32pub fn session_sock_path(name: &str) -> PathBuf {
33    sessions_dir().join(format!("{name}.sock"))
34}
35
36pub fn session_log_path(name: &str) -> PathBuf {
37    sessions_dir().join(format!("{name}.log"))
38}
39
40pub fn session_config_path(name: &str) -> PathBuf {
41    sessions_dir().join(format!("{name}.config.json"))
42}
43
44/// Exclusive lockfile path used to serialize `session_start` for a given name.
45pub fn session_lock_path(name: &str) -> PathBuf {
46    sessions_dir().join(format!("{name}.lock"))
47}
48
49pub fn ensure_sessions_dir() -> Result<()> {
50    crate::fsutil::ensure_dir_0700(&sessions_dir())
51}
52
53pub fn is_process_alive(pid: u32) -> bool {
54    if pid == 0 {
55        return false;
56    }
57    // signal 0: existence check
58    let rc = unsafe { libc::kill(pid as i32, 0) };
59    rc == 0
60}
61
62pub fn session_is_alive(meta: &SessionMeta) -> bool {
63    is_process_alive(meta.pid)
64}
65
66pub fn load_meta(name: &str) -> Result<Option<SessionMeta>> {
67    let path = session_meta_path(name);
68    if !path.exists() {
69        return Ok(None);
70    }
71    let text = fs::read_to_string(&path)?;
72    match serde_json::from_str(&text) {
73        Ok(m) => Ok(Some(m)),
74        Err(_) => Ok(None),
75    }
76}
77
78pub fn write_meta(name: &str, meta: &SessionMeta) -> Result<()> {
79    ensure_sessions_dir()?;
80    let path = session_meta_path(name);
81    let json = serde_json::to_vec_pretty(meta)?;
82    crate::fsutil::atomic_write_0600(&path, &json)?;
83    Ok(())
84}
85
86/// Remove meta/sock/log (and leftover config) for a session name.
87pub fn unlink_session_files(name: &str) {
88    for p in [
89        session_meta_path(name),
90        session_sock_path(name),
91        session_log_path(name),
92        session_config_path(name),
93    ] {
94        let _ = fs::remove_file(p);
95    }
96}
97
98/// Try to acquire the exclusive start-lock for `name`. Returns `Ok(true)` if
99/// acquired, `Ok(false)` if another live process holds it. If the lockfile is
100/// stale (owner PID no longer alive), it is cleared and acquisition retried
101/// once so a crashed process can't wedge future starts forever.
102pub fn try_acquire_session_lock(name: &str) -> Result<bool> {
103    use std::io::Write;
104    let lock_path = session_lock_path(name);
105    for attempt in 0..2 {
106        let mut opts = fs::OpenOptions::new();
107        opts.write(true).create_new(true);
108        #[cfg(unix)]
109        {
110            use std::os::unix::fs::OpenOptionsExt;
111            opts.mode(0o600);
112        }
113        match opts.open(&lock_path) {
114            Ok(mut f) => {
115                let _ = write!(f, "{}", std::process::id());
116                let _ = f.sync_all();
117                return Ok(true);
118            }
119            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
120                if attempt == 0 {
121                    // Check whether the lock owner is still alive; if not, it's
122                    // stale (e.g. a crashed process) and safe to clear.
123                    let stale = match fs::read_to_string(&lock_path) {
124                        Ok(s) => match s.trim().parse::<u32>() {
125                            Ok(pid) => !is_process_alive(pid),
126                            Err(_) => true,
127                        },
128                        Err(_) => true,
129                    };
130                    if stale {
131                        let _ = fs::remove_file(&lock_path);
132                        continue;
133                    }
134                    return Ok(false);
135                }
136                return Ok(false);
137            }
138            Err(e) => return Err(e.into()),
139        }
140    }
141    Ok(false)
142}
143
144/// Release the exclusive start-lock for `name`.
145pub fn release_session_lock(name: &str) {
146    let _ = fs::remove_file(session_lock_path(name));
147}
148
149/// If meta exists but PID is dead, clear leftovers. Returns true if cleaned.
150pub fn clear_stale_session(name: &str) -> Result<bool> {
151    if let Some(meta) = load_meta(name)? {
152        if !session_is_alive(&meta) {
153            unlink_session_files(name);
154            return Ok(true);
155        }
156        return Ok(false);
157    }
158    // Orphan sock without meta
159    let sock = session_sock_path(name);
160    if sock.exists() {
161        unlink_session_files(name);
162        return Ok(true);
163    }
164    Ok(false)
165}
166
167pub fn validate_session_name(name: &str) -> Result<()> {
168    if name.is_empty()
169        || name.contains('/')
170        || name.contains('\\')
171        || name.contains("..")
172        || name.contains('\0')
173    {
174        return Err(Error::usage(format!(
175            "invalid session name {name:?}; use a simple identifier"
176        )));
177    }
178    Ok(())
179}
180
181pub fn chmod_0600(path: &Path) -> Result<()> {
182    #[cfg(unix)]
183    {
184        use std::os::unix::fs::PermissionsExt;
185        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
186    }
187    let _ = path;
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
195    use tempfile::tempdir;
196
197    #[test]
198    fn paths_and_stale() {
199        let _g = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
200        let dir = tempdir().unwrap();
201        set_cache_dir_override(Some(dir.path().to_path_buf()));
202        ensure_sessions_dir().unwrap();
203        assert!(sessions_dir().ends_with("sessions"));
204
205        write_meta(
206            "demo",
207            &SessionMeta {
208                pid: 999_999_999,
209                source: "echo".into(),
210                transport: "stdio".into(),
211                created_at: 1.0,
212                idle_secs: 1800,
213                last_activity_at: 1.0,
214            },
215        )
216        .unwrap();
217        assert!(clear_stale_session("demo").unwrap());
218        assert!(!session_meta_path("demo").exists());
219        set_cache_dir_override(None);
220    }
221
222    #[test]
223    fn name_validation() {
224        assert!(validate_session_name("myfs").is_ok());
225        assert!(validate_session_name("../x").is_err());
226        assert!(validate_session_name("a/b").is_err());
227    }
228}