Skip to main content

frame_core/runtime/
component_runtime.rs

1//! The composed component runtime and its opaque support-module handles.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use beamr::atom::Atom;
7use beamr::module::ModuleRegistry;
8use beamr::namespace::NamespaceId;
9use beamr::scheduler::{Scheduler, SchedulerConfig, SchedulerServices};
10
11use crate::composition::{compose_scheduler, deferred_erlang_imports, resolve_atom};
12use crate::registry::ComponentRegistry;
13use crate::supervision::LifecycleConfig;
14
15use super::error::RuntimeError;
16
17/// Stated-by-the-application runtime policy. No field has a default: frame
18/// supplies no lifecycle values, so the embedding application declares each
19/// one explicitly.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct RuntimePolicy {
22    /// Scheduler worker threads for the component tree. Must be positive —
23    /// the wrapper refuses zero rather than letting the runtime silently
24    /// substitute the machine's parallelism.
25    pub scheduler_threads: usize,
26    /// Deadline bounding each spawn, mailbox round-trip, or tombstone
27    /// observation. Must be nonzero.
28    pub operation_timeout: Duration,
29    /// Caller-supplied per-fragment content byte limit (F-5a R1 — no hidden
30    /// default). `None` states loudly that this host configured no limit;
31    /// registering a fragment-carrying component then refuses typed.
32    pub max_fragment_bytes: Option<std::num::NonZeroUsize>,
33}
34
35/// Opaque, single-use handle to a loaded support module.
36///
37/// Returned by [`ComponentRuntime::load_support_module`] and consumed by
38/// [`ComponentRuntime::unload_support_module`]; because it is neither `Clone`
39/// nor `Copy`, a double unload is unrepresentable.
40#[derive(Debug)]
41#[must_use = "an unconsumed handle leaves its module loaded; unload it through the runtime"]
42pub struct SupportModuleHandle {
43    module: Atom,
44    name: String,
45}
46
47impl SupportModuleHandle {
48    /// A swap reference to the same module NAME: what the F-7b dev reload
49    /// swaps against. Deliberately separate from the handle itself — a
50    /// ref cannot unload, so the double-unload law the handle encodes
51    /// stays unrepresentable while swaps stay cheap to thread around.
52    #[must_use]
53    pub fn swap_ref(&self) -> SupportModuleRef {
54        SupportModuleRef {
55            module: self.module,
56            name: self.name.clone(),
57        }
58    }
59}
60
61/// A cloneable reference to a loaded support module's NAME, for hot
62/// swaps. Carries no unload authority.
63#[derive(Clone, Debug)]
64pub struct SupportModuleRef {
65    module: Atom,
66    name: String,
67}
68
69/// The composed component runtime: scheduler + registry, correctly assembled.
70///
71/// Composition happens once, in [`ComponentRuntime::compose`], with the
72/// built-in-function registry populated before any bytecode can load (see the
73/// [module docs](crate::runtime) for the full composition story). The
74/// scheduler itself stays private: the host's authority surface is
75/// [`ComponentRuntime::registry`], support modules travel as opaque handles,
76/// and teardown is the residue-checked [`ComponentRuntime::shutdown`].
77pub struct ComponentRuntime {
78    scheduler: Arc<Scheduler>,
79    registry: Arc<ComponentRegistry>,
80}
81
82impl ComponentRuntime {
83    /// Composes the runtime with its built-in-function registry populated
84    /// before any bytecode can load (the composition
85    /// [`crate::composition::compose_scheduler`] performs, absorbed).
86    ///
87    /// # Errors
88    ///
89    /// Refuses a zero thread count or zero operation timeout with typed
90    /// policy errors, and propagates every typed scheduler-composition
91    /// failure.
92    pub fn compose(policy: RuntimePolicy) -> Result<Self, RuntimeError> {
93        if policy.scheduler_threads == 0 {
94            return Err(RuntimeError::ZeroSchedulerThreads);
95        }
96        if policy.operation_timeout.is_zero() {
97            return Err(RuntimeError::ZeroOperationTimeout);
98        }
99        let scheduler = Arc::new(
100            compose_scheduler(
101                SchedulerConfig {
102                    thread_count: Some(policy.scheduler_threads),
103                    ..SchedulerConfig::default()
104                },
105                SchedulerServices::minimal(),
106                Arc::new(ModuleRegistry::new()),
107            )
108            .map_err(|source| RuntimeError::Composition { source })?,
109        );
110        let registry = Arc::new(ComponentRegistry::new(
111            Arc::clone(&scheduler),
112            LifecycleConfig {
113                operation_timeout: policy.operation_timeout,
114                max_fragment_bytes: policy.max_fragment_bytes,
115            },
116        ));
117        Ok(Self {
118            scheduler,
119            registry,
120        })
121    }
122
123    /// Borrows the host's authority surface (register, grant, start, probe,
124    /// stop — the unchanged [`ComponentRegistry`] API).
125    #[must_use]
126    pub fn registry(&self) -> &ComponentRegistry {
127        &self.registry
128    }
129
130    /// A shared handle to the same authority surface, for a management
131    /// adapter running on its own thread (the F-7b dev door). The handle
132    /// may outlive `shutdown`; operations against a stopped scheduler
133    /// fail typed, never silently.
134    #[must_use]
135    pub fn registry_handle(&self) -> Arc<ComponentRegistry> {
136        Arc::clone(&self.registry)
137    }
138
139    /// Loads a support module (e.g. a component's FFI bytecode) and returns
140    /// an opaque handle for ordered unload.
141    ///
142    /// The freshly committed module passes the same deferred-`erlang:*` wall
143    /// the registry applies to component bytecode: a module whose import
144    /// table still defers a built-in would die at first dispatch, so it is
145    /// refused — and removed again — up front.
146    ///
147    /// # Errors
148    ///
149    /// Returns typed failures for loader refusal, a module absent immediately
150    /// after load, or deferred `erlang:*` imports.
151    pub fn load_support_module(
152        &self,
153        bytecode: &[u8],
154    ) -> Result<SupportModuleHandle, RuntimeError> {
155        let loaded = self.scheduler.hot_load_module(bytecode).map_err(|error| {
156            RuntimeError::SupportModuleLoad {
157                detail: error.to_string(),
158            }
159        })?;
160        let module = loaded.module_name;
161        let name = resolve_atom(&self.scheduler, module);
162        let Some(deferred) = deferred_erlang_imports(&self.scheduler, module) else {
163            return Err(RuntimeError::SupportModuleAbsent { module: name });
164        };
165        if !deferred.is_empty() {
166            // The just-loaded module is unusable; take it back out before
167            // refusing so the refusal does not leak a loaded generation. A
168            // cleanup failure is logged loudly but never masks the refusal
169            // itself.
170            if !self.scheduler.delete_module(module) {
171                tracing::error!(
172                    module = %name,
173                    "failed to delete support module after deferred-BIF refusal"
174                );
175            }
176            return Err(RuntimeError::SupportModuleDeferredBifImports {
177                module: name,
178                imports: deferred.join(", "),
179            });
180        }
181        Ok(SupportModuleHandle { module, name })
182    }
183
184    /// Ordered unload with verification: purge any retained old generation,
185    /// delete the current one, then verify the module is actually gone.
186    ///
187    /// # Errors
188    ///
189    /// Returns typed failures for an unsafe purge, a failed delete (a stale
190    /// handle to an already-unloaded module lands here), or post-delete
191    /// lookup residue.
192    pub fn unload_support_module(&self, handle: SupportModuleHandle) -> Result<(), RuntimeError> {
193        let SupportModuleHandle { module, name } = handle;
194        if self.scheduler.check_old_code(module) {
195            self.scheduler.purge_module(module).map_err(|error| {
196                RuntimeError::SupportModulePurge {
197                    module: name.clone(),
198                    detail: error.to_string(),
199                }
200            })?;
201        }
202        if !self.scheduler.delete_module(module) {
203            return Err(RuntimeError::SupportModuleDelete { module: name });
204        }
205        if self
206            .scheduler
207            .lookup_module_in(NamespaceId::DEFAULT, module)
208            .is_some()
209        {
210            return Err(RuntimeError::SupportModuleStillLoaded { module: name });
211        }
212        Ok(())
213    }
214
215    /// Hot-swaps a support module to new bytes of the SAME module name —
216    /// see [`SupportSwapper::swap`], which this delegates to.
217    ///
218    /// # Errors
219    ///
220    /// Returns typed failures for an unsafe purge, loader refusal, a
221    /// module absent immediately after load, or deferred `erlang:*`
222    /// imports.
223    pub fn swap_support_module(
224        &self,
225        current: &SupportModuleRef,
226        bytecode: &[u8],
227    ) -> Result<SupportModuleRef, RuntimeError> {
228        self.support_swapper().swap(current, bytecode)
229    }
230
231    /// A thread-shareable handle for support-module swaps, for a
232    /// management adapter running on its own thread (the F-7b dev door).
233    #[must_use]
234    pub fn support_swapper(&self) -> SupportSwapper {
235        SupportSwapper {
236            scheduler: Arc::clone(&self.scheduler),
237        }
238    }
239
240    /// Live process count — the zero-residue check after ordered stop.
241    #[must_use]
242    pub fn live_process_count(&self) -> usize {
243        self.scheduler.process_count()
244    }
245
246    /// Final teardown; refuses if the process tree still has residue.
247    ///
248    /// `Scheduler::shutdown` is never cleanup: every component must have
249    /// completed its ordered stop first. A refusal deliberately does NOT stop
250    /// the scheduler — live processes are the embedder's unfinished ordered
251    /// stop, and reaping them here would hide it.
252    ///
253    /// # Errors
254    ///
255    /// Returns the live process count as a typed residue refusal.
256    pub fn shutdown(self) -> Result<(), RuntimeError> {
257        let residue = self.scheduler.process_count();
258        if residue != 0 {
259            return Err(RuntimeError::ProcessResidue { count: residue });
260        }
261        self.scheduler.shutdown();
262        Ok(())
263    }
264}
265
266/// A `Send + Sync` handle for hot-swapping support modules from the
267/// management adapter's thread (the F-7b dev reload's FFI leg).
268pub struct SupportSwapper {
269    scheduler: Arc<Scheduler>,
270}
271
272impl SupportSwapper {
273    /// Hot-swaps a support module to new bytes of the SAME module name:
274    /// purges the generation the previous swap retained, then hot-loads
275    /// the new bytes — the module-generation wall never bites because
276    /// every swap purges before it loads. Only legal while every
277    /// component using the module is stopped (the barrier's order
278    /// guarantees this); a purge refusal is the typed drain-failure
279    /// signal.
280    ///
281    /// A deferred-`erlang:*` refusal here does NOT delete the module the
282    /// way `load_support_module`'s initial-load cleanup does: at this
283    /// point the bad bytes are current and the previous bytes are
284    /// retained old — the caller's rollback swap purges and reloads the
285    /// previous bytes, which a delete would sabotage.
286    ///
287    /// # Errors
288    ///
289    /// Returns typed failures for an unsafe purge, loader refusal, a
290    /// module absent immediately after load, or deferred `erlang:*`
291    /// imports.
292    pub fn swap(
293        &self,
294        current: &SupportModuleRef,
295        bytecode: &[u8],
296    ) -> Result<SupportModuleRef, RuntimeError> {
297        if self.scheduler.check_old_code(current.module) {
298            self.scheduler
299                .purge_module(current.module)
300                .map_err(|error| RuntimeError::SupportModulePurge {
301                    module: current.name.clone(),
302                    detail: error.to_string(),
303                })?;
304        }
305        let loaded = self.scheduler.hot_load_module(bytecode).map_err(|error| {
306            RuntimeError::SupportModuleLoad {
307                detail: error.to_string(),
308            }
309        })?;
310        let module = loaded.module_name;
311        let name = resolve_atom(&self.scheduler, module);
312        let Some(deferred) = deferred_erlang_imports(&self.scheduler, module) else {
313            return Err(RuntimeError::SupportModuleAbsent { module: name });
314        };
315        if !deferred.is_empty() {
316            return Err(RuntimeError::SupportModuleDeferredBifImports {
317                module: name,
318                imports: deferred.join(", "),
319            });
320        }
321        Ok(SupportModuleRef { module, name })
322    }
323}