1use std::fs::OpenOptions;
12use std::io;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone)]
17pub struct LockHolder {
18 pub pid: u32,
19 pub kind: String, }
21
22impl LockHolder {
23 fn serialize(&self) -> String {
24 format!("{}\n{}\n", self.pid, self.kind)
25 }
26
27 fn deserialize(s: &str) -> Option<Self> {
28 let mut lines = s.lines();
29 let pid: u32 = lines.next()?.parse().ok()?;
30 let kind = lines.next().unwrap_or("unknown").to_string();
31 Some(Self { pid, kind })
32 }
33}
34
35#[derive(Debug)]
38pub struct SessionLock {
39 _lock: crate::core::lock_file::ExclusiveLock,
40 path: PathBuf,
41}
42
43impl SessionLock {
44 pub fn release_and_remove(self) {
49 let path = self.path.clone();
50 drop(self);
51 let _ = std::fs::remove_file(path);
52 }
53
54 pub fn try_acquire(dir: &Path, id: &str, holder: LockHolder) -> Result<Self, SessionLockError> {
59 if let Ok(Some(successor)) = crate::session::read_compacted_into_in_dir(dir, id) {
61 return Err(SessionLockError::CompactedInto {
62 session_id: id.to_string(),
63 successor_id: successor,
64 });
65 }
66 let path = dir.join(format!("{}.lock", id));
67 std::fs::create_dir_all(dir).map_err(|e| SessionLockError::Io(e, path.clone()))?;
68 let file = OpenOptions::new()
69 .create(true)
70 .truncate(false)
71 .read(true)
72 .write(true)
73 .open(&path)
74 .map_err(|e| SessionLockError::Io(e, path.clone()))?;
75 #[cfg(unix)]
76 {
77 use std::os::unix::fs::PermissionsExt;
78 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
79 }
80 if let Some(lock) =
81 crate::core::lock_file::try_lock(file).map_err(|e| SessionLockError::Io(e, path.clone()))?
82 {
83 use std::io::Write;
85 let _ = lock.file().set_len(0);
86 let _ = lock.file().write_all(holder.serialize().as_bytes());
87 Ok(Self { _lock: lock, path })
88 } else {
89 let body = std::fs::read_to_string(&path).unwrap_or_default();
91 let existing = LockHolder::deserialize(&body);
92 Err(SessionLockError::Held {
93 session_id: id.to_string(),
94 holder: existing,
95 })
96 }
97 }
98
99 pub fn path(&self) -> &Path {
101 &self.path
102 }
103}
104
105pub fn lock_path(dir: &Path, id: &str) -> PathBuf {
107 dir.join(format!("{}.lock", id))
108}
109
110pub fn sessions_dir() -> PathBuf {
112 crate::config::get_active_config_dir().join("sessions")
113}
114
115#[derive(Debug)]
116pub enum SessionLockError {
117 Io(io::Error, PathBuf),
118 Held {
119 session_id: String,
120 holder: Option<LockHolder>,
121 },
122 CompactedInto {
125 session_id: String,
126 successor_id: String,
127 },
128}
129
130impl std::fmt::Display for SessionLockError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 Self::Io(e, path) => write!(f, "session lock I/O error on {}: {}", path.display(), e),
134 Self::Held {
135 session_id,
136 holder: Some(h),
137 } => write!(
138 f,
139 "session {} is live in another process (pid {}, {}) \
140 — use `synaps --attach {}`, or `synaps daemon sessions`",
141 session_id, h.pid, h.kind, session_id
142 ),
143 Self::Held {
144 session_id,
145 holder: None,
146 } => write!(
147 f,
148 "session {} is live in another process \
149 — use `synaps --attach {}`, or `synaps daemon sessions`",
150 session_id, session_id
151 ),
152 Self::CompactedInto {
153 session_id,
154 successor_id,
155 } => write!(
156 f,
157 "session {} was compacted into {} \
158 — continue {} instead",
159 session_id, successor_id, successor_id
160 ),
161 }
162 }
163}
164
165impl std::error::Error for SessionLockError {}
166
167#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn holder(kind: &str) -> LockHolder {
174 LockHolder {
175 pid: std::process::id(),
176 kind: kind.to_string(),
177 }
178 }
179
180 #[test]
181 fn take_and_refuse() {
182 let dir = tempfile::tempdir().unwrap();
183 let lock = SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap();
184 let err = SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap_err();
186 let msg = err.to_string();
187 assert!(msg.contains("abc123"), "error names the session: {msg}");
188 assert!(
189 msg.contains(&format!("pid {}", std::process::id())),
190 "error names the pid: {msg}"
191 );
192 drop(lock);
194 SessionLock::try_acquire(dir.path(), "abc123", holder("tui")).unwrap();
195 }
196
197 #[test]
198 fn different_sessions_independent() {
199 let dir = tempfile::tempdir().unwrap();
200 let _a = SessionLock::try_acquire(dir.path(), "aaa", holder("tui")).unwrap();
201 let _b = SessionLock::try_acquire(dir.path(), "bbb", holder("tui")).unwrap();
202 }
203
204 #[test]
205 fn stale_lock_from_dead_process_does_not_block() {
206 let dir = tempfile::tempdir().unwrap();
209 let path = dir.path().join("stale.lock");
210 std::fs::write(&path, "99999\ndaemon\n").unwrap();
211 let _lock = SessionLock::try_acquire(dir.path(), "stale", holder("tui")).unwrap();
213 }
214}
215
216#[cfg(all(test, unix))]
217mod fork_tests {
218 use super::*;
219
220 #[test]
224 #[serial_test::serial(process_spawn)]
225 fn a_dropped_lock_is_free_while_a_forked_child_holds_its_descriptor() {
226 let dir = tempfile::tempdir().unwrap();
227 let holder = |kind: &str| LockHolder { pid: std::process::id(), kind: kind.to_string() };
228 let first = SessionLock::try_acquire(dir.path(), "s", holder("daemon")).unwrap();
229 let child = crate::core::lock_file::ParkedChild::park();
230 drop(first);
231 let second = SessionLock::try_acquire(dir.path(), "s", holder("tui"));
232 child.release();
233 if let Err(e) = second {
234 panic!("dropped session lock still held via a forked child's copy: {e}");
235 }
236 }
237}