Skip to main content

amont_runtime/hooks/
wait.rs

1//! Lines a tool prints to say it is waiting on a lock (ADR-0009,
2//! `hooks.liveness`): the vocabulary, the framing that finds them in a
3//! stream of bytes, and nothing that touches a clock.
4//!
5//! Two tiers, deliberately. A **marker** is a line the real binary prints,
6//! matched exactly, and it pauses the silence clock: the tool has said what
7//! it is doing and the clock would otherwise call that stuck. A line that
8//! merely **looks like a wait** earns one retry after a silence kill and
9//! nothing more, because a loose match on `lock` would also pause the clock
10//! for a test suite whose test ids contain the word.
11
12/// Which cargo lock a `Blocking waiting for file lock on …` line names.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CargoLockWhat {
15    BuildDirectory,
16    PackageCache,
17    /// A lock cargo names that this vocabulary does not (a future one).
18    Other,
19}
20
21/// A declared wait: the tool and, where it says, the lock.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum WaitKind {
24    CargoLock(CargoLockWhat),
25    UvLock,
26}
27
28impl WaitKind {
29    /// The wait as a message names it: `the cargo lock on the build
30    /// directory`, `the uv lock`.
31    pub fn describe(self) -> &'static str {
32        match self {
33            WaitKind::CargoLock(CargoLockWhat::BuildDirectory) => {
34                "the cargo lock on the build directory"
35            }
36            WaitKind::CargoLock(CargoLockWhat::PackageCache) => {
37                "the cargo lock on the package cache"
38            }
39            WaitKind::CargoLock(CargoLockWhat::Other) => "a cargo lock",
40            WaitKind::UvLock => "a uv lock",
41        }
42    }
43
44    /// The short form the progress region has room for: `cargo lock`.
45    pub fn short(self) -> &'static str {
46        match self {
47            WaitKind::CargoLock(_) => "cargo lock",
48            WaitKind::UvLock => "uv lock",
49        }
50    }
51
52    /// Who holds it, as far as a message can guess.
53    pub fn holders(self) -> &'static str {
54        match self {
55            WaitKind::CargoLock(_) => {
56                "another cargo holds it (a second worktree, rust-analyzer, a build in another session)"
57            }
58            WaitKind::UvLock => "another uv holds it (a second worktree, or an install in another session)",
59        }
60    }
61
62    /// Stored in an `AtomicU8` on `Activity`; 0 is "none".
63    pub fn code(self) -> u8 {
64        match self {
65            WaitKind::CargoLock(CargoLockWhat::BuildDirectory) => 1,
66            WaitKind::CargoLock(CargoLockWhat::PackageCache) => 2,
67            WaitKind::CargoLock(CargoLockWhat::Other) => 3,
68            WaitKind::UvLock => 4,
69        }
70    }
71
72    pub fn from_code(code: u8) -> Option<WaitKind> {
73        Some(match code {
74            1 => WaitKind::CargoLock(CargoLockWhat::BuildDirectory),
75            2 => WaitKind::CargoLock(CargoLockWhat::PackageCache),
76            3 => WaitKind::CargoLock(CargoLockWhat::Other),
77            4 => WaitKind::UvLock,
78            _ => return None,
79        })
80    }
81}
82
83/// `line` without its CSI escape sequences (`ESC [ … m` and the rest of the
84/// family), so a tool told `CARGO_TERM_COLOR=always` still matches. Only
85/// the `ESC [` form: that is what colour is, and a stray lone `ESC` is kept
86/// as text rather than guessed at.
87pub fn strip_csi(line: &str) -> String {
88    let mut out = String::with_capacity(line.len());
89    let mut chars = line.chars().peekable();
90    while let Some(c) = chars.next() {
91        if c == '\u{1b}' && chars.peek() == Some(&'[') {
92            chars.next();
93            // Parameter and intermediate bytes, then one final byte in @..~.
94            for c in chars.by_ref() {
95                if ('@'..='~').contains(&c) {
96                    break;
97                }
98            }
99            continue;
100        }
101        out.push(c);
102    }
103    out
104}
105
106/// The exact line a tool prints while it waits on a lock, or `None`.
107///
108/// cargo (stderr): `    Blocking waiting for file lock on build directory`
109/// and `… on package cache` — the status verb is right-aligned, so the
110/// line is trimmed first, and the match is anchored at its start so a test
111/// that prints the words mid-line is not a wait.
112/// uv (stderr): `Waiting to acquire <kind> lock for \`<path>\``.
113pub fn marker(line: &str) -> Option<WaitKind> {
114    let line = line.trim();
115    if let Some(what) = line.strip_prefix("Blocking waiting for file lock on ") {
116        return Some(WaitKind::CargoLock(match what.trim() {
117            "build directory" => CargoLockWhat::BuildDirectory,
118            "package cache" => CargoLockWhat::PackageCache,
119            _ => CargoLockWhat::Other,
120        }));
121    }
122    if line.starts_with("Waiting to acquire ") && line.contains(" lock for `") {
123        return Some(WaitKind::UvLock);
124    }
125    None
126}
127
128/// Whether a line reads like a wait, loosely: whole phrases only, so a test
129/// id such as `test_lock.py::x` or a file named `lock.rs` is not one. The
130/// second tier — what earns one retry after a silence kill, never a pause.
131pub fn looks_like_wait(line: &str) -> bool {
132    let lower = line.to_ascii_lowercase();
133    [
134        "waiting for file lock",
135        "waiting to acquire",
136        "blocking waiting",
137        "waiting for lock",
138    ]
139    .iter()
140    .any(|phrase| lower.contains(phrase))
141}
142
143/// The longest line the framer keeps while waiting for its newline. A tool
144/// that writes more than this without a newline is not printing status
145/// lines; the fragment is dropped rather than grown.
146pub const MAX_LINE: usize = 4096;
147
148/// Turns a stream of byte chunks into complete lines, one chunk at a time.
149/// What the stderr reader hands to [`marker`].
150#[derive(Default)]
151pub struct LineFramer {
152    tail: Vec<u8>,
153    /// The current line already overflowed [`MAX_LINE`]: its remainder, up
154    /// to the next newline, is dropped too.
155    overflowed: bool,
156}
157
158impl LineFramer {
159    /// The complete lines `chunk` finishes, oldest first, with their newline
160    /// (and a trailing `\r`) removed and invalid UTF-8 replaced.
161    pub fn feed(&mut self, chunk: &[u8]) -> Vec<String> {
162        let mut lines = Vec::new();
163        for byte in chunk {
164            if *byte == b'\n' {
165                if !self.overflowed {
166                    let line = String::from_utf8_lossy(&self.tail).into_owned();
167                    lines.push(line.trim_end_matches('\r').to_string());
168                }
169                self.tail.clear();
170                self.overflowed = false;
171                continue;
172            }
173            if self.overflowed {
174                continue;
175            }
176            if self.tail.len() >= MAX_LINE {
177                self.tail.clear();
178                self.overflowed = true;
179                continue;
180            }
181            self.tail.push(*byte);
182        }
183        lines
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const BUILD_DIR: &str = "    Blocking waiting for file lock on build directory";
192    const PACKAGE_CACHE: &str = "    Blocking waiting for file lock on package cache";
193    /// The build-directory line as cargo prints it under
194    /// `CARGO_TERM_COLOR=always`: the verb in bold cyan.
195    const COLOURED: &str =
196        "\u{1b}[1m\u{1b}[36m    Blocking\u{1b}[0m waiting for file lock on build directory";
197    const UV: &str = "Waiting to acquire write lock for `/home/u/.cache/uv/environments-v2`";
198
199    #[test]
200    fn the_real_cargo_and_uv_lines_are_markers() {
201        assert_eq!(
202            marker(BUILD_DIR),
203            Some(WaitKind::CargoLock(CargoLockWhat::BuildDirectory))
204        );
205        assert_eq!(
206            marker(PACKAGE_CACHE),
207            Some(WaitKind::CargoLock(CargoLockWhat::PackageCache))
208        );
209        assert_eq!(
210            marker("Blocking waiting for file lock on the git checkouts"),
211            Some(WaitKind::CargoLock(CargoLockWhat::Other))
212        );
213        assert_eq!(marker(UV), Some(WaitKind::UvLock));
214    }
215
216    /// A status line, the words mid-line, and a look-alike are not markers.
217    #[test]
218    fn other_lines_are_not_markers() {
219        assert_eq!(marker("    Checking foo v0.1.0"), None);
220        assert_eq!(
221            marker("test prints Blocking waiting for file lock on build directory"),
222            None
223        );
224        assert_eq!(marker("waiting for lock on x"), None);
225        assert_eq!(marker(""), None);
226    }
227
228    /// Colour codes come before the verb on a terminal; stripped, the line
229    /// is the plain one.
230    #[test]
231    fn a_coloured_line_matches_once_its_escapes_are_stripped() {
232        assert_eq!(marker(COLOURED), None);
233        assert_eq!(strip_csi(COLOURED), BUILD_DIR);
234        assert_eq!(
235            marker(&strip_csi(COLOURED)),
236            Some(WaitKind::CargoLock(CargoLockWhat::BuildDirectory))
237        );
238        assert_eq!(strip_csi("plain"), "plain");
239        assert_eq!(strip_csi("\u{1b}[2K\u{1b}[1Gline"), "line");
240        // A lone escape is text, not a sequence.
241        assert_eq!(strip_csi("a\u{1b}b"), "a\u{1b}b");
242    }
243
244    /// The loose tier accepts phrases and rejects the bare word.
245    #[test]
246    fn looks_like_wait_matches_phrases_not_words() {
247        assert!(looks_like_wait("waiting for lock on x"));
248        assert!(looks_like_wait(BUILD_DIR));
249        assert!(looks_like_wait("Waiting to acquire write lock"));
250        assert!(looks_like_wait("BLOCKING WAITING on the index"));
251        assert!(!looks_like_wait("test_lock.py::test_it PASSED"));
252        assert!(!looks_like_wait("src/lock.rs: 3 warnings"));
253        assert!(!looks_like_wait("lock"));
254    }
255
256    /// The retry fixture's line sits exactly between the tiers.
257    #[test]
258    fn the_look_alike_line_is_no_marker() {
259        let line = "waiting for lock on x";
260        assert_eq!(marker(line), None);
261        assert!(looks_like_wait(line));
262    }
263
264    /// A marker split across two reads is found when its newline arrives;
265    /// the framer keeps nothing of a finished line.
266    #[test]
267    fn framing_joins_a_line_split_across_chunks() {
268        let mut f = LineFramer::default();
269        assert_eq!(f.feed(b"    Blocking waiting for"), Vec::<String>::new());
270        assert_eq!(
271            f.feed(b" file lock on build directory\n    Check"),
272            vec![BUILD_DIR.to_string()]
273        );
274        assert_eq!(f.feed(b"ing foo\r\n"), vec!["    Checking foo".to_string()]);
275        assert_eq!(f.feed(b"a\nb\n"), vec!["a".to_string(), "b".to_string()]);
276    }
277
278    /// A line longer than the cap is dropped whole, including the part
279    /// that arrives after the cap, and the framer does not grow.
280    #[test]
281    fn a_line_past_the_cap_is_dropped_not_grown() {
282        let mut f = LineFramer::default();
283        let long = vec![b'x'; 5 * 1024];
284        assert_eq!(f.feed(&long), Vec::<String>::new());
285        assert!(f.tail.len() <= MAX_LINE);
286        assert_eq!(f.feed(b"tail\nnext\n"), vec!["next".to_string()]);
287    }
288
289    #[test]
290    fn kinds_round_trip_through_their_code() {
291        for kind in [
292            WaitKind::CargoLock(CargoLockWhat::BuildDirectory),
293            WaitKind::CargoLock(CargoLockWhat::PackageCache),
294            WaitKind::CargoLock(CargoLockWhat::Other),
295            WaitKind::UvLock,
296        ] {
297            assert_eq!(WaitKind::from_code(kind.code()), Some(kind));
298        }
299        assert_eq!(WaitKind::from_code(0), None);
300    }
301}