frame-host 0.3.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:4190` and
//!   walks forward to the next free port, so two scaffolded apps boot side by
//!   side with zero config edits. 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;

/// The preferred page-server port when `[frame].bind` is absent. The walk
/// starts here and moves forward to the next free port.
pub const PREFERRED_PAGE_PORT: u16 = 4190;

/// 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
    /// 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 port.
    ///
    /// `AddrInUse` moves to the next port; 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> {
        for port in PREFERRED_PAGE_PORT..=u16::MAX {
            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: PREFERRED_PAGE_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::{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 walk_forward_prefers_4190_when_free() -> Result<(), Box<dyn std::error::Error>> {
        // If 4190 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 4190.
        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_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(())
    }
}