Skip to main content

nautilus_plugin/
loader.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Host-side plug-in loader.
17//!
18//! Gated behind the `host` feature so plug-in cdylibs never pull in
19//! `libloading`. Use [`PluginLoader`] from the live node startup to load every
20//! configured plug-in path in order before any subscriptions are registered.
21
22#![allow(unsafe_code)]
23
24use std::{
25    ffi::OsStr,
26    fmt::{Debug, Display},
27    mem::ManuallyDrop,
28    path::{Path, PathBuf},
29    slice,
30    sync::OnceLock,
31};
32
33use libloading::{Library, Symbol};
34
35use crate::{
36    NAUTILUS_PLUGIN_ABI_VERSION, NAUTILUS_PLUGIN_INIT_SYMBOL, PLUGIN_BUILD_ID_VERSION,
37    boundary::{BorrowedStr, PluginError, PluginErrorCode, PluginResult},
38    host::{HostContext, HostLogLevel, HostVTable},
39    manifest::{
40        PluginBuildId, PluginInitFn, PluginManifest, PluginManifestValidationErrors,
41        ValidatedPluginManifest,
42    },
43    surfaces::commands::{
44        CancelAllOrdersHandle, CancelOrderHandle, CancelOrdersHandle, CloseAllPositionsHandle,
45        ClosePositionHandle, ModifyOrderHandle, QueryAccountHandle, QueryOrderHandle,
46        SubmitOrderHandle, SubmitOrderListHandle,
47    },
48};
49
50/// Errors that can occur while loading a plug-in.
51#[derive(Debug, thiserror::Error)]
52pub enum LoadError {
53    #[error("failed to open plug-in '{path}': {source}")]
54    Open {
55        path: PathBuf,
56        #[source]
57        source: libloading::Error,
58    },
59
60    #[error("plug-in '{path}' is missing the `nautilus_plugin_init` symbol: {source}")]
61    MissingSymbol {
62        path: PathBuf,
63        #[source]
64        source: libloading::Error,
65    },
66
67    #[error("plug-in '{path}' returned a null manifest from `nautilus_plugin_init`")]
68    NullManifest { path: PathBuf },
69
70    #[error("plug-in '{path}' ABI mismatch: host = {expected}, plug-in = {actual}; {diagnostics}")]
71    AbiMismatch {
72        path: PathBuf,
73        expected: u32,
74        actual: u32,
75        diagnostics: Box<PluginManifestDiagnostics>,
76    },
77
78    #[error("plug-in '{path}' manifest validation failed: {diagnostics}; {errors}")]
79    InvalidManifest {
80        path: PathBuf,
81        diagnostics: Box<PluginManifestDiagnostics>,
82        #[source]
83        errors: PluginManifestValidationErrors,
84    },
85
86    #[error(
87        "plug-in '{path}' redeclares custom-data type '{type_name}' already provided by '{existing_path}'"
88    )]
89    DuplicateCustomDataType {
90        path: PathBuf,
91        type_name: String,
92        existing_path: PathBuf,
93    },
94}
95
96/// Owned manifest diagnostics captured before a rejected plug-in is unloaded.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct PluginManifestDiagnostics {
99    /// Manifest plug-in name, or empty when the manifest published none.
100    pub plugin_name: String,
101    /// Manifest plug-in version, or empty when the manifest published none.
102    pub plugin_version: String,
103    /// Manifest build identifier captured as owned strings.
104    pub build_id: PluginBuildIdDiagnostics,
105}
106
107impl PluginManifestDiagnostics {
108    fn from_manifest(manifest: &PluginManifest) -> Self {
109        Self {
110            plugin_name: borrowed_str_diagnostic(manifest.plugin_name),
111            plugin_version: borrowed_str_diagnostic(manifest.plugin_version),
112            build_id: PluginBuildIdDiagnostics::from_build_id(&manifest.build_id),
113        }
114    }
115
116    fn from_abi_mismatch_manifest(manifest: &PluginManifest) -> Self {
117        let build_id = if manifest.build_id.schema_version == PLUGIN_BUILD_ID_VERSION {
118            PluginBuildIdDiagnostics::from_build_id(&manifest.build_id)
119        } else {
120            PluginBuildIdDiagnostics::schema_only(manifest.build_id.schema_version)
121        };
122        Self {
123            plugin_name: borrowed_str_diagnostic(manifest.plugin_name),
124            plugin_version: borrowed_str_diagnostic(manifest.plugin_version),
125            build_id,
126        }
127    }
128}
129
130impl Display for PluginManifestDiagnostics {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        let plugin_name = unknown_if_empty(&self.plugin_name);
133        let plugin_version = unknown_if_empty(&self.plugin_version);
134        let build_id = &self.build_id;
135        write!(
136            f,
137            "manifest name='{plugin_name}', version='{plugin_version}', {build_id}"
138        )
139    }
140}
141
142/// Owned build identifier diagnostics for loader errors and logs.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct PluginBuildIdDiagnostics {
145    /// Build identifier schema version published by the manifest.
146    pub schema_version: u32,
147    /// `nautilus-plugin` crate version, or empty when unavailable.
148    pub nautilus_plugin_version: String,
149    /// Rust compiler version, or empty when unavailable.
150    pub rustc_version: String,
151    /// Cargo target triple, or empty when unavailable.
152    pub target_triple: String,
153    /// Cargo build profile, or empty when unavailable.
154    pub build_profile: String,
155    /// Model fixed-point precision mode, or empty when unavailable.
156    pub precision_mode: String,
157    /// Maximum fixed-point decimal precision, or none when unavailable.
158    pub fixed_precision: Option<u8>,
159}
160
161impl PluginBuildIdDiagnostics {
162    fn from_build_id(build_id: &PluginBuildId) -> Self {
163        Self {
164            schema_version: build_id.schema_version,
165            nautilus_plugin_version: borrowed_str_diagnostic(build_id.nautilus_plugin_version),
166            rustc_version: borrowed_str_diagnostic(build_id.rustc_version),
167            target_triple: borrowed_str_diagnostic(build_id.target_triple),
168            build_profile: borrowed_str_diagnostic(build_id.build_profile),
169            precision_mode: borrowed_str_diagnostic(build_id.precision_mode),
170            fixed_precision: Some(build_id.fixed_precision),
171        }
172    }
173
174    fn schema_only(schema_version: u32) -> Self {
175        Self {
176            schema_version,
177            nautilus_plugin_version: String::new(),
178            rustc_version: String::new(),
179            target_triple: String::new(),
180            build_profile: String::new(),
181            precision_mode: String::new(),
182            fixed_precision: None,
183        }
184    }
185}
186
187impl Display for PluginBuildIdDiagnostics {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        let schema_version = self.schema_version;
190        let nautilus_plugin_version = unknown_if_empty(&self.nautilus_plugin_version);
191        let rustc_version = unknown_if_empty(&self.rustc_version);
192        let target_triple = unknown_if_empty(&self.target_triple);
193        let build_profile = unknown_if_empty(&self.build_profile);
194        let precision_mode = unknown_if_empty(&self.precision_mode);
195        let fixed_precision = self
196            .fixed_precision
197            .map_or_else(|| "<unknown>".to_string(), |value| value.to_string());
198        write!(f, "build_id(schema={schema_version}, ")?;
199        write!(f, "nautilus_plugin_version='{nautilus_plugin_version}', ")?;
200        write!(f, "rustc='{rustc_version}', target='{target_triple}', ")?;
201        write!(
202            f,
203            "profile='{build_profile}', precision_mode='{precision_mode}', "
204        )?;
205        write!(f, "fixed_precision={fixed_precision})")
206    }
207}
208
209fn unknown_if_empty(value: &str) -> &str {
210    if value.is_empty() { "<unknown>" } else { value }
211}
212
213fn borrowed_str_diagnostic(value: BorrowedStr<'_>) -> String {
214    if value.ptr.is_null() || value.len == 0 {
215        return String::new();
216    }
217
218    // SAFETY: manifest strings live in static cdylib storage while the
219    // library is loaded.
220    let bytes = unsafe { slice::from_raw_parts(value.ptr, value.len) };
221    String::from_utf8_lossy(bytes).into_owned()
222}
223
224/// One loaded plug-in. Holds the `Library` alive for the process lifetime so
225/// the manifest pointer never dangles.
226///
227/// `library` is wrapped in [`ManuallyDrop`] so dropping the `LoadedPlugin`
228/// (or the owning `PluginLoader`) does NOT `dlclose` the cdylib. v1 leaks
229/// the handle intentionally: any manifest, vtable, or `drop_fn` pointer the
230/// host has copied into its registries must outlive the loader. Unloading
231/// would dangle every such pointer, and a later custom-data drop call would
232/// jump into freed code.
233pub struct LoadedPlugin {
234    path: PathBuf,
235    _library: ManuallyDrop<Library>,
236    manifest: ValidatedPluginManifest<'static>,
237}
238
239impl Debug for LoadedPlugin {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        f.debug_struct(stringify!(LoadedPlugin))
242            .field("path", &self.path)
243            .finish()
244    }
245}
246
247/// SAFETY: `LoadedPlugin` only exposes the manifest through `&self`, and the
248/// manifest is immutable static data inside the loaded library.
249unsafe impl Send for LoadedPlugin {}
250/// SAFETY: see above.
251unsafe impl Sync for LoadedPlugin {}
252
253impl LoadedPlugin {
254    /// Returns the file path this plug-in was loaded from.
255    #[must_use]
256    pub fn path(&self) -> &Path {
257        &self.path
258    }
259
260    /// Returns the manifest the plug-in published at init time.
261    #[must_use]
262    pub fn manifest(&self) -> &PluginManifest {
263        self.manifest.manifest()
264    }
265
266    /// Returns a host-side manifest view that carries validation invariants.
267    #[must_use]
268    pub fn validated_manifest(&self) -> ValidatedPluginManifest<'static> {
269        self.manifest
270    }
271}
272
273/// Loader for plug-in cdylibs.
274///
275/// Owns every `Library` for the lifetime of the live node, since v1 does not
276/// support `dlclose`. Caller walks the returned [`LoadedPlugin`] manifests to
277/// register entries into the relevant runtime registries.
278#[derive(Debug, Default)]
279pub struct PluginLoader {
280    loaded: Vec<LoadedPlugin>,
281    host: Option<*const HostVTable>,
282}
283
284/// SAFETY: `*const HostVTable` is a process-lifetime static pointer; the host
285/// commits to keeping the vtable live and the inner fn pointers are Sync by
286/// construction.
287unsafe impl Send for PluginLoader {}
288/// SAFETY: see above.
289unsafe impl Sync for PluginLoader {}
290
291impl PluginLoader {
292    /// Creates a new, empty loader that hands every plug-in the default
293    /// `nautilus-plugin` host vtable. The default vtable carries
294    /// `NotImplemented` stubs for stateful host callbacks; use
295    /// [`PluginLoader::with_host`] to install a live-node vtable.
296    #[must_use]
297    pub fn new() -> Self {
298        Self {
299            loaded: Vec::new(),
300            host: None,
301        }
302    }
303
304    /// Creates a new, empty loader that will hand every loaded plug-in the
305    /// supplied `host` vtable instead of the default.
306    ///
307    /// `host` must remain live for the lifetime of every plug-in loaded
308    /// through this loader (typically the process lifetime).
309    #[must_use]
310    pub fn with_host(host: *const HostVTable) -> Self {
311        Self {
312            loaded: Vec::new(),
313            host: Some(host),
314        }
315    }
316
317    /// Loads every plug-in path in order. Stops on the first error.
318    pub fn load_all<P>(&mut self, paths: impl IntoIterator<Item = P>) -> Result<(), LoadError>
319    where
320        P: AsRef<OsStr>,
321    {
322        for p in paths {
323            self.load(p.as_ref())?;
324        }
325        Ok(())
326    }
327
328    /// Loads a single plug-in cdylib.
329    pub fn load(&mut self, path: impl AsRef<OsStr>) -> Result<&LoadedPlugin, LoadError> {
330        let path_buf = PathBuf::from(path.as_ref());
331
332        // SAFETY: `Library::new` is unsafe because loading runs arbitrary code
333        // in the cdylib's static initializers. The caller of `PluginLoader`
334        // commits to trusting the plug-in path before adding it to config.
335        let library = unsafe { Library::new(path.as_ref()) }.map_err(|e| LoadError::Open {
336            path: path_buf.clone(),
337            source: e,
338        })?;
339
340        let manifest_ptr = {
341            // SAFETY: looking up a known symbol name in an opened library.
342            let init: Symbol<PluginInitFn> = unsafe { library.get(NAUTILUS_PLUGIN_INIT_SYMBOL) }
343                .map_err(|e| LoadError::MissingSymbol {
344                    path: path_buf.clone(),
345                    source: e,
346                })?;
347            let host = self.host.unwrap_or_else(host_vtable);
348            // SAFETY: calling the plug-in's published init symbol with our
349            // host vtable. The plug-in promises to read the vtable and return
350            // a valid `*const PluginManifest` or null.
351            unsafe { init(host) }
352        };
353
354        let manifest = validate_manifest_ptr(manifest_ptr, &path_buf)?;
355
356        let collision = {
357            let new_types: Vec<&str> = manifest.custom_data().map(|e| e.type_name()).collect();
358            let existing: Vec<(&str, &Path)> = self
359                .loaded
360                .iter()
361                .flat_map(|loaded| {
362                    let loaded_path = loaded.path();
363                    loaded
364                        .validated_manifest()
365                        .custom_data()
366                        .map(move |entry| (entry.type_name(), loaded_path))
367                })
368                .collect();
369            first_duplicate_custom_data_type(&new_types, &existing).map(
370                |(type_name, existing_path)| (type_name.to_string(), existing_path.to_path_buf()),
371            )
372        };
373
374        if let Some((type_name, existing_path)) = collision {
375            return Err(LoadError::DuplicateCustomDataType {
376                path: path_buf,
377                type_name,
378                existing_path,
379            });
380        }
381
382        let manifest_ref = manifest.manifest();
383        let abi = manifest_ref.abi_version;
384        let custom_data_count = manifest.custom_data().len();
385        let actor_count = manifest.actors().len();
386        let strategy_count = manifest.strategies().len();
387        let controller_count = manifest.controllers().len();
388        let build_id = PluginBuildIdDiagnostics::from_build_id(&manifest_ref.build_id);
389        log::info!(
390            target: "nautilus_plugin",
391            "Loaded plug-in '{}' (abi={abi}, {build_id}, custom_data={custom_data_count}, actors={actor_count}, strategies={strategy_count}, controllers={controller_count}) from {}",
392            manifest.plugin_name(),
393            path_buf.display(),
394        );
395
396        self.loaded.push(LoadedPlugin {
397            path: path_buf,
398            _library: ManuallyDrop::new(library),
399            manifest,
400        });
401        Ok(self.loaded.last().expect("just pushed"))
402    }
403
404    /// Returns every loaded plug-in in load order.
405    #[must_use]
406    pub fn loaded(&self) -> &[LoadedPlugin] {
407        &self.loaded
408    }
409
410    /// Returns the number of loaded plug-ins.
411    #[must_use]
412    pub fn len(&self) -> usize {
413        self.loaded.len()
414    }
415
416    /// Returns whether no plug-ins have been loaded.
417    #[must_use]
418    pub fn is_empty(&self) -> bool {
419        self.loaded.is_empty()
420    }
421}
422
423/// Validates a manifest pointer returned from `nautilus_plugin_init`.
424///
425/// Factored out so the `NullManifest` and `AbiMismatch` branches are
426/// directly testable without spinning up a dedicated cdylib for each
427/// failure mode.
428fn validate_manifest_ptr(
429    manifest_ptr: *const PluginManifest,
430    path: &Path,
431) -> Result<ValidatedPluginManifest<'static>, LoadError> {
432    if manifest_ptr.is_null() {
433        return Err(LoadError::NullManifest {
434            path: path.to_path_buf(),
435        });
436    }
437    // SAFETY: pointer is non-null per the check above.
438    let manifest = unsafe { &*manifest_ptr };
439    let abi = manifest.abi_version;
440    if abi != NAUTILUS_PLUGIN_ABI_VERSION {
441        return Err(LoadError::AbiMismatch {
442            path: path.to_path_buf(),
443            expected: NAUTILUS_PLUGIN_ABI_VERSION,
444            actual: abi,
445            diagnostics: Box::new(PluginManifestDiagnostics::from_abi_mismatch_manifest(
446                manifest,
447            )),
448        });
449    }
450
451    match ValidatedPluginManifest::new(manifest) {
452        Ok(manifest) => Ok(manifest),
453        Err(errors) => Err(LoadError::InvalidManifest {
454            path: path.to_path_buf(),
455            diagnostics: Box::new(PluginManifestDiagnostics::from_manifest(manifest)),
456            errors,
457        }),
458    }
459}
460
461/// Returns the first custom-data type name in `new_types` that a previously
462/// loaded plug-in (`existing`) already declares, paired with the path that
463/// declared it first.
464///
465/// Host JSON-deserializer registration is keyed by type name and keeps the
466/// first registration, so a second plug-in declaring an already-registered
467/// type name would have its decoder silently ignored. The loader rejects the
468/// collision instead of letting it pass unnoticed. The intra-plug-in case
469/// (one manifest declaring a name twice) is already caught by manifest
470/// validation; this guards the cross-plug-in case the single-manifest check
471/// cannot see.
472fn first_duplicate_custom_data_type<'a>(
473    new_types: &[&'a str],
474    existing: &[(&'a str, &'a Path)],
475) -> Option<(&'a str, &'a Path)> {
476    new_types.iter().find_map(|&new_type| {
477        existing
478            .iter()
479            .find(|(existing_type, _)| *existing_type == new_type)
480            .map(|&(_, path)| (new_type, path))
481    })
482}
483
484/// Returns the process-wide static `HostVTable` exposed to plug-ins.
485///
486/// One `&'static HostVTable` is enough because plug-ins never compare
487/// vtables; they only call through the function pointers. During alpha,
488/// methods can be added by rebuilding plug-ins to match the host.
489fn host_vtable() -> *const HostVTable {
490    static HOST: OnceLock<HostVTable> = OnceLock::new();
491    std::ptr::from_ref(HOST.get_or_init(|| HostVTable {
492        abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
493        clock_now_ns: host_clock_now_ns,
494        log: host_log,
495        cache_instrument: host_cache_instrument_unbound,
496        cache_account: host_cache_account_unbound,
497        cache_order: host_cache_order_unbound,
498        cache_position: host_cache_position_unbound,
499        cache_orders_for_strategy: host_cache_orders_for_strategy_unbound,
500        cache_positions_for_strategy: host_cache_positions_for_strategy_unbound,
501        subscribe_quotes: host_subscribe_quotes_unbound,
502        unsubscribe_quotes: host_unsubscribe_quotes_unbound,
503        subscribe_trades: host_subscribe_trades_unbound,
504        unsubscribe_trades: host_unsubscribe_trades_unbound,
505        subscribe_bars: host_subscribe_bars_unbound,
506        unsubscribe_bars: host_unsubscribe_bars_unbound,
507        subscribe_book_deltas: host_subscribe_book_deltas_unbound,
508        unsubscribe_book_deltas: host_unsubscribe_book_deltas_unbound,
509        subscribe_book_at_interval: host_subscribe_book_at_interval_unbound,
510        unsubscribe_book_at_interval: host_unsubscribe_book_at_interval_unbound,
511        msgbus_publish: host_msgbus_publish_unbound,
512        set_time_alert: host_set_time_alert_unbound,
513        set_timer: host_set_timer_unbound,
514        cancel_timer: host_cancel_timer_unbound,
515        submit_order: host_submit_order_unbound,
516        cancel_order: host_cancel_order_unbound,
517        modify_order: host_modify_order_unbound,
518        submit_order_list: host_submit_order_list_unbound,
519        cancel_orders: host_cancel_orders_unbound,
520        cancel_all_orders: host_cancel_all_orders_unbound,
521        close_position: host_close_position_unbound,
522        close_all_positions: host_close_all_positions_unbound,
523        query_account: host_query_account_unbound,
524        query_order: host_query_order_unbound,
525    }))
526}
527
528unsafe extern "C" fn host_clock_now_ns() -> u64 {
529    use std::time::{SystemTime, UNIX_EPOCH};
530
531    SystemTime::now()
532        .duration_since(UNIX_EPOCH)
533        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
534}
535
536macro_rules! unbound_bytes_fn {
537    ($name:ident, $message:literal, ($($arg:ident : $ty:ty),* $(,)?)) => {
538        unsafe extern "C" fn $name($($arg: $ty),*) -> PluginResult<crate::OwnedBytes> {
539            $(let _ = $arg;)*
540            PluginResult::Err(PluginError::new(PluginErrorCode::NotImplemented, $message))
541        }
542    };
543}
544
545macro_rules! unbound_unit_fn {
546    ($name:ident, $message:literal, ($($arg:ident : $ty:ty),* $(,)?)) => {
547        unsafe extern "C" fn $name($($arg: $ty),*) -> PluginResult<()> {
548            $(let _ = $arg;)*
549            PluginResult::Err(PluginError::new(PluginErrorCode::NotImplemented, $message))
550        }
551    };
552}
553
554unbound_bytes_fn!(
555    host_cache_instrument_unbound,
556    "cache_instrument is not wired into this host vtable",
557    (ctx: *const HostContext, instrument_id: BorrowedStr<'_>)
558);
559unbound_bytes_fn!(
560    host_cache_account_unbound,
561    "cache_account is not wired into this host vtable",
562    (ctx: *const HostContext, account_id: BorrowedStr<'_>)
563);
564unbound_bytes_fn!(
565    host_cache_order_unbound,
566    "cache_order is not wired into this host vtable",
567    (ctx: *const HostContext, client_order_id: BorrowedStr<'_>)
568);
569unbound_bytes_fn!(
570    host_cache_position_unbound,
571    "cache_position is not wired into this host vtable",
572    (ctx: *const HostContext, position_id: BorrowedStr<'_>)
573);
574unbound_bytes_fn!(
575    host_cache_orders_for_strategy_unbound,
576    "cache_orders_for_strategy is not wired into this host vtable",
577    (ctx: *const HostContext, strategy_id: BorrowedStr<'_>)
578);
579unbound_bytes_fn!(
580    host_cache_positions_for_strategy_unbound,
581    "cache_positions_for_strategy is not wired into this host vtable",
582    (ctx: *const HostContext, strategy_id: BorrowedStr<'_>)
583);
584
585unbound_unit_fn!(
586    host_subscribe_quotes_unbound,
587    "subscribe_quotes is not wired into this host vtable",
588    (
589        ctx: *const HostContext,
590        instrument_id: BorrowedStr<'_>,
591        client_id: BorrowedStr<'_>,
592        params_json: BorrowedStr<'_>,
593    )
594);
595unbound_unit_fn!(
596    host_unsubscribe_quotes_unbound,
597    "unsubscribe_quotes is not wired into this host vtable",
598    (
599        ctx: *const HostContext,
600        instrument_id: BorrowedStr<'_>,
601        client_id: BorrowedStr<'_>,
602        params_json: BorrowedStr<'_>,
603    )
604);
605unbound_unit_fn!(
606    host_subscribe_trades_unbound,
607    "subscribe_trades is not wired into this host vtable",
608    (
609        ctx: *const HostContext,
610        instrument_id: BorrowedStr<'_>,
611        client_id: BorrowedStr<'_>,
612        params_json: BorrowedStr<'_>,
613    )
614);
615unbound_unit_fn!(
616    host_unsubscribe_trades_unbound,
617    "unsubscribe_trades is not wired into this host vtable",
618    (
619        ctx: *const HostContext,
620        instrument_id: BorrowedStr<'_>,
621        client_id: BorrowedStr<'_>,
622        params_json: BorrowedStr<'_>,
623    )
624);
625unbound_unit_fn!(
626    host_subscribe_bars_unbound,
627    "subscribe_bars is not wired into this host vtable",
628    (
629        ctx: *const HostContext,
630        bar_type: BorrowedStr<'_>,
631        client_id: BorrowedStr<'_>,
632        params_json: BorrowedStr<'_>,
633    )
634);
635unbound_unit_fn!(
636    host_unsubscribe_bars_unbound,
637    "unsubscribe_bars is not wired into this host vtable",
638    (
639        ctx: *const HostContext,
640        bar_type: BorrowedStr<'_>,
641        client_id: BorrowedStr<'_>,
642        params_json: BorrowedStr<'_>,
643    )
644);
645unbound_unit_fn!(
646    host_subscribe_book_deltas_unbound,
647    "subscribe_book_deltas is not wired into this host vtable",
648    (
649        ctx: *const HostContext,
650        instrument_id: BorrowedStr<'_>,
651        book_type: u8,
652        depth: usize,
653        client_id: BorrowedStr<'_>,
654        managed: u8,
655        params_json: BorrowedStr<'_>,
656    )
657);
658unbound_unit_fn!(
659    host_unsubscribe_book_deltas_unbound,
660    "unsubscribe_book_deltas is not wired into this host vtable",
661    (
662        ctx: *const HostContext,
663        instrument_id: BorrowedStr<'_>,
664        client_id: BorrowedStr<'_>,
665        params_json: BorrowedStr<'_>,
666    )
667);
668unbound_unit_fn!(
669    host_subscribe_book_at_interval_unbound,
670    "subscribe_book_at_interval is not wired into this host vtable",
671    (
672        ctx: *const HostContext,
673        instrument_id: BorrowedStr<'_>,
674        book_type: u8,
675        depth: usize,
676        interval_ms: usize,
677        client_id: BorrowedStr<'_>,
678        params_json: BorrowedStr<'_>,
679    )
680);
681unbound_unit_fn!(
682    host_unsubscribe_book_at_interval_unbound,
683    "unsubscribe_book_at_interval is not wired into this host vtable",
684    (
685        ctx: *const HostContext,
686        instrument_id: BorrowedStr<'_>,
687        interval_ms: usize,
688        client_id: BorrowedStr<'_>,
689        params_json: BorrowedStr<'_>,
690    )
691);
692unbound_unit_fn!(
693    host_msgbus_publish_unbound,
694    "msgbus_publish is not wired into this host vtable",
695    (
696        ctx: *const HostContext,
697        topic: BorrowedStr<'_>,
698        payload: crate::Slice<'_, u8>,
699    )
700);
701unbound_unit_fn!(
702    host_set_time_alert_unbound,
703    "set_time_alert is not wired into this host vtable",
704    (
705        ctx: *const HostContext,
706        name: BorrowedStr<'_>,
707        alert_time_ns: u64,
708        allow_past: u8,
709    )
710);
711unbound_unit_fn!(
712    host_set_timer_unbound,
713    "set_timer is not wired into this host vtable",
714    (
715        ctx: *const HostContext,
716        name: BorrowedStr<'_>,
717        interval_ns: u64,
718        start_time_ns: u64,
719        stop_time_ns: u64,
720        allow_past: u8,
721        fire_immediately: u8,
722    )
723);
724unbound_unit_fn!(
725    host_cancel_timer_unbound,
726    "cancel_timer is not wired into this host vtable",
727    (ctx: *const HostContext, name: BorrowedStr<'_>)
728);
729
730unsafe extern "C" fn host_submit_order_unbound(
731    _ctx: *const HostContext,
732    _command: *const SubmitOrderHandle,
733) -> PluginResult<()> {
734    PluginResult::Err(PluginError::new(
735        PluginErrorCode::NotImplemented,
736        "submit_order is not wired into this host vtable",
737    ))
738}
739
740unsafe extern "C" fn host_cancel_order_unbound(
741    _ctx: *const HostContext,
742    _command: *const CancelOrderHandle,
743) -> PluginResult<()> {
744    PluginResult::Err(PluginError::new(
745        PluginErrorCode::NotImplemented,
746        "cancel_order is not wired into this host vtable",
747    ))
748}
749
750unsafe extern "C" fn host_modify_order_unbound(
751    _ctx: *const HostContext,
752    _command: *const ModifyOrderHandle,
753) -> PluginResult<()> {
754    PluginResult::Err(PluginError::new(
755        PluginErrorCode::NotImplemented,
756        "modify_order is not wired into this host vtable",
757    ))
758}
759
760unsafe extern "C" fn host_submit_order_list_unbound(
761    _ctx: *const HostContext,
762    _command: *const SubmitOrderListHandle,
763) -> PluginResult<()> {
764    PluginResult::Err(PluginError::new(
765        PluginErrorCode::NotImplemented,
766        "submit_order_list is not wired into this host vtable",
767    ))
768}
769
770unsafe extern "C" fn host_cancel_orders_unbound(
771    _ctx: *const HostContext,
772    _command: *const CancelOrdersHandle,
773) -> PluginResult<()> {
774    PluginResult::Err(PluginError::new(
775        PluginErrorCode::NotImplemented,
776        "cancel_orders is not wired into this host vtable",
777    ))
778}
779
780unsafe extern "C" fn host_cancel_all_orders_unbound(
781    _ctx: *const HostContext,
782    _command: *const CancelAllOrdersHandle,
783) -> PluginResult<()> {
784    PluginResult::Err(PluginError::new(
785        PluginErrorCode::NotImplemented,
786        "cancel_all_orders is not wired into this host vtable",
787    ))
788}
789
790unsafe extern "C" fn host_close_position_unbound(
791    _ctx: *const HostContext,
792    _command: *const ClosePositionHandle,
793) -> PluginResult<()> {
794    PluginResult::Err(PluginError::new(
795        PluginErrorCode::NotImplemented,
796        "close_position is not wired into this host vtable",
797    ))
798}
799
800unsafe extern "C" fn host_close_all_positions_unbound(
801    _ctx: *const HostContext,
802    _command: *const CloseAllPositionsHandle,
803) -> PluginResult<()> {
804    PluginResult::Err(PluginError::new(
805        PluginErrorCode::NotImplemented,
806        "close_all_positions is not wired into this host vtable",
807    ))
808}
809
810unsafe extern "C" fn host_query_account_unbound(
811    _ctx: *const HostContext,
812    _command: *const QueryAccountHandle,
813) -> PluginResult<()> {
814    PluginResult::Err(PluginError::new(
815        PluginErrorCode::NotImplemented,
816        "query_account is not wired into this host vtable",
817    ))
818}
819
820unsafe extern "C" fn host_query_order_unbound(
821    _ctx: *const HostContext,
822    _command: *const QueryOrderHandle,
823) -> PluginResult<()> {
824    PluginResult::Err(PluginError::new(
825        PluginErrorCode::NotImplemented,
826        "query_order is not wired into this host vtable",
827    ))
828}
829
830unsafe extern "C" fn host_log(
831    level: HostLogLevel,
832    target: BorrowedStr<'_>,
833    message: BorrowedStr<'_>,
834) {
835    // SAFETY: producer holds the storage live across the call.
836    let target = unsafe { target.as_str() };
837    // SAFETY: see above.
838    let message = unsafe { message.as_str() };
839    match level {
840        HostLogLevel::Error => log::error!(target: "nautilus_plugin", "[{target}] {message}"),
841        HostLogLevel::Warn => log::warn!(target: "nautilus_plugin", "[{target}] {message}"),
842        HostLogLevel::Info => log::info!(target: "nautilus_plugin", "[{target}] {message}"),
843        HostLogLevel::Debug => log::debug!(target: "nautilus_plugin", "[{target}] {message}"),
844        HostLogLevel::Trace => log::trace!(target: "nautilus_plugin", "[{target}] {message}"),
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use nautilus_model::types::fixed::FIXED_PRECISION;
851    use rstest::rstest;
852
853    use super::*;
854    use crate::{
855        boundary::Slice,
856        manifest::{CustomDataRegistration, PluginBuildId},
857        surfaces::custom_data::{CustomDataVTable, PluginCustomData, custom_data_vtable},
858    };
859
860    #[derive(Clone, PartialEq)]
861    struct LoaderTestTick;
862
863    impl PluginCustomData for LoaderTestTick {
864        const TYPE_NAME: &'static str = "LoaderTestTick";
865
866        fn ts_event(&self) -> u64 {
867            0
868        }
869
870        fn ts_init(&self) -> u64 {
871            0
872        }
873
874        fn to_json(&self) -> anyhow::Result<Vec<u8>> {
875            Ok(Vec::new())
876        }
877
878        fn from_json(_payload: &[u8]) -> anyhow::Result<Self> {
879            Ok(Self)
880        }
881
882        fn schema_ipc() -> anyhow::Result<Vec<u8>> {
883            Ok(Vec::new())
884        }
885
886        fn encode_batch(_items: &[&Self]) -> anyhow::Result<Vec<u8>> {
887            Ok(Vec::new())
888        }
889
890        fn decode_batch(
891            _ipc_bytes: &[u8],
892            _metadata: &[(String, String)],
893        ) -> anyhow::Result<Vec<Self>> {
894            Ok(Vec::new())
895        }
896    }
897
898    fn custom_data_vtable_missing_to_json() -> *const CustomDataVTable {
899        let valid = custom_data_vtable::<LoaderTestTick>();
900        // SAFETY: generated test vtable lives for the process lifetime.
901        let valid = unsafe { &*valid };
902        let vtable = Box::leak(Box::new(CustomDataVTable {
903            type_name: valid.type_name,
904            schema_ipc: valid.schema_ipc,
905            from_json: valid.from_json,
906            encode_batch: valid.encode_batch,
907            decode_batch: valid.decode_batch,
908            ts_event: valid.ts_event,
909            ts_init: valid.ts_init,
910            to_json: None,
911            clone_handle: valid.clone_handle,
912            drop_handle: valid.drop_handle,
913            eq_handles: valid.eq_handles,
914        }));
915        std::ptr::from_ref(&*vtable)
916    }
917
918    #[rstest]
919    fn empty_loader_is_empty() {
920        let loader = PluginLoader::new();
921        assert!(loader.is_empty());
922        assert_eq!(loader.len(), 0);
923        assert!(loader.loaded().is_empty());
924    }
925
926    #[rstest]
927    fn first_duplicate_custom_data_type_finds_cross_plugin_collision() {
928        let path_a = Path::new("/plugins/a.so");
929        let path_b = Path::new("/plugins/b.so");
930        let existing = [
931            ("AlphaTick", path_a),
932            ("BetaTick", path_a),
933            ("GammaTick", path_b),
934        ];
935        let new_types = ["DeltaTick", "BetaTick"];
936
937        let hit = first_duplicate_custom_data_type(&new_types, &existing);
938
939        assert_eq!(hit, Some(("BetaTick", path_a)));
940    }
941
942    #[rstest]
943    fn first_duplicate_custom_data_type_returns_none_when_disjoint() {
944        let path_a = Path::new("/plugins/a.so");
945        let existing = [("AlphaTick", path_a)];
946        let new_types = ["BetaTick", "GammaTick"];
947
948        assert_eq!(
949            first_duplicate_custom_data_type(&new_types, &existing),
950            None
951        );
952    }
953
954    #[rstest]
955    fn first_duplicate_custom_data_type_handles_empty_inputs() {
956        let path_a = Path::new("/plugins/a.so");
957        let existing = [("AlphaTick", path_a)];
958
959        assert_eq!(first_duplicate_custom_data_type(&[], &existing), None);
960        assert_eq!(first_duplicate_custom_data_type(&["AlphaTick"], &[]), None);
961    }
962
963    #[rstest]
964    fn missing_file_reports_open_error_with_path_and_source() {
965        let mut loader = PluginLoader::new();
966        let path = "/nonexistent/path/to/plugin.so";
967        let err = loader.load(path).expect_err("should fail to open");
968        match &err {
969            LoadError::Open { path: p, source: _ } => {
970                assert_eq!(p.as_os_str(), path);
971            }
972            other => panic!("expected Open, was {other:?}"),
973        }
974        let rendered = format!("{err}");
975        assert!(
976            rendered.contains(path),
977            "rendered error should include the path, was: {rendered}",
978        );
979    }
980
981    #[rstest]
982    fn host_vtable_singleton_matches_abi() {
983        let p = host_vtable();
984        assert!(!p.is_null());
985        // SAFETY: pointer is to a static `OnceLock`-backed HostVTable.
986        let v = unsafe { &*p };
987        assert_eq!(v.abi_version, NAUTILUS_PLUGIN_ABI_VERSION);
988    }
989
990    #[rstest]
991    fn host_vtable_clock_now_ns_returns_unix_nanos() {
992        let p = host_vtable();
993        // SAFETY: pointer is to a static `OnceLock`-backed HostVTable.
994        let v = unsafe { &*p };
995        // SAFETY: the fn pointer is non-null and pointing at host_clock_now_ns
996        // which uses SystemTime::now without dereferencing any input.
997        let now = unsafe { (v.clock_now_ns)() };
998        // Sanity bound: any time after 2020-01-01 in UNIX nanoseconds.
999        assert!(now > 1_577_836_800_000_000_000u64);
1000    }
1001
1002    #[rstest]
1003    fn host_vtable_log_does_not_panic() {
1004        let p = host_vtable();
1005        // SAFETY: see above.
1006        let v = unsafe { &*p };
1007        let target = BorrowedStr::from_str("nautilus_plugin_test");
1008        let message = BorrowedStr::from_str("test message");
1009        // SAFETY: target and message outlive the call; the host_log fn
1010        // only forwards to the `log` crate macros.
1011        unsafe { (v.log)(HostLogLevel::Info, target, message) };
1012    }
1013
1014    #[rstest]
1015    fn validate_manifest_ptr_rejects_null() {
1016        let path = std::path::Path::new("/test/plugin.so");
1017        let err = validate_manifest_ptr(std::ptr::null(), path).unwrap_err();
1018        match err {
1019            LoadError::NullManifest { path: p } => assert_eq!(p, path),
1020            other => panic!("expected NullManifest, was {other:?}"),
1021        }
1022    }
1023
1024    #[rstest]
1025    fn validate_manifest_ptr_rejects_abi_mismatch() {
1026        let bad_manifest = PluginManifest {
1027            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
1028            plugin_name: BorrowedStr::from_str("bad"),
1029            plugin_vendor: BorrowedStr::from_str(""),
1030            plugin_version: BorrowedStr::from_str("0.0.0"),
1031            build_id: PluginBuildId::current(),
1032            custom_data: Slice::empty(),
1033            actors: Slice::empty(),
1034            strategies: Slice::empty(),
1035            controllers: Slice::empty(),
1036        };
1037        let path = std::path::Path::new("/test/plugin.so");
1038        let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1039        match &err {
1040            LoadError::AbiMismatch {
1041                path: p,
1042                expected,
1043                actual,
1044                diagnostics,
1045            } => {
1046                assert_eq!(p, path);
1047                assert_eq!(*expected, NAUTILUS_PLUGIN_ABI_VERSION);
1048                assert_eq!(*actual, NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1));
1049                assert_eq!(diagnostics.plugin_name.as_str(), "bad");
1050                assert_eq!(diagnostics.plugin_version.as_str(), "0.0.0");
1051                assert_eq!(
1052                    diagnostics.build_id.nautilus_plugin_version.as_str(),
1053                    env!("CARGO_PKG_VERSION")
1054                );
1055                assert_eq!(diagnostics.build_id.fixed_precision, Some(FIXED_PRECISION));
1056            }
1057            other => panic!("expected AbiMismatch, was {other:?}"),
1058        }
1059
1060        let rendered = format!("{err}");
1061        assert!(rendered.contains("manifest name='bad'"));
1062        assert!(rendered.contains("nautilus_plugin_version='"));
1063        assert!(rendered.contains("rustc='"));
1064        assert!(rendered.contains("target='"));
1065        assert!(rendered.contains("profile='"));
1066        assert!(rendered.contains("precision_mode='"));
1067        assert!(rendered.contains("fixed_precision="));
1068    }
1069
1070    #[rstest]
1071    fn abi_mismatch_diagnostics_mark_unavailable_build_id_fields() {
1072        let bad_manifest = PluginManifest {
1073            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
1074            plugin_name: BorrowedStr::empty(),
1075            plugin_vendor: BorrowedStr::empty(),
1076            plugin_version: BorrowedStr::empty(),
1077            build_id: PluginBuildId {
1078                schema_version: 7,
1079                nautilus_plugin_version: BorrowedStr::empty(),
1080                rustc_version: BorrowedStr::empty(),
1081                target_triple: BorrowedStr::empty(),
1082                build_profile: BorrowedStr::empty(),
1083                precision_mode: BorrowedStr::empty(),
1084                fixed_precision: 0,
1085            },
1086            custom_data: Slice::empty(),
1087            actors: Slice::empty(),
1088            strategies: Slice::empty(),
1089            controllers: Slice::empty(),
1090        };
1091        let path = std::path::Path::new("/test/plugin.so");
1092        let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1093        let rendered = format!("{err}");
1094
1095        assert!(rendered.contains("plug-in '/test/plugin.so' ABI mismatch"));
1096        assert!(rendered.contains(&format!("host = {NAUTILUS_PLUGIN_ABI_VERSION}")));
1097        assert!(rendered.contains(&format!(
1098            "plug-in = {}",
1099            NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1)
1100        )));
1101        assert!(rendered.contains("manifest name='<unknown>'"));
1102        assert!(rendered.contains("version='<unknown>'"));
1103        assert!(rendered.contains("build_id(schema=7"));
1104        assert!(rendered.contains("nautilus_plugin_version='<unknown>'"));
1105        assert!(rendered.contains("rustc='<unknown>'"));
1106        assert!(rendered.contains("target='<unknown>'"));
1107        assert!(rendered.contains("profile='<unknown>'"));
1108        assert!(rendered.contains("precision_mode='<unknown>'"));
1109        assert!(rendered.contains("fixed_precision=<unknown>"));
1110    }
1111
1112    #[rstest]
1113    fn validate_manifest_ptr_accepts_matching_manifest() {
1114        let registrations = Box::leak(Box::new([CustomDataRegistration {
1115            type_name: BorrowedStr::from_str("LoaderTestTick"),
1116            vtable: custom_data_vtable::<LoaderTestTick>(),
1117        }]));
1118        let good_manifest = PluginManifest {
1119            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1120            plugin_name: BorrowedStr::from_str("good"),
1121            plugin_vendor: BorrowedStr::from_str(""),
1122            plugin_version: BorrowedStr::from_str("0.0.0"),
1123            build_id: PluginBuildId::current(),
1124            custom_data: Slice::from_slice(registrations),
1125            actors: Slice::empty(),
1126            strategies: Slice::empty(),
1127            controllers: Slice::empty(),
1128        };
1129        let path = std::path::Path::new("/test/plugin.so");
1130        let manifest = validate_manifest_ptr(&raw const good_manifest, path)
1131            .expect("matching manifest accepted");
1132        let custom_data = manifest.custom_data().next().expect("custom data entry");
1133
1134        assert_eq!(manifest.plugin_name(), "good");
1135        assert_eq!(custom_data.type_name(), "LoaderTestTick");
1136        assert_eq!(custom_data.vtable().as_ptr(), registrations[0].vtable);
1137    }
1138
1139    #[rstest]
1140    fn validate_manifest_ptr_rejects_invalid_manifest_with_diagnostics() {
1141        static NULL_VTABLE_CUSTOM_DATA: [CustomDataRegistration; 1] = [CustomDataRegistration {
1142            type_name: BorrowedStr::from_str("BadTick"),
1143            vtable: std::ptr::null(),
1144        }];
1145
1146        let bad_manifest = PluginManifest {
1147            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1148            plugin_name: BorrowedStr::empty(),
1149            plugin_vendor: BorrowedStr::from_str(""),
1150            plugin_version: BorrowedStr::from_str("0.0.0"),
1151            build_id: PluginBuildId {
1152                schema_version: crate::PLUGIN_BUILD_ID_VERSION + 1,
1153                ..PluginBuildId::current()
1154            },
1155            custom_data: Slice::from_slice(&NULL_VTABLE_CUSTOM_DATA),
1156            actors: Slice::empty(),
1157            strategies: Slice::empty(),
1158            controllers: Slice::empty(),
1159        };
1160        let path = std::path::Path::new("/test/plugin.so");
1161        let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1162
1163        match &err {
1164            LoadError::InvalidManifest {
1165                path: p,
1166                diagnostics,
1167                errors,
1168            } => {
1169                assert_eq!(p, path);
1170                assert_eq!(diagnostics.plugin_name.as_str(), "");
1171                assert_eq!(diagnostics.plugin_version.as_str(), "0.0.0");
1172                assert!(
1173                    errors
1174                        .messages()
1175                        .iter()
1176                        .any(|message| message == "plugin_name must not be empty")
1177                );
1178                assert!(
1179                    errors
1180                        .messages()
1181                        .iter()
1182                        .any(|message| message == "custom_data[0].vtable must not be null")
1183                );
1184            }
1185            other => panic!("expected InvalidManifest, was {other:?}"),
1186        }
1187
1188        let rendered = format!("{err}");
1189        assert!(rendered.contains("plug-in '/test/plugin.so' manifest validation failed"));
1190        assert!(rendered.contains("manifest name='<unknown>'"));
1191        assert!(rendered.contains("plugin_name must not be empty"));
1192        let expected_schema_error = format!(
1193            "build_id.schema_version {} does not match supported schema {}",
1194            crate::PLUGIN_BUILD_ID_VERSION + 1,
1195            crate::PLUGIN_BUILD_ID_VERSION
1196        );
1197        assert!(rendered.contains(&expected_schema_error));
1198        assert!(rendered.contains("custom_data[0].vtable must not be null"));
1199    }
1200
1201    #[rstest]
1202    fn validate_manifest_ptr_rejects_malformed_vtable_with_diagnostics() {
1203        let registrations = Box::leak(Box::new([CustomDataRegistration {
1204            type_name: BorrowedStr::from_str("BadTick"),
1205            vtable: custom_data_vtable_missing_to_json(),
1206        }]));
1207        let bad_manifest = PluginManifest {
1208            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1209            plugin_name: BorrowedStr::from_str("bad-vtable"),
1210            plugin_vendor: BorrowedStr::from_str(""),
1211            plugin_version: BorrowedStr::from_str("0.0.0"),
1212            build_id: PluginBuildId::current(),
1213            custom_data: Slice::from_slice(registrations),
1214            actors: Slice::empty(),
1215            strategies: Slice::empty(),
1216            controllers: Slice::empty(),
1217        };
1218        let path = std::path::Path::new("/test/plugin.so");
1219        let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1220
1221        match &err {
1222            LoadError::InvalidManifest {
1223                path: p,
1224                diagnostics,
1225                errors,
1226            } => {
1227                assert_eq!(p, path);
1228                assert_eq!(diagnostics.plugin_name.as_str(), "bad-vtable");
1229                assert!(errors.messages().iter().any(|message| message
1230                    == "custom_data[0] type 'BadTick' vtable.to_json must not be null"));
1231            }
1232            other => panic!("expected InvalidManifest, was {other:?}"),
1233        }
1234
1235        let rendered = format!("{err}");
1236        assert!(rendered.contains("manifest name='bad-vtable'"));
1237        assert!(rendered.contains("custom_data[0] type 'BadTick' vtable.to_json must not be null"));
1238    }
1239
1240    #[rstest]
1241    #[case::submit("submit_order is not wired into this host vtable")]
1242    #[case::cancel("cancel_order is not wired into this host vtable")]
1243    #[case::modify("modify_order is not wired into this host vtable")]
1244    #[case::submit_list("submit_order_list is not wired into this host vtable")]
1245    #[case::cancel_list("cancel_orders is not wired into this host vtable")]
1246    #[case::cancel_all("cancel_all_orders is not wired into this host vtable")]
1247    #[case::close_position("close_position is not wired into this host vtable")]
1248    #[case::close_all("close_all_positions is not wired into this host vtable")]
1249    #[case::query_account("query_account is not wired into this host vtable")]
1250    #[case::query_order("query_order is not wired into this host vtable")]
1251    fn host_order_command_stubs_return_not_implemented(#[case] expected: &str) {
1252        use nautilus_core::{UUID4, UnixNanos};
1253        use nautilus_model::{
1254            enums::{OrderSide, OrderType, TimeInForce},
1255            identifiers::{
1256                AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TraderId,
1257            },
1258            orders::{MarketOrder, OrderAny},
1259            types::Quantity,
1260        };
1261
1262        use crate::surfaces::commands::{
1263            CancelAllOrdersCommand, CancelOrderCommand, CancelOrdersCommand,
1264            CloseAllPositionsCommand, ClosePositionCommand, ModifyOrderCommand,
1265            QueryAccountCommand, QueryOrderCommand, SubmitOrderCommand, SubmitOrderListCommand,
1266        };
1267
1268        let _ = OrderType::Market;
1269
1270        // The default loader's host vtable installs NotImplemented stubs for
1271        // callbacks that need live-node state.
1272        let p = host_vtable();
1273        // SAFETY: pointer is to a static `OnceLock`-backed HostVTable.
1274        let v = unsafe { &*p };
1275        let ctx = std::ptr::null::<HostContext>();
1276        let order = OrderAny::Market(MarketOrder::new(
1277            TraderId::from("TRADER-001"),
1278            StrategyId::from("S-001"),
1279            InstrumentId::from("ETH-USDT.BINANCE"),
1280            ClientOrderId::from("O-1"),
1281            OrderSide::Buy,
1282            Quantity::from("1.0"),
1283            TimeInForce::Gtc,
1284            UUID4::new(),
1285            UnixNanos::default(),
1286            false,
1287            false,
1288            None,
1289            None,
1290            None,
1291            None,
1292            None,
1293            None,
1294            None,
1295            None,
1296        ));
1297        let submit_handle =
1298            SubmitOrderHandle::new(SubmitOrderCommand::new(order.clone(), None, None, None));
1299        let cancel_handle = CancelOrderHandle::new(CancelOrderCommand::new(
1300            ClientOrderId::from("O-1"),
1301            None,
1302            None,
1303        ));
1304        let modify_handle = ModifyOrderHandle::new(ModifyOrderCommand::new(
1305            ClientOrderId::from("O-1"),
1306            None,
1307            None,
1308            None,
1309            None,
1310            None,
1311        ));
1312        let submit_list_handle =
1313            SubmitOrderListHandle::new(SubmitOrderListCommand::new(vec![order], None, None, None));
1314        let cancel_orders_handle =
1315            CancelOrdersHandle::new(CancelOrdersCommand::new(vec![], None, None));
1316        let cancel_all_handle = CancelAllOrdersHandle::new(CancelAllOrdersCommand::new(
1317            InstrumentId::from("ETH-USDT.BINANCE"),
1318            None,
1319            None,
1320            None,
1321        ));
1322        let close_handle = ClosePositionHandle::new(ClosePositionCommand::new(
1323            PositionId::from("P-001"),
1324            None,
1325            None,
1326            None,
1327            None,
1328            None,
1329        ));
1330        let close_all_handle = CloseAllPositionsHandle::new(CloseAllPositionsCommand::new(
1331            InstrumentId::from("ETH-USDT.BINANCE"),
1332            None,
1333            None,
1334            None,
1335            None,
1336            None,
1337            None,
1338        ));
1339        let query_account_handle = QueryAccountHandle::new(QueryAccountCommand::new(
1340            AccountId::from("BINANCE-001"),
1341            None,
1342            None,
1343        ));
1344        let query_order_handle = QueryOrderHandle::new(QueryOrderCommand::new(
1345            ClientOrderId::from("O-1"),
1346            None,
1347            None,
1348        ));
1349
1350        let r = match expected {
1351            s if s.starts_with("submit_order_list") =>
1352            // SAFETY: stub does not deref ctx; handle outlives the call.
1353            unsafe { (v.submit_order_list)(ctx, &raw const submit_list_handle) },
1354            s if s.starts_with("submit_order") =>
1355            // SAFETY: see above.
1356            unsafe { (v.submit_order)(ctx, &raw const submit_handle) },
1357            s if s.starts_with("cancel_orders") =>
1358            // SAFETY: see above.
1359            unsafe { (v.cancel_orders)(ctx, &raw const cancel_orders_handle) },
1360            s if s.starts_with("cancel_all_orders") =>
1361            // SAFETY: see above.
1362            unsafe { (v.cancel_all_orders)(ctx, &raw const cancel_all_handle) },
1363            s if s.starts_with("cancel_order") =>
1364            // SAFETY: see above.
1365            unsafe { (v.cancel_order)(ctx, &raw const cancel_handle) },
1366            s if s.starts_with("modify_order") =>
1367            // SAFETY: see above.
1368            unsafe { (v.modify_order)(ctx, &raw const modify_handle) },
1369            s if s.starts_with("close_position") =>
1370            // SAFETY: see above.
1371            unsafe { (v.close_position)(ctx, &raw const close_handle) },
1372            s if s.starts_with("close_all_positions") =>
1373            // SAFETY: see above.
1374            unsafe { (v.close_all_positions)(ctx, &raw const close_all_handle) },
1375            s if s.starts_with("query_account") =>
1376            // SAFETY: see above.
1377            unsafe { (v.query_account)(ctx, &raw const query_account_handle) },
1378            s if s.starts_with("query_order") =>
1379            // SAFETY: see above.
1380            unsafe { (v.query_order)(ctx, &raw const query_order_handle) },
1381            _ => unreachable!(),
1382        };
1383
1384        let err = r.into_result().unwrap_err();
1385        assert_eq!(err.code, PluginErrorCode::NotImplemented);
1386        assert_eq!(err.message_string(), expected);
1387    }
1388
1389    #[rstest]
1390    #[case::instrument("cache_instrument")]
1391    #[case::account("cache_account")]
1392    #[case::order("cache_order")]
1393    #[case::position("cache_position")]
1394    #[case::orders_for_strategy("cache_orders_for_strategy")]
1395    #[case::positions_for_strategy("cache_positions_for_strategy")]
1396    fn host_cache_stubs_return_not_implemented(#[case] method: &str) {
1397        let p = host_vtable();
1398        // SAFETY: pointer is to a static `OnceLock`-backed HostVTable.
1399        let v = unsafe { &*p };
1400        let ctx = std::ptr::null::<HostContext>();
1401        let value = BorrowedStr::from_str("VALUE");
1402
1403        let r = match method {
1404            // SAFETY: stubs do not dereference ctx or borrowed values.
1405            "cache_instrument" => unsafe { (v.cache_instrument)(ctx, value) },
1406            // SAFETY: see above.
1407            "cache_account" => unsafe { (v.cache_account)(ctx, value) },
1408            // SAFETY: see above.
1409            "cache_order" => unsafe { (v.cache_order)(ctx, value) },
1410            // SAFETY: see above.
1411            "cache_position" => unsafe { (v.cache_position)(ctx, value) },
1412            // SAFETY: see above.
1413            "cache_orders_for_strategy" => unsafe { (v.cache_orders_for_strategy)(ctx, value) },
1414            // SAFETY: see above.
1415            "cache_positions_for_strategy" => unsafe {
1416                (v.cache_positions_for_strategy)(ctx, value)
1417            },
1418            _ => unreachable!(),
1419        };
1420
1421        let err = match r.into_result() {
1422            Ok(_) => panic!("{method} unexpectedly succeeded"),
1423            Err(e) => e,
1424        };
1425        assert_eq!(err.code, PluginErrorCode::NotImplemented);
1426        assert_eq!(
1427            err.message_string(),
1428            format!("{method} is not wired into this host vtable")
1429        );
1430    }
1431
1432    #[rstest]
1433    #[case::subscribe_quotes("subscribe_quotes")]
1434    #[case::unsubscribe_quotes("unsubscribe_quotes")]
1435    #[case::subscribe_trades("subscribe_trades")]
1436    #[case::unsubscribe_trades("unsubscribe_trades")]
1437    #[case::subscribe_bars("subscribe_bars")]
1438    #[case::unsubscribe_bars("unsubscribe_bars")]
1439    #[case::subscribe_book_deltas("subscribe_book_deltas")]
1440    #[case::unsubscribe_book_deltas("unsubscribe_book_deltas")]
1441    #[case::subscribe_book_at_interval("subscribe_book_at_interval")]
1442    #[case::unsubscribe_book_at_interval("unsubscribe_book_at_interval")]
1443    #[case::msgbus_publish("msgbus_publish")]
1444    #[case::set_time_alert("set_time_alert")]
1445    #[case::set_timer("set_timer")]
1446    #[case::cancel_timer("cancel_timer")]
1447    fn host_stateful_unit_stubs_return_not_implemented(#[case] method: &str) {
1448        let p = host_vtable();
1449        // SAFETY: pointer is to a static `OnceLock`-backed HostVTable.
1450        let v = unsafe { &*p };
1451        let ctx = std::ptr::null::<HostContext>();
1452        let value = BorrowedStr::from_str("VALUE");
1453        let empty = BorrowedStr::empty();
1454
1455        let r = match method {
1456            // SAFETY: stubs do not dereference ctx or borrowed values.
1457            "subscribe_quotes" => unsafe { (v.subscribe_quotes)(ctx, value, empty, empty) },
1458            // SAFETY: see above.
1459            "unsubscribe_quotes" => unsafe { (v.unsubscribe_quotes)(ctx, value, empty, empty) },
1460            // SAFETY: see above.
1461            "subscribe_trades" => unsafe { (v.subscribe_trades)(ctx, value, empty, empty) },
1462            // SAFETY: see above.
1463            "unsubscribe_trades" => unsafe { (v.unsubscribe_trades)(ctx, value, empty, empty) },
1464            // SAFETY: see above.
1465            "subscribe_bars" => unsafe { (v.subscribe_bars)(ctx, value, empty, empty) },
1466            // SAFETY: see above.
1467            "unsubscribe_bars" => unsafe { (v.unsubscribe_bars)(ctx, value, empty, empty) },
1468            // SAFETY: see above.
1469            "subscribe_book_deltas" => unsafe {
1470                (v.subscribe_book_deltas)(ctx, value, 0, 0, empty, 0, empty)
1471            },
1472            // SAFETY: see above.
1473            "unsubscribe_book_deltas" => unsafe {
1474                (v.unsubscribe_book_deltas)(ctx, value, empty, empty)
1475            },
1476            // SAFETY: see above.
1477            "subscribe_book_at_interval" => unsafe {
1478                (v.subscribe_book_at_interval)(ctx, value, 0, 0, 1, empty, empty)
1479            },
1480            // SAFETY: see above.
1481            "unsubscribe_book_at_interval" => unsafe {
1482                (v.unsubscribe_book_at_interval)(ctx, value, 1, empty, empty)
1483            },
1484            // SAFETY: see above.
1485            "msgbus_publish" => unsafe { (v.msgbus_publish)(ctx, value, crate::Slice::empty()) },
1486            // SAFETY: see above.
1487            "set_time_alert" => unsafe { (v.set_time_alert)(ctx, value, 1, 0) },
1488            // SAFETY: see above.
1489            "set_timer" => unsafe { (v.set_timer)(ctx, value, 1, 0, 0, 0, 0) },
1490            // SAFETY: see above.
1491            "cancel_timer" => unsafe { (v.cancel_timer)(ctx, value) },
1492            _ => unreachable!(),
1493        };
1494
1495        let err = r.into_result().unwrap_err();
1496        assert_eq!(err.code, PluginErrorCode::NotImplemented);
1497        assert_eq!(
1498            err.message_string(),
1499            format!("{method} is not wired into this host vtable")
1500        );
1501    }
1502}