Skip to main content

agent_core/core/
session_lock.rs

1//! Per-session journal ownership lock.
2//!
3//! Whoever holds a `Runtime` for session X holds an exclusive advisory
4//! `flock` on `<sessions_dir>/<id>.lock`. Two runtimes for the same
5//! journal are never allowed — the second caller gets an actionable error
6//! pointing at the holder's pid.
7//!
8//! The lock is released on drop (or when the process dies — `flock`
9//! semantics). Stale lock files from dead processes never block.
10
11use std::fs::{File, OpenOptions};
12use std::io;
13use std::path::{Path, PathBuf};
14
15use fs4::fs_std::FileExt;
16
17/// Body written into the lock file so the error message can name the holder.
18#[derive(Debug, Clone)]
19pub struct LockHolder {
20    pub pid: u32,
21    pub kind: String, // "daemon", "tui", "chat", …
22}
23
24impl LockHolder {
25    fn serialize(&self) -> String {
26        format!("{}\n{}\n", self.pid, self.kind)
27    }
28
29    fn deserialize(s: &str) -> Option<Self> {
30        let mut lines = s.lines();
31        let pid: u32 = lines.next()?.parse().ok()?;
32        let kind = lines.next().unwrap_or("unknown").to_string();
33        Some(Self { pid, kind })
34    }
35}
36
37/// Held for the runtime's lifetime; dropping releases the `flock`.
38#[derive(Debug)]
39pub struct SessionLock {
40    _file: File,
41    path: PathBuf,
42}
43
44impl SessionLock {
45    /// Release the lock AND remove the lock file. For a session that never
46    /// journaled (ended `Idle`, F18) the `.lock` next to no `.json` is pure
47    /// litter; a session WITH a journal keeps its file (harmless, reaped on
48    /// the next acquire). Best effort: a failed unlink is not an error.
49    pub fn release_and_remove(self) {
50        let path = self.path.clone();
51        drop(self);
52        let _ = std::fs::remove_file(path);
53    }
54
55    /// Try to acquire an exclusive `flock` on `<dir>/<id>.lock`.
56    ///
57    /// Returns `Ok(lock)` on success; `Err` with an actionable message if
58    /// another process holds it (or on I/O error).
59    pub fn try_acquire(dir: &Path, id: &str, holder: LockHolder) -> Result<Self, SessionLockError> {
60        // F24: refuse to lock a session that was compacted into a successor.
61        if let Ok(Some(successor)) = crate::session::read_compacted_into_in_dir(dir, id) {
62            return Err(SessionLockError::CompactedInto {
63                session_id: id.to_string(),
64                successor_id: successor,
65            });
66        }
67        let path = dir.join(format!("{}.lock", id));
68        std::fs::create_dir_all(dir).map_err(|e| SessionLockError::Io(e, path.clone()))?;
69        let file = OpenOptions::new()
70            .create(true)
71            .truncate(false)
72            .read(true)
73            .write(true)
74            .open(&path)
75            .map_err(|e| SessionLockError::Io(e, path.clone()))?;
76        #[cfg(unix)]
77        {
78            use std::os::unix::fs::PermissionsExt;
79            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
80        }
81        if FileExt::try_lock_exclusive(&file).map_err(|e| SessionLockError::Io(e, path.clone()))? {
82            // We hold it — write our identity.
83            use std::io::Write;
84            let _ = file.set_len(0);
85            let _ = (&file).write_all(holder.serialize().as_bytes());
86            Ok(Self { _file: file, path })
87        } else {
88            // Someone else holds it — read their identity.
89            let body = std::fs::read_to_string(&path).unwrap_or_default();
90            let existing = LockHolder::deserialize(&body);
91            Err(SessionLockError::Held {
92                session_id: id.to_string(),
93                holder: existing,
94            })
95        }
96    }
97
98    /// Path of the lock file (for diagnostics).
99    pub fn path(&self) -> &Path {
100        &self.path
101    }
102}
103
104/// Lock-file path for a session (for cleanup).
105pub fn lock_path(dir: &Path, id: &str) -> PathBuf {
106    dir.join(format!("{}.lock", id))
107}
108
109/// The sessions directory (delegates to config).
110pub fn sessions_dir() -> PathBuf {
111    crate::config::get_active_config_dir().join("sessions")
112}
113
114#[derive(Debug)]
115pub enum SessionLockError {
116    Io(io::Error, PathBuf),
117    Held {
118        session_id: String,
119        holder: Option<LockHolder>,
120    },
121    /// The session was compacted into a successor — locking the predecessor
122    /// would fork pre-compaction history (F24).
123    CompactedInto {
124        session_id: String,
125        successor_id: String,
126    },
127}
128
129impl std::fmt::Display for SessionLockError {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            Self::Io(e, path) => write!(f, "session lock I/O error on {}: {}", path.display(), e),
133            Self::Held {
134                session_id,
135                holder: Some(h),
136            } => write!(
137                f,
138                "session {} is live in another process (pid {}, {}) \
139                 — use `synaps --attach {}`, or `synaps daemon sessions`",
140                session_id, h.pid, h.kind, session_id
141            ),
142            Self::Held {
143                session_id,
144                holder: None,
145            } => write!(
146                f,
147                "session {} is live in another process \
148                 — use `synaps --attach {}`, or `synaps daemon sessions`",
149                session_id, session_id
150            ),
151            Self::CompactedInto {
152                session_id,
153                successor_id,
154            } => write!(
155                f,
156                "session {} was compacted into {} \
157                 — continue {} instead",
158                session_id, successor_id, successor_id
159            ),
160        }
161    }
162}
163
164impl std::error::Error for SessionLockError {}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn holder(kind: &str) -> LockHolder {
171        LockHolder {
172            pid: std::process::id(),
173            kind: kind.to_string(),
174        }
175    }
176
177    #[test]
178    fn take_and_refuse() {
179        let dir = tempfile::tempdir().unwrap();
180        let lock = SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap();
181        // Second acquire must fail.
182        let err = SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap_err();
183        let msg = err.to_string();
184        assert!(msg.contains("abc123"), "error names the session: {msg}");
185        assert!(
186            msg.contains(&format!("pid {}", std::process::id())),
187            "error names the pid: {msg}"
188        );
189        // Drop releases.
190        drop(lock);
191        SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap();
192    }
193
194    #[test]
195    fn different_sessions_independent() {
196        let dir = tempfile::tempdir().unwrap();
197        let _a = SessionLock::try_acquire(dir.path(), "aaa", holder("tui")).unwrap();
198        let _b = SessionLock::try_acquire(dir.path(), "bbb", holder("tui")).unwrap();
199    }
200
201    #[test]
202    fn stale_lock_from_dead_process_does_not_block() {
203        // flock is released when the fd is closed (process death does that).
204        // We simulate by writing a lock file body without holding the flock.
205        let dir = tempfile::tempdir().unwrap();
206        let path = dir.path().join("stale.lock");
207        std::fs::write(&path, "99999\ndaemon\n").unwrap();
208        // Must succeed — no flock held.
209        let _lock = SessionLock::try_acquire(dir.path(), "stale", holder("tui")).unwrap();
210    }
211}