Skip to main content

nautilus_plugin/bridge/
controller.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 adapter that owns a plug-in controller instance.
17
18#![allow(unsafe_code)]
19#![allow(
20    clippy::multiple_unsafe_ops_per_block,
21    reason = "vtable deref and FFI call form a single boundary callback; \
22              SAFETY comments cover both ops together"
23)]
24
25use std::{
26    fmt::Debug,
27    panic::{AssertUnwindSafe, catch_unwind},
28};
29
30use nautilus_common::timer::TimeEvent;
31
32use crate::{
33    boundary::{BorrowedStr, OwnedBytes, PluginResult},
34    bridge::registry::{
35        ControllerHostContextInner, drop_controller_host_context, leak_controller_host_context,
36    },
37    host::{ControllerHostContext, ControllerHostVTable},
38    manifest::ValidatedControllerVTable,
39    surfaces::controller::PluginControllerHandle,
40};
41
42/// Adapts a plug-in controller (vtable + handle from a cdylib) into a
43/// host-owned runtime component.
44pub struct PluginControllerAdapter {
45    plugin_name: String,
46    type_name: String,
47    vtable: ValidatedControllerVTable,
48    handle: *mut PluginControllerHandle,
49    ctx: *const ControllerHostContext,
50}
51
52// SAFETY: the adapter owns the plug-in handle exclusively and never aliases
53// it across threads. The vtable pointer is process-lifetime static. Live-node
54// lifecycle dispatch runs on the node thread; the bound is needed for host
55// containers that require `Send`.
56unsafe impl Send for PluginControllerAdapter {}
57
58impl Debug for PluginControllerAdapter {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct(stringify!(PluginControllerAdapter))
61            .field("plugin_name", &self.plugin_name)
62            .field("type_name", &self.type_name)
63            .finish()
64    }
65}
66
67impl PluginControllerAdapter {
68    /// Constructs a new adapter by calling the plug-in's `create` thunk.
69    ///
70    /// `host` must point at a process-lifetime [`ControllerHostVTable`].
71    /// `config_json` is forwarded verbatim to the plug-in's
72    /// `PluginController::new` implementation.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the plug-in's `create` thunk panics or returns a
77    /// null handle.
78    ///
79    /// # Safety
80    ///
81    /// `host` must outlive the adapter and all controller callbacks.
82    pub unsafe fn new(
83        plugin_name: impl Into<String>,
84        type_name: impl Into<String>,
85        vtable: ValidatedControllerVTable,
86        host: *const ControllerHostVTable,
87        config_json: &str,
88    ) -> anyhow::Result<Self> {
89        let plugin_name = plugin_name.into();
90        let type_name = type_name.into();
91        // SAFETY: vtable comes from a validated manifest entry.
92        let create = unsafe { validated_slot!(ControllerVTable, vtable.as_ptr(), create) };
93        let ctx = leak_controller_host_context(ControllerHostContextInner {
94            plugin_name: plugin_name.clone(),
95            type_name: type_name.clone(),
96        });
97
98        let cfg = BorrowedStr::from_str(config_json);
99        // SAFETY: vtable is non-null, host outlives the adapter, ctx + cfg
100        // are live across the call.
101        let handle = guard_call(&plugin_name, &type_name, "create", || unsafe {
102            create(host, ctx, cfg)
103        })
104        .ok_or_else(|| {
105            // SAFETY: ctx came from leak_controller_host_context above.
106            unsafe { drop_controller_host_context(ctx) };
107            anyhow::anyhow!("plug-in controller '{type_name}' panicked in create")
108        })?;
109
110        if handle.is_null() {
111            // SAFETY: ctx came from leak_controller_host_context above.
112            unsafe { drop_controller_host_context(ctx) };
113            anyhow::bail!("plug-in controller '{type_name}' returned a null handle from create");
114        }
115
116        Ok(Self {
117            plugin_name,
118            type_name,
119            vtable,
120            handle,
121            ctx,
122        })
123    }
124
125    /// Runs the controller's static `prepare` hook.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the plug-in rejects the request.
130    pub fn prepare(&self, request_json: &str) -> anyhow::Result<OwnedBytes> {
131        let request = BorrowedStr::from_str(request_json);
132        let result = guard_call(&self.plugin_name, &self.type_name, "prepare", || unsafe {
133            validated_slot!(ControllerVTable, self.vtable.as_ptr(), prepare)(request)
134        });
135        finish_bytes(result, &self.plugin_name, &self.type_name, "prepare")
136    }
137
138    /// Returns the canonical type name reported by the plug-in.
139    #[must_use]
140    pub fn type_name(&self) -> &str {
141        &self.type_name
142    }
143
144    /// Returns the plug-in name (manifest `name`) the adapter wraps.
145    #[must_use]
146    pub fn plugin_name(&self) -> &str {
147        &self.plugin_name
148    }
149
150    /// Dispatches `on_start` to the plug-in controller.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the plug-in callback fails.
155    pub fn on_start(&mut self) -> anyhow::Result<()> {
156        invoke_lifecycle(self, "on_start", |adapter| unsafe {
157            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_start)(adapter.handle)
158        })
159    }
160
161    /// Dispatches `on_stop` to the plug-in controller.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the plug-in callback fails.
166    pub fn on_stop(&mut self) -> anyhow::Result<()> {
167        invoke_lifecycle(self, "on_stop", |adapter| unsafe {
168            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_stop)(adapter.handle)
169        })
170    }
171
172    /// Dispatches `on_resume` to the plug-in controller.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the plug-in callback fails.
177    pub fn on_resume(&mut self) -> anyhow::Result<()> {
178        invoke_lifecycle(self, "on_resume", |adapter| unsafe {
179            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_resume)(adapter.handle)
180        })
181    }
182
183    /// Dispatches `on_reset` to the plug-in controller.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the plug-in callback fails.
188    pub fn on_reset(&mut self) -> anyhow::Result<()> {
189        invoke_lifecycle(self, "on_reset", |adapter| unsafe {
190            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_reset)(adapter.handle)
191        })
192    }
193
194    /// Dispatches `on_dispose` to the plug-in controller.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the plug-in callback fails.
199    pub fn on_dispose(&mut self) -> anyhow::Result<()> {
200        invoke_lifecycle(self, "on_dispose", |adapter| unsafe {
201            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_dispose)(adapter.handle)
202        })
203    }
204
205    /// Dispatches `on_degrade` to the plug-in controller.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the plug-in callback fails.
210    pub fn on_degrade(&mut self) -> anyhow::Result<()> {
211        invoke_lifecycle(self, "on_degrade", |adapter| unsafe {
212            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_degrade)(adapter.handle)
213        })
214    }
215
216    /// Dispatches `on_fault` to the plug-in controller.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the plug-in callback fails.
221    pub fn on_fault(&mut self) -> anyhow::Result<()> {
222        invoke_lifecycle(self, "on_fault", |adapter| unsafe {
223            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_fault)(adapter.handle)
224        })
225    }
226
227    /// Dispatches `on_time_event` to the plug-in controller.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the plug-in callback fails.
232    pub fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
233        invoke_event(self, "on_time_event", event, |adapter, p| unsafe {
234            validated_slot!(ControllerVTable, adapter.vtable.as_ptr(), on_time_event)(
235                adapter.handle,
236                p,
237            )
238        })
239    }
240}
241
242impl Drop for PluginControllerAdapter {
243    fn drop(&mut self) {
244        if !self.handle.is_null() {
245            let _ = catch_unwind(AssertUnwindSafe(|| {
246                // SAFETY: vtable + handle are live; drop_handle ignores null.
247                unsafe {
248                    validated_slot!(ControllerVTable, self.vtable.as_ptr(), drop_handle)(
249                        self.handle,
250                    );
251                };
252            }));
253            self.handle = std::ptr::null_mut();
254        }
255        // SAFETY: ctx originated from leak_controller_host_context in `new`.
256        unsafe { drop_controller_host_context(self.ctx) };
257        self.ctx = std::ptr::null();
258    }
259}
260
261fn guard_call<R>(plugin: &str, type_name: &str, method: &str, f: impl FnOnce() -> R) -> Option<R> {
262    match catch_unwind(AssertUnwindSafe(f)) {
263        Ok(r) => Some(r),
264        Err(_payload) => {
265            log::error!(
266                target: "nautilus_plugin",
267                "plug-in '{plugin}' ({type_name}) panicked in {method}",
268            );
269            None
270        }
271    }
272}
273
274fn invoke_lifecycle(
275    adapter: &PluginControllerAdapter,
276    method: &str,
277    f: impl FnOnce(&PluginControllerAdapter) -> PluginResult<()>,
278) -> anyhow::Result<()> {
279    let plugin_name = adapter.plugin_name.clone();
280    let type_name = adapter.type_name.clone();
281    let result = guard_call(&plugin_name, &type_name, method, || f(adapter));
282    finish(result, &plugin_name, &type_name, method)
283}
284
285fn invoke_event<T>(
286    adapter: &PluginControllerAdapter,
287    method: &str,
288    payload: &T,
289    f: impl FnOnce(&PluginControllerAdapter, *const T) -> PluginResult<()>,
290) -> anyhow::Result<()> {
291    let plugin_name = adapter.plugin_name.clone();
292    let type_name = adapter.type_name.clone();
293    let ptr: *const T = payload;
294    let result = guard_call(&plugin_name, &type_name, method, || f(adapter, ptr));
295    finish(result, &plugin_name, &type_name, method)
296}
297
298fn finish(
299    result: Option<PluginResult<()>>,
300    plugin_name: &str,
301    type_name: &str,
302    method: &str,
303) -> anyhow::Result<()> {
304    match result {
305        Some(r) => r.into_result().map_err(|e| {
306            anyhow::anyhow!(
307                "plug-in '{plugin_name}' ({type_name}) {method} returned error: {}",
308                e.message_string()
309            )
310        }),
311        None => anyhow::bail!("plug-in '{plugin_name}' ({type_name}) panicked in {method}"),
312    }
313}
314
315fn finish_bytes(
316    result: Option<PluginResult<OwnedBytes>>,
317    plugin_name: &str,
318    type_name: &str,
319    method: &str,
320) -> anyhow::Result<OwnedBytes> {
321    match result {
322        Some(r) => r.into_result().map_err(|e| {
323            anyhow::anyhow!(
324                "plug-in '{plugin_name}' ({type_name}) {method} returned error: {}",
325                e.message_string()
326            )
327        }),
328        None => anyhow::bail!("plug-in '{plugin_name}' ({type_name}) panicked in {method}"),
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use std::sync::atomic::{AtomicU64, Ordering};
335
336    use rstest::rstest;
337
338    use super::*;
339    use crate::{
340        bridge::{
341            host::controller_host_vtable,
342            registry::{controller_host_context_live_count, controller_host_context_test_lock},
343        },
344        host::{ControllerHostContext, ControllerHostVTable},
345        surfaces::controller::{ControllerVTable, PluginController, controller_vtable},
346    };
347
348    static STARTS: AtomicU64 = AtomicU64::new(0);
349
350    struct DropTestController;
351
352    impl PluginController for DropTestController {
353        const TYPE_NAME: &'static str = "DropTestController";
354
355        fn new(
356            _host: *const ControllerHostVTable,
357            _ctx: *const ControllerHostContext,
358            _config_json: &str,
359        ) -> Self {
360            Self
361        }
362
363        fn on_start(&mut self) -> anyhow::Result<()> {
364            STARTS.fetch_add(1, Ordering::SeqCst);
365            Ok(())
366        }
367    }
368
369    fn drop_test_controller_vtable() -> ValidatedControllerVTable {
370        // SAFETY: generated vtables are process-lifetime static and fill
371        // every required controller slot.
372        unsafe {
373            ValidatedControllerVTable::from_raw_unchecked(controller_vtable::<DropTestController>())
374        }
375    }
376
377    static NULL_CREATE_VTABLE: ControllerVTable = ControllerVTable {
378        prepare: Some(null_create_prepare),
379        create: Some(null_create),
380        drop_handle: Some(null_create_drop_handle),
381        type_name: Some(null_create_type_name),
382        on_start: Some(null_create_lifecycle),
383        on_stop: Some(null_create_lifecycle),
384        on_resume: Some(null_create_lifecycle),
385        on_reset: Some(null_create_lifecycle),
386        on_dispose: Some(null_create_lifecycle),
387        on_degrade: Some(null_create_lifecycle),
388        on_fault: Some(null_create_lifecycle),
389        on_time_event: Some(null_create_time_event),
390    };
391
392    unsafe extern "C" fn null_create_prepare(
393        _request_json: BorrowedStr<'_>,
394    ) -> PluginResult<OwnedBytes> {
395        PluginResult::Ok(OwnedBytes::empty())
396    }
397
398    unsafe extern "C" fn null_create(
399        _host: *const ControllerHostVTable,
400        _ctx: *const ControllerHostContext,
401        _config_json: BorrowedStr<'_>,
402    ) -> *mut PluginControllerHandle {
403        std::ptr::null_mut()
404    }
405
406    unsafe extern "C" fn null_create_drop_handle(_handle: *mut PluginControllerHandle) {}
407
408    unsafe extern "C" fn null_create_type_name() -> BorrowedStr<'static> {
409        BorrowedStr::from_str("NullCreateController")
410    }
411
412    unsafe extern "C" fn null_create_lifecycle(
413        _handle: *mut PluginControllerHandle,
414    ) -> PluginResult<()> {
415        PluginResult::Ok(())
416    }
417
418    unsafe extern "C" fn null_create_time_event(
419        _handle: *mut PluginControllerHandle,
420        _event: *const TimeEvent,
421    ) -> PluginResult<()> {
422        PluginResult::Ok(())
423    }
424
425    fn null_create_vtable() -> ValidatedControllerVTable {
426        // SAFETY: the test vtable is process-lifetime static and fills every
427        // required slot, but intentionally returns a null handle.
428        unsafe { ValidatedControllerVTable::from_raw_unchecked(&raw const NULL_CREATE_VTABLE) }
429    }
430
431    #[rstest]
432    fn drop_frees_controller_host_context() {
433        let _guard = controller_host_context_test_lock();
434        let before = controller_host_context_live_count();
435        // SAFETY: controller_host_vtable is process-lifetime static.
436        let adapter = unsafe {
437            PluginControllerAdapter::new(
438                "test-plugin",
439                DropTestController::TYPE_NAME,
440                drop_test_controller_vtable(),
441                controller_host_vtable(),
442                "{}",
443            )
444        }
445        .expect("controller adapter construction succeeds");
446        assert_eq!(controller_host_context_live_count(), before + 1);
447
448        drop(adapter);
449
450        assert_eq!(controller_host_context_live_count(), before);
451    }
452
453    #[rstest]
454    fn null_create_frees_controller_host_context() {
455        let _guard = controller_host_context_test_lock();
456        let before = controller_host_context_live_count();
457
458        // SAFETY: controller_host_vtable is process-lifetime static.
459        let error = unsafe {
460            PluginControllerAdapter::new(
461                "test-plugin",
462                "NullCreateController",
463                null_create_vtable(),
464                controller_host_vtable(),
465                "{}",
466            )
467        }
468        .expect_err("null controller handle is rejected");
469
470        assert!(
471            error
472                .to_string()
473                .contains("returned a null handle from create")
474        );
475        assert_eq!(controller_host_context_live_count(), before);
476    }
477
478    #[rstest]
479    fn lifecycle_dispatches_to_controller() {
480        let _guard = controller_host_context_test_lock();
481        STARTS.store(0, Ordering::SeqCst);
482        // SAFETY: controller_host_vtable is process-lifetime static.
483        let mut adapter = unsafe {
484            PluginControllerAdapter::new(
485                "test-plugin",
486                DropTestController::TYPE_NAME,
487                drop_test_controller_vtable(),
488                controller_host_vtable(),
489                "{}",
490            )
491        }
492        .expect("controller adapter construction succeeds");
493
494        adapter.on_start().expect("on_start dispatches");
495
496        assert_eq!(STARTS.load(Ordering::SeqCst), 1);
497    }
498}