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
//! Boot and ordered shutdown of the frame-core host authority around an
//! embedding application's component installs (design §4.2).

use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::thread::JoinHandle;

use frame_core::component::ComponentId;
use frame_core::event::{
    EventReceiveError, LifecycleEventKind, LifecycleState, LifecycleSubscription,
};
use frame_core::registry::ComponentRegistry;
use frame_core::runtime::{ComponentRuntime, RuntimePolicy, SupportModuleHandle};
use frame_core::status::ComponentStatus;

use crate::error::HostError;
use crate::spec::ComponentInstall;

/// Bounded buffer between the registry's lifecycle stream and the console
/// logger. Overflow drops oldest-first and is itself reported (never silent):
/// the logger emits the subscription's lag counter whenever it grows.
const LIFECYCLE_EVENT_BUFFER: usize = 256;

/// Wait quantum for the logger's blocking receive. Delivery is event-driven
/// (condvar wakeups); this bound only caps how long one idle block lasts and
/// is not a polling interval — the thread exits once every installed
/// component's Removed transition has been observed, published by ordered
/// shutdown before the join.
const LOGGER_WAIT_QUANTUM: std::time::Duration = std::time::Duration::from_secs(3600);

/// The booted frame-core host: the composed component runtime and, through
/// it, the embedding application's installed components.
pub struct HostRuntime {
    runtime: ComponentRuntime,
    /// Installed component identities, in install order; stopped and removed
    /// in reverse order at shutdown.
    components: Vec<ComponentId>,
    /// Loaded support-module handles, in load order; unloaded in reverse
    /// order at shutdown, after every component is removed.
    support: Vec<SupportModuleHandle>,
}

/// Join handle for the console lifecycle logger thread.
pub struct EventLoggerHandle {
    join: JoinHandle<()>,
}

impl HostRuntime {
    /// Composes the component runtime (scheduler + registry, capability
    /// table included) under the application's stated policy, without
    /// registering anything yet.
    ///
    /// Composition goes through [`frame_core::runtime::ComponentRuntime`],
    /// the only composition surface embedding hosts touch: its BIF registry
    /// is populated before any component bytecode loads — bare scheduler
    /// construction installs an empty registry and every `erlang:*` import
    /// defers into a process-fatal dispatch refusal (the
    /// attribution/gcbif-wedge root cause, told in full in the wrapper's
    /// module docs).
    ///
    /// # Errors
    ///
    /// Returns a typed failure when runtime composition refuses.
    pub fn new(policy: RuntimePolicy) -> Result<Self, HostError> {
        let runtime = ComponentRuntime::compose(policy)?;
        Ok(Self {
            runtime,
            components: Vec::new(),
            support: Vec::new(),
        })
    }

    /// Borrows the component registry, the host's authority surface.
    #[must_use]
    pub const fn registry(&self) -> &ComponentRegistry {
        self.runtime.registry()
    }

    /// Spawns the console lifecycle logger (console-first: every lifecycle
    /// transition and capability denial is logged to stdout as it happens).
    ///
    /// Must run before [`Self::install`] so the Registered transitions are
    /// captured. The thread exits once it has observed the Removed
    /// transition of every component in `expected`, which ordered shutdown
    /// publishes.
    ///
    /// # Errors
    ///
    /// Returns typed failures for a poisoned event stream or thread spawn
    /// refusal.
    pub fn spawn_event_logger(
        &self,
        expected: HashSet<ComponentId>,
    ) -> Result<EventLoggerHandle, HostError> {
        let capacity =
            NonZeroUsize::new(LIFECYCLE_EVENT_BUFFER).ok_or(HostError::SynchronizationPoisoned)?;
        let subscription = self.registry().subscribe(capacity)?;
        let join = std::thread::Builder::new()
            .name("frame-host-lifecycle-log".to_owned())
            .spawn(move || log_lifecycle_stream(&subscription, &expected))
            .map_err(|source| HostError::EventLoggerSpawn { source })?;
        Ok(EventLoggerHandle { join })
    }

    /// Installs the application's components in order: loads each install's
    /// support modules, registers the manifest with its bytecode, reads the
    /// capability table through the host facade (grants precede start:
    /// declarations never grant authority — the host's facade alone grants),
    /// starts the component, and requires it to reach Running.
    ///
    /// # Errors
    ///
    /// Propagates every typed runtime, registry, or capability failure and
    /// refuses a component that is not Running after start. On failure the
    /// already-installed components remain registered; the caller tears the
    /// stack down through [`Self::shutdown`]'s ordered path or best-effort
    /// removal.
    pub fn install(
        &mut self,
        installs: Vec<ComponentInstall>,
    ) -> Result<Vec<ComponentStatus>, HostError> {
        let mut statuses = Vec::with_capacity(installs.len());
        for install in installs {
            for bytecode in &install.support_modules {
                let handle = self.runtime.load_support_module(bytecode)?;
                self.support.push(handle);
            }
            let id = install.meta.id;
            tracing::info!(
                component = %id,
                name = %install.meta.name,
                "registering application component"
            );
            self.registry().register(install.meta, install.bytecode)?;
            self.components.push(id);

            // Grants are applied before the component starts: a component
            // must never observe itself running without the capabilities it
            // declared. Reading the table here makes the pre-start state
            // honest and visible.
            let grants = self.registry().host_capabilities().grants_for(id)?;
            tracing::info!(
                component = %id,
                grants = grants.len(),
                "capability table registered component"
            );

            self.registry().start(id)?;
            let status = self
                .registry()
                .status(id)?
                .ok_or(HostError::StatusMissing { id })?;
            if status.state != LifecycleState::Running {
                return Err(HostError::NotRunning {
                    id,
                    state: status.state,
                });
            }
            for child in &status.children {
                tracing::info!(
                    component = %id,
                    child = %child.name,
                    pid = child.pid,
                    "supervised child is live"
                );
            }
            statuses.push(status);
        }
        Ok(statuses)
    }

    /// Best-effort removal of every installed component after a failed
    /// boot, so the failure does not leak a live process tree. Every
    /// cleanup failure is logged loudly; the original boot failure is what
    /// the caller reports.
    pub fn abandon_after_boot_failure(&self) {
        for id in self.components.iter().rev() {
            match self.registry().remove(*id) {
                Ok(outcome) => {
                    tracing::warn!(component = %id, outcome = ?outcome, "removed component after failed boot");
                }
                Err(cleanup) => {
                    tracing::error!(component = %id, %cleanup, "cleanup after failed boot also failed");
                }
            }
        }
    }

    /// Ordered shutdown: stop and remove every component in reverse install
    /// order, unload support modules in reverse load order, verify zero
    /// residue, release the runtime, and join the logger (which exits on the
    /// final Removed transition).
    ///
    /// # Errors
    ///
    /// Propagates typed stop/remove/unload failures, live-process residue
    /// after the drain, and a panicked logger thread. Runtime teardown is
    /// never treated as cleanup — the wrapper itself refuses residue too.
    pub fn shutdown(mut self, logger: EventLoggerHandle) -> Result<(), HostError> {
        for id in self.components.iter().rev() {
            let stop_outcome = self.registry().stop(*id)?;
            tracing::info!(component = %id, outcome = ?stop_outcome, "ordered stop completed");
            let remove_outcome = self.registry().remove(*id)?;
            tracing::info!(component = %id, outcome = ?remove_outcome, "component removed");
        }
        while let Some(handle) = self.support.pop() {
            self.runtime.unload_support_module(handle)?;
        }
        let residue = self.runtime.live_process_count();
        if residue != 0 {
            return Err(HostError::ProcessResidue { count: residue });
        }
        self.runtime.shutdown()?;
        logger
            .join
            .join()
            .map_err(|_| HostError::EventLoggerPanicked)
    }
}

/// Drains the lifecycle stream to the console until every expected
/// component's Removed transition (or stream closure).
fn log_lifecycle_stream(subscription: &LifecycleSubscription, expected: &HashSet<ComponentId>) {
    let mut reported_lag = 0;
    let mut removed: HashSet<ComponentId> = HashSet::new();
    loop {
        let event = match subscription.recv_timeout(LOGGER_WAIT_QUANTUM) {
            Ok(event) => event,
            Err(EventReceiveError::Timeout) => continue,
            Err(EventReceiveError::Closed) => {
                tracing::info!("lifecycle event stream closed; console logger exiting");
                return;
            }
            Err(EventReceiveError::Poisoned) => {
                tracing::error!(
                    "lifecycle event stream synchronization poisoned; console logger exiting"
                );
                return;
            }
        };
        let lagged = subscription.lagged_events();
        if lagged > reported_lag {
            tracing::warn!(
                dropped = lagged - reported_lag,
                total_dropped = lagged,
                "lifecycle logger overflowed its bounded buffer; oldest events were dropped"
            );
            reported_lag = lagged;
        }
        match &event.kind {
            LifecycleEventKind::Transition { from, to } => {
                tracing::info!(
                    sequence = event.sequence,
                    component = %event.component_id,
                    from = ?from,
                    to = ?to,
                    "component lifecycle transition"
                );
                if *to == LifecycleState::Removed {
                    removed.insert(event.component_id);
                    if removed.is_superset(expected) {
                        tracing::info!(
                            "every application component removed; console logger exiting"
                        );
                        return;
                    }
                }
            }
            LifecycleEventKind::CapabilityDenied(denial) => {
                tracing::warn!(
                    sequence = event.sequence,
                    component = %denial.component_id,
                    kind = ?denial.kind,
                    scope = ?denial.scope,
                    declared = denial.declared,
                    "capability check denied"
                );
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::manifest;

    #[test]
    fn manifest_declares_the_contract_and_presence_child() {
        let meta = manifest::component_meta();
        assert_eq!(meta.provides.len(), 1);
        assert_eq!(meta.provides[0].id, manifest::CONTRACT_ID);
        assert_eq!(meta.children.len(), 1);
        assert_eq!(meta.children[0].name, manifest::PRESENCE_CHILD);
        assert!(
            meta.needs.is_empty(),
            "class-(b) manifest must declare no host needs"
        );
        assert!(meta.supervision.is_some());
    }
}