frame_host/page.rs
1//! Page-server port resolution (2026-07-22 portless ruling).
2//!
3//! The page server's address obeys one of two contracts, chosen by whether
4//! `[frame].bind` is stated:
5//!
6//! - **Stated → LAW.** The listener binds exactly `[frame].bind`. A taken port
7//! is a LOUD failure ([`HostError::PageServerUnavailable`]) naming the socket
8//! role, the `frame.toml` key, and the coherence rule — never a silent move
9//! to some other port. This is what the live fixtures and production use.
10//! - **Absent → prefer-and-walk.** The listener prefers `127.0.0.1:6010` and
11//! walks forward to the next free, Fetch-safe port, so two scaffolded apps
12//! boot side by side with zero config edits. The former preference, 4190, is
13//! blocklisted by the WHATWG Fetch Standard; keep that history explicit so
14//! the default is never moved back to a port browsers refuse to fetch. The
15//! chosen URL is printed loudly on boot.
16//!
17//! Either way the resolved listener is BOUND and held here — never probed and
18//! dropped — so the port cannot be stolen between resolution and serving
19//! (there is no bind/rebind race). [`crate::server::ShellServer::adopt`] adopts
20//! the held listener directly. The REAL bound address is what flows into the
21//! derived `[bus.websocket].allowed_origins`
22//! ([`crate::config::FrameConfig::finalize_page_origins`]) and the boot log
23//! line, so the page's origin and the bus's allow-list can never drift.
24
25use std::net::{Ipv4Addr, SocketAddr, TcpListener};
26
27use crate::config::FrameSection;
28use crate::error::HostError;
29
30/// Ports blocked by the WHATWG Fetch Standard's
31/// [§ 2.9 Port blocking](https://fetch.spec.whatwg.org/#port-blocking).
32const FETCH_BAD_PORTS: &[u16] = &[
33 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101,
34 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427,
35 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
36 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4_190, 5060, 5061, 6000, 6566, 6665, 6666, 6667,
37 6668, 6669, 6679, 6697, 10080,
38];
39
40/// The preferred page-server port when `[frame].bind` is absent.
41///
42/// This moved to 6010 because the former default is blocked by Fetch. The walk
43/// starts here and moves forward to the next free port not in `FETCH_BAD_PORTS`.
44pub const PREFERRED_PAGE_PORT: u16 = 6010;
45
46/// A resolved, BOUND page-server listener plus its real bound address.
47///
48/// Holding this value keeps the port reserved (the socket is already in
49/// `LISTEN` state, queuing connections at the kernel backlog) until
50/// [`crate::server::ShellServer::adopt`] takes ownership of the listener to
51/// run the accept loop — so no other process can claim the port in the window
52/// between resolution and serving.
53#[derive(Debug)]
54pub struct PageServer {
55 listener: TcpListener,
56 addr: SocketAddr,
57}
58
59impl PageServer {
60 /// Resolves the page-server listener from the `[frame]` section.
61 ///
62 /// A stated `[frame].bind` binds exactly (loud failure if taken); an absent
63 /// one prefers [`PREFERRED_PAGE_PORT`] and walks forward to the next free,
64 /// Fetch-safe port.
65 ///
66 /// # Errors
67 ///
68 /// Returns [`HostError::PageServerUnavailable`] when a stated `[frame].bind`
69 /// is already in use, or [`HostError::NoFreePagePort`] when the whole
70 /// forward walk from the preferred port finds nothing free (practically
71 /// impossible; a genuinely exhausted local port space).
72 pub fn resolve(frame: &FrameSection) -> Result<Self, HostError> {
73 match frame.bind {
74 Some(addr) => Self::bind_exact(addr),
75 None => Self::walk_forward(),
76 }
77 }
78
79 /// Binds exactly the stated address; a taken port is a loud, named failure.
80 fn bind_exact(addr: SocketAddr) -> Result<Self, HostError> {
81 match TcpListener::bind(addr) {
82 Ok(listener) => Self::from_listener(listener),
83 Err(source) => Err(HostError::PageServerUnavailable { addr, source }),
84 }
85 }
86
87 /// Prefers [`PREFERRED_PAGE_PORT`] and walks forward to the next free,
88 /// Fetch-safe port.
89 ///
90 /// Ports in `FETCH_BAD_PORTS` are skipped without a bind attempt.
91 /// `AddrInUse` moves to the next candidate; any other bind error is fatal
92 /// and surfaced immediately (a permission or resource failure is not a
93 /// "try the next port" condition — masking it would be a silent fallback).
94 fn walk_forward() -> Result<Self, HostError> {
95 Self::walk_forward_from(PREFERRED_PAGE_PORT)
96 }
97
98 fn walk_forward_from(start_port: u16) -> Result<Self, HostError> {
99 for port in start_port..=u16::MAX {
100 if FETCH_BAD_PORTS.contains(&port) {
101 continue;
102 }
103 let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
104 match TcpListener::bind(addr) {
105 Ok(listener) => return Self::from_listener(listener),
106 Err(source) if source.kind() == std::io::ErrorKind::AddrInUse => {}
107 Err(source) => return Err(HostError::PageServerUnavailable { addr, source }),
108 }
109 }
110 Err(HostError::NoFreePagePort { from: start_port })
111 }
112
113 /// Wraps a freshly bound listener, capturing its real local address.
114 fn from_listener(listener: TcpListener) -> Result<Self, HostError> {
115 let addr = listener.local_addr().map_err(|source| HostError::Bind {
116 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
117 source,
118 })?;
119 Ok(Self { listener, addr })
120 }
121
122 /// The REAL bound page-server address — the one printed on boot and folded
123 /// into the derived bus origin allow-list.
124 #[must_use]
125 pub const fn local_addr(&self) -> SocketAddr {
126 self.addr
127 }
128
129 /// Consumes the resolved page server, yielding the held listener for the
130 /// shell server's accept loop to adopt.
131 #[must_use]
132 pub fn into_listener(self) -> TcpListener {
133 self.listener
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::{FETCH_BAD_PORTS, PREFERRED_PAGE_PORT, PageServer};
140 use crate::config::FrameSection;
141 use crate::error::HostError;
142 use std::net::{Ipv4Addr, SocketAddr, TcpListener};
143 use std::path::PathBuf;
144
145 fn frame_section(bind: Option<SocketAddr>) -> FrameSection {
146 FrameSection {
147 bind,
148 assets: PathBuf::from("page/dist"),
149 auth_token: String::new(),
150 channel: Some("demo.events".to_owned()),
151 }
152 }
153
154 #[test]
155 fn preferred_page_port_is_not_fetch_blocked() {
156 assert!(
157 !FETCH_BAD_PORTS.contains(&PREFERRED_PAGE_PORT),
158 "preferred page port {PREFERRED_PAGE_PORT} is blocked by the WHATWG Fetch Standard"
159 );
160 }
161
162 #[test]
163 fn walk_forward_prefers_6010_when_free() -> Result<(), Box<dyn std::error::Error>> {
164 // If 6010 is already taken by an unrelated process this proves the walk
165 // still lands on a free forward port; if it is free it proves the
166 // preference. Either outcome is a bound listener at or above 6010.
167 let page = PageServer::resolve(&frame_section(None))?;
168 assert!(
169 page.local_addr().port() >= PREFERRED_PAGE_PORT,
170 "walk-forward must land at or after the preferred port"
171 );
172 Ok(())
173 }
174
175 #[test]
176 fn walk_forward_skips_a_fetch_blocked_start() -> Result<(), Box<dyn std::error::Error>> {
177 let blocked_start = 4_190;
178 assert!(FETCH_BAD_PORTS.contains(&blocked_start));
179
180 let page = PageServer::walk_forward_from(blocked_start)?;
181 assert_ne!(page.local_addr().port(), blocked_start);
182 assert!(!FETCH_BAD_PORTS.contains(&page.local_addr().port()));
183 Ok(())
184 }
185
186 #[test]
187 fn walk_forward_skips_a_squatted_preferred_port() -> Result<(), Box<dyn std::error::Error>> {
188 // Squat the preferred port for the duration of the resolve; the walk
189 // must move past it to a strictly-higher free port.
190 let squatter =
191 TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, PREFERRED_PAGE_PORT)));
192 let Ok(squatter) = squatter else {
193 // The preferred port was already unavailable for an unrelated
194 // reason; the plain preference test still covers the walk.
195 return Ok(());
196 };
197 let page = PageServer::resolve(&frame_section(None))?;
198 assert!(
199 page.local_addr().port() > PREFERRED_PAGE_PORT,
200 "the walk must skip the squatted preferred port"
201 );
202 drop(squatter);
203 Ok(())
204 }
205
206 #[test]
207 fn explicit_bind_that_is_taken_fails_loudly() -> Result<(), Box<dyn std::error::Error>> {
208 // Hold an ephemeral port, then demand it explicitly: the resolve must
209 // refuse loudly rather than move.
210 let held = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
211 let taken = held.local_addr()?;
212 let result = PageServer::resolve(&frame_section(Some(taken)));
213 let Err(HostError::PageServerUnavailable { addr, .. }) = result else {
214 return Err(format!("expected PageServerUnavailable, got {result:?}").into());
215 };
216 assert_eq!(addr, taken, "the refusal must name the taken address");
217 Ok(())
218 }
219
220 #[test]
221 fn explicit_bind_that_is_free_binds_exactly() -> Result<(), Box<dyn std::error::Error>> {
222 // Find a free port, release it, then demand it explicitly.
223 let scratch = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
224 let free = scratch.local_addr()?;
225 drop(scratch);
226 let page = PageServer::resolve(&frame_section(Some(free)))?;
227 assert_eq!(
228 page.local_addr(),
229 free,
230 "an explicit free bind must bind exactly, never move"
231 );
232 Ok(())
233 }
234}