frame_cli/preflight.rs
1//! Pre-boot port-availability guidance for EXPLICITLY-stated addresses,
2//! shared by `frame run` and `frame host`.
3//!
4//! Under the portless ruling (2026-07-22) a frame.toml need state no ports at
5//! all: the page server prefers `127.0.0.1:6010` and walks forward to a free
6//! port, and a synthesized `[bus]`'s internal listeners bind OS-assigned free
7//! ports. Those are chosen at boot and cannot collide, so there is nothing to
8//! probe for them.
9//!
10//! A STATED address is different: it is LAW, the host binds exactly it, and a
11//! taken port is a loud refusal. This module probes each stated socket BEFORE
12//! boot and, on a collision, returns a [`CliError::PortUnavailable`] that names
13//! the socket role, the exact frame.toml key, and the coherence rule to follow
14//! when moving it — an earlier, more actionable surfacing of the same refusal
15//! the host would raise mid-bind.
16//!
17//! A socket counts as stated when its address is present in the loaded config
18//! with a non-zero port: `[frame].bind` when present (the page server), and any
19//! `[bus]` listener whose port is not the OS-assigned `0` sentinel. A fully
20//! portless config therefore probes nothing and this is a clean pass.
21
22use std::net::{SocketAddr, TcpListener};
23
24use frame_host::FrameConfig;
25
26use crate::error::CliError;
27
28/// The coherence rule for the page server's own address: its origin is
29/// echoed into the bus's allow-list, so moving it demands a matching edit
30/// there or the served page is refused by the bus on Origin grounds.
31const PAGE_COHERENCE: &str = "Pick a free port for [frame].bind, and because the page's origin \
32 (http://HOST:PORT) is echoed in [bus.websocket].allowed_origins, change allowed_origins to \
33 match or the served page is Origin-refused by the bus. Keep all four frame.toml ports \
34 distinct: [frame].bind, [bus].listen_address, [bus].health_listen_address, and \
35 [bus.websocket].listen_address. Or omit [frame].bind entirely to let the host prefer 6010 \
36 and walk forward to a free port.";
37
38/// The coherence rule for the three bus sockets: keep all four distinct and,
39/// if the page server itself moves, mirror it into the allow-list.
40const BUS_COHERENCE: &str = "Pick a free port and keep all four frame.toml ports distinct: \
41 [frame].bind, [bus].listen_address, [bus].health_listen_address, and \
42 [bus.websocket].listen_address. If you move [frame].bind, also change \
43 [bus.websocket].allowed_origins to its new origin. Or omit the whole [bus] section to let \
44 the host bind OS-assigned free ports and advertise them via /frame/config.json.";
45
46/// One host socket: its human role, the frame.toml key that states it, the
47/// address it binds, and the coherence rule for moving it.
48struct HostSocket {
49 role: &'static str,
50 key: &'static str,
51 addr: SocketAddr,
52 coherence: &'static str,
53}
54
55/// The EXPLICITLY-stated host sockets in `config`, in bind order. A page
56/// socket appears only when `[frame].bind` is stated; a bus socket appears
57/// only when its port is non-zero (a `0` port is the OS-assigned sentinel of a
58/// synthesized portless bus, chosen at boot and never a collision).
59fn stated_sockets(config: &FrameConfig) -> Vec<HostSocket> {
60 let mut sockets = Vec::new();
61 if let Some(addr) = config.frame.bind {
62 sockets.push(HostSocket {
63 role: "page server",
64 key: "[frame].bind",
65 addr,
66 coherence: PAGE_COHERENCE,
67 });
68 }
69 if config.bus.listen_address.port() != 0 {
70 sockets.push(HostSocket {
71 role: "bus",
72 key: "[bus].listen_address",
73 addr: config.bus.listen_address,
74 coherence: BUS_COHERENCE,
75 });
76 }
77 if config.bus.health_listen_address.port() != 0 {
78 sockets.push(HostSocket {
79 role: "bus health",
80 key: "[bus].health_listen_address",
81 addr: config.bus.health_listen_address,
82 coherence: BUS_COHERENCE,
83 });
84 }
85 if let Some(websocket) = config.bus.websocket.as_ref()
86 && websocket.listen_address.port() != 0
87 {
88 sockets.push(HostSocket {
89 role: "websocket",
90 key: "[bus.websocket].listen_address",
91 addr: websocket.listen_address,
92 coherence: BUS_COHERENCE,
93 });
94 }
95 sockets
96}
97
98/// Probes each explicitly-stated host socket, failing with actionable guidance
99/// the moment one is unavailable — before the host is spawned or booted. A
100/// fully portless config probes nothing and passes cleanly.
101///
102/// # Errors
103///
104/// Returns [`CliError::PortUnavailable`] naming the socket role, the frame.toml
105/// key, the address, and the coherence rule when a stated socket cannot be
106/// bound.
107pub fn ensure_ports_available(config: &FrameConfig) -> Result<(), CliError> {
108 for socket in stated_sockets(config) {
109 // A successful bind proves the port is free; the listener is dropped
110 // immediately so the host can claim it a moment later.
111 match TcpListener::bind(socket.addr) {
112 Ok(listener) => drop(listener),
113 Err(source) => {
114 return Err(CliError::PortUnavailable {
115 role: socket.role,
116 key: socket.key,
117 addr: socket.addr,
118 coherence: socket.coherence,
119 source,
120 });
121 }
122 }
123 }
124 Ok(())
125}