Skip to main content

reflow_pack_loader/
lib.rs

1//! Runtime loader for Reflow actor packs.
2//!
3//! A pack is a cdylib that exports two symbols:
4//!
5//! - `reflow_pack_abi_version(): u32` — handshake against `REFLOW_PACK_ABI_VERSION`
6//! - `reflow_pack_register(host: *mut PackHostVtable): i32` — calls back
7//!   into the host to register template factories
8//!
9//! Packs can be loaded either as raw dylibs (developer loop) or wrapped in
10//! a `.rflpack` zip bundle (distribution format). See [`bundle`].
11
12pub mod bundle;
13pub mod host;
14
15use std::collections::HashMap;
16use std::ffi::{CStr, c_char, c_void};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use anyhow::{Context, Result, anyhow, bail};
21use once_cell::sync::Lazy;
22use parking_lot::RwLock;
23use reflow_actor::Actor;
24
25use bundle::{
26    ABI_VERSION_SYMBOL, DEFAULT_ENTRYPOINT, PackManifest, extract_bundle, is_bundle_file,
27    read_manifest,
28};
29use host::{PackFactoryDropFn, PackFactoryFn, PackHostVtable, PackRegisterStatus};
30
31/// ABI version the host was built with. Packs must emit the same value
32/// from their `reflow_pack_abi_version` symbol.
33pub const REFLOW_PACK_ABI_VERSION: u32 = {
34    // Fallback for rust-analyzer / IDEs where build.rs hasn't run yet.
35    let s = match option_env!("REFLOW_PACK_ABI_VERSION") {
36        Some(s) => s,
37        None => "0",
38    };
39    parse_u32(s.as_bytes())
40};
41
42/// Host triple the loader was compiled for. Used to pick the right dylib
43/// out of a `.rflpack` manifest.
44pub const REFLOW_PACK_HOST_TRIPLE: &str = match option_env!("REFLOW_PACK_HOST_TRIPLE") {
45    Some(s) => s,
46    None => "unknown",
47};
48
49const fn parse_u32(bytes: &[u8]) -> u32 {
50    let mut out: u32 = 0;
51    let mut i = 0;
52    while i < bytes.len() {
53        let b = bytes[i];
54        if b < b'0' || b > b'9' {
55            return 0;
56        }
57        out = out * 10 + (b - b'0') as u32;
58        i += 1;
59    }
60    out
61}
62
63// ─── registry ──────────────────────────────────────────────────────────────
64
65struct FactoryRecord {
66    factory: PackFactoryFn,
67    drop: PackFactoryDropFn,
68    user_data: SendPtr,
69    // Keep the owning library pinned for the lifetime of the factory.
70    _owner: Arc<LoadedPack>,
71}
72
73impl Drop for FactoryRecord {
74    fn drop(&mut self) {
75        if let Some(drop_fn) = self.drop {
76            unsafe { drop_fn(self.user_data.0) };
77        }
78    }
79}
80
81struct SendPtr(*mut c_void);
82// Pack factories are explicitly documented as needing to be callable from
83// any thread. The pack author is responsible for making user_data Send+Sync.
84unsafe impl Send for SendPtr {}
85unsafe impl Sync for SendPtr {}
86
87struct LoadedPack {
88    name: String,
89    version: String,
90    #[allow(dead_code)]
91    manifest: Option<PackManifest>,
92    source_path: PathBuf,
93    templates: parking_lot::Mutex<Vec<String>>,
94    /// The dlopen handle. Native-only — wasm packs are loaded via
95    /// `WebAssembly.instantiate` from JS and registered through a
96    /// separate path that doesn't keep a Rust-side handle.
97    #[cfg(not(target_arch = "wasm32"))]
98    _lib: libloading::Library,
99}
100
101pub struct PackRegistry {
102    inner: RwLock<PackRegistryInner>,
103}
104
105struct PackRegistryInner {
106    /// Template-id → factory record. First-registered wins; duplicates
107    /// are rejected.
108    templates: HashMap<String, Arc<FactoryRecord>>,
109    /// Keyed by manifest.name (or file stem for raw dylib loads).
110    loaded: HashMap<String, Arc<LoadedPack>>,
111}
112
113impl PackRegistry {
114    fn new() -> Self {
115        Self {
116            inner: RwLock::new(PackRegistryInner {
117                templates: HashMap::new(),
118                loaded: HashMap::new(),
119            }),
120        }
121    }
122
123    /// Return a fresh actor instance for `template_id` if a pack has
124    /// registered it. `None` means "not a pack-owned template — fall back
125    /// to the bundled catalog."
126    pub fn instantiate(&self, template_id: &str) -> Option<Arc<dyn Actor>> {
127        let record = self.inner.read().templates.get(template_id).cloned()?;
128        let factory = record.factory?;
129        let ptr = unsafe { factory(record.user_data.0) };
130        if ptr.is_null() {
131            return None;
132        }
133        unsafe { host::PackActorHandle::unbox(ptr) }
134    }
135
136    /// List every template id any loaded pack has published.
137    pub fn template_ids(&self) -> Vec<String> {
138        let g = self.inner.read();
139        let mut v: Vec<String> = g.templates.keys().cloned().collect();
140        v.sort();
141        v
142    }
143
144    /// List every loaded pack's name + version + owning template count.
145    pub fn loaded_packs(&self) -> Vec<LoadedPackInfo> {
146        let g = self.inner.read();
147        g.loaded
148            .values()
149            .map(|p| LoadedPackInfo {
150                name: p.name.clone(),
151                version: p.version.clone(),
152                source_path: p.source_path.clone(),
153                templates: p.templates.lock().clone(),
154            })
155            .collect()
156    }
157}
158
159#[derive(Debug, Clone, serde::Serialize)]
160pub struct LoadedPackInfo {
161    pub name: String,
162    pub version: String,
163    pub source_path: PathBuf,
164    pub templates: Vec<String>,
165}
166
167/// Process-global pack registry. Packs are shared across every runtime
168/// handle in the process — matching the behaviour of the bundled component
169/// catalog (also a global static).
170pub static PACK_REGISTRY: Lazy<PackRegistry> = Lazy::new(PackRegistry::new);
171
172// ─── load ──────────────────────────────────────────────────────────────────
173
174/// Load a pack from either a raw dylib path or a `.rflpack` bundle.
175///
176/// Idempotent per pack name: loading the same pack twice is a no-op (returns
177/// `Ok` with the previously-registered template set reported via
178/// [`PackRegistry::loaded_packs`]).
179///
180/// **Native only.** wasm32 targets load packs through
181/// `WebAssembly.instantiate` from the JS side; this `dlopen`-based
182/// path is excluded there because `libloading` itself doesn't compile.
183#[cfg(not(target_arch = "wasm32"))]
184pub fn load_pack<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
185    let path = path.as_ref();
186    let canonical =
187        std::fs::canonicalize(path).with_context(|| format!("canonicalize {}", path.display()))?;
188
189    let (dylib_path, manifest): (PathBuf, Option<PackManifest>) =
190        if is_bundle_file(&canonical).unwrap_or(false) {
191            let extracted =
192                extract_bundle(&canonical, REFLOW_PACK_ABI_VERSION, REFLOW_PACK_HOST_TRIPLE)?;
193            (extracted.dylib_path, Some(extracted.manifest))
194        } else {
195            (canonical.clone(), None)
196        };
197
198    let pack_name = manifest
199        .as_ref()
200        .map(|m| m.name.clone())
201        .unwrap_or_else(|| {
202            canonical
203                .file_stem()
204                .and_then(|s| s.to_str())
205                .unwrap_or("pack")
206                .to_string()
207        });
208    let pack_version = manifest
209        .as_ref()
210        .map(|m| m.version.clone())
211        .unwrap_or_else(|| "0.0.0".to_string());
212    let entrypoint_name = manifest
213        .as_ref()
214        .map(|m| m.entrypoint.clone())
215        .unwrap_or_else(|| DEFAULT_ENTRYPOINT.to_string());
216
217    // Already loaded? Report the existing template set without reloading.
218    {
219        let g = PACK_REGISTRY.inner.read();
220        if let Some(existing) = g.loaded.get(&pack_name) {
221            return Ok(existing.templates.lock().clone());
222        }
223    }
224
225    // dlopen the dylib. The library's lifetime is tied to the LoadedPack
226    // Arc — once dropped, the OS unloads the image.
227    let lib = unsafe {
228        libloading::Library::new(&dylib_path)
229            .with_context(|| format!("dlopen {}", dylib_path.display()))?
230    };
231
232    // ABI symbol first — cheap sanity check even if manifest already
233    // vouched for it.
234    unsafe {
235        let sym: libloading::Symbol<unsafe extern "C" fn() -> u32> = lib
236            .get(ABI_VERSION_SYMBOL.as_bytes())
237            .with_context(|| format!("pack missing symbol `{ABI_VERSION_SYMBOL}`"))?;
238        let v = sym();
239        if v != REFLOW_PACK_ABI_VERSION {
240            bail!(
241                "pack `{pack_name}` ABI {v} != host ABI {} — rebuild pack against current toolchain",
242                REFLOW_PACK_ABI_VERSION
243            );
244        }
245    }
246
247    let owner = Arc::new(LoadedPack {
248        name: pack_name.clone(),
249        version: pack_version,
250        manifest,
251        source_path: canonical,
252        templates: parking_lot::Mutex::new(Vec::new()),
253        _lib: lib,
254    });
255
256    // Invoke the pack's register function. It calls back into us via
257    // `register_template_trampoline` with a per-load key so we can attribute
258    // factories to this pack.
259    {
260        let lib = &owner._lib;
261        let register: libloading::Symbol<unsafe extern "C" fn(*mut PackHostVtable) -> i32> = unsafe {
262            lib.get(entrypoint_name.as_bytes())
263                .with_context(|| format!("pack missing symbol `{entrypoint_name}`"))?
264        };
265        let key = RegisterKey::new(Arc::clone(&owner));
266        let key_box = Box::into_raw(Box::new(key));
267        let mut vtable = PackHostVtable {
268            host_data: key_box as *mut c_void,
269            register_template: register_template_trampoline,
270        };
271        let status = unsafe { register(&mut vtable as *mut PackHostVtable) };
272        // Reclaim the key so we don't leak it.
273        let _ = unsafe { Box::from_raw(key_box) };
274        if status != PackRegisterStatus::Ok as i32 {
275            bail!("pack `{pack_name}` register returned status {status} — registration aborted");
276        }
277    }
278
279    // Publish.
280    let templates = owner.templates.lock().clone();
281    PACK_REGISTRY
282        .inner
283        .write()
284        .loaded
285        .insert(pack_name, Arc::clone(&owner));
286    Ok(templates)
287}
288
289/// Read the manifest from a `.rflpack` without loading any code. Fails on
290/// raw dylibs (there's no manifest to read).
291pub fn inspect_pack<P: AsRef<Path>>(path: P) -> Result<PackManifest> {
292    let path = path.as_ref();
293    if !is_bundle_file(path).unwrap_or(false) {
294        bail!("inspect_pack requires a .rflpack bundle — raw dylibs have no manifest");
295    }
296    read_manifest(path)
297}
298
299// ─── host vtable implementation ────────────────────────────────────────────
300
301/// Per-load key handed to the pack through `host_data`. The trampoline
302/// downcasts the pointer on every call.
303struct RegisterKey {
304    owner: Arc<LoadedPack>,
305}
306
307impl RegisterKey {
308    fn new(owner: Arc<LoadedPack>) -> Self {
309        Self { owner }
310    }
311}
312
313unsafe extern "C" fn register_template_trampoline(
314    host_data: *mut c_void,
315    template_id: *const c_char,
316    factory: PackFactoryFn,
317    drop: PackFactoryDropFn,
318    factory_user_data: *mut c_void,
319) -> i32 {
320    if host_data.is_null() || template_id.is_null() || factory.is_none() {
321        return PackRegisterStatus::NullArg as i32;
322    }
323    let key = unsafe { &*(host_data as *const RegisterKey) };
324    let id = match unsafe { CStr::from_ptr(template_id) }.to_str() {
325        Ok(s) => s.to_string(),
326        Err(_) => return PackRegisterStatus::BadUtf8 as i32,
327    };
328
329    let record = Arc::new(FactoryRecord {
330        factory,
331        drop,
332        user_data: SendPtr(factory_user_data),
333        _owner: Arc::clone(&key.owner),
334    });
335
336    let mut g = PACK_REGISTRY.inner.write();
337    if g.templates.contains_key(&id) {
338        return PackRegisterStatus::Duplicate as i32;
339    }
340    g.templates.insert(id.clone(), record);
341    key.owner.templates.lock().push(id);
342    PackRegisterStatus::Ok as i32
343}
344
345// ─── utilities ─────────────────────────────────────────────────────────────
346
347/// True if the given template id is owned by a loaded pack. Cheap —
348/// callers can use this to decide whether to consult the pack registry or
349/// fall straight through to the bundled catalog.
350pub fn has_template(template_id: &str) -> bool {
351    PACK_REGISTRY
352        .inner
353        .read()
354        .templates
355        .contains_key(template_id)
356}
357
358/// Convenience wrapper around [`PackRegistry::instantiate`] on the global
359/// registry.
360pub fn instantiate(template_id: &str) -> Option<Arc<dyn Actor>> {
361    PACK_REGISTRY.instantiate(template_id)
362}
363
364/// Convenience: JSON array of pack info, matching the wire format
365/// `rfl_pack_list_json` surfaces.
366pub fn list_packs_json() -> Result<String> {
367    let list = PACK_REGISTRY.loaded_packs();
368    serde_json::to_string(&list).map_err(|e| anyhow!("serialize pack list: {e}"))
369}
370
371// Silence unused-import warnings when the crate's only callers are across
372// the cdylib boundary.
373#[allow(dead_code)]
374fn _types_used() {
375    let _: PackFactoryFn = None;
376    let _: PackFactoryDropFn = None;
377}