1use std::io::{BufRead, BufReader, Read, Write};
18use std::net::{SocketAddr, TcpListener, TcpStream};
19use std::sync::Arc;
20
21use crate::broker::broker_http_port::{BrokerHttpPort, ResolvedHttpBind};
22use crate::broker::http_endpoint_registry::HttpEndpointRegistry;
23
24#[derive(Debug, thiserror::Error)]
26pub enum BrokerHttpServerError {
27 #[error("bind {addr}:{port} failed: {source}")]
29 Bind {
30 addr: std::net::IpAddr,
32 port: u16,
34 #[source]
36 source: std::io::Error,
37 },
38}
39
40pub struct BrokerHttpServer {
43 listener: TcpListener,
44 local: SocketAddr,
45 registry: Arc<HttpEndpointRegistry>,
46}
47
48impl BrokerHttpServer {
49 pub fn bind(
58 config: BrokerHttpPort,
59 registry: Arc<HttpEndpointRegistry>,
60 ) -> Result<Self, BrokerHttpServerError> {
61 let resolved = BrokerHttpPort::resolve(config);
62 let listener = match resolved.port {
63 BrokerHttpPort::Static { port } => try_bind(resolved, port)?,
64 BrokerHttpPort::Dynamic => try_bind(resolved, 0)?,
65 BrokerHttpPort::StaticOrFallback { preferred } => match try_bind(resolved, preferred) {
66 Ok(l) => l,
67 Err(BrokerHttpServerError::Bind { source, .. })
68 if source.kind() == std::io::ErrorKind::AddrInUse =>
69 {
70 try_bind(resolved, 0)?
71 }
72 Err(other) => return Err(other),
73 },
74 };
75 let local = listener
76 .local_addr()
77 .map_err(|source| BrokerHttpServerError::Bind {
78 addr: resolved.addr,
79 port: 0,
80 source,
81 })?;
82 Ok(Self {
83 listener,
84 local,
85 registry,
86 })
87 }
88
89 pub fn local_addr(&self) -> SocketAddr {
93 self.local
94 }
95
96 pub fn serve_once(&self) -> std::io::Result<()> {
99 let (stream, _peer) = self.listener.accept()?;
100 handle_one(stream, &self.registry)
101 }
102}
103
104fn try_bind(resolved: ResolvedHttpBind, port: u16) -> Result<TcpListener, BrokerHttpServerError> {
105 let bind_addr = SocketAddr::new(resolved.addr, port);
106 TcpListener::bind(bind_addr).map_err(|source| BrokerHttpServerError::Bind {
107 addr: resolved.addr,
108 port,
109 source,
110 })
111}
112
113fn handle_one(mut stream: TcpStream, registry: &HttpEndpointRegistry) -> std::io::Result<()> {
114 let mut reader = BufReader::new(stream.try_clone()?);
117 let mut request_line = String::new();
118 let _ = reader.read_line(&mut request_line);
119 let mut headers_done = false;
120 while !headers_done {
121 let mut buf = [0u8; 1];
122 if reader.read(&mut buf)? == 0 {
123 break;
124 }
125 if buf[0] == b'\r' {
126 let mut peek = [0u8; 3];
127 let n = reader.read(&mut peek)?;
128 if n >= 3 && peek == [b'\n', b'\r', b'\n'] {
129 headers_done = true;
130 }
131 }
132 }
135
136 let path = request_line
137 .split_whitespace()
138 .nth(1)
139 .unwrap_or("/")
140 .to_string();
141
142 let (status_line, content_type, body) =
143 if request_line.starts_with("GET ") && (path == "/" || path.is_empty()) {
144 (
145 "HTTP/1.1 200 OK",
146 "text/html; charset=utf-8",
147 render_aggregator_page(registry),
148 )
149 } else {
150 (
151 "HTTP/1.1 404 Not Found",
152 "text/plain; charset=utf-8",
153 "not found\n".to_string(),
154 )
155 };
156
157 let response = format!(
158 "{status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
159 body.len(),
160 body,
161 );
162 stream.write_all(response.as_bytes())?;
163 stream.flush()?;
164 Ok(())
165}
166
167fn render_aggregator_page(registry: &HttpEndpointRegistry) -> String {
178 let mut snap = registry.snapshot();
179 snap.sort_by(|a, b| a.0.cmp(&b.0));
180
181 let mut buttons = String::new();
182 let mut initial_src = String::new();
183 if snap.is_empty() {
184 buttons.push_str(r#"<span class="empty">no backends registered yet</span>"#);
185 } else {
186 for (id, port) in &snap {
187 match port {
188 Some(p) => {
189 let url = format!("http://127.0.0.1:{p}/");
190 if initial_src.is_empty() {
191 initial_src.clone_from(&url);
192 }
193 buttons.push_str(&format!(
194 r#"<button onclick="document.getElementById('agg').src={url:?}">{}</button>"#,
195 html_escape(id),
196 ));
197 }
198 None => {
199 buttons.push_str(&format!(
200 r#"<button disabled title="backend has not reported a port yet">{} (starting…)</button>"#,
201 html_escape(id),
202 ));
203 }
204 }
205 }
206 }
207
208 let initial_src_attr = if initial_src.is_empty() {
209 "about:blank".to_string()
210 } else {
211 initial_src
212 };
213
214 format!(
215 r#"<!doctype html>
216<html lang="en">
217<head>
218<meta charset="utf-8">
219<title>running-process broker-v2 aggregator</title>
220<style>
221 html, body {{ margin: 0; padding: 0; height: 100%; font-family: system-ui, sans-serif; }}
222 #bar {{ display: flex; gap: 0.4rem; padding: 0.4rem; border-bottom: 1px solid #ccc; background: #f5f5f5; }}
223 #bar button {{ padding: 0.3rem 0.8rem; }}
224 #agg {{ width: 100%; height: calc(100% - 3rem); border: 0; }}
225 .empty {{ color: #888; font-style: italic; }}
226</style>
227</head>
228<body>
229<nav id="bar">{buttons}</nav>
230<iframe id="agg" src="{initial_src_attr}"></iframe>
231</body>
232</html>
233"#
234 )
235}
236
237fn html_escape(s: &str) -> String {
238 let mut out = String::with_capacity(s.len());
239 for c in s.chars() {
240 match c {
241 '<' => out.push_str("<"),
242 '>' => out.push_str(">"),
243 '&' => out.push_str("&"),
244 '"' => out.push_str("""),
245 '\'' => out.push_str("'"),
246 _ => out.push(c),
247 }
248 }
249 out
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use std::sync::Arc;
256 use std::thread;
257 use std::time::Duration;
258
259 fn make_server() -> BrokerHttpServer {
260 let reg = Arc::new(HttpEndpointRegistry::new());
261 reg.track("zccache".to_string());
262 reg.register_backend_http_endpoint("fbuild".to_string(), 8002);
263 BrokerHttpServer::bind(BrokerHttpPort::Dynamic, reg).expect("dynamic bind succeeds")
264 }
265
266 #[test]
267 fn dynamic_bind_yields_nonzero_port() {
268 let s = make_server();
269 let addr = s.local_addr();
270 assert_ne!(addr.port(), 0, "OS should have assigned a real port");
271 }
272
273 #[test]
274 fn placeholder_renders_registered_backends() {
275 let s = make_server();
276 let local = s.local_addr();
277 let handle = thread::spawn(move || {
278 s.serve_once().expect("serve_once succeeds");
279 });
280 let mut client = TcpStream::connect(local).expect("connect");
282 client
283 .write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")
284 .expect("write request");
285 client
286 .set_read_timeout(Some(Duration::from_secs(30)))
294 .expect("set_read_timeout");
295 let mut buf = String::new();
296 client.read_to_string(&mut buf).expect("read response");
297
298 assert!(
299 buf.contains("200 OK"),
300 "expected 200 OK in response, got:\n{buf}"
301 );
302 assert!(
303 buf.contains("text/html"),
304 "expected HTML content-type, got:\n{buf}"
305 );
306 assert!(
307 buf.contains("<iframe id=\"agg\""),
308 "expected aggregator iframe element, got:\n{buf}"
309 );
310 assert!(
311 buf.contains("http://127.0.0.1:8002/"),
312 "expected fbuild URL wired into selector, got:\n{buf}"
313 );
314 assert!(
315 buf.contains("zccache (starting"),
316 "expected zccache pending-state button, got:\n{buf}"
317 );
318 assert!(
319 buf.contains("src=\"http://127.0.0.1:8002/\""),
320 "expected fbuild URL as initial iframe src, got:\n{buf}"
321 );
322
323 handle.join().expect("server thread joins");
324 }
325
326 #[test]
327 fn aggregator_page_with_no_backends_shows_empty_state() {
328 let reg = Arc::new(HttpEndpointRegistry::new());
329 let s =
330 BrokerHttpServer::bind(BrokerHttpPort::Dynamic, reg).expect("dynamic bind succeeds");
331 let local = s.local_addr();
332 let handle = thread::spawn(move || {
333 s.serve_once().expect("serve_once succeeds");
334 });
335 let mut client = TcpStream::connect(local).expect("connect");
336 client
337 .write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")
338 .expect("write request");
339 client
340 .set_read_timeout(Some(Duration::from_secs(30)))
348 .expect("set_read_timeout");
349 let mut buf = String::new();
350 client.read_to_string(&mut buf).expect("read response");
351
352 assert!(buf.contains("no backends registered yet"), "got:\n{buf}");
353 assert!(
354 buf.contains("src=\"about:blank\""),
355 "empty selector should default the iframe to about:blank, got:\n{buf}"
356 );
357 handle.join().expect("server thread joins");
358 }
359
360 #[test]
361 fn static_or_fallback_falls_back_on_eaddrinuse() {
362 let blocker = TcpListener::bind("127.0.0.1:0").expect("blocker bind");
364 let preferred = blocker.local_addr().expect("blocker addr").port();
365
366 let reg = Arc::new(HttpEndpointRegistry::new());
367 let s = BrokerHttpServer::bind(BrokerHttpPort::StaticOrFallback { preferred }, reg)
368 .expect("StaticOrFallback should fall back to OS-allocated");
369 let fallback_port = s.local_addr().port();
370 assert_ne!(
371 fallback_port, preferred,
372 "StaticOrFallback should have picked a different port"
373 );
374 assert_ne!(fallback_port, 0, "OS should have assigned a real port");
375 drop(blocker);
376 }
377}