Skip to main content

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}