Skip to content
C++
Domain Track
Difficulty 3/5

Plugin System in C++

C++ plugin architecture — dynamic loading with dlopen/LoadLibrary, plugin interfaces, versioning, hot reload, and CMake shared library setup.

TL;DR

C++ plugin systems use shared libraries loaded at runtime via dlopen (POSIX) or LoadLibrary (Windows). Define a stable C interface or versioned ABI for plugins, and use a factory function (create_plugin) to avoid C++ name mangling issues.


Plugin Interface Design

cpp
// plugin_api.h — shared between host and all plugins
// Use only C++ features with stable ABI: vtables, C linkage

#pragma once
#include <cstdint>

// Version the API so plugins can check compatibility
struct PluginVersion {
    uint16_t major;  // breaking changes
    uint16_t minor;  // backward-compatible additions
};
constexpr PluginVersion kPluginApiVersion{2, 0};

// Plugin interface — pure virtual, no data members
struct Plugin {
    virtual const char* name()        const = 0;
    virtual const char* version()     const = 0;
    virtual PluginVersion api_version() const = 0;

    virtual bool initialize(const char* config) = 0;
    virtual void process(const void* data, size_t size) = 0;
    virtual void shutdown() = 0;

    virtual ~Plugin() = default;
};

// C linkage factory functions — exported from the shared library
extern "C" {
    Plugin*       create_plugin();
    void          destroy_plugin(Plugin*);
    PluginVersion plugin_api_version();  // pre-load version check
}

Plugin Implementation (in .so/.dll)

cpp
// my_plugin.cpp — compiled as a shared library

#include "plugin_api.h"
#include <cstring>

class MyPlugin : public Plugin {
    bool initialized_ = false;

public:
    const char* name()    const override { return "MyPlugin"; }
    const char* version() const override { return "1.0.0"; }
    PluginVersion api_version() const override { return {2, 0}; }

    bool initialize(const char* config) override {
        // parse config, set up resources
        initialized_ = true;
        return true;
    }

    void process(const void* data, size_t size) override {
        if (!initialized_) return;
        // process data...
    }

    void shutdown() override { initialized_ = false; }
};

extern "C" {
    Plugin* create_plugin()          { return new MyPlugin; }
    void    destroy_plugin(Plugin* p){ delete p; }
    PluginVersion plugin_api_version(){ return {2, 0}; }
}

Plugin Loader (Host Side)

cpp
// plugin_loader.h — POSIX version

#include "plugin_api.h"
#include <dlfcn.h>
#include <stdexcept>
#include <memory>
#include <filesystem>

class PluginLoader {
    void*   handle_  = nullptr;
    Plugin* plugin_  = nullptr;
    void (*destroy_)(Plugin*) = nullptr;

public:
    explicit PluginLoader(const std::filesystem::path& path) {
        // RTLD_LAZY: resolve symbols on use; RTLD_LOCAL: don't pollute global namespace
        handle_ = dlopen(path.c_str(), RTLD_LAZY | RTLD_LOCAL);
        if (!handle_)
            throw std::runtime_error(std::string("dlopen failed: ") + dlerror());

        // Check API version before constructing plugin
        using VersionFn = PluginVersion(*)();
        auto version_fn = reinterpret_cast<VersionFn>(dlsym(handle_, "plugin_api_version"));
        if (!version_fn)
            throw std::runtime_error("missing plugin_api_version symbol");

        auto ver = version_fn();
        if (ver.major != kPluginApiVersion.major)
            throw std::runtime_error("plugin API major version mismatch");

        // Get factory and destructor
        using CreateFn  = Plugin*(*)();
        using DestroyFn = void(*)(Plugin*);
        auto create  = reinterpret_cast<CreateFn>(dlsym(handle_, "create_plugin"));
        destroy_     = reinterpret_cast<DestroyFn>(dlsym(handle_, "destroy_plugin"));

        if (!create || !destroy_)
            throw std::runtime_error("missing create/destroy symbols");

        plugin_ = create();
        if (!plugin_) throw std::runtime_error("create_plugin returned null");
    }

    ~PluginLoader() {
        if (plugin_ && destroy_) destroy_(plugin_);
        if (handle_) dlclose(handle_);
    }

    // Non-copyable, movable
    PluginLoader(const PluginLoader&) = delete;
    PluginLoader& operator=(const PluginLoader&) = delete;

    Plugin* get() { return plugin_; }
    Plugin* operator->() { return plugin_; }
};

Plugin Registry

cpp
class PluginRegistry {
    std::vector<std::unique_ptr<PluginLoader>> loaders_;
    std::unordered_map<std::string, Plugin*>   by_name_;

public:
    void load_dir(const std::filesystem::path& dir) {
        for (const auto& entry : std::filesystem::directory_iterator(dir)) {
            if (entry.path().extension() != ".so") continue;
            try {
                auto loader = std::make_unique<PluginLoader>(entry.path());
                Plugin* p = loader->get();
                by_name_[p->name()] = p;
                loaders_.push_back(std::move(loader));
                std::println("loaded plugin: {} v{}", p->name(), p->version());
            } catch (const std::exception& e) {
                std::println(stderr, "failed to load {}: {}", entry.path().string(), e.what());
            }
        }
    }

    Plugin* find(std::string_view name) const {
        auto it = by_name_.find(std::string{name});
        return it != by_name_.end() ? it->second : nullptr;
    }

    size_t size() const { return loaders_.size(); }
};

// Usage
PluginRegistry registry;
registry.load_dir("plugins/");

if (auto* p = registry.find("MyPlugin")) {
    p->initialize("{}");
    p->process(data.data(), data.size());
    p->shutdown();
}

Hot Reload Pattern

cpp
class ReloadablePlugin {
    std::filesystem::path path_;
    std::filesystem::file_time_type last_modified_;
    std::unique_ptr<PluginLoader>   loader_;

public:
    explicit ReloadablePlugin(std::filesystem::path p) : path_{std::move(p)} {
        reload();
    }

    bool check_reload() {
        auto mtime = std::filesystem::last_write_time(path_);
        if (mtime != last_modified_) {
            reload();
            return true;
        }
        return false;
    }

    Plugin* get() { return loader_ ? loader_->get() : nullptr; }

private:
    void reload() {
        loader_ = std::make_unique<PluginLoader>(path_);
        last_modified_ = std::filesystem::last_write_time(path_);
        loader_->get()->initialize("{}");
        std::println("reloaded {}", path_.filename().string());
    }
};

// In main loop:
ReloadablePlugin rp{"plugins/renderer.so"};
while (running) {
    rp.check_reload();  // reload if .so changed on disk
    if (auto* p = rp.get())
        p->process(frame_data.data(), frame_data.size());
}

CMake: Building a Plugin

cmake
# Host application
add_executable(host main.cpp plugin_loader.cpp)
target_link_libraries(host PRIVATE dl)  # link libdl on Linux

# Plugin (shared library)
add_library(my_plugin SHARED my_plugin.cpp)
target_include_directories(my_plugin PRIVATE ${CMAKE_SOURCE_DIR}/include)

# Remove "lib" prefix on Linux so plugin can be named "my_plugin.so"
set_target_properties(my_plugin PROPERTIES PREFIX "")

# Copy plugin to output directory
add_custom_command(TARGET my_plugin POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:my_plugin>
            ${CMAKE_BINARY_DIR}/plugins/)

ABI Stability Tips

  • Avoid std::string, std::vector in plugin interfaces — their layout can differ between compilers and standard library versions
  • Use const char*, raw pointers, and sizes for data passing
  • Never return C++ exceptions across the plugin boundary — catch all in plugin, return error codes
  • Version the API struct explicitly; bump major version on any breaking change

Plugin Discovery

Scanning a directory for plugins is the most common discovery strategy. A manifest file alongside each .so gives the host metadata (name, version, dependencies) before loading the library, which avoids paying the dlopen cost for incompatible plugins.

cpp
// plugin_manifest.json — sits next to my_plugin.so
// {
//   "name": "MyPlugin",
//   "api_major": 2,
//   "provides": ["renderer"],
//   "depends": []
// }

#include <nlohmann/json.hpp>
#include <filesystem>
#include <fstream>

struct PluginManifest {
    std::string              name;
    int                      api_major;
    std::vector<std::string> provides;
    std::vector<std::string> depends;
};

std::optional<PluginManifest> load_manifest(const std::filesystem::path& so_path) {
    auto json_path = so_path;
    json_path.replace_extension(".json");
    if (!std::filesystem::exists(json_path)) return std::nullopt;

    std::ifstream f{json_path};
    auto j = nlohmann::json::parse(f, nullptr, /*exceptions=*/false);
    if (j.is_discarded()) return std::nullopt;

    return PluginManifest{
        j.value("name", ""),
        j.value("api_major", 0),
        j.value("provides", std::vector<std::string>{}),
        j.value("depends",  std::vector<std::string>{}),
    };
}

void discover_plugins(const std::filesystem::path& dir, int required_api_major) {
    for (const auto& entry : std::filesystem::directory_iterator(dir)) {
        if (entry.path().extension() != ".so") continue;
        auto manifest = load_manifest(entry.path());
        if (!manifest) continue;
        if (manifest->api_major != required_api_major) {
            std::println(stderr, "skip {}: api {} != {}", manifest->name,
                         manifest->api_major, required_api_major);
            continue;
        }
        // safe to dlopen now
    }
}

Error Handling Across Plugin Boundaries

C++ exceptions must never cross a plugin boundary — the plugin and host may have been compiled with different compilers or RTTI settings. The safest contract is a C-style error-code return plus a last_error() accessor that returns a static string.

cpp
// In plugin_api.h
enum class PluginError : int32_t {
    Ok           =  0,
    InvalidConfig= -1,
    OutOfMemory  = -2,
    Unsupported  = -3,
    Internal     = -99,
};

struct Plugin {
    // ... existing methods ...

    // All operations return an error code; never throw
    virtual PluginError initialize(const char* config_json) noexcept = 0;
    virtual PluginError process(const void* data, size_t size) noexcept = 0;

    // Thread-local last-error detail string (valid until next call)
    virtual const char* last_error() const noexcept = 0;

    virtual ~Plugin() = default;
};

// Plugin implementation wraps internals with try/catch
PluginError MyPlugin::initialize(const char* cfg) noexcept {
    try {
        parse_config(cfg);
        return PluginError::Ok;
    } catch (const std::bad_alloc&) {
        last_error_ = "out of memory during init";
        return PluginError::OutOfMemory;
    } catch (const std::exception& e) {
        last_error_ = e.what();
        return PluginError::Internal;
    }
}

// Host checks result
if (auto err = plugin->initialize(cfg); err != PluginError::Ok)
    std::println(stderr, "plugin init failed ({}): {}", int(err), plugin->last_error());

Thread Safety in Plugin Systems

If multiple threads call plugin methods concurrently, each plugin must document its threading contract explicitly. A simple capability flag in the manifest prevents the host from scheduling parallel calls to plugins that are not thread-safe.

cpp
// Extend PluginVersion or add a capabilities bitmap
enum class PluginCaps : uint32_t {
    None          = 0,
    ThreadSafe    = 1 << 0,  // process() safe to call from multiple threads
    Reentrant     = 1 << 1,  // safe to call from signal/callback within process()
};

struct Plugin {
    virtual PluginCaps capabilities() const noexcept = 0;
    // ...
};

// Host dispatch — serialise calls for non-thread-safe plugins
class SafePluginProxy {
    Plugin*                   plugin_;
    std::mutex                mu_;           // only used when not thread-safe

public:
    explicit SafePluginProxy(Plugin* p) : plugin_{p} {}

    PluginError process(const void* data, size_t size) {
        bool ts = (plugin_->capabilities() & PluginCaps::ThreadSafe) != PluginCaps::None;
        if (ts) return plugin_->process(data, size);
        std::scoped_lock lock{mu_};
        return plugin_->process(data, size);
    }
};

Hot Reload Workflow

A clean hot-reload sequence is: drain in-flight work → destroy the old plugin → unload the library → load the new library → re-initialize. Skipping the drain step causes use-after-free if a thread is still inside process() when the library is unmapped.

cpp
class HotReloadManager {
    std::atomic<bool>      draining_{false};
    std::atomic<int>       in_flight_{0};
    std::mutex             reload_mu_;
    std::unique_ptr<PluginLoader> loader_;
    std::filesystem::path  path_;

public:
    // Called by worker threads before/after using the plugin
    struct Guard {
        HotReloadManager& mgr;
        Guard(HotReloadManager& m) : mgr{m} { ++mgr.in_flight_; }
        ~Guard() { --mgr.in_flight_; }
    };

    Guard acquire() { return Guard{*this}; }
    Plugin* get()   { return loader_ ? loader_->get() : nullptr; }

    void reload() {
        // 1. Signal drain and spin-wait for workers to finish
        draining_ = true;
        while (in_flight_.load() > 0) std::this_thread::yield();

        // 2. Replace loader under lock (no other thread holds plugin ref now)
        std::scoped_lock lock{reload_mu_};
        loader_ = std::make_unique<PluginLoader>(path_);
        loader_->get()->initialize("{}");

        draining_ = false;
        std::println("hot-reloaded {}", path_.filename().string());
    }
};

// Worker thread:
// auto g = mgr.acquire();        // bump in-flight counter
// mgr.get()->process(data, sz);  // safe — reload will wait for us

Cross-Platform Portability

dlopen/dlsym/dlclose on POSIX and LoadLibrary/GetProcAddress/FreeLibrary on Windows have the same logical model but differ in error reporting and symbol visibility defaults. A thin abstraction removes the #ifdef clutter from loader code.

cpp
// dynlib.h — portable dynamic library handle

#pragma once
#include <string>
#include <stdexcept>

#ifdef _WIN32
#  define WIN32_LEAN_AND_MEAN
#  include <windows.h>
   using DynHandle = HMODULE;
   inline DynHandle  dynlib_open(const char* path) { return LoadLibraryA(path); }
   inline void*      dynlib_sym (DynHandle h, const char* sym) {
                         return reinterpret_cast<void*>(GetProcAddress(h, sym)); }
   inline void       dynlib_close(DynHandle h) { FreeLibrary(h); }
   inline std::string dynlib_error() {
       char buf[256]; FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM,nullptr,
           GetLastError(),0,buf,sizeof(buf),nullptr); return buf; }
#else
#  include <dlfcn.h>
   using DynHandle = void*;
   inline DynHandle   dynlib_open(const char* path) {
                          return dlopen(path, RTLD_LAZY | RTLD_LOCAL); }
   inline void*       dynlib_sym (DynHandle h, const char* sym) { return dlsym(h,sym); }
   inline void        dynlib_close(DynHandle h) { dlclose(h); }
   inline std::string dynlib_error() { return dlerror() ?: "unknown"; }
#endif
ConcernPOSIX (dlopen)Windows (LoadLibrary)
Default symbol visibilityAll exported__declspec(dllexport) required
Search path env varLD_LIBRARY_PATHPATH or SetDllDirectory
Extension.so (Linux) / .dylib (macOS).dll
Debug suffix conventionNoneOften -d.dll
Thread-safe closeYesYes

Security Considerations

Loading arbitrary shared libraries is equivalent to running arbitrary code — a malicious .so executes immediately inside your process with your privileges. Practical mitigations layer code signing, path allowlisting, and capability-limited interfaces.

cpp
// 1. Allowlist plugin directories — never load from user-writable paths
void validate_plugin_path(const std::filesystem::path& p) {
    const std::filesystem::path allowed{"/opt/myapp/plugins"};
    if (!p.string().starts_with(allowed.string()))
        throw std::runtime_error("plugin outside allowed directory: " + p.string());
}

// 2. Expose only the minimum interface to plugins
//    Good: plugin receives a narrow "context" object
struct HostContext {
    virtual void log(const char* msg) noexcept = 0;
    virtual bool read_config(const char* key, char* out, size_t len) noexcept = 0;
    // NOT: a raw file handle, socket, or memory allocator
};

// 3. Capability flags in manifest (defence-in-depth before load)
//    If manifest claims Networking but your app doesn't need it, reject load.

On Linux, additional hardening options include running plugins in a child process connected via a Unix socket (true isolation), or using seccomp to restrict syscalls available to plugin code.


Testing Plugin Systems

Test two concerns separately: the plugin loader mechanism itself, and each plugin's behaviour against the interface contract. A mock plugin compiled directly (not via dlopen) lets you test host logic without filesystem dependencies.

cpp
// test_host.cpp — mock plugin, linked directly, no dlopen needed

#include "plugin_api.h"
#include <catch2/catch_test_macros.hpp>

class MockPlugin : public Plugin {
public:
    const char*   name()        const noexcept override { return "MockPlugin"; }
    const char*   version()     const noexcept override { return "0.0.1"; }
    PluginVersion api_version() const noexcept override { return {2, 0}; }
    PluginError   initialize(const char*) noexcept override { return PluginError::Ok; }
    PluginError   process(const void*, size_t) noexcept override { return PluginError::Ok; }
    void          shutdown() noexcept override {}
    const char*   last_error() const noexcept override { return ""; }
};

TEST_CASE("registry finds plugin by name") {
    PluginRegistry reg;
    reg.register_direct(std::make_unique<MockPlugin>());
    REQUIRE(reg.find("MockPlugin") != nullptr);
}

TEST_CASE("SafePluginProxy serialises calls for non-thread-safe plugin") {
    MockPlugin mp;
    SafePluginProxy proxy{&mp};
    // Call from two threads simultaneously — should not crash
    std::thread t1{[&]{ proxy.process(nullptr, 0); }};
    std::thread t2{[&]{ proxy.process(nullptr, 0); }};
    t1.join(); t2.join();
}

For interface contract tests, run the same test suite against every plugin binary in the plugins directory to verify ABI compliance.


See Also