frame-host 0.4.0

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
//! Page-server port resolution (2026-07-22 portless ruling).
//!
//! The page server's address obeys one of two contracts, chosen by whether
//! `[frame].bind` is stated:
//!
//! - **Stated → LAW.** The listener binds exactly `[frame].bind`. A taken port
//!   is a LOUD failure ([`HostError::PageServerUnavailable`]) naming the socket
//!   role, the `frame.toml` key, and the coherence rule — never a silent move
//!   to some other port. This is what the live fixtures and production use.
//! - **Absent → prefer-and-walk.** The listener prefers `127.0.0.1:6010` and
//!   walks forward to the next free, Fetch-safe port, so two scaffolded apps
//!   boot side by side with zero config edits. The former preference, 4190, is
//!   blocklisted by the WHATWG Fetch Standard; keep that history explicit so
//!   the default is never moved back to a port browsers refuse to fetch. The
//!   chosen URL is printed loudly on boot.
//!
//! Either way the resolved listener is BOUND and held here — never probed and
//! dropped — so the port cannot be stolen between resolution and serving
//! (there is no bind/rebind race). [`crate::server::ShellServer::adopt`] adopts
//! the held listener directly. The REAL bound address is what flows into the
//! derived `[bus.websocket].allowed_origins`
//! ([`crate::config::FrameConfig::finalize_page_origins`]) and the boot log
//! line, so the page's origin and the bus's allow-list can never drift.

use std::net::{Ipv4Addr, SocketAddr, TcpListener};

use crate::config::FrameSection;
use crate::error::HostError;

/// Ports blocked by the WHATWG Fetch Standard's
/// [§ 2.9 Port blocking](https://fetch.spec.whatwg.org/#port-blocking).
const FETCH_BAD_PORTS: &[u16] = &[
    0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101,
    102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427,
    465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
    993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4_190, 5060, 5061, 6000, 6566, 6665, 6666, 6667,
    6668, 6669, 6679, 6697, 10080,
];

/// The preferred page-server port when `[frame].bind` is absent.
///
/// This moved to 6010 because the former default is blocked by Fetch. The walk
/// starts here and moves forward to the next free port not in `FETCH_BAD_PORTS`.
pub const PREFERRED_PAGE_PORT: u16 = 6010;

/// A resolved, BOUND page-server listener plus its real bound address.
///
/// Holding this value keeps the port reserved (the socket is already in
/// `LISTEN` state, queuing connections at the kernel backlog) until
/// [`crate::server::ShellServer::adopt`] takes ownership of the listener to
/// run the accept loop — so no other process can claim the port in the window
/// between resolution and serving.
#[derive(Debug)]
pub struct PageServer {
    listener: TcpListener,
    addr: SocketAddr,
}

impl PageServer {
    /// Resolves the page-server listener from the `[frame]` section.
    ///
    /// A stated `[frame].bind` binds exactly (loud failure if taken); an absent
    /// one prefers [`PREFERRED_PAGE_PORT`] and walks forward to the next free,
    /// Fetch-safe port.
    ///
    /// # Errors
    ///
    /// Returns [`HostError::PageServerUnavailable`] when a stated `[frame].bind`
    /// is already in use, or [`HostError::NoFreePagePort`] when the whole
    /// forward walk from the preferred port finds nothing free (practically
    /// impossible; a genuinely exhausted local port space).
    pub fn resolve(frame: &FrameSection) -> Result<Self, HostError> {
        match frame.bind {
            Some(addr) => Self::bind_exact(addr),
            None => Self::walk_forward(),
        }
    }

    /// Binds exactly the stated address; a taken port is a loud, named failure.
    fn bind_exact(addr: SocketAddr) -> Result<Self, HostError> {
        match TcpListener::bind(addr) {
            Ok(listener) => Self::from_listener(listener),
            Err(source) => Err(HostError::PageServerUnavailable { addr, source }),
        }
    }

    /// Prefers [`PREFERRED_PAGE_PORT`] and walks forward to the next free,
    /// Fetch-safe port.
    ///
    /// Ports in `FETCH_BAD_PORTS` are skipped without a bind attempt.
    /// `AddrInUse` moves to the next candidate; any other bind error is fatal
    /// and surfaced immediately (a permission or resource failure is not a
    /// "try the next port" condition — masking it would be a silent fallback).
    fn walk_forward() -> Result<Self, HostError> {
        Self::walk_forward_from(PREFERRED_PAGE_PORT)
    }

    fn walk_forward_from(start_port: u16) -> Result<Self, HostError> {
        for port in start_port..=u16::MAX {
            if FETCH_BAD_PORTS.contains(&port) {
                continue;
            }
            let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
            match TcpListener::bind(addr) {
                Ok(listener) => return Self::from_listener(listener),
                Err(source) if source.kind() == std::io::ErrorKind::AddrInUse => {}
                Err(source) => return Err(HostError::PageServerUnavailable { addr, source }),
            }
        }
        Err(HostError::NoFreePagePort { from: start_port })
    }

    /// Wraps a freshly bound listener, capturing its real local address.
    fn from_listener(listener: TcpListener) -> Result<Self, HostError> {
        let addr = listener.local_addr().map_err(|source| HostError::Bind {
            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
            source,
        })?;
        Ok(Self { listener, addr })
    }

    /// The REAL bound page-server address — the one printed on boot and folded
    /// into the derived bus origin allow-list.
    #[must_use]
    pub const fn local_addr(&self) -> SocketAddr {
        self.addr
    }

    /// Consumes the resolved page server, yielding the held listener for the
    /// shell server's accept loop to adopt.
    #[must_use]
    pub fn into_listener(self) -> TcpListener {
        self.listener
    }
}

#[cfg(test)]
mod tests {
    use super::{FETCH_BAD_PORTS, PREFERRED_PAGE_PORT, PageServer};
    use crate::config::FrameSection;
    use crate::error::HostError;
    use std::net::{Ipv4Addr, SocketAddr, TcpListener};
    use std::path::PathBuf;

    fn frame_section(bind: Option<SocketAddr>) -> FrameSection {
        FrameSection {
            bind,
            assets: PathBuf::from("page/dist"),
            auth_token: String::new(),
            channel: Some("demo.events".to_owned()),
        }
    }

    #[test]
    fn preferred_page_port_is_not_fetch_blocked() {
        assert!(
            !FETCH_BAD_PORTS.contains(&PREFERRED_PAGE_PORT),
            "preferred page port {PREFERRED_PAGE_PORT} is blocked by the WHATWG Fetch Standard"
        );
    }

    #[test]
    fn walk_forward_prefers_6010_when_free() -> Result<(), Box<dyn std::error::Error>> {
        // If 6010 is already taken by an unrelated process this proves the walk
        // still lands on a free forward port; if it is free it proves the
        // preference. Either outcome is a bound listener at or above 6010.
        let page = PageServer::resolve(&frame_section(None))?;
        assert!(
            page.local_addr().port() >= PREFERRED_PAGE_PORT,
            "walk-forward must land at or after the preferred port"
        );
        Ok(())
    }

    #[test]
    fn walk_forward_skips_a_fetch_blocked_start() -> Result<(), Box<dyn std::error::Error>> {
        let blocked_start = 4_190;
        assert!(FETCH_BAD_PORTS.contains(&blocked_start));

        let page = PageServer::walk_forward_from(blocked_start)?;
        assert_ne!(page.local_addr().port(), blocked_start);
        assert!(!FETCH_BAD_PORTS.contains(&page.local_addr().port()));
        Ok(())
    }

    #[test]
    fn walk_forward_skips_a_squatted_preferred_port() -> Result<(), Box<dyn std::error::Error>> {
        // Squat the preferred port for the duration of the resolve; the walk
        // must move past it to a strictly-higher free port.
        let squatter =
            TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, PREFERRED_PAGE_PORT)));
        let Ok(squatter) = squatter else {
            // The preferred port was already unavailable for an unrelated
            // reason; the plain preference test still covers the walk.
            return Ok(());
        };
        let page = PageServer::resolve(&frame_section(None))?;
        assert!(
            page.local_addr().port() > PREFERRED_PAGE_PORT,
            "the walk must skip the squatted preferred port"
        );
        drop(squatter);
        Ok(())
    }

    #[test]
    fn explicit_bind_that_is_taken_fails_loudly() -> Result<(), Box<dyn std::error::Error>> {
        // Hold an ephemeral port, then demand it explicitly: the resolve must
        // refuse loudly rather than move.
        let held = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
        let taken = held.local_addr()?;
        let result = PageServer::resolve(&frame_section(Some(taken)));
        let Err(HostError::PageServerUnavailable { addr, .. }) = result else {
            return Err(format!("expected PageServerUnavailable, got {result:?}").into());
        };
        assert_eq!(addr, taken, "the refusal must name the taken address");
        Ok(())
    }

    #[test]
    fn explicit_bind_that_is_free_binds_exactly() -> Result<(), Box<dyn std::error::Error>> {
        // Find a free port, release it, then demand it explicitly.
        let scratch = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
        let free = scratch.local_addr()?;
        drop(scratch);
        let page = PageServer::resolve(&frame_section(Some(free)))?;
        assert_eq!(
            page.local_addr(),
            free,
            "an explicit free bind must bind exactly, never move"
        );
        Ok(())
    }
}