agent_core/core/
session_lock.rs1use std::fs::{File, OpenOptions};
12use std::io;
13use std::path::{Path, PathBuf};
14
15use fs4::fs_std::FileExt;
16
17#[derive(Debug, Clone)]
19pub struct LockHolder {
20 pub pid: u32,
21 pub kind: String, }
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#[derive(Debug)]
39pub struct SessionLock {
40 _file: File,
41 path: PathBuf,
42}
43
44impl SessionLock {
45 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 pub fn try_acquire(dir: &Path, id: &str, holder: LockHolder) -> Result<Self, SessionLockError> {
60 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 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 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 pub fn path(&self) -> &Path {
100 &self.path
101 }
102}
103
104pub fn lock_path(dir: &Path, id: &str) -> PathBuf {
106 dir.join(format!("{}.lock", id))
107}
108
109pub 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 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 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(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 let dir = tempfile::tempdir().unwrap();
206 let path = dir.path().join("stale.lock");
207 std::fs::write(&path, "99999\ndaemon\n").unwrap();
208 let _lock = SessionLock::try_acquire(dir.path(), "stale", holder("tui")).unwrap();
210 }
211}