Skip to main content

release_kit/self_depend/
guard.rs

1//! The gates around the devshell transaction, so the `.envrc` line is
2//! safe on every directory entry.
3//!
4//! Each gate answers one question before anything fetches or spawns:
5//! whether the check is switched off, whether a sync runs inside an
6//! `.envrc` evaluation, whether another shell holds this checkout, and
7//! whether the two files carry uncommitted edits. The lock lives under the
8//! state root, keyed per checkout — not in the target, where an untracked
9//! file is noise, and not in `.git/`, which belongs to git.
10
11use std::fs;
12use std::io::Write as _;
13use std::path::PathBuf;
14
15use camino::Utf8Path;
16
17use super::lock_path;
18use crate::maintenance::GIT_HOOK_VARS;
19use crate::probes::git_bin;
20
21/// A lock whose owner procfs cannot judge is taken over after this long.
22const LOCK_GRACE: std::time::Duration = std::time::Duration::from_secs(15 * 60);
23
24/// The variables a CI runner exports; any of them switches the sync off.
25pub const CI_VARS: [&str; 6] = [
26    "CI",
27    "GITHUB_ACTIONS",
28    "GITLAB_CI",
29    "BUILDKITE",
30    "CIRCLECI",
31    "TF_BUILD",
32];
33
34/// The operator's own off switch for the version check, read from the
35/// environment `.envrc.local` exports.
36pub const SWITCH_VAR: &str = "RK_DEVSHELL_SYNC";
37
38/// The variable direnv's stdlib exports while it evaluates an `.envrc`,
39/// and leaves out of the environment it exports afterward.
40pub const IN_ENVRC_VAR: &str = "DIRENV_IN_ENVRC";
41
42/// Whether this run is inside an `.envrc` evaluation, where a pin move
43/// would be automatic rather than the operator's act.
44#[must_use]
45pub fn in_envrc() -> bool {
46    std::env::var_os(IN_ENVRC_VAR).is_some_and(|value| !value.is_empty())
47}
48
49/// Whether the operator switched the sync off with `RK_DEVSHELL_SYNC=0`.
50#[must_use]
51pub fn switched_off() -> bool {
52    std::env::var(SWITCH_VAR).is_ok_and(|value| value.trim() == "0")
53}
54
55/// Whether a CI variable is set to anything but an explicit no.
56#[must_use]
57pub fn in_ci() -> bool {
58    CI_VARS.iter().any(|var| {
59        std::env::var(var).is_ok_and(|value| {
60            let value = value.trim().to_ascii_lowercase();
61            !(value.is_empty() || value == "0" || value == "false")
62        })
63    })
64}
65
66/// The held single-writer lock; removed on drop.
67#[derive(Debug)]
68pub struct Lock(PathBuf);
69
70impl Drop for Lock {
71    fn drop(&mut self) {
72        let _ = fs::remove_file(&self.0);
73    }
74}
75
76/// What acquisition found.
77#[derive(Debug)]
78pub enum Acquired {
79    /// This run holds the checkout.
80    Held(Lock),
81    /// Another live run holds it: normal, and skipped in silence.
82    Contended,
83    /// The lock cannot be taken at all: the mechanism itself is broken.
84    Unavailable(std::io::Error),
85}
86
87/// Take the checkout's lock, atomically through `O_EXCL`. A lock whose
88/// owner is provably gone — or, where procfs cannot judge, older than
89/// the grace period — is removed and the take retried once.
90#[must_use]
91pub fn acquire(key: &str) -> Acquired {
92    let Some(path) = lock_path(key) else {
93        return Acquired::Unavailable(std::io::Error::other(
94            "neither XDG_STATE_HOME nor HOME is set, so the lock has no root",
95        ));
96    };
97    if let Some(parent) = path.parent()
98        && let Err(source) = fs::create_dir_all(parent)
99    {
100        return Acquired::Unavailable(source);
101    }
102    match take(&path) {
103        Ok(lock) => Acquired::Held(lock),
104        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
105            let pid = fs::read_to_string(&path).ok().and_then(|text| {
106                text.lines()
107                    .find_map(|line| line.strip_prefix("pid=")?.trim().parse::<u64>().ok())
108            });
109            if !super::txn::owner_gone_after(pid, &path, LOCK_GRACE) {
110                return Acquired::Contended;
111            }
112            // A stale lock that cannot be removed is not held by anyone:
113            // silence here would repeat forever without saying why.
114            match fs::remove_file(&path) {
115                Ok(()) => {}
116                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
117                Err(source) => return Acquired::Unavailable(source),
118            }
119            match take(&path) {
120                Ok(lock) => Acquired::Held(lock),
121                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
122                    Acquired::Contended
123                }
124                Err(source) => Acquired::Unavailable(source),
125            }
126        }
127        Err(source) => Acquired::Unavailable(source),
128    }
129}
130
131/// One exclusive create, holding the owner's process id and start time.
132fn take(path: &std::path::Path) -> std::io::Result<Lock> {
133    let mut file = fs::OpenOptions::new()
134        .write(true)
135        .create_new(true)
136        .open(path)?;
137    file.write_all(format!("pid={}\n", std::process::id()).as_bytes())?;
138    file.write_all(format!("started={}\n", crate::applog::now_utc()).as_bytes())?;
139    Ok(Lock(path.to_path_buf()))
140}
141
142/// Whether any of the named files carries uncommitted edits: the flake
143/// pair for the flake manager, the one manager file otherwise.
144///
145/// Judged by the target repository with the hook variables scrubbed, so
146/// a run from inside a git hook judges the target and not the hook's own
147/// repository. Fails closed: a git that did not run counts as dirty.
148#[must_use]
149pub fn files_dirty(target: &Utf8Path, files: &[&str]) -> bool {
150    let mut command = std::process::Command::new(git_bin());
151    for var in GIT_HOOK_VARS {
152        command.env_remove(var);
153    }
154    command
155        .arg("-C")
156        .arg(target.as_std_path())
157        .args(["status", "--porcelain", "--"])
158        .args(files)
159        .output()
160        .map_or(true, |probed| {
161            !probed.status.success() || !probed.stdout.is_empty()
162        })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{Acquired, take};
168
169    #[test]
170    fn the_lock_is_exclusive_and_released_on_drop() {
171        let dir = tempfile::tempdir().expect("a scratch dir exists");
172        let path = dir.path().join("k.lock");
173        let held = take(&path).expect("the first take holds");
174        assert!(path.exists());
175        let second = take(&path).expect_err("the second take refuses");
176        assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
177        drop(held);
178        assert!(!path.exists(), "the lock is removed on drop");
179        let again = take(&path).expect("the lock is free again");
180        assert!(
181            std::fs::read_to_string(&path)
182                .expect("reads")
183                .starts_with("pid="),
184            "the lock names its owner"
185        );
186        drop(again);
187    }
188
189    #[test]
190    fn the_acquired_vocabulary_is_three_states() {
191        let unavailable = Acquired::Unavailable(std::io::Error::other("x"));
192        assert!(matches!(unavailable, Acquired::Unavailable(_)));
193        assert!(matches!(Acquired::Contended, Acquired::Contended));
194    }
195}