frame-host 0.1.0

Frame host server — boots the frame-core host authority with an embedded liminal component and serves the built frame page
Documentation
//! Orchestration: boot frame-core host authority AND the embedded liminal
//! component, serve the console, shut the whole stack down in order.
//!
//! One binary, one config, one start command. There are no side-by-side
//! processes: liminal runs inside this process as a component whose lifecycle
//! the host owns.

use crate::cli::Cli;
use crate::config::FrameConfig;
use crate::embedded::EmbeddedLiminal;
use crate::error::HostError;
use crate::manifest;
use crate::runtime::HostRuntime;
use crate::serve::{ServeStop, serve};

/// Runs the full frame server to completion: load config, boot the frame-core
/// component tree and the embedded liminal component, serve the console until a
/// shutdown signal (or a liminal liveness failure), then tear the whole stack
/// down in order.
///
/// # Errors
///
/// Returns the first typed failure from config loading, either component's boot,
/// serving, or ordered shutdown. A boot failure tears down whatever already
/// booted so no component is left running; a serve failure takes precedence in
/// the report, but no teardown failure is ever hidden. An unexpected liminal
/// termination at runtime surfaces as a nonzero exit after a clean teardown.
pub fn run(cli: &Cli) -> Result<(), HostError> {
    let config = FrameConfig::load(&cli.config)?;

    // Boot frame-core host authority and the demo component (unchanged
    // machinery): the host owns its component tree AND, now, liminal's lifecycle.
    let runtime = HostRuntime::new()?;
    let logger = runtime.spawn_event_logger()?;
    if let Err(error) = runtime.install_demo_component() {
        // Best-effort cleanup so a failed boot does not leak a process tree;
        // the original failure is what gets reported.
        match runtime.registry().remove(manifest::component_id()) {
            Ok(outcome) => {
                tracing::warn!(outcome = ?outcome, "removed demo component after failed boot");
            }
            Err(cleanup) => tracing::error!(%cleanup, "cleanup after failed boot also failed"),
        }
        return Err(error);
    }

    // Boot the embedded liminal component in-process (its own threads; no child
    // process). A bind/boot failure here exits nonzero with the failing
    // component named — never a half-up stack. The frame-core component already
    // booted is torn down first so it is not abandoned with live processes.
    let liminal = match EmbeddedLiminal::boot(&config.liminal) {
        Ok(liminal) => liminal,
        Err(error) => {
            if let Err(teardown) = runtime.shutdown(logger) {
                tracing::error!(
                    %teardown,
                    "frame-core teardown after liminal boot failure also failed"
                );
            }
            return Err(error);
        }
    };
    let liminal_endpoint = liminal.websocket_endpoint();
    tracing::info!(
        endpoint = %liminal_endpoint,
        "embedded liminal component booted; /frame/config.json advertises its real bound WebSocket address"
    );

    let tokio_runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    {
        Ok(tokio_runtime) => tokio_runtime,
        Err(source) => {
            // Both components are already live; tear them down before exiting so
            // the async-runtime failure does not leak liminal listeners or the
            // frame-core scheduler (neither has a Drop that stops them).
            if let Err(teardown) = liminal.shutdown() {
                tracing::error!(%teardown, "embedded liminal teardown after async-runtime build failure also failed");
            }
            if let Err(teardown) = runtime.shutdown(logger) {
                tracing::error!(%teardown, "frame-core teardown after async-runtime build failure also failed");
            }
            return Err(HostError::AsyncRuntime { source });
        }
    };
    let serve_outcome = tokio_runtime.block_on(serve(&config, &liminal, liminal_endpoint));

    // Ordered teardown ALWAYS runs, regardless of how serving ended, and every
    // teardown failure is logged here so none is ever hidden behind an earlier
    // error taken for the exit code.
    let liminal_shutdown = liminal.shutdown();
    if let Err(ref error) = liminal_shutdown {
        tracing::error!(%error, "embedded liminal graceful shutdown failed");
    }
    let host_shutdown = runtime.shutdown(logger);
    if let Err(ref error) = host_shutdown {
        tracing::error!(%error, "frame-core ordered shutdown failed");
    }

    // Report precedence: a serve failure first; then an unexpected liminal exit,
    // surfaced WITH the probe detail the monitor captured (a partially-dead
    // liminal usually also fails teardown, so returning that teardown error here
    // would bury the real root cause — the teardown errors are already logged
    // above); then any teardown failure; else a clean exit.
    let stop = serve_outcome?;
    if let ServeStop::LiminalExited { detail } = stop {
        return Err(HostError::LiminalExited { detail });
    }
    liminal_shutdown?;
    host_shutdown?;
    tracing::info!("frame server exited cleanly");
    Ok(())
}