1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
use core::ffi::c_void;
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use polyplug_abi::runtime::{Compatibility, RuntimeConfig};
use polyplug_abi::types::{LogLevel, StringView};
use polyplug_abi::{HostApi, SupportedLanguage};
use crate::{
compatibility::CapabilityGraph,
error::{GraphError, LoaderError, RuntimeError},
loader::{BundleLoader, ManifestData},
logger::{LoggerClosure, LoggerHandle},
runtime::{ReloadCallback, Runtime},
runtime_store::RuntimeStore,
};
/// `RuntimeConfig::log` trampoline that forwards to the boxed Rust closure
/// installed via [`RuntimeBuilder::logger`].
///
/// # Safety
/// `user_data` must point to the [`LoggerClosure`] owned by the `Runtime`
/// (kept alive for the runtime's lifetime); `scope` and `message` must be
/// valid UTF-8 views for the duration of the call — both are guaranteed by
/// the runtime's logger plumbing, the only producer of these calls.
unsafe extern "C" fn rust_logger_trampoline(
user_data: *mut c_void,
level: u32,
scope: StringView,
message: StringView,
) {
if user_data.is_null() {
return;
}
// SAFETY: user_data points to the runtime-owned LoggerClosure (see function
// docs); the box lives for the runtime's lifetime, which covers every log call.
let callback: &LoggerClosure = unsafe { &*(user_data as *const LoggerClosure) };
// Unknown level values cannot occur from the runtime's own logger, but the
// conversion stays total: collapse anything unexpected to Error.
let level: LogLevel = match LogLevel::from_u32(level) {
Some(l) => l,
None => LogLevel::Error,
};
// SAFETY: the runtime's LoggerHandle built both views from live, UTF-8 Rust
// string data that outlives this call (documented callback contract).
let (scope_str, message_str): (&str, &str) = unsafe { (scope.as_str(), message.as_str()) };
callback.0.emit(level, scope_str, message_str);
}
/// Builder for constructing a Runtime.
pub struct RuntimeBuilder {
plugin_dirs: Vec<PathBuf>,
loaders: Vec<Box<dyn BundleLoader>>,
compatibility: Compatibility,
/// Boxed Rust logger closure (boxed for a thin, stable `user_data`
/// pointer); ownership moves into the Runtime so it outlives every log call.
logger_closure: Option<Box<LoggerClosure>>,
on_reload_cb: Option<ReloadCallback>,
config: RuntimeConfig,
host_language: SupportedLanguage,
}
impl RuntimeBuilder {
/// Create a new RuntimeBuilder with default settings.
pub fn new() -> RuntimeBuilder {
RuntimeBuilder {
plugin_dirs: Vec::new(),
loaders: Vec::new(),
compatibility: Compatibility::default(),
logger_closure: None,
on_reload_cb: None,
config: RuntimeConfig::default(),
host_language: SupportedLanguage::Rust,
}
}
/// Add a directory to scan for plugin bundles during `build()`.
pub fn plugin_dir(mut self, path: PathBuf) -> RuntimeBuilder {
self.plugin_dirs.push(path);
self
}
/// Register a bundle loader.
///
/// The loader is identified by `loader.loader_name()`. Duplicate registrations
/// (same loader name) are detected in `build()` and cause `build()` to return
/// `Err(RuntimeError::Loader(LoaderError::DuplicateLoader { .. }))`.
pub fn loader(mut self, loader: impl BundleLoader + 'static) -> RuntimeBuilder {
self.loaders.push(Box::new(loader));
self
}
/// Set the global compatibility mode for version negotiation.
/// Defaults to `Compatibility::Strict`.
pub fn compatibility(mut self, c: Compatibility) -> RuntimeBuilder {
self.compatibility = c;
self
}
/// Install a Rust closure as the runtime logger.
///
/// Ergonomic wrapper over `RuntimeConfig::log` for Rust hosts: the closure
/// is boxed, owned by the built `Runtime`, and reached through an
/// `extern "C"` trampoline. All levels are delivered
/// (`log_max_level = LogLevel::Trace`) — filter inside the closure if you
/// want less.
///
/// # Callback contract
/// - May be invoked from any thread.
/// - Must NOT re-enter the runtime (calling any runtime/HostApi function
/// from inside the closure may deadlock).
/// - The `scope` and `message` slices are valid only for the duration of
/// the call — copy them (`to_owned`) to retain.
/// - Scope examples: `"registry"`, `"loader.lua"`, `"reload"`.
///
/// Note: a later [`RuntimeBuilder::config`] call overwrites the
/// `log` / `log_user_data` / `log_max_level` fields this installs — set the
/// config first, then the logger.
pub fn logger(
mut self,
cb: impl Fn(LogLevel, &str, &str) + Send + Sync + 'static,
) -> RuntimeBuilder {
let holder: Box<LoggerClosure> = Box::new(LoggerClosure(Box::new(cb)));
self.config.log = Some(rust_logger_trampoline);
self.config.log_user_data = (&*holder) as *const LoggerClosure as *mut c_void;
self.config.log_max_level = LogLevel::Trace as u32;
self.logger_closure = Some(holder);
self
}
/// Register a callback fired after each successful interface swap, before dlclose.
///
/// The callback receives the opaque `RuntimeConfig::on_reload_user_data` pointer
/// (forwarded unchanged) and a `ReloadPhase` describing the reload phase. Set the
/// user-data pointer through [`RuntimeBuilder::config`].
pub fn on_reload(
mut self,
cb: impl Fn(*mut core::ffi::c_void, polyplug_abi::runtime::ReloadPhase) + Send + Sync + 'static,
) -> RuntimeBuilder {
self.on_reload_cb = Some(ReloadCallback(std::sync::Arc::new(cb)));
self
}
pub fn config(mut self, config: RuntimeConfig) -> RuntimeBuilder {
self.config = config;
self
}
/// Set the host language type.
/// Defaults to `SupportedLanguage::Rust`.
pub fn host_language(mut self, language: SupportedLanguage) -> RuntimeBuilder {
self.host_language = language;
self
}
/// Build the runtime.
//
// For MVP: scans plugin_dirs for .so/.dll/.dylib files,
// loads them in sorted order, registers interfaces.
// Full capability graph resolution is a future enhancement.
pub fn build(self) -> Result<Arc<Runtime>, RuntimeError> {
let logger: LoggerHandle = LoggerHandle::from_config(&self.config);
let registry: Arc<RuntimeStore> = Arc::new(RuntimeStore::with_logger(logger));
// Build the owned HostApi. The `Box` gives it a stable heap address that
// is independent of where the `Runtime` value lives, so the pointer handed
// to plugins survives the runtime's move into its `Arc`. Ownership lives in
// the `Runtime` (its last-declared field) and is reclaimed on teardown.
// The `runtime` field is null here and patched once below, after the
// Runtime is placed inside its Arc, so callbacks can recover the Runtime
// via `(*this).runtime`.
let host_abi: Box<HostApi> = Box::new(HostApi {
runtime: core::ptr::null_mut(),
register_guest_contract: crate::runtime::host_register_guest_contract,
alloc: crate::runtime::host_alloc,
free: crate::runtime::host_free,
find_guest_contract: crate::runtime::host_find_guest_contract,
find_all_guest_contracts: crate::runtime::host_find_all_guest_contracts,
resolve_guest_contract: crate::runtime::host_resolve_guest_contract,
get_host_contract: crate::runtime::host_get_host_contract,
resolve_host_contract_interface: crate::runtime::host_resolve_host_contract_interface,
list_bundles: crate::runtime::host_list_bundles,
get_dependencies: crate::runtime::host_get_dependencies,
// Host operations
load_bundle: crate::runtime::host_load_bundle,
reload_bundle: crate::runtime::host_reload_bundle,
register_host_contract: crate::runtime::host_register_host_contract,
register_loader: crate::runtime::host_register_loader,
get_last_error: crate::runtime::host_get_last_error,
get_error_len: crate::runtime::host_get_error_len,
unload_bundle: crate::runtime::host_unload_bundle,
log: crate::runtime::host_log,
create_guest_instance: crate::runtime::host_create_guest_instance,
destroy_guest_instance: crate::runtime::host_destroy_guest_instance,
revision_counter: crate::runtime::host_revision_counter,
reserved: core::ptr::null(),
});
let mut loader_map: HashMap<String, Box<dyn BundleLoader>> = HashMap::new();
// Register user-provided loaders, checking for duplicates.
for loader in self.loaders {
let name: &str = loader.loader_name();
if loader_map.contains_key(name) {
return Err(RuntimeError::Loader(LoaderError::DuplicateLoader {
loader_name: name.to_string(),
}));
}
loader_map.insert(name.to_string(), loader);
}
// Phase 1: Scan plugin directories for bundles
let scan: crate::loader::ScanResult = crate::loader::scan_dirs(&self.plugin_dirs);
// Surface every scan failure as a warning. Scanning is best-effort: a
// corrupt or unreadable bundle must not hide the others, but it must be
// visible to the host.
for diagnostic in &scan.diagnostics {
logger.log(LogLevel::Warn, "builder", || format!("scan: {diagnostic}"));
}
let discovered: Vec<(PathBuf, ManifestData)> = scan.found;
// Snapshot manifests for hot-reload cascade detection.
let mut manifests_map: HashMap<String, crate::loader::ManifestData> = HashMap::new();
for (path, manifest) in &discovered {
let mut stored_manifest: ManifestData = manifest.clone();
stored_manifest.path = path.clone();
manifests_map.insert(stored_manifest.name.clone(), stored_manifest);
}
// Create Runtime first (before loading bundles) so we can pass it to loaders
let runtime: Runtime = Runtime {
registry: Arc::clone(®istry),
host_abi,
loaders: std::sync::RwLock::new(loader_map),
bundle_manifests: std::sync::Mutex::new(manifests_map),
on_reload_cb: self.on_reload_cb,
config: self.config,
logger,
_logger_closure: self.logger_closure,
last_error: std::sync::Mutex::new(String::new()),
host_contracts: std::sync::RwLock::new(HashMap::new()),
singleton_instances: std::sync::RwLock::new(HashMap::new()),
host_language: self.host_language,
init_bundle_stack: std::sync::Mutex::new(HashMap::new()),
active_init_count: core::sync::atomic::AtomicUsize::new(0),
reload_serialize: std::sync::Mutex::new(()),
instance_counts: std::sync::Mutex::new(HashMap::new()),
};
let runtime: Arc<Runtime> = Arc::new(runtime);
// Patch the owned HostApi's `runtime` field to point at the Arc's target.
// The patch pointer is derived ENTIRELY through raw pointers from
// `Arc::as_ptr` — no intermediate `&`/`&mut` to the Runtime or HostApi is
// formed — so the write does not violate Stacked Borrows. `Box<HostApi>` is
// layout-identical to `*mut HostApi` (a single non-null pointer for a sized
// payload), so reading the field as `*mut HostApi` yields the Box's stable
// heap address — the same pointer plugins later receive via `host_abi()`.
//
// SAFETY: `rt_ptr` comes from `Arc::as_ptr` and is valid for the Arc's
// lifetime; `&raw const (*rt_ptr).host_abi` addresses the `Box<HostApi>`
// field in-bounds. Reading it as `*mut HostApi` is sound by the layout
// identity above and yields the live owned HostApi. No plugin has received
// that HostApi yet (bundle loading happens after this write), so this is a
// single writer with no concurrent reader and no aliasing live reference.
// The HostApi is owned by the Runtime the Arc holds, so it outlives the
// runtime pointer written here.
unsafe {
let rt_ptr: *const Runtime = Arc::as_ptr(&runtime);
let box_field_ptr: *const Box<HostApi> = &raw const (*rt_ptr).host_abi;
let host_abi_ptr: *mut HostApi = (box_field_ptr as *const *mut HostApi).read();
(*host_abi_ptr).runtime = rt_ptr as *mut c_void;
}
// If nothing discovered, return Runtime with no loaded bundles (no graph needed)
if !discovered.is_empty() {
// Phase 2: Build capability graph
let graph: CapabilityGraph =
CapabilityGraph::from_manifests_with_logger(&discovered, logger)
.map_err(|e: GraphError| RuntimeError::Graph(e))?;
// Phase 2.5: Validate version compatibility
crate::runtime::validate_bundle_compatibility(&discovered, self.compatibility, logger)?;
// Phase 3: Get topological load order (providers first)
let load_order: Vec<String> = graph
.topological_order()
.map_err(|e: GraphError| RuntimeError::Graph(e))?;
// Phase 4: Build lookup map bundle_name -> (path, manifest)
let mut bundle_map: HashMap<String, (PathBuf, ManifestData)> = HashMap::new();
for entry in discovered {
bundle_map.insert(entry.1.name.clone(), entry);
}
// Phase 5: Dispatch each bundle to its loader in topo order.
//
// Route every discovered bundle through the shared explicit-load path
// (`Runtime::load_manifest_with_source`) so it receives the exact same
// treatment as a bundle loaded via `Runtime::load_bundle`: manifest
// validation, init-time dependency declaration (so the plugin's
// `polyplug_init` can resolve declared dependencies), bundle-metadata
// registration (non-empty descriptors), function-count validation, and
// the `bundle_manifests` insert. The earlier `manifests_map`
// pre-population is overwritten with identical data by that insert.
for bundle_name in &load_order {
let (bundle_path, manifest): &(PathBuf, ManifestData) =
bundle_map.get(bundle_name).ok_or_else(|| {
RuntimeError::Loader(LoaderError::InitFailed {
bundle: bundle_name.clone(),
error: "bundle in topo order but not found in map".to_owned(),
})
})?;
let source: crate::loader::BundleSource =
crate::loader::BundleSource::Path(bundle_path.clone());
runtime
.load_manifest_with_source(
manifest.clone(),
source,
crate::runtime::LoadOptions {
compatibility: self.compatibility,
ignore_function_count_mismatch: false,
},
)
.map_err(|e: RuntimeError| match e {
RuntimeError::Loader(le) => RuntimeError::Loader(le),
other => RuntimeError::Loader(LoaderError::InitFailed {
bundle: manifest.name.clone(),
error: other.to_string(),
}),
})?;
}
}
Ok(runtime)
}
}
impl Default for RuntimeBuilder {
fn default() -> RuntimeBuilder {
RuntimeBuilder::new()
}
}