Skip to main content

running_process/broker/
broker_http_server.rs

1//! Broker HTTP server scaffold (slice 7 of #488).
2//!
3//! Tiny single-threaded HTTP/1.1 server using only `std::net::TcpListener`
4//! — no hyper/axum dep yet, just enough to bind a port, accept a request,
5//! and respond with a placeholder page that lists the currently-registered
6//! backends from [`super::http_endpoint_registry::HttpEndpointRegistry`].
7//!
8//! Honors the resolved bind state from
9//! [`super::broker_http_port::BrokerHttpPort::resolve`]: the port is one of
10//! `Static`, `Dynamic`, or `StaticOrFallback`; the address comes from the
11//! env override or defaults to `127.0.0.1`.
12//!
13//! The aggregator iframe page lands in slice 8. This slice produces only a
14//! plain-text list so consumers can verify the server is reachable and the
15//! registry is wired correctly.
16
17use 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/// Errors raised by `BrokerHttpServer::bind`.
25#[derive(Debug, thiserror::Error)]
26pub enum BrokerHttpServerError {
27    /// `bind(addr:port)` failed and we have no fallback to fall back to.
28    #[error("bind {addr}:{port} failed: {source}")]
29    Bind {
30        /// IP we tried to bind on.
31        addr: std::net::IpAddr,
32        /// Port we tried to bind on.
33        port: u16,
34        /// Underlying IO error.
35        #[source]
36        source: std::io::Error,
37    },
38}
39
40/// A bound but not-yet-serving HTTP listener. Caller decides whether to
41/// drive `serve_once` in a blocking thread, behind tokio, etc.
42pub struct BrokerHttpServer {
43    listener: TcpListener,
44    local: SocketAddr,
45    registry: Arc<HttpEndpointRegistry>,
46}
47
48impl BrokerHttpServer {
49    /// Resolve the [`BrokerHttpPort`] config + env, then bind a
50    /// `TcpListener` on the resulting address.
51    ///
52    /// Behavior per #483 §3:
53    /// - `Static`: bind exactly that port; bubble up the bind error.
54    /// - `Dynamic`: bind to `port=0` (OS-allocated).
55    /// - `StaticOrFallback`: try the preferred port; on EADDRINUSE
56    ///   retry with `port=0`.
57    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    /// The actual bound `SocketAddr` (post-resolution). Use this to
90    /// populate `GetBrokerHttpEndpointResponse.port` and the runtime-file
91    /// shape (slice 9 plumbs the resolved address through).
92    pub fn local_addr(&self) -> SocketAddr {
93        self.local
94    }
95
96    /// Accept ONE connection and respond with the placeholder page,
97    /// then return. Intended for tests + the future slice-7 serve loop.
98    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    // Minimal HTTP/1.1: read until "\r\n\r\n", grab the request line,
115    // route GET / to the placeholder page, fall through to 404.
116    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        // The placeholder server does not consume request bodies; we
133        // assume the client is a no-body GET.
134    }
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
167/// Render the aggregator page (slice 8 of #488): top-bar selector +
168/// full-bleed iframe. The selector emits one button per registered
169/// backend; clicking flips the iframe's `src` to that backend's HTTP
170/// root. Backends whose registry slot is `None` render as disabled
171/// buttons with `(starting…)` text — they don't accidentally try to
172/// load a URL the broker doesn't have yet.
173///
174/// The page is a single self-contained document: no external CSS,
175/// no external JS, no fonts. Keeps it loadable on locked-down
176/// operator boxes and trivially auditable.
177fn 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("&lt;"),
242            '>' => out.push_str("&gt;"),
243            '&' => out.push_str("&amp;"),
244            '"' => out.push_str("&quot;"),
245            '\'' => out.push_str("&#39;"),
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        // Hit the server with a minimal HTTP GET.
281        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            // A harness bound on how long to wait for the answer, not the
287            // property under test — that is the page content asserted below.
288            // Two seconds is enough on an idle machine and not enough on a
289            // busy one: the suite runs these in parallel, and a test that
290            // spawns processes elsewhere can push this past the limit. A
291            // response that is merely slow is not a defect, so the bound is
292            // generous; a server that never answers still fails.
293            .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            // A harness bound on how long to wait for the answer, not the
341            // property under test — that is the page content asserted below.
342            // Two seconds is enough on an idle machine and not enough on a
343            // busy one: the suite runs these in parallel, and a test that
344            // spawns processes elsewhere can push this past the limit. A
345            // response that is merely slow is not a defect, so the bound is
346            // generous; a server that never answers still fails.
347            .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        // Bind a sacrificial listener to force EADDRINUSE on its port.
363        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}