1use std::io::Write;
25use std::path::{Path, PathBuf};
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum InstancePolicy {
30 Single,
32 Multi,
34}
35
36impl InstancePolicy {
37 pub fn from_env_or(default: InstancePolicy) -> InstancePolicy {
40 match std::env::var("FERROX_ALLOW_MULTIPLE_INSTANCES")
41 .ok()
42 .as_deref()
43 {
44 Some("1") | Some("true") | Some("on") => InstancePolicy::Multi,
45 Some("0") | Some("false") | Some("off") => InstancePolicy::Single,
46 _ => default,
47 }
48 }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct InstanceInfo {
54 pub pid: u32,
55 pub command: String,
57 pub model: Option<String>,
59 pub backend: String,
61 pub started_unix: u64,
63}
64
65impl InstanceInfo {
66 fn encode(&self) -> String {
67 format!(
71 "{}\t{}\t{}\t{}\t{}\n",
72 self.pid,
73 scrub(&self.command),
74 scrub(self.model.as_deref().unwrap_or("")),
75 scrub(&self.backend),
76 self.started_unix,
77 )
78 }
79
80 fn decode(line: &str) -> Option<InstanceInfo> {
81 let mut f = line.trim_end_matches('\n').split('\t');
82 let pid = f.next()?.parse().ok()?;
83 let command = f.next()?.to_string();
84 let model = match f.next()? {
85 "" => None,
86 m => Some(m.to_string()),
87 };
88 let backend = f.next()?.to_string();
89 let started_unix = f.next().and_then(|s| s.parse().ok()).unwrap_or(0);
90 Some(InstanceInfo {
91 pid,
92 command,
93 model,
94 backend,
95 started_unix,
96 })
97 }
98
99 pub fn describe(&self) -> String {
101 let model = self
102 .model
103 .as_deref()
104 .map(|m| format!(", {m}"))
105 .unwrap_or_default();
106 format!(
107 "{} pid {}, {}{}",
108 self.command, self.pid, self.backend, model
109 )
110 }
111}
112
113fn scrub(s: &str) -> String {
114 s.replace(['\t', '\n', '\r'], " ")
115}
116
117pub struct InstanceGuard {
122 path: PathBuf,
123}
124
125impl Drop for InstanceGuard {
126 fn drop(&mut self) {
127 let _ = std::fs::remove_file(&self.path);
128 }
129}
130
131#[derive(Debug)]
133pub struct InstanceConflict {
134 pub others: Vec<InstanceInfo>,
135}
136
137impl std::fmt::Display for InstanceConflict {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 writeln!(
140 f,
141 "{} ferrox instance(s) are already running a model on this host:",
142 self.others.len()
143 )?;
144 for o in &self.others {
145 writeln!(f, " - {}", o.describe())?;
146 }
147 write!(
148 f,
149 "Running several models at once does not share the machine -- it \
150 thrashes it, and any timing either process reports is noise. Stop \
151 the other instance, or pass --allow-multiple-instances (or set \
152 FERROX_ALLOW_MULTIPLE_INSTANCES=1) to start anyway."
153 )
154 }
155}
156
157impl std::error::Error for InstanceConflict {}
158
159pub fn current_backend() -> &'static str {
163 #[cfg(feature = "metal")]
164 {
165 if crate::weight_matrix::metal_dense_enabled() {
166 return "metal";
167 }
168 }
169 #[cfg(feature = "cuda")]
170 {
171 if crate::weight_matrix::cuda_dense_enabled() {
172 return "cuda";
173 }
174 }
175 "cpu"
176}
177
178pub fn registry_dir() -> PathBuf {
180 if let Ok(d) = std::env::var("FERROX_INSTANCE_DIR") {
181 return PathBuf::from(d);
182 }
183 let base = std::env::var("XDG_CACHE_HOME")
184 .ok()
185 .filter(|s| !s.is_empty())
186 .map(PathBuf::from)
187 .or_else(|| {
188 std::env::var("HOME")
189 .ok()
190 .map(|h| PathBuf::from(h).join(".cache"))
191 })
192 .unwrap_or_else(std::env::temp_dir);
193 base.join("ferrox").join("instances")
194}
195
196pub fn live_instances() -> Vec<InstanceInfo> {
199 live_instances_in(®istry_dir(), std::process::id())
200}
201
202fn live_instances_in(dir: &Path, self_pid: u32) -> Vec<InstanceInfo> {
203 let Ok(entries) = std::fs::read_dir(dir) else {
204 return Vec::new();
205 };
206 let mut found: Vec<(PathBuf, InstanceInfo)> = Vec::new();
207 for e in entries.flatten() {
208 let path = e.path();
209 let Ok(body) = std::fs::read_to_string(&path) else {
210 continue;
211 };
212 match InstanceInfo::decode(&body) {
213 Some(info) if info.pid != self_pid => found.push((path, info)),
214 Some(_) => {}
217 None => {
218 let _ = std::fs::remove_file(&path);
219 }
220 }
221 }
222 let alive = alive_pids(&found.iter().map(|(_, i)| i.pid).collect::<Vec<_>>());
223 let mut out = Vec::new();
224 for (path, info) in found {
225 if alive.contains(&info.pid) {
226 out.push(info);
227 } else {
228 let _ = std::fs::remove_file(&path);
229 }
230 }
231 out.sort_by_key(|i| i.pid);
232 out
233}
234
235fn alive_pids(pids: &[u32]) -> Vec<u32> {
238 if pids.is_empty() {
239 return Vec::new();
240 }
241 if Path::new("/proc/self").exists() {
242 return pids
243 .iter()
244 .copied()
245 .filter(|p| Path::new(&format!("/proc/{p}")).exists())
246 .collect();
247 }
248 let list = pids
249 .iter()
250 .map(|p| p.to_string())
251 .collect::<Vec<_>>()
252 .join(",");
253 let Ok(out) = std::process::Command::new("ps")
254 .args(["-p", &list, "-o", "pid="])
255 .output()
256 else {
257 return pids.to_vec();
260 };
261 parse_ps_pids(&String::from_utf8_lossy(&out.stdout))
262}
263
264fn parse_ps_pids(stdout: &str) -> Vec<u32> {
265 stdout
266 .split_whitespace()
267 .filter_map(|t| t.parse().ok())
268 .collect()
269}
270
271pub fn register(
278 command: &str,
279 model: Option<&str>,
280 backend: &str,
281 policy: InstancePolicy,
282) -> Result<InstanceGuard, InstanceConflict> {
283 let dir = registry_dir();
284 let pid = std::process::id();
285 let info = InstanceInfo {
286 pid,
287 command: command.to_string(),
288 model: model.map(str::to_string),
289 backend: backend.to_string(),
290 started_unix: std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .map(|d| d.as_secs())
293 .unwrap_or(0),
294 };
295 let path = dir.join(pid.to_string());
296 let wrote = std::fs::create_dir_all(&dir).is_ok()
300 && std::fs::File::create(&path)
301 .and_then(|mut f| f.write_all(info.encode().as_bytes()))
302 .is_ok();
303 let guard = InstanceGuard {
304 path: if wrote { path } else { PathBuf::new() },
305 };
306 if policy == InstancePolicy::Multi {
307 return Ok(guard);
308 }
309 let others = live_instances_in(&dir, pid);
310 if others.is_empty() {
311 Ok(guard)
312 } else {
313 Err(InstanceConflict { others })
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn tmpdir(tag: &str) -> PathBuf {
324 let d =
325 std::env::temp_dir().join(format!("ferrox-instance-test-{tag}-{}", std::process::id()));
326 let _ = std::fs::remove_dir_all(&d);
327 std::fs::create_dir_all(&d).unwrap();
328 d
329 }
330
331 #[test]
332 fn a_record_round_trips_including_an_absent_model() {
333 let i = InstanceInfo {
334 pid: 42,
335 command: "bench".into(),
336 model: None,
337 backend: "metal".into(),
338 started_unix: 7,
339 };
340 assert_eq!(InstanceInfo::decode(&i.encode()), Some(i));
341 }
342
343 #[test]
344 fn a_tab_in_a_model_path_cannot_split_the_record() {
345 let i = InstanceInfo {
346 pid: 42,
347 command: "run".into(),
348 model: Some("models/we\tird\nname.gguf".into()),
349 backend: "cpu".into(),
350 started_unix: 7,
351 };
352 let back = InstanceInfo::decode(&i.encode()).expect("still one record");
353 assert_eq!(back.pid, 42);
354 assert_eq!(back.backend, "cpu", "fields did not shift");
355 assert_eq!(back.started_unix, 7);
356 assert_eq!(back.model.as_deref(), Some("models/we ird name.gguf"));
357 }
358
359 #[test]
360 fn a_dead_pid_is_pruned_rather_than_reported_as_a_conflict() {
361 let d = tmpdir("dead");
362 let dead = InstanceInfo {
364 pid: 4_000_000_000,
365 command: "server".into(),
366 model: Some("m.gguf".into()),
367 backend: "cpu".into(),
368 started_unix: 1,
369 };
370 std::fs::write(d.join(dead.pid.to_string()), dead.encode()).unwrap();
371 assert!(live_instances_in(&d, std::process::id()).is_empty());
372 assert!(
373 !d.join(dead.pid.to_string()).exists(),
374 "the stale entry is deleted, not just skipped"
375 );
376 }
377
378 #[test]
379 fn a_live_pid_is_reported_and_its_entry_kept() {
380 let d = tmpdir("live");
381 let me = InstanceInfo {
384 pid: std::process::id(),
385 command: "run".into(),
386 model: Some("m.gguf".into()),
387 backend: "metal".into(),
388 started_unix: 1,
389 };
390 std::fs::write(d.join(me.pid.to_string()), me.encode()).unwrap();
391 let others = live_instances_in(&d, 0);
392 assert_eq!(others.len(), 1);
393 assert_eq!(others[0].pid, me.pid);
394 assert!(d.join(me.pid.to_string()).exists());
395 }
396
397 #[test]
398 fn a_process_never_conflicts_with_its_own_entry() {
399 let d = tmpdir("self");
400 let me = InstanceInfo {
401 pid: std::process::id(),
402 command: "run".into(),
403 model: None,
404 backend: "cpu".into(),
405 started_unix: 1,
406 };
407 std::fs::write(d.join(me.pid.to_string()), me.encode()).unwrap();
408 assert!(live_instances_in(&d, std::process::id()).is_empty());
409 }
410
411 #[test]
412 fn ps_output_parses_to_pids_and_ignores_a_header() {
413 assert_eq!(parse_ps_pids(" 1234\n 5678\n"), vec![1234, 5678]);
414 assert_eq!(parse_ps_pids(""), Vec::<u32>::new());
415 }
416
417 #[test]
418 fn the_conflict_message_names_every_other_instance_and_the_escape_hatch() {
419 let c = InstanceConflict {
420 others: vec![InstanceInfo {
421 pid: 99,
422 command: "server".into(),
423 model: Some("models/a.gguf".into()),
424 backend: "metal".into(),
425 started_unix: 0,
426 }],
427 };
428 let s = c.to_string();
429 assert!(s.contains("server pid 99, metal, models/a.gguf"), "{s}");
430 assert!(s.contains("--allow-multiple-instances"), "{s}");
431 assert!(s.contains("FERROX_ALLOW_MULTIPLE_INSTANCES=1"), "{s}");
432 }
433
434 #[test]
435 fn the_env_var_selects_a_policy_but_the_caller_default_wins_when_unset() {
436 let prev = std::env::var("FERROX_ALLOW_MULTIPLE_INSTANCES").ok();
439 std::env::set_var("FERROX_ALLOW_MULTIPLE_INSTANCES", "1");
440 assert_eq!(
441 InstancePolicy::from_env_or(InstancePolicy::Single),
442 InstancePolicy::Multi
443 );
444 std::env::set_var("FERROX_ALLOW_MULTIPLE_INSTANCES", "0");
445 assert_eq!(
446 InstancePolicy::from_env_or(InstancePolicy::Multi),
447 InstancePolicy::Single
448 );
449 std::env::remove_var("FERROX_ALLOW_MULTIPLE_INSTANCES");
450 assert_eq!(
451 InstancePolicy::from_env_or(InstancePolicy::Multi),
452 InstancePolicy::Multi
453 );
454 if let Some(p) = prev {
455 std::env::set_var("FERROX_ALLOW_MULTIPLE_INSTANCES", p);
456 }
457 }
458}