Feat: Dynamic plugin loading via Rust cdylibs #53
Open
terylt wants to merge 3 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
cpex-dynamic-plugin, a new crate that lets operators load Rust plugins at runtime from.so/.dylib/.dllfiles without rebuilding the host. Implements the design fromdocs/specs/cpex-rust-spec.md§17.What's in
Single-plugin cdylibs. Plugin authors write a normal
Plugin+HookHandlerimpl, slap thecpex_dynamic_plugin!macro on it, build ascdylib. Operators load via URL-shaped kind:Multi-handler cdylibs. One plugin struct can register handlers for multiple hooks. Operators select a specific handler via the URL fragment:
kind: "lib:/opt/plugins/auth.so#identity.resolve"
Multi-plugin cdylibs. Several distinct plugins can ship in one shared library via the cpex_dynamic_plugins! macro. Operators select an entry via the URL query parameter:
kind: "lib:/opt/plugins/multi.so?entry=rate_limiter"
Multi-plugin cdylibs also export an optional
cpex_plugin_listdiscovery symbol; the host reads it to validate?entry=against available entries up-front and produces friendly diagnostics ("no entry 'foo'. Available: [allow, deny]") instead of raw dlsym errors.Key design decisions
Test coverage
Three reference example cdylibs live alongside the crate at
crates/cpex-dynamic-plugin/examples/{single-plugin,multi-handler,multi-plugin}/.
Out of scope
Potential extension: cross-version ABI via abi_stable
The same-version-only constraint is a deliberate v1 choice — simple to reason about, no perf cost, no large dep surface — but it forces operators to rebuild plugins whenever they upgrade the host.
The abi_stable crate offers an escape hatch. It provides:
at offset 24: type mismatch in inner generic") rather than UB.
With abi_stable in place, an operator could in principle run a 1.74 host against a plugin built with 1.78, or pin a plugin to cpex-core 0.2 while the host moves to 0.3, as long as the published stable interface hasn't changed in incompatible ways.
The cost is substantial: every type crossing the boundary (Plugin, AnyHookHandler, MessagePayload, Extensions, PluginContext, PluginConfig, PluginResult, …) needs to become StableAbi, async trait support is ergonomically rougher, and there's a real per-call perf hit from the stable wrappers. The cleanest migration path is a dedicated
cpex-dynamic-plugin-stable adapter layer that mirrors cpex-core's types in R*-wrapped form, with conversion shims threading them into the real types at load time.
When this would be worth doing: if the deployment model evolves toward third-party plugin authors distributing binaries independently of host releases (a marketplace shape, à la Bevy plugins). For internal-only plugins where operators control both the host build and the plugins they load, the same-version constraint is the right tradeoff— one rebuild per host upgrade is cheap compared to maintaining a stable-ABI adapter layer and validating every cross-version combination in CI.
Potential extension: per-version plugin build container
A lower-cost alternative to a stable ABI is to make same-version builds trivially reproducible. Each CPEX release ships a
cpex/plugin-builder:vX.Y.Zcontainer image with the exact rustc, cpex-core, cpex-dynamic-plugin, and cargo features pinned. Plugin authors don't need to think about the toolchain at all:The output .so is guaranteed to ABI-match the v0.5.0 host. The runtime ABI_VERSION handshake stays in place as a backstop for builds that escape the container.
Versus abi_stable: the container approach attacks the same operational pain (plugin/host version drift) from a different angle — instead of making the runtime tolerant of mismatches, it makes the build environment trivially reproducible so mismatches don't happen. Zero runtime cost, zero changes to cpex-core types, plugin authors keep writing idiomatic Rust (Arc, Vec, async_trait) with no R*-wrapper ceremony. The tradeoff: you can't deliberately
mix versions (host v0.5 + plugin built for v0.4), which abi_stable would allow. For internal deployments that constraint is a feature; for a third-party plugin marketplace it isn't.
Costs to plan for: one image per release to publish and maintain, multi-arch matrix (linux/amd64, linux/arm64, darwin/arm64), and image size (~1 GB for a Rust toolchain image, mitigable via slim base + cargo-chef caching). A
lower-effort variant skips Docker entirely and just publishes a compatibility manifest (rustc version, cpex-core git SHA, feature flags) that a cargo cpex plugin check subcommand validates against the dev's local toolchain — trades determinism for portability.