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}