Skip to main content

isothrum_runner/
net.rs

1//! The host's ports, behind [`HostPorts`]: whether one is taken, a free one to pin, and whether an
2//! address answers. [`LoopbackPorts`] binds real sockets (ported from the launcher's
3//! `runtime/docker/lifecycle.rs`); the `fake` feature's [`FakePorts`] answers from a test's setup.
4
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpListener, UdpSocket};
6use std::time::Duration;
7
8use isoloom_manifest::Protocol;
9
10use crate::BoxFuture;
11
12/// A free port, held until dropped so it isn't handed out twice while others are picked.
13pub struct HeldPort {
14    /// The port.
15    pub port: u16,
16    _hold: Box<dyn Send + Sync>,
17}
18
19impl std::fmt::Debug for HeldPort {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("HeldPort").field("port", &self.port).finish_non_exhaustive()
22    }
23}
24
25impl HeldPort {
26    /// `port`, held by `hold` (a socket, or nothing for a fake).
27    pub fn new(port: u16, hold: impl Send + Sync + 'static) -> HeldPort {
28        HeldPort { port, _hold: Box::new(hold) }
29    }
30}
31
32/// The host's ports.
33pub trait HostPorts: Send + Sync {
34    /// Whether `port` is taken over `protocol` on the loopback.
35    fn in_use(&self, port: u16, protocol: Protocol) -> bool;
36    /// A free loopback port over `protocol`, held.
37    fn pick_free(&self, protocol: Protocol) -> Option<HeldPort>;
38    /// Whether `host:port` accepts a TCP connection within `within`.
39    fn reachable<'a>(&'a self, host: &'a str, port: u16, within: Duration) -> BoxFuture<'a, bool>;
40}
41
42/// The real host: sockets on the loopback. Never all interfaces: binding 0.0.0.0 made Windows
43/// Firewall ask whether the app may accept connections from the network (launcher #230).
44#[derive(Debug, Clone, Copy, Default)]
45pub struct LoopbackPorts;
46
47impl HostPorts for LoopbackPorts {
48    /// Taken when the IPv4 loopback can't be bound, or the IPv6 one is held (a browser opening
49    /// `localhost` may reach it first); an absent IPv6 loopback doesn't count.
50    fn in_use(&self, port: u16, protocol: Protocol) -> bool {
51        let v4 = bind(Ipv4Addr::LOCALHOST.into(), port, protocol);
52        let v6 = bind(Ipv6Addr::LOCALHOST.into(), port, protocol);
53        v4.is_err() || v6.is_err_and(|e| held_elsewhere(&e))
54    }
55
56    fn pick_free(&self, protocol: Protocol) -> Option<HeldPort> {
57        match protocol {
58            Protocol::Udp => {
59                let s = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
60                Some(HeldPort::new(s.local_addr().ok()?.port(), s))
61            }
62            Protocol::Tcp => {
63                let l = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
64                Some(HeldPort::new(l.local_addr().ok()?.port(), l))
65            }
66        }
67    }
68
69    fn reachable<'a>(&'a self, host: &'a str, port: u16, within: Duration) -> BoxFuture<'a, bool> {
70        Box::pin(async move { matches!(tokio::time::timeout(within, tokio::net::TcpStream::connect((host, port))).await, Ok(Ok(_))) })
71    }
72}
73
74/// Binds (then frees) `ip:port` over `protocol`.
75fn bind(ip: IpAddr, port: u16, protocol: Protocol) -> std::io::Result<()> {
76    match protocol {
77        Protocol::Udp => UdpSocket::bind((ip, port)).map(drop),
78        Protocol::Tcp => TcpListener::bind((ip, port)).map(drop),
79    }
80}
81
82/// Whether a failed bind means someone holds the port (or the OS reserved it, as Windows does
83/// for WinNAT and Hyper-V), not that the address doesn't exist here.
84fn held_elsewhere(e: &std::io::Error) -> bool {
85    matches!(e.kind(), std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied)
86}
87
88/// Ports answered from a test's setup (feature `fake`): taken ones, the free ones handed out in
89/// order, and the addresses that answer.
90#[cfg(any(test, feature = "fake"))]
91#[derive(Debug, Default)]
92pub struct FakePorts {
93    taken: std::sync::Mutex<std::collections::BTreeSet<(u16, Protocol)>>,
94    free: std::sync::Mutex<std::collections::VecDeque<u16>>,
95    answering: std::sync::Mutex<std::collections::BTreeSet<(String, u16)>>,
96}
97
98#[cfg(any(test, feature = "fake"))]
99impl FakePorts {
100    /// No port taken; `free` handed out in order.
101    pub fn new(free: impl IntoIterator<Item = u16>) -> FakePorts {
102        FakePorts {
103            free: std::sync::Mutex::new(free.into_iter().collect()),
104            ..FakePorts::default()
105        }
106    }
107
108    /// Marks `port` taken over `protocol`.
109    pub fn take(&self, port: u16, protocol: Protocol) {
110        self.taken.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((port, protocol));
111    }
112
113    /// Makes `host:port` answer.
114    pub fn answer(&self, host: &str, port: u16) {
115        self.answering
116            .lock()
117            .unwrap_or_else(std::sync::PoisonError::into_inner)
118            .insert((host.to_owned(), port));
119    }
120}
121
122#[cfg(any(test, feature = "fake"))]
123impl HostPorts for FakePorts {
124    fn in_use(&self, port: u16, protocol: Protocol) -> bool {
125        self.taken.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(port, protocol))
126    }
127
128    fn pick_free(&self, _protocol: Protocol) -> Option<HeldPort> {
129        self.free
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner)
132            .pop_front()
133            .map(|p| HeldPort::new(p, ()))
134    }
135
136    fn reachable<'a>(&'a self, host: &'a str, port: u16, within: Duration) -> BoxFuture<'a, bool> {
137        let up = self
138            .answering
139            .lock()
140            .unwrap_or_else(std::sync::PoisonError::into_inner)
141            .contains(&(host.to_owned(), port));
142        Box::pin(async move {
143            if !up {
144                tokio::time::sleep(within).await;
145            }
146            up
147        })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// A port free over both TCP and UDP, below every OS's ephemeral range, so nothing else is
156    /// handed it while the test runs.
157    fn port_free_over_both() -> u16 {
158        let start = 20_000 + u16::try_from(std::process::id() % 10_000).unwrap();
159        (start..32_768)
160            .chain(20_000..start)
161            .find(|&p| !LoopbackPorts.in_use(p, Protocol::Tcp) && !LoopbackPorts.in_use(p, Protocol::Udp))
162            .unwrap()
163    }
164
165    #[test]
166    fn a_held_port_is_in_use_over_its_protocol_only() {
167        let tcp_port = port_free_over_both();
168        let _tcp = TcpListener::bind(("127.0.0.1", tcp_port)).unwrap();
169        assert!(LoopbackPorts.in_use(tcp_port, Protocol::Tcp));
170        assert!(!LoopbackPorts.in_use(tcp_port, Protocol::Udp));
171        let udp_port = port_free_over_both();
172        let _udp = UdpSocket::bind(("127.0.0.1", udp_port)).unwrap();
173        assert!(LoopbackPorts.in_use(udp_port, Protocol::Udp), "a UDP holder (dnsmasq on 53) is caught");
174        assert!(!LoopbackPorts.in_use(udp_port, Protocol::Tcp));
175    }
176
177    #[test]
178    fn only_a_held_or_reserved_address_counts_on_the_ipv6_loopback() {
179        use std::io::{Error, ErrorKind};
180        assert!(held_elsewhere(&Error::from(ErrorKind::AddrInUse)));
181        assert!(held_elsewhere(&Error::from(ErrorKind::PermissionDenied)));
182        assert!(!held_elsewhere(&Error::from(ErrorKind::AddrNotAvailable)), "no IPv6 loopback here");
183    }
184
185    #[test]
186    fn picked_ports_are_held_until_dropped() {
187        let a = LoopbackPorts.pick_free(Protocol::Tcp).unwrap();
188        let b = LoopbackPorts.pick_free(Protocol::Tcp).unwrap();
189        assert_ne!(a.port, b.port);
190        assert!(LoopbackPorts.in_use(a.port, Protocol::Tcp));
191    }
192
193    #[tokio::test]
194    async fn a_listening_address_is_reachable() {
195        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
196        let port = listener.local_addr().unwrap().port();
197        assert!(LoopbackPorts.reachable("127.0.0.1", port, Duration::from_secs(2)).await);
198        drop(listener);
199        assert!(!LoopbackPorts.reachable("127.0.0.1", port, Duration::from_secs(2)).await);
200    }
201}