Skip to main content

banc_host/
rig.rs

1//! Rig acquisition: locate the topology config, take the cross-process lock,
2//! hand out fixtures. One `Rig` per process; access to the hardware is
3//! exclusive for as long as it lives (the pattern is embedded-test-stand's
4//! lock-owned-by-fixture, extended with a file lock because nextest runs one
5//! process per test).
6
7use crate::config::{AssistantConfig, RigConfig};
8use crate::node::Node;
9use fs4::fs_std::FileExt;
10use std::fs::File;
11use std::path::PathBuf;
12use std::time::{Duration, Instant};
13
14// Acquisition is deliberately synchronous: the `Rig` outlives every
15// per-trial runtime, so nothing created here may be tied to one. Keeping
16// async out of this path means a runtime-bound resource (socket, client,
17// spawned task) cannot be added to `Rig` without changing this signature —
18// connections belong to per-test fixtures, created on the trial's runtime.
19
20/// How long a process waits for another test process to release the rig
21/// before giving up. Override with BANC_LOCK_TIMEOUT_SECS.
22const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(300);
23
24/// Why `Rig::acquire` did not return a rig.
25pub enum Acquire {
26    /// No rig on this machine — report the test as ignored, with this reason.
27    Skip(String),
28    /// A rig is configured but broken/contended — report a failure.
29    Fail(anyhow::Error),
30}
31
32impl From<anyhow::Error> for Acquire {
33    fn from(e: anyhow::Error) -> Self {
34        Acquire::Fail(e)
35    }
36}
37
38pub struct Rig {
39    pub config: RigConfig,
40    /// Directory the config file lives in; relative paths resolve from here.
41    pub base_dir: PathBuf,
42    _lock: RigLock,
43}
44
45impl Rig {
46    /// Locate + parse the config and take the cross-process lock.
47    ///
48    /// Missing config => `Acquire::Skip` (honest self-skip). Malformed
49    /// config or lock timeout => `Acquire::Fail` (a rig machine that cannot
50    /// run its suite is a failure, not a skip).
51    pub fn acquire() -> Result<Rig, Acquire> {
52        let Some(path) = RigConfig::locate()? else {
53            return Err(Acquire::Skip(format!(
54                "no rig: {} not found (set {} or create one to run on hardware)",
55                crate::config::CONFIG_FILE,
56                crate::config::ENV_VAR,
57            )));
58        };
59        let config = RigConfig::load(&path)?;
60        let base_dir = path.parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
61
62        let lock_path = config
63            .rig
64            .lock_file
65            .clone()
66            .map(|p| if p.is_absolute() { p } else { base_dir.join(p) })
67            .unwrap_or_else(|| base_dir.join("target").join("banc.lock"));
68        let timeout = std::env::var("BANC_LOCK_TIMEOUT_SECS")
69            .ok()
70            .and_then(|s| s.parse().ok())
71            .map(Duration::from_secs)
72            .unwrap_or(DEFAULT_LOCK_TIMEOUT);
73        let lock = RigLock::take(lock_path, timeout)?;
74
75        Ok(Rig { config, base_dir, _lock: lock })
76    }
77
78    /// Connect to a configured assistant by name.
79    pub async fn assistant(&self, name: &str) -> anyhow::Result<Node> {
80        let cfg: &AssistantConfig = self
81            .config
82            .assistant(name)
83            .ok_or_else(|| anyhow::anyhow!("no assistant '{name}' in rig config"))?;
84        Node::connect(cfg).await
85    }
86}
87
88/// Advisory file lock serializing rig access across processes. Held for the
89/// life of the `Rig`; a poisoned/stale holder is handled by the OS releasing
90/// the lock when that process dies.
91struct RigLock {
92    file: File,
93}
94
95impl RigLock {
96    fn take(path: PathBuf, timeout: Duration) -> anyhow::Result<Self> {
97        if let Some(parent) = path.parent() {
98            std::fs::create_dir_all(parent)?;
99        }
100        let file = File::create(&path)
101            .map_err(|e| anyhow::anyhow!("creating rig lock {}: {e}", path.display()))?;
102        let deadline = Instant::now() + timeout;
103        loop {
104            if file.try_lock_exclusive()? {
105                return Ok(RigLock { file });
106            }
107            if Instant::now() >= deadline {
108                anyhow::bail!(
109                    "rig lock {} held by another process for over {timeout:?}",
110                    path.display()
111                );
112            }
113            std::thread::sleep(Duration::from_millis(200));
114        }
115    }
116}
117
118impl Drop for RigLock {
119    fn drop(&mut self) {
120        let _ = FileExt::unlock(&self.file);
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn lock_excludes_second_taker_until_dropped() {
130        let path = std::env::temp_dir()
131            .join(format!("banc-rig-lock-test-{}", std::process::id()));
132        let held = RigLock::take(path.clone(), Duration::ZERO).unwrap();
133        let contended = RigLock::take(path.clone(), Duration::ZERO);
134        assert!(contended.is_err(), "second take must fail while lock is held");
135        drop(held);
136        RigLock::take(path.clone(), Duration::ZERO).unwrap();
137        std::fs::remove_file(path).ok();
138    }
139}