Skip to main content

ferrox_api/
lifecycle.rs

1//! The process-ready handshake: one machine-readable line on the
2//! server's stdout, naming the address it actually bound and the pid
3//! that owns it.
4//!
5//! This is what makes `--port 0` usable, and `--port 0` is what deletes
6//! an entire class of feature from the desktop shell. A supervisor that
7//! must pick the port itself needs to know whether the port is free,
8//! which needs an "is something already listening" probe, which needs
9//! "who owns it" to tell a stale copy of ourselves from a stranger's
10//! server, which needs a platform-specific `lsof`/`netstat` shell-out
11//! and a dialog to explain the result. Letting the kernel pick the port
12//! and having the child *say* what it got replaces all of it with one
13//! line of JSON.
14//!
15//! One line, JSON, on stdout, prefixed by nothing: parsers should read
16//! stdout line by line and ignore any line that is not JSON carrying
17//! `event == `[`READY_EVENT`], since tracing/log output shares the
18//! stream on some configurations.
19
20use serde::{Deserialize, Serialize};
21
22/// The `event` discriminator of the ready line. Present so a parser can
23/// tell this line from any other JSON a future version might print.
24pub const READY_EVENT: &str = "ferrox.server.ready";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ServerReady {
28    /// Always [`READY_EVENT`].
29    pub event: String,
30    /// `host:port` as actually bound -- never the requested value, which
31    /// may have been port 0.
32    pub addr: String,
33    /// The bound port, split out so a caller does not have to parse
34    /// `addr` (IPv6 literals make that its own small mistake).
35    pub port: u16,
36    /// `http` or `https`, so a client can build a base URL without
37    /// guessing whether TLS was configured.
38    pub scheme: String,
39    pub pid: u32,
40    pub version: String,
41}
42
43impl ServerReady {
44    pub fn new(addr: std::net::SocketAddr, scheme: &str, version: &str, pid: u32) -> Self {
45        ServerReady {
46            event: READY_EVENT.to_string(),
47            addr: addr.to_string(),
48            port: addr.port(),
49            scheme: scheme.to_string(),
50            pid,
51            version: version.to_string(),
52        }
53    }
54
55    /// The exact bytes to print (no trailing newline).
56    pub fn to_line(&self) -> String {
57        serde_json::to_string(self).expect("ServerReady is plain data and cannot fail to serialize")
58    }
59
60    /// Parses one line of a child's stdout. `None` for anything that is
61    /// not a ready line, so a caller can feed it every line it reads.
62    pub fn from_line(line: &str) -> Option<Self> {
63        let parsed: ServerReady = serde_json::from_str(line.trim()).ok()?;
64        (parsed.event == READY_EVENT).then_some(parsed)
65    }
66
67    /// Base URL for API calls against this server.
68    pub fn base_url(&self) -> String {
69        format!("{}://{}", self.scheme, self.addr)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
77
78    fn ready(port: u16) -> ServerReady {
79        ServerReady::new(
80            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
81            "http",
82            "0.5.0",
83            4242,
84        )
85    }
86
87    #[test]
88    fn round_trips_through_one_stdout_line() {
89        let line = ready(51234).to_line();
90        assert!(!line.contains('\n'), "the ready line must be a single line");
91        assert_eq!(ServerReady::from_line(&line), Some(ready(51234)));
92    }
93
94    #[test]
95    fn ignores_lines_that_are_not_the_ready_event() {
96        assert!(ServerReady::from_line("2026-08-14 INFO listening").is_none());
97        assert!(ServerReady::from_line("{\"event\":\"something.else\"}").is_none());
98        assert!(ServerReady::from_line("").is_none());
99    }
100
101    #[test]
102    fn port_survives_an_ipv6_address_without_parsing_addr() {
103        // The reason `port` is its own field: splitting an IPv6 `addr`
104        // on ':' finds the wrong colon.
105        let ready = ServerReady::new(
106            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8383),
107            "https",
108            "0.5.0",
109            7,
110        );
111        assert_eq!(ready.port, 8383);
112        assert_eq!(ready.base_url(), "https://[::1]:8383");
113    }
114}