1use std::collections::{BTreeMap, BTreeSet};
15use std::io::{Read, Write};
16use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant};
19
20const PROBE_TIMEOUT: Duration = Duration::from_millis(300);
23
24pub const SLOW_NOTE_AFTER: Duration = Duration::from_secs(30);
27
28pub fn probe(host: &str, port: u16) -> bool {
33 let Some(mut stream) = resolve(host, port)
36 .into_iter()
37 .find_map(|addr| TcpStream::connect_timeout(&addr, PROBE_TIMEOUT).ok())
38 else {
39 return false;
40 };
41 let _ = stream.set_read_timeout(Some(PROBE_TIMEOUT));
42 let _ = stream.set_write_timeout(Some(PROBE_TIMEOUT));
43 let request = format!("HEAD / HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n");
44 if stream.write_all(request.as_bytes()).is_err() {
45 return false;
46 }
47 let mut head = [0u8; 5];
48 let mut read = 0;
49 while read < head.len() {
50 match stream.read(&mut head[read..]) {
51 Ok(0) | Err(_) => return false,
52 Ok(n) => read += n,
53 }
54 }
55 &head == b"HTTP/"
56}
57
58fn resolve(host: &str, port: u16) -> Vec<SocketAddr> {
59 (host, port)
60 .to_socket_addrs()
61 .map(Iterator::collect)
62 .unwrap_or_default()
63}
64
65pub fn unprobeable(args: &[String]) -> Option<String> {
69 let value = |flag: &str| {
70 args.iter().enumerate().find_map(|(i, a)| {
71 a.strip_prefix(&format!("{flag}="))
72 .map(str::to_string)
73 .or_else(|| (a == flag).then(|| args.get(i + 1).cloned()).flatten())
74 })
75 };
76 if value("--local-protocol").as_deref() == Some("https") {
77 return Some("`-- --local-protocol https` serves over TLS".to_string());
78 }
79 match value("--ip") {
80 Some(ip)
81 if !matches!(
82 ip.as_str(),
83 "localhost" | "127.0.0.1" | "::1" | "0.0.0.0" | "*"
84 ) =>
85 {
86 Some(format!("`-- --ip {ip}` serves away from localhost"))
87 }
88 _ => None,
89 }
90}
91
92pub fn ready_json(urls: &BTreeMap<String, String>) -> String {
94 serde_json::to_string_pretty(urls).expect("a string map serialises") + "\n"
95}
96
97pub fn write_atomically(path: &Path, contents: &str) -> std::io::Result<()> {
100 let mut tmp = path.as_os_str().to_owned();
101 tmp.push(".tmp");
102 let tmp = PathBuf::from(tmp);
103 std::fs::write(&tmp, contents)?;
104 std::fs::rename(&tmp, path)
105}
106
107pub fn remove(path: &Path) {
109 let _ = std::fs::remove_file(path);
110}
111
112#[derive(Debug)]
115pub struct Readiness {
116 file: PathBuf,
117 workers: BTreeMap<String, (u16, String)>,
119 answered: BTreeSet<String>,
121 written: bool,
122 started: Instant,
123 noted_slow: bool,
124}
125
126impl Readiness {
127 pub fn new(file: PathBuf, contexts: impl IntoIterator<Item = (String, u16, String)>) -> Self {
130 remove(&file);
131 Readiness {
132 file,
133 workers: contexts
134 .into_iter()
135 .map(|(name, port, url)| (name, (port, url)))
136 .collect(),
137 answered: BTreeSet::new(),
138 written: false,
139 started: Instant::now(),
140 noted_slow: false,
141 }
142 }
143
144 pub fn poll(&mut self, probe: impl Fn(&str, u16) -> bool) -> Option<std::io::Result<()>> {
151 if self.written {
152 return None;
153 }
154 for (name, (port, _)) in &self.workers {
155 if !self.answered.contains(name) && probe("localhost", *port) {
156 self.answered.insert(name.clone());
157 }
158 }
159 if self.answered.len() < self.workers.len() {
160 return None;
161 }
162 self.written = true;
163 let urls: BTreeMap<String, String> = self
164 .workers
165 .iter()
166 .map(|(name, (_, url))| (name.clone(), url.clone()))
167 .collect();
168 Some(write_atomically(&self.file, &ready_json(&urls)))
169 }
170
171 pub fn slow_note(&mut self, now: Instant) -> Option<String> {
176 if self.written || self.noted_slow || now.duration_since(self.started) < SLOW_NOTE_AFTER {
177 return None;
178 }
179 self.noted_slow = true;
180 let pending: Vec<String> = self
181 .workers
182 .iter()
183 .filter(|(name, _)| !self.answered.contains(*name))
184 .map(|(name, (_, url))| format!("`{name}` ({url})"))
185 .collect();
186 Some(format!(
187 "bynk dev: still waiting for {} to answer before writing the `--ready-json` file; it's written only once every context answers a plain-HTTP probe on localhost",
188 pending.join(", ")
189 ))
190 }
191
192 pub fn stop(&self) {
194 remove(&self.file);
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use std::net::TcpListener;
202
203 fn stand_in(reply: &'static [u8]) -> u16 {
205 let listener = TcpListener::bind("127.0.0.1:0").expect("bind a local port");
206 let port = listener.local_addr().unwrap().port();
207 std::thread::spawn(move || {
208 for stream in listener.incoming() {
209 let Ok(mut stream) = stream else { return };
210 let mut buf = [0u8; 512];
211 let _ = stream.read(&mut buf);
212 let _ = stream.write_all(reply);
213 }
214 });
215 port
216 }
217
218 fn free_port() -> u16 {
219 TcpListener::bind("127.0.0.1:0")
221 .unwrap()
222 .local_addr()
223 .unwrap()
224 .port()
225 }
226
227 #[test]
228 fn tls_and_non_loopback_passthroughs_are_flagged() {
229 let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
230 assert!(unprobeable(&args(&["--local-protocol", "https"])).is_some());
231 assert!(unprobeable(&args(&["--local-protocol=https"])).is_some());
232 assert!(unprobeable(&args(&["--ip", "192.168.1.5"])).is_some());
233 assert!(unprobeable(&args(&["--ip", "127.0.0.1"])).is_none());
234 assert!(unprobeable(&args(&["--local-protocol", "http", "--var", "K:V"])).is_none());
235 assert!(unprobeable(&[]).is_none());
236 }
237
238 #[test]
239 fn a_404_is_an_answer() {
240 let port = stand_in(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n");
241 assert!(probe("127.0.0.1", port));
242 }
243
244 #[test]
245 fn nothing_listening_is_no_answer() {
246 assert!(!probe("127.0.0.1", free_port()));
247 }
248
249 #[test]
250 fn a_listener_that_never_replies_is_no_answer() {
251 let listener = TcpListener::bind("127.0.0.1:0").expect("bind a local port");
252 let port = listener.local_addr().unwrap().port();
253 std::thread::spawn(move || {
255 let held: Vec<_> = listener.incoming().take(4).collect();
256 std::thread::sleep(Duration::from_secs(5));
257 drop(held);
258 });
259 let started = Instant::now();
260 assert!(!probe("127.0.0.1", port));
261 assert!(
262 started.elapsed() < Duration::from_secs(3),
263 "the probe gives up at its timeout"
264 );
265 }
266
267 #[test]
268 fn only_unanswered_workers_are_probed_and_a_slow_session_is_noted_once() {
269 let dir = std::env::temp_dir().join(format!("bynk-ready-slow-{}", std::process::id()));
270 std::fs::create_dir_all(&dir).unwrap();
271 let mut r = Readiness::new(
272 dir.join("ready.json"),
273 [
274 ("a".to_string(), 1, "http://localhost:1".to_string()),
275 ("b".to_string(), 2, "http://localhost:2".to_string()),
276 ],
277 );
278 let probed = std::cell::RefCell::new(Vec::new());
279 let probe = |_: &str, port: u16| {
280 probed.borrow_mut().push(port);
281 port == 1
282 };
283 assert!(r.poll(probe).is_none());
284 assert!(r.poll(probe).is_none());
285 assert_eq!(
286 *probed.borrow(),
287 [1, 2, 2],
288 "`a` is probed until it answers, not after"
289 );
290 assert!(r.slow_note(Instant::now()).is_none(), "not yet");
291 let later = Instant::now() + SLOW_NOTE_AFTER;
292 let note = r.slow_note(later).expect("noted after the delay");
293 assert!(
294 note.contains("`b` (http://localhost:2)") && !note.contains("`a`"),
295 "{note}"
296 );
297 assert!(r.slow_note(later).is_none(), "noted once");
298 let _ = std::fs::remove_dir_all(&dir);
299 }
300
301 #[test]
302 fn a_non_http_reply_is_no_answer() {
303 let port = stand_in(b"SSH-2.0-nope\r\n");
304 assert!(!probe("127.0.0.1", port));
305 }
306
307 #[test]
308 fn the_file_is_written_once_every_worker_answers_and_removed_on_stop() {
309 let dir = std::env::temp_dir().join(format!("bynk-ready-{}", std::process::id()));
310 std::fs::create_dir_all(&dir).unwrap();
311 let file = dir.join("ready.json");
312 std::fs::write(&file, "stale").unwrap();
313 let mut r = Readiness::new(
314 file.clone(),
315 [
316 ("a".to_string(), 1, "http://localhost:1".to_string()),
317 ("b".to_string(), 2, "http://localhost:2".to_string()),
318 ],
319 );
320 assert!(!file.exists(), "a stale file is removed at start");
321 assert!(r.poll(|_, port| port == 1).is_none());
322 assert!(
323 !file.exists(),
324 "not written while a worker is still booting"
325 );
326 assert!(r.poll(|_, _| true).expect("written now").is_ok());
327 assert!(r.poll(|_, _| true).is_none(), "written once");
328 let written: BTreeMap<String, String> =
329 serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
330 assert_eq!(written["a"], "http://localhost:1");
331 assert_eq!(written["b"], "http://localhost:2");
332 r.stop();
333 assert!(!file.exists(), "removed when the session stops");
334 let _ = std::fs::remove_dir_all(&dir);
335 }
336}