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}