Skip to main content

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}