C++ plugin systems usually die at the boundary. The demo works, the shared library loads, the class looks clean, and then one compiler update or one different standard library turns the whole thing into a crash generator. std::string crosses the DLL line, exception semantics drift, vtables stop matching, and you’re debugging memory corruption in code that should have been boring.
The host and plugin need to agree on less. A shared C header can define the function table, the entrypoint and the ownership rules without exporting the host’s C++ class layout. Graft adds the manifest, version check and symbol loading around that agreement.
The boundary
The boundary stays narrow on purpose:
- manifest first
- typed entrypoint second
- no shared C++ class ABI
- fail fast before the host calls the plugin entrypoint
This is useful when plugins are built and released separately from the application, or when another language needs to call a narrow part of a C++ library. Both need an interface small enough to describe and version.
The contract
Every graft-native shared library exports one well-known manifest symbol:
inline constexpr const char* MANIFEST_SYMBOL = "icy_graft_manifest";
That manifest carries the fields the host checks before calling the declared entrypoint:
- plugin id
- display name
- version
- runtime kind (
nativeorworker) - entrypoint symbol name
- ABI version
Bad metadata, a mismatched manifest ABI or an unknown runtime kind rejects the library when the manifest is checked. A missing entrypoint symbol fails when the host resolves it. Loading the shared library can run its initialisers before either check, so this is a contract for trusted native code.
Exporting a plugin
#include <icy/graft/graft.h>
namespace detector {
inline constexpr const char* ENTRYPOINT = "motion_detector_api";
}
ICY_GRAFT_PLUGIN("motion-detector",
"Motion Detector",
"1.0.0",
icy::graft::RUNTIME_NATIVE,
detector::ENTRYPOINT)
That macro emits the standard manifest with the current ABI version. The actual entrypoint can be whatever typed function-table or constructor contract your host expects. Graft only cares that the contract is explicit; it does not try to impose a shared C++ class hierarchy across plugins.
The macro declares the manifest; the plugin must also export the function it names. For the example above, that looks like:
extern "C" ICY_GRAFT_EXPORT
const MotionDetectorApi* motion_detector_api()
{
return &API; // API implements the function table in the shared header
}
MotionDetectorApi belongs to the application. Its shared header must define the function signatures, who owns returned memory, how errors travel and how long a handle remains valid. The manifest ABI checks the manifest’s layout; the application still owns compatibility of its function table.
Loading a plugin
#include <icy/graft/graft.h>
using GetApiFunc = const MotionDetectorApi* (*)();
icy::graft::Library lib;
lib.open("./libmotion_detector.so");
const auto& manifest = lib.manifest();
auto getApi = lib.entrypoint<GetApiFunc>();
const MotionDetectorApi* api = getApi();
Library::open() validates the manifest before the host touches the plugin API. If the manifest is missing, the ABI is wrong, or the runtime is nonsense, the load fails immediately.
entrypoint<GetApiFunc>() casts the resolved symbol to the type in your shared header. Both sides must honour that signature. Keep the library open while any returned function pointer, table or object is still in use.
Call the C++ pipeline from Rust
The same problem appears when Rust calls into icey. The binding needs to create a pipeline, attach a source, start it and destroy it. It does not need the layout of the C++ objects underneath.
icy_pipeline_api_manifest describes the icy_pipeline_api function table. The binding checks that surface, then calls it through opaque handles.
plugin -> Graft loader -> host application
Rust wrapper -> icey-sys -> Graft C surface -> icey pipeline
The current pipeline surface exposes an opaque handle, create/destroy operations, RTSP source attachment, WebRTC sender setup, start/stop and an error accessor. Its table carries abi_version and struct_size, separate from the manifest ABI. Status codes cross the boundary; C++ exceptions stay inside the implementation.
The Rust bindings are in development and cover this narrow pipeline surface. A host runtime declaration identifies a host-exported interface; the plugin loader rejects it. A worker declaration names an intended execution model, but does not create or isolate a worker process.
What this buys you
For trusted native extensions, the boundary becomes something the host can actually rely on:
- the host can reject incompatible libraries before calling their entrypoints
- the plugin contract is inspectable without reverse-engineering symbols by convention
- runtime kind stays explicit, so native and worker payloads share vocabulary without sharing mechanics
- the loader logic lives in one place instead of being reimplemented differently in every host
Good fit for media processors, detector backends, codec adapters, and other high-trust in-process extensions where performance matters and the host needs predictable loading.
With Pacm
Pacm delivers the package; graft binds what arrives.
Pacm can install a package that declares:
{
"extension": {
"loader": "graft",
"runtime": "native",
"entrypoint": "lib/libmotion_detector.so",
"abi-version": 1,
"capabilities": ["processor.video", "detector"]
}
}
After install, the host resolves extension.entrypoint relative to the package directory and hands that path to graft::Library.
- Pacm fetches, verifies, extracts, and versions the package
- Graft opens, validates, and binds the runtime payload
Delivery and loading stay as separate concerns, each with its own contract.
