Skip to main content

ferrox_core/
instance.rs

1//! Who else is already running a model on this box.
2//!
3//! One ferrox process holding a model saturates the machine by design:
4//! prefill is a dense GEMM across every core, and the decode pool spins.
5//! Two of them do not run at half speed each -- they thrash, and the
6//! numbers both of them report become meaningless. The same is true of a
7//! `ferrox-server` left running in another terminal while a benchmark
8//! starts.
9//!
10//! So a model-loading process registers itself here first, and by
11//! default refuses to start when another live instance already holds
12//! one. The registry is a directory of one small file per process:
13//!
14//! ```text
15//! $FERROX_INSTANCE_DIR (default ~/.cache/ferrox/instances)/<pid>
16//! ```
17//!
18//! **This is advisory, not a lock.** Two processes starting in the same
19//! instant can each see the other and both refuse -- which is the safe
20//! direction -- but nothing here prevents a determined caller from
21//! running two models. It exists to stop the accident, not to enforce a
22//! policy against the operator.
23
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27/// Whether a second model-loading process may start.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum InstancePolicy {
30    /// Refuse to start while another live instance holds a model.
31    Single,
32    /// Start anyway. The caller has accepted the CPU contention.
33    Multi,
34}
35
36impl InstancePolicy {
37    /// `FERROX_ALLOW_MULTIPLE_INSTANCES=1|true|on` selects `Multi`.
38    /// The CLI flag wins over the environment; this is the fallback.
39    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/// One live ferrox process, as it described itself at startup.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct InstanceInfo {
54    pub pid: u32,
55    /// Subcommand or binary: `run`, `bench`, `server`, …
56    pub command: String,
57    /// Model path, when the process named one.
58    pub model: Option<String>,
59    /// `cpu` / `metal` / `cuda`.
60    pub backend: String,
61    /// Seconds since the Unix epoch, or 0 if the clock would not say.
62    pub started_unix: u64,
63}
64
65impl InstanceInfo {
66    fn encode(&self) -> String {
67        // Tab-separated so no dependency is needed to read it back, and
68        // tabs are stripped from the free-text fields on the way in so a
69        // path can never split a record.
70        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    /// `bench pid 1234, Metal, models/foo.gguf`
100    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
117/// Removes this process's registry entry when it drops.
118///
119/// A `SIGKILL` or a panic-abort skips this, which is why every read
120/// prunes entries whose pid is gone rather than trusting the directory.
121pub 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/// Another live instance already holds a model.
132#[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
159/// The accelerator this process would actually use, for the registry
160/// entry. Compiled-in features only decide what is *possible*; the
161/// runtime toggles decide what is enabled, so both are consulted.
162pub 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
178/// Where the per-process files live. `FERROX_INSTANCE_DIR` overrides.
179pub 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
196/// Every registered instance whose process is still alive, excluding
197/// this one. Entries for dead processes are deleted as they are found.
198pub fn live_instances() -> Vec<InstanceInfo> {
199    live_instances_in(&registry_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            // Unreadable or truncated: a half-written file from a
215            // process that died mid-register. Not ours to interpret.
216            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
235/// Which of `pids` still exist. One `ps` call for the whole set, or
236/// `/proc` on Linux where no child process is needed at all.
237fn 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        // If we cannot tell, assume they are alive: refusing to start is
258        // recoverable, trampling a running model is not.
259        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
271/// Registers this process and enforces `policy`.
272///
273/// Registration happens *before* the conflict check, so two processes
274/// racing each other both see the other and both refuse. That is the
275/// safe direction: a spurious refusal costs a retry, a spurious admit
276/// costs a thrashed host and two meaningless measurements.
277pub 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    // A registry we cannot write is not a reason to refuse to run: the
297    // guard degrades to a no-op rather than making an unwritable cache
298    // directory fatal.
299    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        // `guard` drops here, removing our own entry, so a refused start
314        // does not leave a ghost behind for the next process to trip on.
315        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        // pid 1 is alive on every unix; a pid this large is not in use.
363        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        // This test process is by definition alive; register it under a
382        // different "self" so it counts as an other.
383        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        // Serialised implicitly: these three run in the same test and
437        // restore the variable before returning.
438        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}