agentmux_launcher/
stale_srv.rs1use std::os::unix::io::AsRawFd;
20use std::path::Path;
21use std::time::{Duration, Instant};
22
23const SRV_LOCK_FILE: &str = "srv.lock";
26
27const TERM_GRACE: Duration = Duration::from_secs(10);
30
31pub fn reclaim_stale_srv_lock(data_dir: &Path) {
35 let lock_path = data_dir.join(SRV_LOCK_FILE);
36 if lock_is_free(&lock_path) {
37 return;
38 }
39 let recorded = std::fs::read_to_string(&lock_path)
42 .ok()
43 .and_then(|s| s.trim().parse::<i32>().ok())
44 .filter(|pid| *pid > 1);
45 let Some(pid) = recorded.or_else(|| lock_holder_via_lsof(&lock_path)) else {
46 crate::log(&format!(
47 "stale-srv: {} is held but its holder can't be identified; leaving it",
48 lock_path.display()
49 ));
50 return;
51 };
52 let Some(comm) = ps_field(pid, "comm=", false) else {
57 crate::log(&format!("stale-srv: lock names pid {pid}, which isn't running; leaving it"));
58 return;
59 };
60 let exe_name = Path::new(&comm)
61 .file_name()
62 .map(|n| n.to_string_lossy().into_owned())
63 .unwrap_or_default();
64 if !exe_name.starts_with("agentmux-srv") {
65 crate::log(&format!("stale-srv: lock names pid {pid} ({exe_name}), which isn't agentmux-srv; leaving it"));
66 return;
67 }
68 let headless_env = ps_field(pid, "command=", true)
69 .is_some_and(|with_env| with_env.split_whitespace().any(|w| w == "AGENTMUX_HEADLESS=1" || w == "AGENTMUX_HEADLESS=true"));
70 let headless_arg = ps_field(pid, "command=", false)
71 .is_some_and(|args| args.split_whitespace().any(|a| a == "--headless"));
72 if headless_arg || headless_env {
73 crate::log(&format!("stale-srv: pid {pid} is a headless srv using this data dir; leaving it"));
74 return;
75 }
76
77 let ppid = ps_field(pid, "ppid=", false).and_then(|p| p.trim().parse::<i32>().ok());
83 if let Some(ppid) = ppid.filter(|p| *p > 1) {
84 let parent = ps_field(ppid, "comm=", false)
85 .and_then(|c| Path::new(&c).file_name().map(|n| n.to_string_lossy().into_owned()))
86 .unwrap_or_default();
87 if parent.to_ascii_lowercase().contains("agentmux") {
88 crate::log(&format!(
89 "stale-srv: srv pid {pid} still has its launcher (pid {ppid}, {parent}); another instance owns this data dir, leaving it"
90 ));
91 return;
92 }
93 }
94
95 crate::log(&format!(
96 "stale-srv: srv pid {pid} still holds {} with no launcher; stopping it so this launch can start",
97 lock_path.display()
98 ));
99 unsafe { libc::kill(pid, libc::SIGTERM) };
100 if wait_for_free(&lock_path, TERM_GRACE) {
101 crate::log(&format!("stale-srv: pid {pid} exited after SIGTERM; lock is free"));
102 return;
103 }
104 let target = if unsafe { libc::getpgid(pid) } == pid { -pid } else { pid };
107 unsafe { libc::kill(target, libc::SIGKILL) };
108 if wait_for_free(&lock_path, Duration::from_secs(3)) {
109 crate::log(&format!("stale-srv: pid {pid} killed; lock is free"));
110 } else {
111 crate::log(&format!("stale-srv: lock still held after killing pid {pid}"));
112 }
113}
114
115fn lock_is_free(lock_path: &Path) -> bool {
118 let Ok(file) = std::fs::OpenOptions::new().read(true).open(lock_path) else {
119 return true;
120 };
121 let fd = file.as_raw_fd();
122 if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0 {
123 unsafe { libc::flock(fd, libc::LOCK_UN) };
124 true
125 } else {
126 false
127 }
128}
129
130fn wait_for_free(lock_path: &Path, within: Duration) -> bool {
131 let deadline = Instant::now() + within;
132 while Instant::now() < deadline {
133 if lock_is_free(lock_path) {
134 return true;
135 }
136 std::thread::sleep(Duration::from_millis(100));
137 }
138 lock_is_free(lock_path)
139}
140
141fn lock_holder_via_lsof(lock_path: &Path) -> Option<i32> {
145 let pids: Vec<i32> = processes_with_file_open(lock_path)
146 .into_iter()
147 .filter(|pid| *pid > 1 && *pid != std::process::id() as i32)
148 .collect();
149 pids.iter()
150 .copied()
151 .find(|pid| {
152 ps_field(*pid, "comm=", false)
153 .and_then(|c| Path::new(&c).file_name().map(|n| n.to_string_lossy().starts_with("agentmux-srv")))
154 .unwrap_or(false)
155 })
156 .or_else(|| pids.first().copied())
157}
158
159#[cfg(not(target_os = "linux"))]
161fn processes_with_file_open(path: &Path) -> Vec<i32> {
162 let Ok(out) = std::process::Command::new("/usr/sbin/lsof")
163 .arg("-t")
164 .arg("--")
165 .arg(path)
166 .output()
167 else {
168 return Vec::new();
169 };
170 String::from_utf8_lossy(&out.stdout)
171 .lines()
172 .filter_map(|l| l.trim().parse::<i32>().ok())
173 .collect()
174}
175
176#[cfg(target_os = "linux")]
181fn processes_with_file_open(path: &Path) -> Vec<i32> {
182 let Ok(target) = std::fs::canonicalize(path) else {
183 return Vec::new();
184 };
185 let Ok(procs) = std::fs::read_dir("/proc") else {
186 return Vec::new();
187 };
188 procs
189 .flatten()
190 .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
191 .filter(|pid| {
192 std::fs::read_dir(format!("/proc/{pid}/fd"))
193 .map(|fds| fds.flatten().any(|fd| std::fs::read_link(fd.path()).is_ok_and(|l| l == target)))
194 .unwrap_or(false)
195 })
196 .collect()
197}
198
199fn ps_field(pid: i32, field: &str, with_env: bool) -> Option<String> {
203 let flags = if with_env { "eww" } else { "ww" };
204 let out = std::process::Command::new("/bin/ps")
205 .args([flags, "-o", field, "-p", &pid.to_string()])
206 .output()
207 .ok()?;
208 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
209 (out.status.success() && !s.is_empty()).then_some(s)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn a_missing_or_unheld_lock_is_free() {
218 let dir = tempfile::tempdir().unwrap();
219 let lock = dir.path().join(SRV_LOCK_FILE);
220 assert!(lock_is_free(&lock), "missing file");
221 std::fs::write(&lock, "12345").unwrap();
222 assert!(lock_is_free(&lock), "file nobody holds");
223 }
224
225 #[test]
226 fn a_held_lock_is_not_free_and_is_left_alone_without_a_live_srv() {
227 let dir = tempfile::tempdir().unwrap();
228 let lock = dir.path().join(SRV_LOCK_FILE);
229 let holder = std::fs::OpenOptions::new()
230 .create(true)
231 .write(true)
232 .open(&lock)
233 .unwrap();
234 assert_eq!(unsafe { libc::flock(holder.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0);
235 assert!(!lock_is_free(&lock));
236
237 std::fs::write(&lock, std::process::id().to_string()).unwrap();
240 reclaim_stale_srv_lock(dir.path());
241 assert!(!lock_is_free(&lock));
242 }
243}