1use 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
26pub const ADMIT_MS: u64 = 3_000;
29pub const QUEUE_CAP: usize = 64;
31const HOLD: Duration = Duration::from_secs(10);
33const IDLE: Duration = Duration::from_secs(900);
35const LOG_CAP: u64 = 256 * 1024;
37const 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
46enum Held {
50 Slot(#[allow(dead_code)] UnixStream),
51 Flight(#[allow(dead_code)] std::fs::File),
52}
53
54#[must_use]
57pub fn started() -> Instant {
58 *STARTED.get_or_init(Instant::now)
59}
60
61#[must_use]
63pub fn left_of(budget_ms: u64) -> Duration {
64 Duration::from_millis(budget_ms).saturating_sub(started().elapsed())
65}
66
67#[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
77pub 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 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
154pub 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
167fn 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
204fn 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
230fn 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 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 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 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
301fn 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 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 }
319}
320
321fn 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 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
381fn 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 fn drained(&self) -> bool {
428 let turns = self.lock();
429 turns.running == 0 && turns.queue.is_empty()
430 }
431
432 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 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 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#[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 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
601pub 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 let line = format!("{kind} {now} {} {}\n", took.as_millis(), waited.as_millis());
619 let _ = file.write_all(line.as_bytes());
620}
621
622fn 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#[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#[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#[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}