Every sensor you compile into a monitoring daemon is a sensor you have to ship, support, and restart the daemon to fix, and once your users start asking for hardware you do not own, the math stops working, because the bottleneck is no longer writing the sensor but cutting a release every time someone wants one. The obvious answer is runtime plugins, and the less obvious part is everything that comes after you say yes to dlopen, because a plugin host is not a feature you add but a contract you sign, and the contract is what decides whether the host survives its own first three releases.
LinSight signs that contract with Rust cdylib plugins loaded at startup, and this post is about the ABI choices underneath, the ones that look like ceremony until the first time a stale .so meets a new daemon and you find out whether your design was real.
Why dynamic loading, when static linking is right there
The honest starting position is that dynamic loading in Rust is a bad deal by default, because Rust has no stable ABI, a *mut dyn Trait fat pointer is only meaningful within one rustc release, and the compiler that was your best friend five minutes ago goes completely silent at the dlopen boundary, so you are trading compile-time guarantees for deployment flexibility and you had better be getting something real in return.
What we get in return is that a plugin author runs linsight-cli plugin new my-sensor, writes one trait impl, runs cargo build --release, installs the .so, and has live sensor data in the dashboard without us cutting a daemon release, shipping them a patched binary, or even being awake, and that workflow is the entire justification, because the alternative is a pull request queue of hardware support requests gated on our release cadence, which is a queue nobody wins.
The daemon side stays honest too, since linsightd scans /usr/lib/linsight/plugins/, /usr/local/lib/linsight/plugins/, and the user’s XDG data dir in that order at startup, validates what it finds, and registers plugin sensors right alongside the in-tree ones, with a first-registration-wins rule on sensor-ID collisions so a user-installed plugin cannot silently shadow a distro-shipped one without a log line saying what happened.
The vtable problem, and why stabby
The v0.2 loader did the naive thing, which is a factory function returning *mut dyn LinsightPlugin, and it worked, because the realistic plugin author at the time was rebuilding against the same workspace and the same rustc anyway, so identical trait layout on both sides meant no drift in practice, but we knew that was borrowed time, since the moment the SDK went on crates.io, plugins would start arriving compiled with different rustc minor versions, and vtable layout is stable within a release and absolutely not across them.
The fix is stabby, a crate that gives you an FFI-safe vtable and stable layout types, so the plugin exports a stabby-annotated factory and the daemon loads it through StabbyLibrary::get_stabbied, which type-checks the FFI vtable via stabby’s _stabbied_v3_report companion symbol before a single plugin method ever runs, and that reflection pass is the difference between “the daemon refused to load your plugin with an actionable error” and “the daemon segfaulted inside someone else’s code and the backtrace points at us.”
The trait itself is small, because plugin hosts die from surface area:
impl LinsightPlugin for MyPlugin {
extern "C-unwind" fn init(
&self,
_ctx: &RPluginCtx,
) -> stabby::result::Result<RPluginManifest, RPluginError> {
// return plugin id, display name, version, sensor descriptors
}
extern "C-unwind" fn sample(
&self,
sensor: &RSensorId,
) -> stabby::result::Result<RReading, RPluginError> {
// read one sensor, return one reading
}
}
Two methods do the work, init hands back a manifest describing every sensor the plugin owns, sample reads one sensor on the per-client pump thread, and shutdown has a default no-op for the plugins that own hardware handles or background threads and need explicit teardown when the host drops.
The ABI version is a kill-switch, and the symbol name is the backstop
LINSIGHT_PLUGIN_ABI_VERSION is 6 today, and the rule is blunt: the daemon refuses to load a .so whose reported version does not match, logs the actionable error, skips that plugin, and keeps running, because a monitoring daemon that crashes on a third-party plugin is a monitoring daemon people uninstall, and the registry never gets poisoned by a partial load.
The version check alone is not the whole story, though, because the export_plugin! macro renames the factory symbol on every ABI bump, so the v6 factory is a different symbol than the v5 factory was, which means a stale .so fails the symbol lookup outright rather than loading with an incompatible vtable and getting discovered at the version check, and belt-and-suspenders here is cheap, since stabby’s own vtable reflection is a third backstop underneath both.
We exercised this for real on the v5 to v6 bump, which existed for one reason: the trait methods moved from extern "C" to extern "C-unwind" so that a panic inside a plugin method unwinds across the FFI boundary and the daemon can catch it instead of aborting the whole process, and the migration for plugin authors was changing their signatures and rebuilding, with the compiler flagging every method they missed, which is exactly the kind of breaking change an ABI version is for, mechanical, loud, and impossible to miss.
The release-mode bug that shaped the ABI
The part of this design that was not planned is the mirror-type encoding, and it exists because of a real miscompilation-class bug, not because anyone enjoyed the extra layer. Stabby’s tagged-enum support in 36.2.2 has a match_owned matcher that misroutes closures at opt-level >= 1, which in practice meant a Percent reading round-tripped to Celsius in release builds while debug builds stayed correct, and there are few bugs worse than the kind that only appears when you stop debugging.
So the payload-bearing types that cross the vtable, RUnit, RReading, RCell, are not stabby tagged enums at all but plain structs with an explicit #[repr(u8)] discriminant field plus payload fields, which bypasses the broken matcher entirely, and the unit-only enums are just #[repr(u8)], while From/Into adapters convert between these R-mirror types and the host’s std-typed linsight-core values at exactly one place, the boundary, so plugin authors write clean Rust and the FFI surface stays stabby-clean, with docs/adr/0001-plugin-abi-stabby-deferral.md keeping the whole story for whoever touches this next.
Validation got the same treatment, because every sensor ID a plugin returns goes through SensorId::try_new on the host side, an empty or whitespace-bearing string is a PluginError::Parse and the plugin is rejected before registration, so a malformed third-party .so degrades to a log line and a skipped plugin rather than a corrupt registry that the GUI then has to render around.
What it costs
The price of all this is that you give up the compiler at the exact seam where things go wrong, and no amount of reflection checks changes that, because a plugin can still block the pump thread if its sample is slow, still leak if its shutdown is lazy, and still be built against the wrong SDK by an author who did not read the error message, so the contract has to be enforced in the loader, documented in the SDK, and tested in CI, where tests/dynamic_load.rs builds the example plugin as a real .so and exercises the full load path so the scaffold’s shape cannot silently drift from what the daemon expects.
The old-timer version of this story is COM apartments and LoadLibrary plus GetProcAddress, where the contract lived in header files and prayer, and the modern equivalent is not fundamentally different, a versioned vtable, an explicit kill-switch, and a loader that assumes the worst, except that now the prayer is encoded in a reflection symbol and the failure mode is a clean skip instead of a corrupted process, and that is the whole game, because plugin hosts do not fail on the happy path, they fail on the third-party code you have never seen, built by someone you have never met, against a version you shipped eight months ago.
