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
//! Boot and ordered shutdown of frame-core host authority.

use std::num::NonZeroUsize;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;

use beamr::module::ModuleRegistry;
use beamr::scheduler::{Scheduler, SchedulerConfig, SchedulerServices};
use frame_core::event::{
    EventReceiveError, LifecycleEventKind, LifecycleState, LifecycleSubscription,
};
use frame_core::registry::ComponentRegistry;
use frame_core::status::ComponentStatus;
use frame_core::supervision::LifecycleConfig;

use crate::error::HostError;
use crate::manifest::{
    CONTRACT_ID, LIVENESS_MESSAGE, PRESENCE_BYTECODE, PRESENCE_CHILD, component_id, component_meta,
};

// Explicit host policy: frame-core intentionally supplies no lifecycle
// defaults, so the embedding application declares them (same convention as
// the frame-cli scaffold's generated composition host).

/// Scheduler worker threads for the single-component demo tree.
const SCHEDULER_THREADS: usize = 2;

/// Deadline bounding each spawn, mailbox round-trip, or tombstone observation.
const OPERATION_TIMEOUT: Duration = Duration::from_secs(3);

/// 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 on the demo component's
/// Removed transition, published by ordered shutdown before the join.
const LOGGER_WAIT_QUANTUM: Duration = Duration::from_secs(3600);

/// The booted frame-core host: scheduler, registry, and the demo component.
pub struct HostRuntime {
    scheduler: Arc<Scheduler>,
    registry: ComponentRegistry,
}

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

impl HostRuntime {
    /// Constructs the scheduler and component registry (capability table
    /// included) without registering anything yet.
    ///
    /// The scheduler is composed through
    /// [`frame_core::composition::compose_scheduler`] so its BIF registry is
    /// populated before any component bytecode loads — bare
    /// `Scheduler::with_services` installs an empty registry and every
    /// `erlang:*` import defers into a process-fatal dispatch refusal (the
    /// attribution/gcbif-wedge root cause).
    ///
    /// # Errors
    ///
    /// Returns a typed failure when scheduler composition refuses.
    pub fn new() -> Result<Self, HostError> {
        let scheduler = Arc::new(
            frame_core::composition::compose_scheduler(
                SchedulerConfig {
                    thread_count: Some(SCHEDULER_THREADS),
                    ..SchedulerConfig::default()
                },
                SchedulerServices::minimal(),
                Arc::new(ModuleRegistry::new()),
            )
            .map_err(|source| HostError::Composition { source })?,
        );
        let registry = ComponentRegistry::new(
            Arc::clone(&scheduler),
            LifecycleConfig {
                operation_timeout: OPERATION_TIMEOUT,
            },
        );
        Ok(Self {
            scheduler,
            registry,
        })
    }

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

    /// Borrows the composed scheduler (tests inspect committed modules on it).
    #[must_use]
    pub const fn scheduler(&self) -> &Arc<Scheduler> {
        &self.scheduler
    }

    /// Spawns the console lifecycle logger (D9: console-first).
    ///
    /// Must run before [`Self::install_demo_component`] so the Registered
    /// transition is captured. The thread exits when it observes the demo
    /// component's Removed transition, which ordered shutdown publishes.
    ///
    /// # Errors
    ///
    /// Returns typed failures for a poisoned event stream or thread spawn
    /// refusal.
    pub fn spawn_event_logger(&self) -> 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))
            .map_err(|source| HostError::EventLoggerSpawn { source })?;
        Ok(EventLoggerHandle { join })
    }

    /// Registers, grants-checks, starts, and probes the demo component.
    ///
    /// # Errors
    ///
    /// Propagates every typed registry, capability, or probe failure; a probe
    /// answer other than the fresh-incarnation value is refused loudly.
    pub fn install_demo_component(&self) -> Result<ComponentStatus, HostError> {
        let id = component_id();
        let meta = component_meta();
        tracing::info!(
            component = %id,
            name = %meta.name,
            contract = %CONTRACT_ID,
            "registering demo component"
        );
        self.registry.register(meta, PRESENCE_BYTECODE.to_vec())?;

        // F-1b boot convention: grants are applied before start. This
        // component declares no host-mediated needs, so the honest act is to
        // read the table through the host facade and show it holds no grants.
        let grants = self.registry.host_capabilities().grants_for(id)?;
        tracing::info!(
            component = %id,
            grants = grants.len(),
            "capability table registered component; class-(b) manifest declares no host-mediated needs"
        );

        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,
            });
        }
        let answer = self.registry.probe_child(id, PRESENCE_CHILD)?;
        if answer != LIVENESS_MESSAGE {
            return Err(HostError::ProbeMismatch {
                expected: LIVENESS_MESSAGE,
                actual: answer,
            });
        }
        for child in &status.children {
            tracing::info!(
                component = %id,
                child = %child.name,
                pid = child.pid,
                "supervised child is live"
            );
        }
        Ok(status)
    }

    /// Ordered shutdown: stop, remove, verify zero residue, then release the
    /// scheduler and join the logger (which exits on the Removed transition).
    ///
    /// # Errors
    ///
    /// Propagates typed stop/remove failures, live-process residue after the
    /// drain, and a panicked logger thread. `Scheduler::shutdown` is never
    /// treated as cleanup.
    pub fn shutdown(self, logger: EventLoggerHandle) -> Result<(), HostError> {
        let id = component_id();
        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");
        let residue = self.scheduler.process_count();
        if residue != 0 {
            return Err(HostError::ProcessResidue { count: residue });
        }
        self.scheduler.shutdown();
        logger
            .join
            .join()
            .map_err(|_| HostError::EventLoggerPanicked)
    }
}

/// Drains the lifecycle stream to the console until the demo component's
/// Removed transition or stream closure.
fn log_lifecycle_stream(subscription: &LifecycleSubscription) {
    let mut reported_lag = 0;
    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 event.component_id == component_id() && *to == LifecycleState::Removed {
                    tracing::info!("demo 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());
    }
}