haproxy_spoa_hub_plugin_api/lib.rs
1//! Plugin API for haproxy-spoa-hub.
2//!
3//! # ABI model
4//!
5//! Plugins are loaded from `.so` files at runtime via `dlopen`. The
6//! interface between hub and plugin is a hand-rolled `#[repr(C)]`
7//! vtable defined in [`vtable::PluginVTable`], reached via the
8//! exported `get_plugin_vtable` symbol.
9//!
10//! See [`vtable`] for the load-bearing layout invariants and version
11//! evolution rules. In short: append-only, never reorder, never remove,
12//! version-gate every new field on the host. The matrix test in
13//! `crates/hub/tests/abi_matrix.rs` enforces these invariants by
14//! loading every published plugin version against the current hub.
15//!
16//! # Layout discipline for data types
17//!
18//! `PluginContext`, `SpoeMessage`, `ProcessingResult`, `Diagnostic`,
19//! `ConfigValue`, `SpoeValue`, `TxnVariable`, and `VarScope` are all
20//! `#[derive(StableAbi)]` which gives them `#[repr(C)]` layout.
21//! Existing fields MUST NOT be reordered or removed. Adding a field is
22//! a layout change that requires a coordinated plugin-api version bump
23//! (and is itself a separate concern from vtable evolution — there is
24//! no per-field version-gating on data types).
25//!
26//! `abi_stable` is pinned to `=0.11.3` in `Cargo.toml` so plugins and
27//! hub see byte-identical `RString` / `RVec` / etc. across the FFI
28//! boundary. Bumping that pin is a coordinated rollout, not a routine
29//! patch.
30
31#![allow(non_camel_case_types, non_local_definitions)]
32
33pub mod logging;
34pub mod metrics;
35pub mod types;
36pub mod vtable;
37
38pub use abi_stable;
39pub use abi_stable::std_types::{
40 RBoxError, RHashMap, ROption, RResult, RSlice, RStr, RString, RVec, Tuple2,
41};
42pub use logging::{HUB_LOG_SINK, HubLogSink, LogLevel, LogSinkFn};
43pub use metrics::{MetricKind, MetricLabel, MetricRecorder, RecordMetricFn};
44pub use types::{
45 ConfigValue, Diagnostic, DiagnosticSeverity, PluginContext, ProcessingResult, SpoeMessage,
46 SpoeValue, TxnVariable, VarScope,
47};
48pub use vtable::{
49 GET_PLUGIN_VTABLE_SYMBOL, GetPluginVTableFn, PLUGIN_API_VERSION, PLUGIN_API_VERSION_V1,
50 PLUGIN_API_VERSION_V2, PLUGIN_API_VERSION_V3, PLUGIN_API_VERSION_V4, PluginVTable,
51};
52
53/// Define a plugin and emit the FFI surface (vtable + entry symbol).
54///
55/// Plugin authors write a regular `impl`-style block; the macro emits
56/// `extern "C"` thunks that bridge into it, the static `PluginVTable`,
57/// and the `get_plugin_vtable` symbol the hub looks up after `dlopen`.
58///
59/// `process` is automatically wrapped in `std::panic::catch_unwind` so
60/// a panic during request handling does not abort the hub process.
61///
62/// # Usage
63///
64/// ```rust,ignore
65/// use haproxy_spoa_hub_plugin_api::{ProcessingResult, SpoeMessage, SpoeValue, TxnVariable, define_plugin};
66///
67/// #[derive(Debug, Default)]
68/// struct MyPlugin;
69///
70/// define_plugin!(MyPlugin, {
71/// fn new() -> Self { MyPlugin }
72///
73/// fn init(&mut self, _: &PluginContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
74/// Ok(())
75/// }
76///
77/// fn name(&self) -> &str { "my-plugin" }
78/// fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
79///
80/// fn process(&self, _: &SpoeMessage)
81/// -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>>
82/// {
83/// Ok(ProcessingResult::single(TxnVariable::session(
84/// "result", SpoeValue::String("ok".into()),
85/// )))
86/// }
87/// });
88/// ```
89///
90/// `config_schema` and `validate` are optional. Add them at the end of
91/// the block in that order if you need them. Plugins that omit them
92/// get the default no-op behavior (no schema, empty diagnostics).
93///
94/// # `validate()` contract: do *semantic* checks only
95///
96/// The hub guarantees that by the time your `validate()` body runs,
97/// the [`PluginContext`] passed in has already been validated against
98/// your `config_schema()` (in BOTH the load path and the
99/// `--validate-socket` path; see `crates/hub/src/plugin_loader.rs`'s
100/// `collect_schema_errors` and how it's called from
101/// `crates/hub/src/validate/orchestrator.rs`). Schema errors short-
102/// circuit before `validate()` is called and surface to the operator
103/// as the same diagnostic shape your override would emit.
104///
105/// **You should therefore never reimplement structural validation
106/// inside `validate()`.** No re-checking that a field is a string,
107/// no walking arrays-vs-tables defensively, no type-shape
108/// assumptions. Use whatever typed accessor your plugin already
109/// builds for `init()` (typically `from_context(ctx)` populating a
110/// `PluginConfig` struct) — the structure is guaranteed valid.
111///
112/// `validate()` is for *semantic* checks the JSON Schema can't
113/// express. Examples:
114/// - Compiling `SecLang` directives via Coraza so a typo'd
115/// `SecBogusDirective` becomes a line-numbered diagnostic
116/// (haproxy-spoa-hub-plugin-coraza).
117/// - Resolving a remote auth-server URL to verify it's reachable
118/// (a hypothetical external-auth deep check).
119/// - Anything else that requires runtime context the schema can't
120/// capture.
121///
122/// Historical context: pre-hub-v0.5.2, the `--validate-socket` path
123/// did NOT schema-check before calling `validate()`, so plugins that
124/// added their own structural walking could (and did) drift away
125/// from `config_schema()` and the runtime parser. The coraza plugin's
126/// `applications`-as-array vs `applications`-as-table mismatch in
127/// v0.4.0–v0.4.1 was that bug. Centralising the check in the hub
128/// makes it impossible for a plugin author to accidentally repeat.
129///
130/// # Lifetime constraints
131///
132/// `name` and `version` MUST return `&'static str`. The macro emits an
133/// FFI thunk whose return type is `RStr<'static>`, so a `&str`
134/// borrowed from `&self` will not compile. In practice this means
135/// returning either a string literal or `env!("CARGO_PKG_VERSION")`.
136/// If you need to compute the name from instance fields, store it in
137/// a `&'static str` (e.g. via `Box::leak(...)` at construction time)
138/// or pre-register the strings as `const`s.
139///
140/// # Panic safety
141///
142/// Every author-supplied body is wrapped in `std::panic::catch_unwind`
143/// inside its FFI thunk. A panic surfaces as:
144/// - `process` / `init` / `create`: `RResult::RErr(PluginPanicError)`.
145/// - `validate`: a single `Diagnostic::error` entry returned to the
146/// host (not an abort — required for `--validate-socket` mode).
147/// - `config_schema`: treated as `RNone`.
148/// - `name` / `version`: returned as the literal `"<plugin-panic>"`.
149/// - `shutdown` / `destroy`: absorbed; memory may leak but the hub
150/// stays up.
151#[macro_export]
152macro_rules! define_plugin {
153 (
154 $plugin_ty:ty, {
155 fn new() -> Self $new_body:block
156
157 fn init(&mut $init_self:ident, $ctx_param:ident : &PluginContext $(,)?)
158 -> Result<(), Box<dyn std::error::Error + Send + Sync>> $init_body:block
159
160 fn name(&$name_self:ident) -> &str $name_body:block
161
162 fn version(&$version_self:ident) -> &str $version_body:block
163
164 fn process(
165 &$process_self:ident,
166 $msg_param:ident : &SpoeMessage $(,)?
167 ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> $process_body:block
168
169 $(fn config_schema(&$schema_self:ident) -> Option<&str> $schema_body:block)?
170
171 $(fn validate(&$validate_self:ident, $vctx_param:ident : &PluginContext $(,)?) -> Vec<Diagnostic> $validate_body:block)?
172
173 $(fn drain(&$drain_self:ident, $drain_timeout_param:ident : u64 $(,)?) -> bool $drain_body:block)?
174
175 $(metrics_static = $metrics_static:path;)?
176 }
177 ) => {
178 // Author-supplied bodies become inherent methods on the
179 // plugin type. The thunks below cast the opaque state pointer
180 // back to `&Self` (or `&mut Self` for init) and call them.
181 impl $plugin_ty {
182 #[allow(dead_code)]
183 fn __new() -> Self $new_body
184
185 #[allow(clippy::unnecessary_wraps, dead_code)]
186 fn __init(
187 &mut $init_self,
188 $ctx_param: &$crate::PluginContext,
189 ) -> ::std::result::Result<(), ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>>
190 $init_body
191
192 #[allow(dead_code)]
193 fn __name(&$name_self) -> &'static str $name_body
194
195 #[allow(dead_code)]
196 fn __version(&$version_self) -> &'static str $version_body
197
198 #[allow(clippy::unnecessary_wraps, dead_code)]
199 fn __process(
200 &$process_self,
201 $msg_param: &$crate::SpoeMessage,
202 ) -> ::std::result::Result<
203 $crate::ProcessingResult,
204 ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
205 > $process_body
206
207 $(
208 #[allow(clippy::unnecessary_wraps, dead_code)]
209 fn __config_schema(&$schema_self) -> ::std::option::Option<&'static str> $schema_body
210 )?
211
212 $(
213 #[allow(clippy::unnecessary_wraps, dead_code)]
214 fn __validate(
215 &$validate_self,
216 $vctx_param: &$crate::PluginContext,
217 ) -> ::std::vec::Vec<$crate::Diagnostic> $validate_body
218 )?
219
220 $(
221 #[allow(clippy::unnecessary_wraps, dead_code)]
222 fn __drain(&$drain_self, $drain_timeout_param: u64) -> bool $drain_body
223 )?
224 }
225
226 // FFI thunks. All `unsafe` operations are confined here; the
227 // plugin author's bodies above remain in safe Rust.
228 const _: () = {
229 use ::std::os::raw::c_void;
230 use ::std::panic::{AssertUnwindSafe, catch_unwind};
231
232 // Every thunk that calls into user-supplied code is wrapped
233 // in `catch_unwind`. Without this, a panic in any plugin
234 // method would unwind through `extern "C"` — which Rust
235 // defines as abort — taking the whole hub down. Each thunk
236 // has a sensible fallback for the catch case so the host
237 // observes a structured error rather than UB.
238
239 extern "C" fn create() -> $crate::RResult<*mut c_void, $crate::RBoxError> {
240 match catch_unwind(|| {
241 let plugin: ::std::boxed::Box<$plugin_ty> =
242 ::std::boxed::Box::new(<$plugin_ty>::__new());
243 ::std::boxed::Box::into_raw(plugin).cast::<c_void>()
244 }) {
245 Ok(raw) => $crate::RResult::ROk(raw),
246 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
247 $crate::PluginPanicError,
248 )),
249 }
250 }
251
252 extern "C" fn destroy(state: *mut c_void) {
253 if state.is_null() {
254 return;
255 }
256 // SAFETY: state was produced by `create` via Box::into_raw
257 // on `Box<$plugin_ty>`. The hub guarantees one destroy per
258 // create. A panic in the plugin's Drop is absorbed; the
259 // alternative (abort) would lose every other live plugin.
260 let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
261 ::std::mem::drop(::std::boxed::Box::from_raw(state.cast::<$plugin_ty>()));
262 }));
263 }
264
265 extern "C" fn init(
266 state: *mut c_void,
267 ctx: &$crate::PluginContext,
268 ) -> $crate::RResult<(), $crate::RBoxError> {
269 // SAFETY: state was produced by `create` and not yet
270 // destroyed. Hub holds an exclusive reference during init.
271 let plugin = unsafe { &mut *state.cast::<$plugin_ty>() };
272 match catch_unwind(AssertUnwindSafe(|| plugin.__init(ctx))) {
273 Ok(Ok(())) => $crate::RResult::ROk(()),
274 Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
275 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
276 $crate::PluginPanicError,
277 )),
278 }
279 }
280
281 extern "C" fn process(
282 state: *const c_void,
283 msg: &$crate::SpoeMessage,
284 ) -> $crate::RResult<$crate::ProcessingResult, $crate::RBoxError> {
285 // SAFETY: state is alive between init and destroy. Process
286 // takes &self so concurrent calls share a borrow — the
287 // plugin must use interior mutability for any mutable state.
288 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
289 match catch_unwind(AssertUnwindSafe(|| plugin.__process(msg))) {
290 Ok(Ok(result)) => $crate::RResult::ROk(result),
291 Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
292 Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
293 $crate::PluginPanicError,
294 )),
295 }
296 }
297
298 extern "C" fn name(state: *const c_void) -> $crate::RStr<'static> {
299 // SAFETY: state alive between init and destroy.
300 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
301 match catch_unwind(AssertUnwindSafe(|| plugin.__name())) {
302 Ok(s) => $crate::RStr::from(s),
303 Err(_) => $crate::RStr::from("<plugin-panic>"),
304 }
305 }
306
307 extern "C" fn plugin_version(state: *const c_void) -> $crate::RStr<'static> {
308 // SAFETY: state alive between init and destroy.
309 let plugin = unsafe { &*state.cast::<$plugin_ty>() };
310 match catch_unwind(AssertUnwindSafe(|| plugin.__version())) {
311 Ok(s) => $crate::RStr::from(s),
312 Err(_) => $crate::RStr::from("<plugin-panic>"),
313 }
314 }
315
316 extern "C" fn shutdown(_state: *const c_void) {
317 // The original SpoePlugin trait's shutdown has an empty
318 // default body; the macro mirrors that. Plugins that need
319 // teardown logic do it in `destroy` (Drop on the boxed
320 // state), which is called once after the last use.
321 // Wrapped for forward-compat: if the macro grows a
322 // user-overridable shutdown body, the wrapper is already
323 // here.
324 let _ = catch_unwind(AssertUnwindSafe(|| {
325 let _ = _state;
326 }));
327 }
328
329 extern "C" fn config_schema(_state: *const c_void) -> $crate::ROption<$crate::RString> {
330 // catch_unwind around the (potentially user-overridden)
331 // body. On panic, return RNone so the hub skips schema
332 // validation rather than aborting.
333 catch_unwind(AssertUnwindSafe(|| {
334 $crate::__define_plugin_config_schema_thunk!(_state, $plugin_ty $(, $schema_body)?)
335 }))
336 .unwrap_or($crate::ROption::RNone)
337 }
338
339 extern "C" fn validate(
340 _state: *const c_void,
341 _ctx: &$crate::PluginContext,
342 ) -> $crate::RVec<$crate::Diagnostic> {
343 // catch_unwind around the (potentially user-overridden)
344 // body. On panic, return a single error Diagnostic so
345 // the hub surfaces a structured failure instead of
346 // aborting (critical for --validate-socket mode).
347 catch_unwind(AssertUnwindSafe(|| {
348 $crate::__define_plugin_validate_thunk!(_state, _ctx, $plugin_ty $(, $validate_body)?)
349 }))
350 .unwrap_or_else(|_| {
351 let mut diags = $crate::RVec::new();
352 diags.push($crate::Diagnostic::error(
353 0,
354 0,
355 "plugin's validate() panicked",
356 ));
357 diags
358 })
359 }
360
361 extern "C" fn set_metric_recorder(
362 _state: *mut c_void,
363 _record_fn: $crate::RecordMetricFn,
364 _ctx: *const c_void,
365 ) {
366 // The thunk dispatches to the plugin's `metrics_static`
367 // declaration (when present) by calling install() on
368 // the named MetricRecorder. When absent, no-op.
369 // catch_unwind so a panic in install() doesn't unwind
370 // through extern "C".
371 let _ = catch_unwind(AssertUnwindSafe(|| {
372 $crate::__define_plugin_metrics_thunk!(_state, _record_fn, _ctx $(, $metrics_static)?)
373 }));
374 }
375
376 extern "C" fn set_log_sink(
377 _state: *mut c_void,
378 sink_fn: $crate::LogSinkFn,
379 ctx: *const c_void,
380 max_level: $crate::LogLevel,
381 ) {
382 // Process-wide for this .so, not per instance: the `log`
383 // crate has one global logger per library image.
384 let _ = catch_unwind(AssertUnwindSafe(|| {
385 $crate::HUB_LOG_SINK.install(sink_fn, ctx, max_level);
386 }));
387 }
388
389 extern "C" fn drain(_state: *const c_void, _timeout_ms: u64) -> bool {
390 // Dispatch to user-supplied `fn drain` if present;
391 // otherwise the default thunk returns true immediately
392 // (correct for synchronous plugins that complete all
393 // work inside `process()` — coraza, external-auth,
394 // sso-auth, fingerprinting, maxmind, ...).
395 //
396 // catch_unwind so a panic in a user-defined drain
397 // doesn't unwind through extern "C". On panic we
398 // return false — the hub treats that as a forced
399 // shutdown (counter incremented, in-flight work lost)
400 // which is the right outcome for a misbehaving plugin.
401 catch_unwind(AssertUnwindSafe(|| {
402 $crate::__define_plugin_drain_thunk!(_state, _timeout_ms, $plugin_ty $(, $drain_body)?)
403 }))
404 .unwrap_or(false)
405 }
406
407 #[allow(non_upper_case_globals)]
408 static PLUGIN_VTABLE_INSTANCE: $crate::PluginVTable = $crate::PluginVTable {
409 api_version: $crate::PLUGIN_API_VERSION,
410 create,
411 destroy,
412 init,
413 process,
414 name,
415 plugin_version,
416 shutdown,
417 config_schema,
418 validate,
419 set_metric_recorder,
420 drain,
421 set_log_sink,
422 };
423
424 #[unsafe(no_mangle)]
425 pub extern "C" fn get_plugin_vtable() -> *const $crate::PluginVTable {
426 &PLUGIN_VTABLE_INSTANCE
427 }
428 };
429 };
430}
431
432/// Internal helper used by `define_plugin!` to expand the optional
433/// `config_schema` arm. With body → call the impl; without → return None.
434#[doc(hidden)]
435#[macro_export]
436macro_rules! __define_plugin_config_schema_thunk {
437 ($state:ident, $plugin_ty:ty) => {{
438 let _ = $state;
439 $crate::ROption::RNone
440 }};
441 ($state:ident, $plugin_ty:ty, $body:block) => {{
442 // SAFETY: state alive between init and destroy.
443 let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
444 match plugin.__config_schema() {
445 ::std::option::Option::Some(s) => $crate::ROption::RSome($crate::RString::from(s)),
446 ::std::option::Option::None => $crate::ROption::RNone,
447 }
448 }};
449}
450
451/// Internal helper used by `define_plugin!` to expand the optional
452/// `validate` arm. With body → call the impl; without → return empty.
453#[doc(hidden)]
454#[macro_export]
455macro_rules! __define_plugin_validate_thunk {
456 ($state:ident, $ctx:ident, $plugin_ty:ty) => {{
457 let _ = $state;
458 let _ = $ctx;
459 $crate::RVec::new()
460 }};
461 ($state:ident, $ctx:ident, $plugin_ty:ty, $body:block) => {{
462 // SAFETY: state alive between init and destroy.
463 let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
464 $crate::RVec::from(plugin.__validate($ctx))
465 }};
466}
467
468/// Internal helper used by `define_plugin!` to expand the optional
469/// `drain` arm. With body → call the impl; without → return true
470/// immediately (synchronous plugin, nothing in flight to wait for).
471#[doc(hidden)]
472#[macro_export]
473macro_rules! __define_plugin_drain_thunk {
474 ($state:ident, $timeout_ms:ident, $plugin_ty:ty) => {{
475 let _ = $state;
476 let _ = $timeout_ms;
477 true
478 }};
479 ($state:ident, $timeout_ms:ident, $plugin_ty:ty, $body:block) => {{
480 // SAFETY: state alive between init and destroy. Drain is
481 // called between the registry swap and shutdown — both are
482 // operations on still-live state, so &Self is fine.
483 let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
484 plugin.__drain($timeout_ms)
485 }};
486}
487
488/// Internal helper used by `define_plugin!` to expand the optional
489/// `metrics_static` arm. With a path → install the recorder on the
490/// named static `MetricRecorder`; without → ignore (the plugin doesn't
491/// emit metrics).
492///
493/// The recorder remains a static because callbacks may run from any plugin
494/// thread and need a process-stable address. Other plugin state should live on
495/// the plugin struct: `define_plugin!` captures each receiver identifier, so
496/// author bodies can use `self` normally.
497#[doc(hidden)]
498#[macro_export]
499macro_rules! __define_plugin_metrics_thunk {
500 ($state:ident, $record_fn:ident, $ctx:ident) => {{
501 // Plugin didn't declare `metrics_static` — no-op install.
502 let _ = $state;
503 let _ = $record_fn;
504 let _ = $ctx;
505 }};
506 ($state:ident, $record_fn:ident, $ctx:ident, $metrics_static:path) => {{
507 let _ = $state;
508 // Install on the static MetricRecorder the plugin pointed to.
509 // The static is global so it's reachable from every thread
510 // for the plugin's lifetime, matching the recorder's lifetime
511 // contract (valid until destroy returns; static lives forever
512 // → trivially satisfies the bound).
513 $metrics_static.install($record_fn, $ctx);
514 }};
515}
516
517/// Error raised when a plugin's `process` panics. Wrapped in `RBoxError`
518/// by the `define_plugin!` macro's panic-safety net.
519#[derive(Debug)]
520pub struct PluginPanicError;
521
522impl std::fmt::Display for PluginPanicError {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 write!(f, "plugin panicked during message processing")
525 }
526}
527
528impl std::error::Error for PluginPanicError {}
529
530#[cfg(test)]
531mod stateful_macro_tests {
532 use super::*;
533
534 #[derive(Debug)]
535 struct StatefulPlugin {
536 value: i32,
537 }
538
539 define_plugin!(StatefulPlugin, {
540 fn new() -> Self {
541 StatefulPlugin { value: 1 }
542 }
543
544 fn init(
545 &mut self,
546 context: &PluginContext,
547 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
548 self.value = context
549 .get_config("value")
550 .and_then(ConfigValue::as_integer)
551 .unwrap_or(1) as i32;
552 Ok(())
553 }
554
555 fn name(&self) -> &str {
556 let _ = self.value;
557 "stateful-test"
558 }
559
560 fn version(&self) -> &str {
561 let _ = self.value;
562 "0.0.0"
563 }
564
565 fn process(
566 &self,
567 _message: &SpoeMessage,
568 ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> {
569 Ok(ProcessingResult::single(TxnVariable::transaction(
570 "value",
571 SpoeValue::Int32(self.value),
572 )))
573 }
574
575 fn config_schema(&self) -> Option<&str> {
576 let _ = self.value;
577 None
578 }
579
580 fn validate(&self, _context: &PluginContext) -> Vec<Diagnostic> {
581 let _ = self.value;
582 Vec::new()
583 }
584
585 fn drain(&self, _timeout_ms: u64) -> bool {
586 self.value > 0
587 }
588 });
589
590 #[test]
591 fn author_bodies_can_read_and_write_instance_state() {
592 let mut config = RHashMap::new();
593 config.insert("value".into(), ConfigValue::Integer(42));
594 let context = PluginContext {
595 name: "stateful-test".into(),
596 config,
597 };
598 let message = SpoeMessage {
599 name: "test".into(),
600 args: RHashMap::new(),
601 stream_id: 1,
602 frame_id: 1,
603 };
604
605 let mut plugin = StatefulPlugin::__new();
606 plugin.__init(&context).expect("init should succeed");
607 let result = plugin.__process(&message).expect("process should succeed");
608
609 assert!(matches!(result.variables[0].value, SpoeValue::Int32(42)));
610 assert!(plugin.__drain(0));
611 }
612}