frame-core 0.2.0

Component model, lifecycle, process isolation, and WASM module host
Documentation
//! Acceptance for `frame_core::runtime` — the B6 wrapper surface.
//!
//! Every lifecycle act here goes through the wrapper ALONE: compose, support
//! module load, register, start, probe, send, stop, unload, shutdown. No test
//! in this file composes a scheduler, hot-loads a module, or purges/deletes a
//! module through beamr directly — that is the wrapper's whole point (the
//! scaffold-unification design, §5.2).
//!
//! Fixture: the attribution bundle's `graph_view_presence` module (real
//! `erlang:'+'/2` arithmetic — a fresh child answers probe 0 and every
//! non-protocol mailbox integer raises the answer by one), committed under
//! `fixtures/gcbif/` beside its source. Support-module fixture:
//! `spike_worker_ffi.beam`, the same bytecode the registry suites use.

use std::time::Duration;

use frame_core::component::{
    ChildArgument, ChildSpec, ComponentId, ComponentMeta, ServiceCapability, SupervisionPolicy,
};
use frame_core::event::LifecycleState;
use frame_core::registry::StopOutcome;
use frame_core::runtime::{ComponentRuntime, RuntimeError, RuntimePolicy, mailbox_integer};

/// Real-arithmetic presence module from the gcbif attribution bundle.
const PRESENCE_BEAM: &[u8] = include_bytes!("fixtures/gcbif/graph_view_presence.beam");

/// Support-module fixture (a mailbox FFI implementation, no processes of its
/// own) — stands in for the scaffold's Gleam FFI bytecode.
const SUPPORT_BEAM: &[u8] = include_bytes!("fixtures/spike_worker_ffi.beam");

const CHILD: &str = "presence";
const LIVENESS_MESSAGE: i64 = 0;
const STOP_MESSAGE: i64 = 2;

fn policy() -> RuntimePolicy {
    RuntimePolicy {
        scheduler_threads: 2,
        operation_timeout: Duration::from_secs(3),
    }
}

fn component_id() -> ComponentId {
    ComponentId::derive("frame.tests.runtime-wrapper", "presence")
}

fn meta() -> ComponentMeta {
    ComponentMeta {
        id: component_id(),
        name: "wrapper acceptance presence component".to_owned(),
        version: "0.1.0".to_owned(),
        requires: Vec::new(),
        provides: vec![ServiceCapability {
            id: "frame.tests:runtime-wrapper@v1".to_owned(),
            description: "real-arithmetic presence child driven through the wrapper".to_owned(),
        }],
        needs: Vec::new(),
        children: vec![ChildSpec {
            name: CHILD.to_owned(),
            module: "graph_view_presence".to_owned(),
            function: "run".to_owned(),
            arguments: vec![ChildArgument::SupervisorPid],
            liveness_message: LIVENESS_MESSAGE,
            stop_message: STOP_MESSAGE,
        }],
        supervision: Some(SupervisionPolicy {
            max_restarts: 3,
            window: Duration::from_secs(30),
        }),
    }
}

#[test]
fn compose_refuses_zero_scheduler_threads() {
    let refused = ComponentRuntime::compose(RuntimePolicy {
        scheduler_threads: 0,
        operation_timeout: Duration::from_secs(3),
    });
    assert!(matches!(refused, Err(RuntimeError::ZeroSchedulerThreads)));
}

#[test]
fn compose_refuses_zero_operation_timeout() {
    let refused = ComponentRuntime::compose(RuntimePolicy {
        scheduler_threads: 2,
        operation_timeout: Duration::ZERO,
    });
    assert!(matches!(refused, Err(RuntimeError::ZeroOperationTimeout)));
}

/// The scaffold's whole composition, wrapper-only: compose → load support
/// module → register → start → probe → send (a folded mailbox integer and a
/// plain news value) → ordered stop → zero residue → unload → shutdown.
#[test]
fn full_lifecycle_runs_through_the_wrapper_alone() -> Result<(), Box<dyn std::error::Error>> {
    let runtime = ComponentRuntime::compose(policy())?;
    let support = runtime.load_support_module(SUPPORT_BEAM)?;

    let id = component_id();
    runtime
        .registry()
        .register(meta(), PRESENCE_BEAM.to_vec())?;
    runtime.registry().start(id)?;
    let status = runtime
        .registry()
        .status(id)?
        .ok_or("component status missing after start")?;
    assert_eq!(status.state, LifecycleState::Running);

    // Fresh child answers 0; every non-protocol integer raises the answer.
    assert_eq!(runtime.registry().probe_child(id, CHILD)?, 0);

    // A folded identifier is deliverable end to end: above the reserved
    // protocol range, within the mailbox integer domain, accepted by send.
    let entity_ack = mailbox_integer(b"wrapper-acceptance-entity-id", STOP_MESSAGE + 1)?;
    assert!(entity_ack > STOP_MESSAGE);
    runtime.registry().send_child(id, CHILD, entity_ack)?;
    assert_eq!(runtime.registry().probe_child(id, CHILD)?, 1);

    assert_eq!(runtime.registry().stop(id)?, StopOutcome::Stopped);
    assert_eq!(runtime.live_process_count(), 0);
    runtime.unload_support_module(support)?;
    runtime.shutdown()?;
    Ok(())
}

#[test]
fn support_module_load_refuses_invalid_bytecode() -> Result<(), Box<dyn std::error::Error>> {
    let runtime = ComponentRuntime::compose(policy())?;
    let refused = runtime.load_support_module(b"this is not BEAM bytecode");
    assert!(matches!(
        refused,
        Err(RuntimeError::SupportModuleLoad { .. })
    ));
    runtime.shutdown()?;
    Ok(())
}

/// Unload is the verified purge/delete dance: reloading the same module
/// leaves an old generation (purged on the first unload), and a second handle
/// to the now-deleted module is refused loudly, never silently ignored.
#[test]
fn support_module_unload_verifies_and_refuses_double_unload()
-> Result<(), Box<dyn std::error::Error>> {
    let runtime = ComponentRuntime::compose(policy())?;
    let first = runtime.load_support_module(SUPPORT_BEAM)?;
    let second = runtime.load_support_module(SUPPORT_BEAM)?;

    // First unload purges the retained old generation, deletes the current
    // one, and verifies the module is actually gone.
    runtime.unload_support_module(first)?;

    // The module is gone, so the stale handle's unload must be a typed
    // refusal (the delete step reports the module was not loaded).
    let refused = runtime.unload_support_module(second);
    assert!(matches!(
        refused,
        Err(RuntimeError::SupportModuleDelete { .. })
    ));
    runtime.shutdown()?;
    Ok(())
}

/// Shutdown refuses while the process tree still has residue: a running
/// component was never stopped, so teardown must not proceed.
#[test]
fn shutdown_refuses_process_residue() -> Result<(), Box<dyn std::error::Error>> {
    let runtime = ComponentRuntime::compose(policy())?;
    let id = component_id();
    runtime
        .registry()
        .register(meta(), PRESENCE_BEAM.to_vec())?;
    runtime.registry().start(id)?;
    assert!(runtime.live_process_count() > 0);

    // Deliberately no ordered stop. The refusal consumes the runtime; its
    // scheduler threads live until this test process exits — that is the
    // refusal's honest cost, and exactly why embedders must stop first.
    match runtime.shutdown() {
        Err(RuntimeError::ProcessResidue { count }) => {
            assert!(count > 0, "residue refusal must report the live count");
            Ok(())
        }
        Err(other) => Err(format!("expected ProcessResidue, got {other}").into()),
        Ok(()) => Err("shutdown accepted a runtime with live processes".into()),
    }
}