majordomus_cli/environment/probe.rs
1//! Bounded outside contact: the only two things the environment resolver does that it
2//! cannot finish on its own — run a program, and open a socket — each with a hard budget.
3//!
4//! Both exist because a snapshot runs on a shell prompt. A toolchain shim that fetches a
5//! release on first use, a port held by a process that is wedged rather than dead: either
6//! would turn `cd` into a hang. Nothing here waits longer than it was given, and a probe
7//! that ran out of time reports `unknown` rather than a verdict it did not earn.
8//!
9//! What is deliberately absent: name resolution. A host that is not an IP literal is not
10//! probed at all, so no snapshot can reach DNS, and therefore no snapshot can reach the
11//! network. The lease a Majordomus server publishes always names a loopback address.
12
13use std::net::{IpAddr, SocketAddr, TcpStream};
14use std::process::{Command, Output, Stdio};
15use std::sync::mpsc;
16use std::time::Duration;
17
18use super::ServiceAvailability;
19
20/// Run a command and take its output, or give up after `budget`.
21///
22/// The child is waited for on its own thread so that its pipes are drained while the
23/// caller's clock runs: polling `try_wait` from here instead would deadlock the moment a
24/// child wrote more than a pipe buffer. On expiry the child is killed and `None` comes
25/// back; a caller that cannot say what it wanted must say that, not guess.
26pub fn bounded_output(command: &mut Command, budget: Duration) -> Option<Output> {
27 let child = command
28 .stdin(Stdio::null())
29 .stdout(Stdio::piped())
30 .stderr(Stdio::piped())
31 .spawn()
32 .ok()?;
33 // The child's id, kept here so it can still be signalled after the waiting thread
34 // has taken ownership of the `Child` itself.
35 #[cfg(unix)]
36 let pid = child.id();
37 let (tx, rx) = mpsc::channel();
38 std::thread::spawn(move || {
39 let _ = tx.send(child.wait_with_output());
40 });
41 match rx.recv_timeout(budget) {
42 Ok(Ok(output)) => Some(output),
43 Ok(Err(_)) => None,
44 Err(_) => {
45 // Out of time. The waiting thread owns the child, so the signal goes to the
46 // process directly; the thread then completes and exits on its own.
47 #[cfg(unix)]
48 // SAFETY: `pid` is this process's own child, and SIGKILL to a child is
49 // defined behaviour whether or not it has already been reaped: an already
50 // reaped pid returns ESRCH, which is ignored.
51 unsafe {
52 libc::kill(pid as libc::pid_t, libc::SIGKILL);
53 }
54 None
55 }
56 }
57}
58
59/// Does something accept a connection at `url` within `budget`?
60///
61/// Only the authority of the URL is used, and only when its host is an IP literal:
62/// resolving a name would be a network call, and this runs on a shell prompt. A refused
63/// connection is [`ServiceAvailability::NotRunning`] — the port is free, so nothing is
64/// there. Anything else, expiry included, is [`ServiceAvailability::Unknown`], because a
65/// probe that did not finish has not learnt that a service is down.
66///
67/// ```
68/// use majordomus_cli::environment::probe::authority;
69/// assert_eq!(authority("http://127.0.0.1:8741"), Some(("127.0.0.1".to_string(), 8741)));
70/// assert_eq!(authority("http://127.0.0.1:8741/docs"), Some(("127.0.0.1".to_string(), 8741)));
71/// assert_eq!(authority("http://example.test:80"), Some(("example.test".to_string(), 80)));
72/// assert_eq!(authority("not a url"), None);
73/// ```
74pub fn reachable(url: &str, budget: Duration) -> ServiceAvailability {
75 let Some((host, port)) = authority(url) else {
76 return ServiceAvailability::Unknown;
77 };
78 let Ok(ip) = host.parse::<IpAddr>() else {
79 // A name would need DNS. Nothing here resolves names.
80 return ServiceAvailability::Unknown;
81 };
82 match TcpStream::connect_timeout(&SocketAddr::new(ip, port), budget) {
83 Ok(_) => ServiceAvailability::Available,
84 Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
85 ServiceAvailability::NotRunning
86 }
87 Err(_) => ServiceAvailability::Unknown,
88 }
89}
90
91/// The host and port of an `http://host:port[/path]` URL.
92pub fn authority(url: &str) -> Option<(String, u16)> {
93 let rest = url
94 .strip_prefix("http://")
95 .or(url.strip_prefix("https://"))?;
96 let authority = rest.split(['/', '?', '#']).next()?;
97 let (host, port) = authority.rsplit_once(':')?;
98 let port: u16 = port.parse().ok()?;
99 (!host.is_empty()).then(|| (host.to_string(), port))
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn a_command_that_finishes_gives_its_output() {
108 let out = bounded_output(Command::new("printf").arg("hello"), Duration::from_secs(5))
109 .expect("printf answers");
110 assert_eq!(String::from_utf8_lossy(&out.stdout), "hello");
111 }
112
113 #[test]
114 fn a_command_that_does_not_finish_is_abandoned_within_its_budget() {
115 let started = std::time::Instant::now();
116 let out = bounded_output(Command::new("sleep").arg("30"), Duration::from_millis(150));
117 assert!(out.is_none(), "a command over its budget yields nothing");
118 assert!(
119 started.elapsed() < Duration::from_secs(5),
120 "it returned after {:?}, so the budget did not bound it",
121 started.elapsed()
122 );
123 }
124
125 #[test]
126 fn a_command_that_does_not_exist_is_not_a_panic() {
127 assert!(bounded_output(
128 &mut Command::new("majordomus-no-such-program"),
129 Duration::from_secs(1)
130 )
131 .is_none());
132 }
133
134 #[test]
135 fn a_port_nothing_listens_on_is_not_running_rather_than_unknown() {
136 // Bind and drop, so the port is one the kernel just had free. Between the drop and
137 // the probe another process on a busy machine can take that very port, and then the
138 // probe is right and the assertion is wrong — which is a race in the test, not a
139 // defect in `reachable`. So the attempt is repeated: one clean answer proves the
140 // property, and every attempt racing is what a failure would have to mean.
141 let mut last = None;
142 for _ in 0..8 {
143 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
144 let port = listener.local_addr().expect("an address").port();
145 drop(listener);
146 let url = format!("http://127.0.0.1:{port}");
147 let answer = reachable(&url, Duration::from_millis(200));
148 if answer == ServiceAvailability::NotRunning {
149 return;
150 }
151 last = Some(answer);
152 }
153 panic!("every attempt found something listening on a port just freed: {last:?}");
154 }
155
156 #[test]
157 fn a_port_something_listens_on_is_available() {
158 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
159 let url = format!(
160 "http://127.0.0.1:{}",
161 listener.local_addr().expect("an address").port()
162 );
163 assert_eq!(
164 reachable(&url, Duration::from_millis(500)),
165 ServiceAvailability::Available
166 );
167 }
168
169 /// The offline guarantee, as an assertion: a host that is not an IP literal is never
170 /// looked up, so the resolver cannot reach a name server from a shell prompt.
171 #[test]
172 fn a_host_that_is_not_an_ip_literal_is_never_resolved() {
173 let started = std::time::Instant::now();
174 assert_eq!(
175 reachable("http://majordomus.invalid:80", Duration::from_secs(30)),
176 ServiceAvailability::Unknown
177 );
178 assert!(
179 started.elapsed() < Duration::from_millis(100),
180 "it took {:?}, which is long enough to have asked a resolver",
181 started.elapsed()
182 );
183 }
184
185 #[test]
186 fn a_url_without_a_port_has_no_authority_to_probe() {
187 assert_eq!(authority("http://127.0.0.1"), None);
188 assert_eq!(authority("majordomus://repository"), None);
189 }
190}