Skip to main content

ljos_cli/
hookd.rs

1//! One admission point for `ljos hook`. Every tool call on a host used to
2//! start a full hook process at once, so a burst of thirty calls from six
3//! seats was thirty processes contending for the same CPUs and memory.
4//! `ljos hookd`, one per user, hands out a fixed number of slots over a
5//! unix socket, in the order the calls asked. A gate call that gets no
6//! slot in time is refused: a gate that cannot judge must not allow.
7//!
8//! The judging stays in the calling process. The guard reads that
9//! process's parent, session and cgroup to tell the runner's call from a
10//! tool's, and a server judging on its behalf would have to take the
11//! caller's word for all of them.
12//!
13//! When no server answers, the first call starts one, and the calls that
14//! meet no server take one host-wide lock in turn. A missing server costs
15//! throughput, never the cap.
16
17use std::io::{BufRead, BufReader, Read, Write};
18use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
19use std::os::unix::io::AsRawFd;
20use std::os::unix::net::{UnixListener, UnixStream};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::{Arc, Condvar, Mutex, OnceLock};
24use std::time::{Duration, Instant};
25
26/// The longest a call waits for a slot. The runner's gate deadline counts
27/// from the call's start, so the wait and the work share that budget.
28pub const ADMIT_MS: u64 = 3_000;
29/// Calls waiting at once before the next one is refused outright.
30pub const QUEUE_CAP: usize = 64;
31/// A slot held this long is taken back, so a stopped call cannot keep it.
32const HOLD: Duration = Duration::from_secs(10);
33/// An idle server exits after this long; the next call starts another.
34const IDLE: Duration = Duration::from_secs(900);
35/// The timing log is cut back past this size.
36const LOG_CAP: u64 = 256 * 1024;
37/// The longest socket path a `sockaddr_un` takes everywhere ljos runs:
38/// 104 bytes with the NUL on macOS, 108 on Linux.
39const SOCKET_PATH_MAX: usize = 103;
40
41static STARTED: OnceLock<Instant> = OnceLock::new();
42static STDIN: Mutex<Option<Vec<u8>>> = Mutex::new(None);
43static HELD: Mutex<Option<Held>> = Mutex::new(None);
44static WAITED_MS: AtomicU64 = AtomicU64::new(0);
45
46/// What keeps this call's turn: a slot from the server, or the
47/// single-flight lock when there was no server. Dropping it gives the
48/// turn to the next call.
49enum Held {
50    Slot(#[allow(dead_code)] UnixStream),
51    Flight(#[allow(dead_code)] std::fs::File),
52}
53
54/// When this process started its hook call. Deadlines count from here,
55/// so time spent in the queue is spent from the same budget.
56#[must_use]
57pub fn started() -> Instant {
58    *STARTED.get_or_init(Instant::now)
59}
60
61/// What is left of `budget_ms`, counted from [`started`].
62#[must_use]
63pub fn left_of(budget_ms: u64) -> Duration {
64    Duration::from_millis(budget_ms).saturating_sub(started().elapsed())
65}
66
67/// The stdin [`admit`] read to learn the call's event, for the hook to
68/// judge. `None` when admission did not read it.
69#[must_use]
70pub fn take_stdin() -> Option<Vec<u8>> {
71    STDIN
72        .lock()
73        .unwrap_or_else(std::sync::PoisonError::into_inner)
74        .take()
75}
76
77/// Wait for this hook call's turn before any heavy start-up. Returns at
78/// once for anything but a plain `ljos hook`. A gate that gets no turn
79/// within [`ADMIT_MS`] is refused in the runner's JSON and the process
80/// exits; any other event exits quietly, since it has nothing to stop.
81pub fn admit() {
82    let _ = started();
83    let args: Vec<String> = std::env::args().skip(1).collect();
84    if !admitted_command(&args)
85        || std::env::var_os("LJOS_IN_HOOK").is_some()
86        || std::env::var("LJOS_HOOKD").is_ok_and(|v| v == "off")
87    {
88        return;
89    }
90    let mut bytes = Vec::new();
91    if std::io::stdin().read_to_end(&mut bytes).is_err() {
92        return;
93    }
94    let event = flag_value(&args, "--event");
95    if !args.iter().any(|a| a == "--prompt") {
96        if let Some(code) = crate::answer_cursor_read_bytes(&bytes) {
97            std::process::exit(code);
98        }
99    }
100    let input = String::from_utf8_lossy(&bytes).into_owned();
101    *STDIN
102        .lock()
103        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(bytes);
104    // These two events are judged on preToolUse. Answer them before a
105    // slot, so a slow server cannot turn an allow into exit 2.
106    // The direct path has already loaded the seat env. A promoted rule
107    // may still ask the pack once. Record the call first: a bare seat
108    // binds the conversation from that record after this process exits.
109    if !args.iter().any(|a| a == "--prompt") {
110        if let Some((output, code)) = crate::cursor_side_answer(&input, event.as_deref()) {
111            let call = crate::hook_call_as(&input, event.as_deref());
112            crate::bind_call_conversation(&input, &call);
113            let _ = crate::subagent::note_hook_call(&input, &call.event);
114            std::process::exit(crate::emit_side_answer(
115                &input,
116                event.as_deref(),
117                &output,
118                code,
119            ));
120        }
121    }
122    let gate = !args.iter().any(|a| a == "--prompt")
123        && crate::is_gate_event(&crate::hook_call_as(&input, event.as_deref()).event);
124    match wait_turn() {
125        Ok((held, waited)) => {
126            WAITED_MS.store(waited, Ordering::Relaxed);
127            *HELD
128                .lock()
129                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(held);
130        }
131        Err(why) => {
132            note_time("refused", started().elapsed(), Duration::ZERO);
133            let why = format!("hook queue: {why}");
134            if gate {
135                if let Some(text) = crate::hook_failure_output(&input, event.as_deref(), &why) {
136                    let code = crate::gate_exit(&text);
137                    let mut out = std::io::stdout().lock();
138                    let _ = out.write_all(text.as_bytes());
139                    let _ = out.flush();
140                    if code != 0 {
141                        eprintln!("ljos hook: {why}; the call is refused");
142                    }
143                    std::process::exit(code);
144                }
145                eprintln!("ljos hook: {why}; the call is refused");
146                std::process::exit(crate::GATE_REFUSED_EXIT);
147            }
148            eprintln!("ljos hook: {why}; this event is skipped");
149            std::process::exit(0);
150        }
151    }
152}
153
154/// Give this call's turn to the next one and log how long the call took.
155/// The gate's answer calls this once it is written.
156pub fn release() {
157    let held = HELD
158        .lock()
159        .unwrap_or_else(std::sync::PoisonError::into_inner)
160        .take();
161    if held.is_some() {
162        let waited = Duration::from_millis(WAITED_MS.load(Ordering::Relaxed));
163        note_time("hook", started().elapsed(), waited);
164    }
165}
166
167/// Whether `args` (after the program name) are a hook call that reads a
168/// payload and answers it. The git and runner forms, `--text` and the
169/// queue's own verbs are not admitted here.
170fn admitted_command(args: &[String]) -> bool {
171    if args.first().map(String::as_str) != Some("hook") {
172        return false;
173    }
174    !args.iter().skip(1).any(|a| {
175        let name = a.split('=').next().unwrap_or(a);
176        matches!(
177            name,
178            "--text"
179                | "--git-commit"
180                | "--git-hook"
181                | "--brief-once"
182                | "--runner"
183                | "--drain"
184                | "--apply-book"
185                | "-h"
186                | "--help"
187        )
188    })
189}
190
191fn flag_value(args: &[String], flag: &str) -> Option<String> {
192    let mut it = args.iter();
193    while let Some(arg) = it.next() {
194        if arg == flag {
195            return it.next().cloned();
196        }
197        if let Some(value) = arg.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
198            return Some(value.to_string());
199        }
200    }
201    None
202}
203
204/// The runtime directory, made private when missing.
205fn run_dir() -> PathBuf {
206    let dir = crate::runtime_dir();
207    if !dir.exists() {
208        use std::os::unix::fs::DirBuilderExt as _;
209        let _ = std::fs::DirBuilder::new()
210            .recursive(true)
211            .mode(0o700)
212            .create(&dir);
213    }
214    dir
215}
216
217fn env_num(key: &str) -> Option<u64> {
218    std::env::var(key).ok()?.trim().parse().ok()
219}
220
221fn admit_budget() -> Duration {
222    Duration::from_millis(env_num("LJOS_HOOK_ADMIT_MS").unwrap_or(ADMIT_MS))
223}
224
225enum Ask {
226    NoServer,
227    Refused(String),
228}
229
230/// A turn from the server, or with no server, the single-flight lock.
231/// The error says why no turn came within the budget.
232fn wait_turn() -> Result<(Held, u64), String> {
233    let dir = run_dir();
234    let sock = dir.join("hookd.sock");
235    let deadline = started() + admit_budget();
236    // No server can listen on a path this long; do not wait for one.
237    if sock.as_os_str().len() > SOCKET_PATH_MAX {
238        return flight(&dir, deadline);
239    }
240    match ask(&sock, deadline) {
241        Ok(turn) => return Ok(turn),
242        Err(Ask::Refused(why)) => return Err(why),
243        Err(Ask::NoServer) => {}
244    }
245    let _starting = start_server(&dir);
246    let retry_until = (Instant::now() + Duration::from_millis(200)).min(deadline);
247    while Instant::now() < retry_until {
248        std::thread::sleep(Duration::from_millis(5));
249        match ask(&sock, deadline) {
250            Ok(turn) => return Ok(turn),
251            Err(Ask::Refused(why)) => return Err(why),
252            Err(Ask::NoServer) => {}
253        }
254    }
255    flight(&dir, deadline)
256}
257
258fn ask(sock: &Path, deadline: Instant) -> Result<(Held, u64), Ask> {
259    let mut stream = UnixStream::connect(sock).map_err(|_| Ask::NoServer)?;
260    let left = deadline.saturating_duration_since(Instant::now());
261    if left.is_zero() {
262        return Err(Ask::Refused(format!(
263            "no slot within {} ms",
264            admit_budget().as_millis()
265        )));
266    }
267    // The server answers `late` at the deadline itself; the margin is for
268    // a server that stopped answering.
269    let _ = stream.set_read_timeout(Some(left + Duration::from_millis(250)));
270    writeln!(stream, "wait {}", left.as_millis()).map_err(|_| Ask::NoServer)?;
271    let mut line = String::new();
272    let mut reader = BufReader::new(stream.try_clone().map_err(|_| Ask::NoServer)?);
273    match reader.read_line(&mut line) {
274        // A server that went away before it answered: the remaining
275        // budget goes to the single-flight lock.
276        Ok(0) => Err(Ask::NoServer),
277        Ok(_) => {
278            let mut words = line.split_whitespace();
279            match (words.next(), words.next()) {
280                (Some("go"), waited) => Ok((
281                    Held::Slot(stream),
282                    waited.and_then(|w| w.parse().ok()).unwrap_or(0),
283                )),
284                (Some("full"), waiting) => Err(Ask::Refused(format!(
285                    "{} hook calls are already waiting for a slot",
286                    waiting.unwrap_or("too many")
287                ))),
288                _ => Err(Ask::Refused(format!(
289                    "no slot came free within {} ms",
290                    admit_budget().as_millis()
291                ))),
292            }
293        }
294        Err(_) => Err(Ask::Refused(format!(
295            "the hook server did not answer within {} ms",
296            admit_budget().as_millis()
297        ))),
298    }
299}
300
301/// The inode, size and mtime of the file at `path`; `None` when it is gone.
302fn binary_stamp(path: &Path) -> Option<(u64, u64, i64)> {
303    let meta = std::fs::metadata(path).ok()?;
304    Some((meta.ino(), meta.len(), meta.mtime()))
305}
306
307fn open_lock(path: &Path) -> Option<std::fs::File> {
308    std::fs::OpenOptions::new()
309        .create(true)
310        .append(true)
311        .mode(0o600)
312        .open(path)
313        .ok()
314}
315
316fn try_flock(file: &std::fs::File) -> bool {
317    // SAFETY: flock on a descriptor `file` owns; the lock goes with it.
318    unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 }
319}
320
321/// Start `ljos hookd` unless another call is starting one. The returned
322/// lock is held while this call waits for the socket, so a burst starts
323/// one server, not one each.
324fn start_server(dir: &Path) -> Option<std::fs::File> {
325    use std::os::unix::process::CommandExt as _;
326    let lock = open_lock(&dir.join("hookd.start"))?;
327    if !try_flock(&lock) {
328        return None;
329    }
330    let exe = std::env::current_exe().ok()?;
331    let mut cmd = std::process::Command::new(exe);
332    cmd.arg("hookd")
333        .env_clear()
334        .stdin(std::process::Stdio::null())
335        .stdout(std::process::Stdio::null())
336        .stderr(std::process::Stdio::null());
337    for key in [
338        "PATH",
339        "HOME",
340        "USER",
341        "XDG_RUNTIME_DIR",
342        "TMPDIR",
343        "LJOS_HOOK_SLOTS",
344        "LJOS_HOOK_QUEUE",
345    ] {
346        if let Some(val) = std::env::var_os(key) {
347            cmd.env(key, val);
348        }
349    }
350    // SAFETY: setsid is async-signal-safe and touches only the child.
351    unsafe {
352        cmd.pre_exec(|| {
353            libc::setsid();
354            Ok(())
355        });
356    }
357    let _ = cmd.spawn();
358    Some(lock)
359}
360
361fn flight(dir: &Path, deadline: Instant) -> Result<(Held, u64), String> {
362    let asked = Instant::now();
363    let Some(file) = open_lock(&dir.join("hookd.flight")) else {
364        return Err("no hook server, and the single-flight lock cannot be opened".into());
365    };
366    loop {
367        if try_flock(&file) {
368            let waited = u64::try_from(asked.elapsed().as_millis()).unwrap_or(u64::MAX);
369            return Ok((Held::Flight(file), waited));
370        }
371        if Instant::now() >= deadline {
372            return Err(format!(
373                "no hook server, and the single-flight lock stayed taken for {} ms",
374                admit_budget().as_millis()
375            ));
376        }
377        std::thread::sleep(Duration::from_millis(5));
378    }
379}
380
381/// How many hook calls may run at once: `LJOS_HOOK_SLOTS`, else the CPU
382/// count between 2 and 8.
383fn slots() -> usize {
384    env_num("LJOS_HOOK_SLOTS")
385        .and_then(|n| usize::try_from(n).ok())
386        .filter(|n| *n > 0)
387        .unwrap_or_else(|| {
388            std::thread::available_parallelism()
389                .map_or(2, std::num::NonZeroUsize::get)
390                .clamp(2, 8)
391        })
392}
393
394fn queue_cap() -> usize {
395    env_num("LJOS_HOOK_QUEUE")
396        .and_then(|n| usize::try_from(n).ok())
397        .unwrap_or(QUEUE_CAP)
398}
399
400struct Turns {
401    running: usize,
402    queue: std::collections::VecDeque<u64>,
403    next: u64,
404    last: Instant,
405}
406
407struct Gate {
408    turns: Mutex<Turns>,
409    moved: Condvar,
410    slots: usize,
411    cap: usize,
412}
413
414impl Gate {
415    fn lock(&self) -> std::sync::MutexGuard<'_, Turns> {
416        self.turns
417            .lock()
418            .unwrap_or_else(std::sync::PoisonError::into_inner)
419    }
420
421    fn idle(&self) -> bool {
422        let turns = self.lock();
423        turns.running == 0 && turns.queue.is_empty() && turns.last.elapsed() > IDLE
424    }
425
426    /// No call holds a slot or waits for one.
427    fn drained(&self) -> bool {
428        let turns = self.lock();
429        turns.running == 0 && turns.queue.is_empty()
430    }
431
432    /// One call: queue it, hand it a slot in order, and take the slot back
433    /// when the caller closes the socket, exits or holds it past [`HOLD`].
434    fn serve(&self, stream: &UnixStream) {
435        let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
436        let mut line = String::new();
437        if BufReader::new(stream).read_line(&mut line).is_err() {
438            return;
439        }
440        let ms = line
441            .strip_prefix("wait ")
442            .and_then(|v| v.trim().parse().ok())
443            .unwrap_or(ADMIT_MS)
444            .min(crate::CURSOR_GATE_DEADLINE_MS);
445        let asked = Instant::now();
446        let deadline = asked + Duration::from_millis(ms);
447        let mut turns = self.lock();
448        turns.last = asked;
449        if turns.queue.len() >= self.cap {
450            let waiting = turns.queue.len();
451            drop(turns);
452            let _ = writeln!(&mut &*stream, "full {waiting}");
453            return;
454        }
455        let ticket = turns.next;
456        turns.next += 1;
457        turns.queue.push_back(ticket);
458        loop {
459            if turns.queue.front() == Some(&ticket) && turns.running < self.slots {
460                turns.queue.pop_front();
461                turns.running += 1;
462                break;
463            }
464            let left = deadline.saturating_duration_since(Instant::now());
465            if left.is_zero() {
466                turns.queue.retain(|t| *t != ticket);
467                drop(turns);
468                self.moved.notify_all();
469                let _ = writeln!(&mut &*stream, "late");
470                return;
471            }
472            turns = self
473                .moved
474                .wait_timeout(turns, left)
475                .unwrap_or_else(std::sync::PoisonError::into_inner)
476                .0;
477        }
478        drop(turns);
479        self.moved.notify_all();
480        let waited = asked.elapsed().as_millis();
481        if writeln!(&mut &*stream, "go {waited}").is_ok() {
482            let _ = stream.set_read_timeout(Some(HOLD));
483            let mut buf = [0u8; 64];
484            while matches!((&mut &*stream).read(&mut buf), Ok(n) if n > 0) {}
485        }
486        let mut turns = self.lock();
487        turns.running -= 1;
488        turns.last = Instant::now();
489        drop(turns);
490        self.moved.notify_all();
491    }
492}
493
494#[cfg(target_os = "linux")]
495fn same_user(stream: &UnixStream) -> bool {
496    let mut cred = libc::ucred {
497        pid: 0,
498        uid: 0,
499        gid: 0,
500    };
501    let mut len = libc::socklen_t::try_from(std::mem::size_of::<libc::ucred>()).unwrap_or(0);
502    // SAFETY: SO_PEERCRED writes one ucred into `cred`, of `len` bytes.
503    let rc = unsafe {
504        libc::getsockopt(
505            stream.as_raw_fd(),
506            libc::SOL_SOCKET,
507            libc::SO_PEERCRED,
508            std::ptr::addr_of_mut!(cred).cast(),
509            &mut len,
510        )
511    };
512    // SAFETY: geteuid only reads the process identity.
513    rc == 0 && cred.uid == unsafe { libc::geteuid() }
514}
515
516#[cfg(not(target_os = "linux"))]
517fn same_user(_stream: &UnixStream) -> bool {
518    true
519}
520
521/// `ljos hookd`: hand out hook slots on `hookd.sock` in the runtime
522/// directory until idle. A second server finds the lock taken and exits
523/// 0, so starting one is safe at any time. The server also exits when its
524/// socket is removed or replaced.
525#[must_use]
526pub fn serve() -> i32 {
527    let dir = run_dir();
528    let Some(lock) = open_lock(&dir.join("hookd.lock")) else {
529        eprintln!(
530            "ljos hookd: cannot open {}",
531            dir.join("hookd.lock").display()
532        );
533        return 1;
534    };
535    if !try_flock(&lock) {
536        return 0;
537    }
538    let sock = dir.join("hookd.sock");
539    let _ = std::fs::remove_file(&sock);
540    let listener = match UnixListener::bind(&sock) {
541        Ok(listener) => listener,
542        Err(e) => {
543            eprintln!("ljos hookd: cannot listen on {}: {e}", sock.display());
544            return 1;
545        }
546    };
547    let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
548    let ino = std::fs::metadata(&sock).map(|m| m.ino()).unwrap_or(0);
549    // The binary this server runs. An install replaces the file under its
550    // path; the server then exits as soon as no call holds or waits for a
551    // slot, and the next call starts one from the installed binary.
552    let exe = std::env::current_exe().ok();
553    let exe_stamp = exe.as_deref().and_then(binary_stamp);
554    let gate = Arc::new(Gate {
555        turns: Mutex::new(Turns {
556            running: 0,
557            queue: std::collections::VecDeque::new(),
558            next: 0,
559            last: Instant::now(),
560        }),
561        moved: Condvar::new(),
562        slots: slots(),
563        cap: queue_cap(),
564    });
565    {
566        let gate = Arc::clone(&gate);
567        let sock = sock.clone();
568        std::thread::spawn(move || loop {
569            std::thread::sleep(Duration::from_secs(2));
570            if !std::fs::metadata(&sock).is_ok_and(|m| m.ino() == ino) {
571                std::process::exit(0);
572            }
573            let replaced = exe
574                .as_deref()
575                .is_some_and(|path| binary_stamp(path) != exe_stamp);
576            if gate.idle() || (replaced && gate.drained()) {
577                let _ = std::fs::remove_file(&sock);
578                std::process::exit(0);
579            }
580        });
581    }
582    for conn in listener.incoming() {
583        let Ok(stream) = conn else { continue };
584        if !same_user(&stream) {
585            continue;
586        }
587        let gate = Arc::clone(&gate);
588        let _ = std::thread::Builder::new()
589            .name("hookd-call".into())
590            .stack_size(128 * 1024)
591            .spawn(move || gate.serve(&stream));
592    }
593    drop(lock);
594    0
595}
596
597fn times_path() -> PathBuf {
598    crate::runtime_dir().join("hook-times.log")
599}
600
601/// Append one timing row: `kind`, the unix time, the call's duration and
602/// the time it waited on a lock, in milliseconds. `kind` is `hook` for an
603/// answered call, `refused` for one the queue turned away and `book` for
604/// a bookkeeping job.
605pub fn note_time(kind: &str, took: Duration, waited: Duration) {
606    let path = times_path();
607    if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_CAP) {
608        let _ = std::fs::rename(&path, path.with_extension("log.1"));
609    }
610    let Some(mut file) = open_lock(&path) else {
611        return;
612    };
613    let now = std::time::SystemTime::now()
614        .duration_since(std::time::UNIX_EPOCH)
615        .map_or(0, |d| d.as_secs());
616    // One write per line: calls append at once, and a short O_APPEND
617    // write is not split by another writer's.
618    let line = format!("{kind} {now} {} {}\n", took.as_millis(), waited.as_millis());
619    let _ = file.write_all(line.as_bytes());
620}
621
622/// The 95th percentile of `values`, or `None` when there are none.
623fn p95(values: &mut [u64]) -> Option<u64> {
624    if values.is_empty() {
625        return None;
626    }
627    values.sort_unstable();
628    let rank = (values.len() * 95).div_ceil(100).max(1) - 1;
629    Some(values[rank])
630}
631
632/// Hook latency and lock waits from the timing log, for `ljos doctor`.
633#[derive(Debug, Default, PartialEq, Eq)]
634pub struct Timings {
635    pub calls: usize,
636    pub p95_ms: Option<u64>,
637    pub admit_p95_ms: Option<u64>,
638    pub refused: usize,
639    pub book_jobs: usize,
640    pub book_lock_p95_ms: Option<u64>,
641}
642
643/// [`Timings`] from the log text.
644#[must_use]
645pub fn timings_of(text: &str) -> Timings {
646    let mut took = Vec::new();
647    let mut admit = Vec::new();
648    let mut book = Vec::new();
649    let mut refused = 0;
650    for line in text.lines() {
651        let mut words = line.split_whitespace();
652        let kind = words.next();
653        let _at = words.next();
654        let (Some(ms), Some(wait)) = (
655            words.next().and_then(|w| w.parse::<u64>().ok()),
656            words.next().and_then(|w| w.parse::<u64>().ok()),
657        ) else {
658            continue;
659        };
660        match kind {
661            Some("hook") => {
662                took.push(ms);
663                admit.push(wait);
664            }
665            Some("refused") => refused += 1,
666            Some("book") => book.push(wait),
667            _ => {}
668        }
669    }
670    Timings {
671        calls: took.len(),
672        p95_ms: p95(&mut took),
673        admit_p95_ms: p95(&mut admit),
674        refused,
675        book_jobs: book.len(),
676        book_lock_p95_ms: p95(&mut book),
677    }
678}
679
680/// The doctor's line for hook latency and lock waits.
681#[must_use]
682pub fn timings_line() -> String {
683    let path = times_path();
684    let mut text = std::fs::read_to_string(path.with_extension("log.1")).unwrap_or_default();
685    text.push_str(&std::fs::read_to_string(&path).unwrap_or_default());
686    let t = timings_of(&text);
687    let ms = |v: Option<u64>| v.map_or_else(|| "-".to_string(), |v| format!("{v} ms"));
688    if t.calls == 0 && t.refused == 0 && t.book_jobs == 0 {
689        return "no hook calls timed yet".to_string();
690    }
691    format!(
692        "p95 {} over {} gate calls; queue wait p95 {}; {} refused by the queue; \
693         book lock wait p95 {} over {} jobs",
694        ms(t.p95_ms),
695        t.calls,
696        ms(t.admit_p95_ms),
697        t.refused,
698        ms(t.book_lock_p95_ms),
699        t.book_jobs
700    )
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn only_a_plain_hook_is_admitted() {
709        let args = |v: &[&str]| v.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
710        assert!(admitted_command(&args(&["hook"])));
711        assert!(admitted_command(&args(&["hook", "--event", "PreToolUse"])));
712        assert!(admitted_command(&args(&["hook", "--prompt"])));
713        assert!(!admitted_command(&args(&["hook", "--git-commit"])));
714        assert!(!admitted_command(&args(&["hook", "--runner=gemini"])));
715        assert!(!admitted_command(&args(&["hook", "--drain"])));
716        assert!(!admitted_command(&args(&["note", "x"])));
717        assert!(!admitted_command(&args(&[])));
718    }
719
720    #[test]
721    fn the_event_flag_reads_both_spellings() {
722        let args = |v: &[&str]| v.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
723        assert_eq!(
724            flag_value(&args(&["hook", "--event", "Stop"]), "--event").as_deref(),
725            Some("Stop")
726        );
727        assert_eq!(
728            flag_value(&args(&["hook", "--event=Stop"]), "--event").as_deref(),
729            Some("Stop")
730        );
731        assert_eq!(flag_value(&args(&["hook"]), "--event"), None);
732    }
733
734    #[test]
735    fn timings_read_p95_and_waits_from_the_log() {
736        let mut text = String::new();
737        for ms in 1..=100 {
738            text.push_str(&format!("hook 0 {ms} {}\n", ms / 10));
739        }
740        text.push_str("refused 0 3000 0\nbook 0 40 900\nbook 0 10 5\ngarbage\n");
741        let t = timings_of(&text);
742        assert_eq!(t.calls, 100);
743        assert_eq!(t.p95_ms, Some(95));
744        assert_eq!(t.admit_p95_ms, Some(9));
745        assert_eq!(t.refused, 1);
746        assert_eq!(t.book_jobs, 2);
747        assert_eq!(t.book_lock_p95_ms, Some(900));
748        assert_eq!(timings_of(""), Timings::default());
749    }
750
751    #[test]
752    fn a_full_queue_and_a_late_turn_are_refused_in_order() {
753        let gate = Arc::new(Gate {
754            turns: Mutex::new(Turns {
755                running: 0,
756                queue: std::collections::VecDeque::new(),
757                next: 0,
758                last: Instant::now(),
759            }),
760            moved: Condvar::new(),
761            slots: 1,
762            cap: 1,
763        });
764        let pair = || UnixStream::pair().unwrap();
765        let call = |gate: &Arc<Gate>, ms: u64| {
766            let (mine, theirs) = pair();
767            let gate = Arc::clone(gate);
768            std::thread::spawn(move || gate.serve(&theirs));
769            writeln!(&mut &mine, "wait {ms}").unwrap();
770            let mut line = String::new();
771            BufReader::new(mine.try_clone().unwrap())
772                .read_line(&mut line)
773                .unwrap();
774            (mine, line)
775        };
776        let (first, said) = call(&gate, 1000);
777        assert!(said.starts_with("go "), "{said}");
778        let (mine, theirs) = pair();
779        {
780            let gate = Arc::clone(&gate);
781            std::thread::spawn(move || gate.serve(&theirs));
782        }
783        writeln!(&mut &mine, "wait 2000").unwrap();
784        std::thread::sleep(Duration::from_millis(100));
785        let (_third, said) = call(&gate, 1000);
786        assert_eq!(said.trim(), "full 1");
787        drop(first);
788        let mut line = String::new();
789        BufReader::new(mine.try_clone().unwrap())
790            .read_line(&mut line)
791            .unwrap();
792        assert!(line.starts_with("go "), "{line}");
793        let (_late, said) = call(&gate, 50);
794        assert_eq!(said.trim(), "late");
795    }
796}