1use super::ipc::{self, Action, Request, Response};
4use super::{FireOutcome, RoutineError, PROTOCOL_VERSION};
5use serde_json::Value;
6use std::io::Read;
7use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
8use std::os::unix::process::CommandExt;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command};
11use std::time::{Duration, Instant};
12
13pub const STARTUP_FD_ENV: &str = "ASCHED_STARTUP_FD";
14const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(3);
15const POLL_INTERVAL: Duration = Duration::from_millis(50);
16
17#[derive(Debug, Clone)]
33pub struct RoutineClient {
34 root: PathBuf,
35 startup_timeout: Duration,
36 request_timeout: Duration,
37}
38
39impl RoutineClient {
40 pub fn new(root: PathBuf) -> Self {
41 Self {
42 root,
43 startup_timeout: DEFAULT_STARTUP_TIMEOUT,
44 request_timeout: Duration::from_secs(30),
45 }
46 }
47
48 pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
50 self.startup_timeout = timeout;
51 self
52 }
53
54 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
56 self.request_timeout = timeout;
57 self
58 }
59
60 pub fn root(&self) -> &Path {
61 &self.root
62 }
63
64 pub fn socket_path(&self) -> PathBuf {
65 self.root.join("daemon-v1.sock")
66 }
67
68 pub fn fire(
69 &self,
70 project: &Path,
71 kind: &str,
72 payload: Value,
73 event_id: &str,
74 ) -> Result<FireOutcome, RoutineError> {
75 let response = self.request(&Request::new(
76 project.to_path_buf(),
77 Action::Fire {
78 kind: kind.to_string(),
79 payload,
80 event_id: event_id.to_string(),
81 },
82 ))?;
83 match response {
84 Response::Fire { outcome } => Ok(outcome),
85 _ => Err(RoutineError::Corrupt(
86 "routine daemon returned a non-fire response".into(),
87 )),
88 }
89 }
90
91 pub fn request(&self, request: &Request) -> Result<Response, RoutineError> {
93 ipc::send_with_timeout(&self.socket_path(), request, self.request_timeout)?.into_result()
94 }
95
96 pub fn start(&self, mut command: Command) -> Result<(), RoutineError> {
98 let status = Request::new(PathBuf::new(), Action::Status);
99 let deadline = self.startup_deadline()?;
100 self.ensure_started(&status, &mut command, deadline)
101 }
102
103 pub fn request_with_start(
105 &self,
106 request: &Request,
107 mut command: Command,
108 ) -> Result<Response, RoutineError> {
109 if matches!(&request.action, Action::Status | Action::Shutdown) {
110 return Err(RoutineError::Validation(
111 "status and shutdown requests cannot auto-start the routine daemon".into(),
112 ));
113 }
114
115 let deadline = self.startup_deadline()?;
116 let status = Request::new(request.project.clone(), Action::Status);
118 self.ensure_started(&status, &mut command, deadline)?;
119 self.request(request)
120 }
121
122 fn startup_deadline(&self) -> Result<Instant, RoutineError> {
123 Instant::now()
124 .checked_add(self.startup_timeout)
125 .ok_or_else(|| {
126 RoutineError::Validation("routine daemon startup timeout is too large".into())
127 })
128 }
129
130 fn ensure_started(
131 &self,
132 status: &Request,
133 command: &mut Command,
134 deadline: Instant,
135 ) -> Result<(), RoutineError> {
136 let remaining = deadline.saturating_duration_since(Instant::now());
137 if remaining.is_zero() {
138 return Err(self.startup_timeout_error());
139 }
140 match ipc::send_with_timeout(&self.socket_path(), status, remaining)
141 .and_then(Response::into_result)
142 {
143 Ok(response) => {
144 validate_status_response(response)?;
145 return Ok(());
146 }
147 Err(RoutineError::Unavailable(_)) => {}
148 Err(error) => return Err(error),
149 }
150
151 self.spawn_and_await(status, command, deadline)
152 }
153
154 fn spawn_and_await(
155 &self,
156 status: &Request,
157 command: &mut Command,
158 deadline: Instant,
159 ) -> Result<(), RoutineError> {
160 if Instant::now() >= deadline {
161 return Err(self.startup_timeout_error());
162 }
163 let (read_end, write_end) = startup_pipe()?;
164 let read_fd = read_end.as_raw_fd();
165 let write_fd = write_end.as_raw_fd();
166 command.env(STARTUP_FD_ENV, write_fd.to_string());
167 unsafe {
168 command.pre_exec(move || {
169 libc::close(read_fd);
170 if libc::setsid() == -1 {
171 return Err(std::io::Error::last_os_error());
172 }
173 Ok(())
174 });
175 }
176 let mut child = command.spawn()?;
177 drop(write_end);
178
179 let mut read_end = std::fs::File::from(read_end);
180 let result = self.await_startup(status, &mut child, &mut read_end, deadline);
181 if result.is_err() {
182 stop_startup_child(&mut child);
183 }
184 result
185 }
186
187 fn await_startup(
188 &self,
189 status: &Request,
190 child: &mut Child,
191 read_end: &mut std::fs::File,
192 deadline: Instant,
193 ) -> Result<(), RoutineError> {
194 while Instant::now() < deadline {
195 if fd_readable(read_end.as_raw_fd())? {
196 let mut startup = [0_u8; 4096];
198 let bytes = read_end.read(&mut startup)?;
199 let startup = std::str::from_utf8(&startup[..bytes]).map_err(|_| {
200 RoutineError::Corrupt(
201 "routine daemon returned non-UTF-8 startup response".into(),
202 )
203 })?;
204 if startup == "ready" {
205 return self
206 .poll_ready(status, deadline)?
207 .then_some(())
208 .ok_or_else(|| {
209 RoutineError::Unavailable(
210 "routine daemon reported ready but did not accept status requests"
211 .into(),
212 )
213 });
214 }
215 if let Some(error) = startup.strip_prefix("error:") {
216 if self.poll_ready(status, deadline)? {
217 stop_startup_child(child);
219 return Ok(());
220 }
221 return Err(RoutineError::Unavailable(format!(
222 "routine daemon failed to start: {error}"
223 )));
224 }
225 if startup.is_empty() {
226 if let Some(exit) = child.try_wait()? {
227 return Err(RoutineError::Unavailable(format!(
228 "routine daemon exited during startup: {exit}"
229 )));
230 }
231 }
232 return Err(RoutineError::Corrupt(
233 "routine daemon returned an invalid startup response".into(),
234 ));
235 }
236 if let Some(exit) = child.try_wait()? {
237 return Err(RoutineError::Unavailable(format!(
238 "routine daemon exited during startup: {exit}"
239 )));
240 }
241 std::thread::sleep(POLL_INTERVAL);
242 }
243 Err(self.startup_timeout_error())
244 }
245
246 fn poll_ready(&self, status: &Request, deadline: Instant) -> Result<bool, RoutineError> {
247 while Instant::now() < deadline {
248 let remaining = deadline.saturating_duration_since(Instant::now());
249 if remaining.is_zero() {
250 return Ok(false);
251 }
252 match ipc::send_with_timeout(&self.socket_path(), status, remaining)
253 .and_then(Response::into_result)
254 {
255 Ok(response) => {
256 validate_status_response(response)?;
257 return Ok(true);
258 }
259 Err(RoutineError::Unavailable(_)) => {}
260 Err(error) => return Err(error),
261 }
262 std::thread::sleep(POLL_INTERVAL);
263 }
264 Ok(false)
265 }
266
267 fn startup_timeout_error(&self) -> RoutineError {
268 RoutineError::Unavailable(format!(
269 "routine daemon did not become ready within {} ms",
270 self.startup_timeout.as_millis()
271 ))
272 }
273}
274
275fn validate_status_response(response: Response) -> Result<(), RoutineError> {
276 match response {
277 Response::Daemon { protocol, .. } if protocol == PROTOCOL_VERSION => Ok(()),
278 Response::Daemon { protocol, .. } => Err(RoutineError::ProtocolMismatch {
279 client: PROTOCOL_VERSION,
280 daemon: protocol,
281 }),
282 _ => Err(RoutineError::Corrupt(
283 "routine daemon returned a non-daemon response to a status request".into(),
284 )),
285 }
286}
287
288fn startup_pipe() -> Result<(OwnedFd, OwnedFd), RoutineError> {
289 let mut fds = [0; 2];
290 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
291 return Err(std::io::Error::last_os_error().into());
292 }
293 unsafe { Ok((OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1]))) }
294}
295
296fn fd_readable(fd: i32) -> Result<bool, RoutineError> {
297 let mut descriptor = libc::pollfd {
298 fd,
299 events: libc::POLLIN | libc::POLLHUP,
300 revents: 0,
301 };
302 let result = unsafe { libc::poll(&mut descriptor, 1, 0) };
303 if result < 0 {
304 return Err(std::io::Error::last_os_error().into());
305 }
306 Ok(result > 0)
307}
308
309fn stop_startup_child(child: &mut Child) {
310 if matches!(child.try_wait(), Ok(Some(_))) {
311 return;
312 }
313 unsafe {
314 libc::kill(-(child.id() as i32), libc::SIGKILL);
315 }
316 let _ = child.wait();
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::io::Write;
323 use std::os::fd::FromRawFd;
324 use std::sync::atomic::{AtomicU64, Ordering};
325 use std::sync::{Arc, Barrier};
326
327 static NEXT: AtomicU64 = AtomicU64::new(0);
328
329 fn test_root(label: &str) -> PathBuf {
330 PathBuf::from(".tmp").join(format!(
331 "routine-client-{label}-{}-{}",
332 std::process::id(),
333 NEXT.fetch_add(1, Ordering::Relaxed)
334 ))
335 }
336
337 fn helper_command(root: &Path, mode: &str) -> Command {
338 let mut command = Command::new(std::env::current_exe().unwrap());
339 command
340 .arg("--exact")
341 .arg("routine::client::tests::daemon_helper")
342 .arg("--ignored")
343 .arg("--nocapture")
344 .env("ASCHED_TEST_ROOT", root)
345 .env("ASCHED_TEST_MODE", mode);
346 command
347 }
348
349 fn status(root: &Path) -> Request {
350 Request::new(root.to_path_buf(), Action::Status)
351 }
352
353 fn list(root: &Path) -> Request {
354 Request::new(root.to_path_buf(), Action::List)
355 }
356
357 #[test]
358 fn direct_request_does_not_start_an_absent_daemon() {
359 let root = test_root("direct");
360 let result = RoutineClient::new(root.clone()).request(&status(&root));
361 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
362 assert!(!root.exists());
363 }
364
365 #[test]
366 fn given_accepting_socket_without_response_when_requested_then_configured_timeout_is_honored() {
367 use std::os::unix::net::UnixListener;
368
369 let root = test_root("request-timeout");
370 std::fs::create_dir_all(&root).unwrap();
371 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
372 let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
373 let (release_tx, release_rx) = std::sync::mpsc::channel();
374 let server = std::thread::spawn(move || {
375 let (_stream, _) = listener.accept().unwrap();
376 let _ = accepted_tx.send(());
377 let _ = release_rx.recv_timeout(Duration::from_secs(1));
378 });
379 let timeout = Duration::from_millis(75);
380 let started = Instant::now();
381
382 let result = RoutineClient::new(root.clone())
383 .with_request_timeout(timeout)
384 .request(&status(&root));
385 let elapsed = started.elapsed();
386 let accepted = accepted_rx.recv_timeout(Duration::from_secs(1)).is_ok();
387 let _ = release_tx.send(());
388 let joined = server.join().is_ok();
389 let _ = std::fs::remove_dir_all(root);
390
391 assert_eq!(
392 (
393 matches!(result, Err(RoutineError::Unavailable(_))),
394 accepted,
395 elapsed >= Duration::from_millis(50),
396 elapsed < Duration::from_millis(500),
397 joined,
398 ),
399 (true, true, true, true, true)
400 );
401 }
402
403 #[test]
404 fn lifecycle_requests_cannot_use_auto_start() {
405 for action in [Action::Status, Action::Shutdown] {
406 let root = test_root("lifecycle");
407 let request = Request::new(root.clone(), action);
408 let result = RoutineClient::new(root.clone())
409 .request_with_start(&request, helper_command(&root, "must_not_start"));
410
411 assert!(matches!(result, Err(RoutineError::Validation(_))));
412 assert!(!root.exists());
413 }
414 }
415
416 #[test]
417 fn auto_start_serves_list_and_direct_shutdown_stops_daemon() {
418 let root = test_root("start");
419 let client = RoutineClient::new(root.clone());
420 let response = client
421 .request_with_start(&list(&root), helper_command(&root, "daemon"))
422 .unwrap();
423 assert!(matches!(response, Response::Routines { .. }));
424 client
425 .request(&Request::new(root.clone(), Action::Shutdown))
426 .unwrap();
427 let _ = std::fs::remove_dir_all(root);
428 }
429
430 #[test]
431 fn concurrent_auto_starts_converge_on_one_daemon() {
432 let root = test_root("race");
433 let barrier = Arc::new(Barrier::new(3));
434 let mut callers = Vec::new();
435 for _ in 0..2 {
436 let root = root.clone();
437 let barrier = barrier.clone();
438 callers.push(std::thread::spawn(move || {
439 barrier.wait();
440 RoutineClient::new(root.clone())
441 .request_with_start(&list(&root), helper_command(&root, "daemon"))
442 }));
443 }
444 barrier.wait();
445 for caller in callers {
446 assert!(matches!(
447 caller.join().unwrap(),
448 Ok(Response::Routines { .. })
449 ));
450 }
451 RoutineClient::new(root.clone())
452 .request(&Request::new(root.clone(), Action::Shutdown))
453 .unwrap();
454 let _ = std::fs::remove_dir_all(root);
455 }
456
457 #[test]
458 fn singleton_race_reaps_the_losing_startup_child() {
459 let root = test_root("race-reap");
460 let barrier = Arc::new(Barrier::new(3));
461 let mut callers = Vec::new();
462 for _ in 0..2 {
463 let root = root.clone();
464 let barrier = barrier.clone();
465 callers.push(std::thread::spawn(move || {
466 let client = RoutineClient::new(root.clone());
467 let mut command = helper_command(&root, "daemon_with_pid");
468 barrier.wait();
469 let deadline = client.startup_deadline().unwrap();
470 client.spawn_and_await(&status(&root), &mut command, deadline)
471 }));
472 }
473 barrier.wait();
474 for caller in callers {
475 caller.join().unwrap().unwrap();
476 }
477
478 let winner_pid = match RoutineClient::new(root.clone())
479 .request(&status(&root))
480 .unwrap()
481 {
482 Response::Daemon { pid, .. } => pid,
483 response => panic!("expected daemon response, got {response:?}"),
484 };
485 let helper_pids: Vec<u32> = std::fs::read_dir(&root)
486 .unwrap()
487 .filter_map(|entry| {
488 let name = entry.ok()?.file_name();
489 name.to_str()?
490 .strip_prefix("helper-")?
491 .strip_suffix(".pid")?
492 .parse()
493 .ok()
494 })
495 .collect();
496 assert_eq!(helper_pids.len(), 2);
497 let loser_pid = *helper_pids.iter().find(|&&pid| pid != winner_pid).unwrap();
498 assert_eq!(unsafe { libc::kill(loser_pid as i32, 0) }, -1);
499 assert_eq!(
500 std::io::Error::last_os_error().raw_os_error(),
501 Some(libc::ESRCH)
502 );
503
504 RoutineClient::new(root.clone())
505 .request(&Request::new(root.clone(), Action::Shutdown))
506 .unwrap();
507 let _ = std::fs::remove_dir_all(root);
508 }
509
510 #[test]
511 fn startup_timeout_kills_and_reaps_the_spawned_process() {
512 let root = test_root("timeout");
513 std::fs::create_dir_all(&root).unwrap();
514 let pid_path = root.join("helper.pid");
515 let result = RoutineClient::new(root.clone())
516 .with_startup_timeout(Duration::from_millis(300))
517 .request_with_start(&list(&root), helper_command(&root, "hang"));
518 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
519 let pid: i32 = std::fs::read_to_string(&pid_path).unwrap().parse().unwrap();
520 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
521 assert_eq!(
522 std::io::Error::last_os_error().raw_os_error(),
523 Some(libc::ESRCH)
524 );
525 let _ = std::fs::remove_dir_all(root);
526 }
527
528 #[test]
529 fn initial_availability_probe_respects_startup_timeout() {
530 use std::io::{BufRead, BufReader};
531 use std::os::unix::net::UnixListener;
532
533 let root = test_root("probe-timeout");
534 std::fs::create_dir_all(&root).unwrap();
535 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
536 let server = std::thread::spawn(move || {
537 let (stream, _) = listener.accept().unwrap();
538 let mut request = String::new();
539 BufReader::new(stream).read_line(&mut request).unwrap();
540 std::thread::sleep(Duration::from_secs(1));
541 });
542 let started = Instant::now();
543 let result = RoutineClient::new(root.clone())
544 .with_startup_timeout(Duration::from_millis(300))
545 .request_with_start(&list(&root), helper_command(&root, "must_not_start"));
546
547 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
548 assert!(started.elapsed() < Duration::from_secs(1));
549 let pid_path = root.join("helper.pid");
550 if pid_path.exists() {
552 assert_helper_reaped(&pid_path);
553 }
554 server.join().unwrap();
555 let _ = std::fs::remove_dir_all(root);
556 }
557
558 #[test]
559 fn explicit_start_does_not_issue_a_project_scoped_request() {
560 let root = test_root("explicit-start");
561 let client = RoutineClient::new(root.clone());
562
563 client.start(helper_command(&root, "daemon")).unwrap();
564 assert!(matches!(
565 client.request(&status(&root)).unwrap(),
566 Response::Daemon { .. }
567 ));
568 client
569 .request(&Request::new(root.clone(), Action::Shutdown))
570 .unwrap();
571 let _ = std::fs::remove_dir_all(root);
572 }
573
574 #[test]
575 fn unrepresentable_startup_timeout_is_rejected_without_spawning() {
576 let root = test_root("unrepresentable-timeout");
577 let result = RoutineClient::new(root.clone())
578 .with_startup_timeout(Duration::MAX)
579 .request_with_start(&list(&root), helper_command(&root, "must_not_start"));
580 assert!(matches!(result, Err(RoutineError::Validation(_))));
581 assert!(!root.join("helper.pid").exists());
582 }
583
584 #[test]
585 fn invalid_transport_response_does_not_trigger_a_start() {
586 use std::io::{BufRead, BufReader};
587 use std::os::unix::net::UnixListener;
588
589 let root = test_root("transport");
590 std::fs::create_dir_all(&root).unwrap();
591 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
592 let server = std::thread::spawn(move || {
593 let (mut stream, _) = listener.accept().unwrap();
594 let mut request = String::new();
595 BufReader::new(stream.try_clone().unwrap())
596 .read_line(&mut request)
597 .unwrap();
598 stream.write_all(b"not-json\n").unwrap();
599 });
600 let result = RoutineClient::new(root.clone())
601 .request_with_start(&list(&root), helper_command(&root, "must_not_start"));
602 assert!(matches!(result, Err(RoutineError::Corrupt(_))));
603 server.join().unwrap();
604 assert!(!root.join("helper.pid").exists());
605 let _ = std::fs::remove_dir_all(root);
606 }
607
608 #[test]
609 fn non_daemon_status_response_does_not_trigger_a_start() {
610 use std::io::{BufRead, BufReader};
611 use std::os::unix::net::UnixListener;
612
613 let root = test_root("wrong-status-response");
614 std::fs::create_dir_all(&root).unwrap();
615 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
616 let server = std::thread::spawn(move || {
617 let (mut stream, _) = listener.accept().unwrap();
618 let mut request = String::new();
619 BufReader::new(stream.try_clone().unwrap())
620 .read_line(&mut request)
621 .unwrap();
622 stream
623 .write_all(b"{\"result\":\"ok\",\"revision\":null}\n")
624 .unwrap();
625 });
626 let result = RoutineClient::new(root.clone())
627 .request_with_start(&list(&root), helper_command(&root, "must_not_start"));
628 assert!(matches!(result, Err(RoutineError::Corrupt(_))));
629 server.join().unwrap();
630 assert!(!root.join("helper.pid").exists());
631 let _ = std::fs::remove_dir_all(root);
632 }
633
634 #[test]
635 fn mismatched_status_protocol_does_not_trigger_a_start() {
636 use std::io::{BufRead, BufReader};
637 use std::os::unix::net::UnixListener;
638
639 let root = test_root("wrong-status-protocol");
640 std::fs::create_dir_all(&root).unwrap();
641 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
642 let server = std::thread::spawn(move || {
643 let (mut stream, _) = listener.accept().unwrap();
644 let mut request = String::new();
645 BufReader::new(stream.try_clone().unwrap())
646 .read_line(&mut request)
647 .unwrap();
648 writeln!(
649 stream,
650 "{{\"result\":\"daemon\",\"protocol\":{},\"pid\":1}}",
651 PROTOCOL_VERSION + 1
652 )
653 .unwrap();
654 });
655 let result = RoutineClient::new(root.clone())
656 .request_with_start(&list(&root), helper_command(&root, "must_not_start"));
657 assert!(matches!(
658 result,
659 Err(RoutineError::ProtocolMismatch {
660 client: PROTOCOL_VERSION,
661 daemon,
662 }) if daemon == PROTOCOL_VERSION + 1
663 ));
664 server.join().unwrap();
665 assert!(!root.join("helper.pid").exists());
666 let _ = std::fs::remove_dir_all(root);
667 }
668
669 #[test]
670 fn ambiguous_mutation_disconnect_is_not_retried_or_auto_started() {
671 use std::io::{BufRead, BufReader};
672 use std::os::unix::net::UnixListener;
673
674 let root = test_root("ambiguous-mutation");
675 std::fs::create_dir_all(&root).unwrap();
676 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
677 let server = std::thread::spawn(move || {
678 let (mut probe, _) = listener.accept().unwrap();
679 let mut probe_request = String::new();
680 BufReader::new(probe.try_clone().unwrap())
681 .read_line(&mut probe_request)
682 .unwrap();
683 let probe_request: Request = serde_json::from_str(&probe_request).unwrap();
684 assert!(matches!(probe_request.action, Action::Status));
685 writeln!(
686 probe,
687 "{{\"result\":\"daemon\",\"protocol\":{PROTOCOL_VERSION},\"pid\":1}}"
688 )
689 .unwrap();
690
691 let (mutation, _) = listener.accept().unwrap();
692 let mut mutation_request = String::new();
693 let mut mutation = BufReader::new(mutation);
694 mutation.read_line(&mut mutation_request).unwrap();
695 let mutation_request: Request = serde_json::from_str(&mutation_request).unwrap();
696 assert!(matches!(mutation_request.action, Action::Delete { .. }));
697 mutation
698 .get_ref()
699 .shutdown(std::net::Shutdown::Write)
700 .unwrap();
701 });
702 let request = Request::new(
703 root.clone(),
704 Action::Delete {
705 revision: 1,
706 name: "daily".into(),
707 },
708 );
709 let result = RoutineClient::new(root.clone())
710 .request_with_start(&request, helper_command(&root, "must_not_start"));
711 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
712 server.join().unwrap();
713 assert!(!root.join("helper.pid").exists());
714 let _ = std::fs::remove_dir_all(root);
715 }
716
717 #[test]
718 fn startup_error_handshake_respects_timeout_and_reaps_child() {
719 let root = test_root("error-timeout");
720 std::fs::create_dir_all(&root).unwrap();
721 let pid_path = root.join("helper.pid");
722 let started = Instant::now();
723 let result = RoutineClient::new(root.clone())
724 .with_startup_timeout(Duration::from_millis(300))
725 .request_with_start(&list(&root), helper_command(&root, "error_hang"));
726 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
727 assert!(started.elapsed() < Duration::from_secs(2));
728 let pid: i32 = std::fs::read_to_string(&pid_path).unwrap().parse().unwrap();
729 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
730 assert_eq!(
731 std::io::Error::last_os_error().raw_os_error(),
732 Some(libc::ESRCH)
733 );
734 let _ = std::fs::remove_dir_all(root);
735 }
736
737 #[test]
738 fn invalid_startup_notification_is_corrupt_and_reaps_child() {
739 let root = test_root("invalid-notification");
740 std::fs::create_dir_all(&root).unwrap();
741 let pid_path = root.join("helper.pid");
742 let result = RoutineClient::new(root.clone())
743 .request_with_start(&list(&root), helper_command(&root, "invalid_hang"));
744 assert!(matches!(result, Err(RoutineError::Corrupt(_))));
745 assert_helper_reaped(&pid_path);
746 let _ = std::fs::remove_dir_all(root);
747 }
748
749 #[test]
750 fn early_child_exit_is_unavailable_and_reaped() {
751 let root = test_root("early-exit");
752 std::fs::create_dir_all(&root).unwrap();
753 let pid_path = root.join("helper.pid");
754 let result = RoutineClient::new(root.clone())
755 .request_with_start(&list(&root), helper_command(&root, "exit"));
756 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
757 assert_helper_reaped(&pid_path);
758 let _ = std::fs::remove_dir_all(root);
759 }
760
761 #[test]
762 fn ready_but_unreachable_is_unavailable_and_reaps_child() {
763 let root = test_root("ready-unreachable");
764 std::fs::create_dir_all(&root).unwrap();
765 let pid_path = root.join("helper.pid");
766 let result = RoutineClient::new(root.clone())
767 .with_startup_timeout(Duration::from_millis(300))
768 .request_with_start(&list(&root), helper_command(&root, "ready_hang"));
769 assert!(matches!(result, Err(RoutineError::Unavailable(_))));
770 assert_helper_reaped(&pid_path);
771 let _ = std::fs::remove_dir_all(root);
772 }
773
774 #[test]
775 fn ready_status_read_cannot_exceed_startup_timeout() {
776 let root = test_root("ready-status-hang");
777 std::fs::create_dir_all(&root).unwrap();
778 let pid_path = root.join("helper.pid");
779 let started = Instant::now();
780 let result = RoutineClient::new(root.clone())
781 .with_startup_timeout(Duration::from_millis(300))
782 .request_with_start(&list(&root), helper_command(&root, "ready_status_hang"));
783
784 assert!(matches!(
785 result,
786 Err(RoutineError::Unavailable(message))
787 if message.contains("reported ready but did not accept status requests")
788 ));
789 assert!(started.elapsed() < Duration::from_secs(2));
790 assert_helper_reaped(&pid_path);
791 let _ = std::fs::remove_dir_all(root);
792 }
793
794 #[test]
795 fn ready_with_non_daemon_status_is_corrupt_and_reaps_child() {
796 let root = test_root("ready-wrong-status");
797 std::fs::create_dir_all(&root).unwrap();
798 let pid_path = root.join("helper.pid");
799 let result = RoutineClient::new(root.clone())
800 .request_with_start(&list(&root), helper_command(&root, "ready_wrong_status"));
801 assert!(matches!(result, Err(RoutineError::Corrupt(_))));
802 assert_helper_reaped(&pid_path);
803 let _ = std::fs::remove_dir_all(root);
804 }
805
806 fn assert_helper_reaped(pid_path: &Path) {
807 let pid: i32 = std::fs::read_to_string(pid_path).unwrap().parse().unwrap();
808 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
809 assert_eq!(
810 std::io::Error::last_os_error().raw_os_error(),
811 Some(libc::ESRCH)
812 );
813 }
814
815 #[test]
816 #[ignore = "spawned by RoutineClient lifecycle tests"]
817 fn daemon_helper() {
818 let root = PathBuf::from(std::env::var_os("ASCHED_TEST_ROOT").unwrap());
819 let mode = std::env::var("ASCHED_TEST_MODE").unwrap();
820 if mode == "hang" || mode == "must_not_start" {
821 std::fs::write(root.join("helper.pid"), std::process::id().to_string()).unwrap();
822 std::thread::sleep(Duration::from_secs(30));
823 return;
824 }
825 let fd = std::env::var(STARTUP_FD_ENV)
826 .unwrap()
827 .parse::<i32>()
828 .unwrap();
829 let mut startup = unsafe { std::fs::File::from_raw_fd(fd) };
830 if mode == "ready_wrong_status" {
831 use std::io::{BufRead, BufReader};
832 use std::os::unix::net::UnixListener;
833
834 std::fs::create_dir_all(&root).unwrap();
835 std::fs::write(root.join("helper.pid"), std::process::id().to_string()).unwrap();
836 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
837 startup.write_all(b"ready").unwrap();
838 let (mut stream, _) = listener.accept().unwrap();
839 let mut request = String::new();
840 BufReader::new(stream.try_clone().unwrap())
841 .read_line(&mut request)
842 .unwrap();
843 stream
844 .write_all(b"{\"result\":\"ok\",\"revision\":null}\n")
845 .unwrap();
846 std::thread::sleep(Duration::from_secs(30));
847 return;
848 }
849 if mode == "ready_status_hang" {
850 use std::os::unix::net::UnixListener;
851
852 std::fs::create_dir_all(&root).unwrap();
853 std::fs::write(root.join("helper.pid"), std::process::id().to_string()).unwrap();
854 let listener = UnixListener::bind(root.join("daemon-v1.sock")).unwrap();
855 startup.write_all(b"ready").unwrap();
856 let _connection = listener.accept().unwrap();
857 std::thread::sleep(Duration::from_secs(30));
858 return;
859 }
860 if matches!(mode.as_str(), "invalid_hang" | "exit" | "ready_hang") {
861 std::fs::write(root.join("helper.pid"), std::process::id().to_string()).unwrap();
862 let notification = match mode.as_str() {
863 "invalid_hang" => Some("invalid"),
864 "ready_hang" => Some("ready"),
865 "exit" => None,
866 _ => unreachable!(),
867 };
868 if let Some(notification) = notification {
869 startup.write_all(notification.as_bytes()).unwrap();
870 std::thread::sleep(Duration::from_secs(30));
871 }
872 return;
873 }
874 if mode == "error_hang" {
875 std::fs::write(root.join("helper.pid"), std::process::id().to_string()).unwrap();
876 startup.write_all(b"error:startup failed").unwrap();
877 std::thread::sleep(Duration::from_secs(30));
878 return;
879 }
880 if mode == "daemon_with_pid" {
881 std::fs::create_dir_all(&root).unwrap();
883 std::fs::write(
884 root.join(format!("helper-{}.pid", std::process::id())),
885 std::process::id().to_string(),
886 )
887 .unwrap();
888 }
889 let _ = super::super::daemon::serve_with_startup(root, move |result| {
890 let message = match result {
891 Ok(()) => "ready".to_string(),
892 Err(error) => format!("error:{error}"),
893 };
894 startup.write_all(message.as_bytes()).unwrap();
895 });
896 }
897}