Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmake/PjPluginManifest.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ function(pj_emit_plugin_manifest TARGET)
#
# -Bsymbolic-functions is Linux/ELF-specific. On macOS the linker uses
# two-level namespace by default (equivalent behavior), so the flag is omitted.
#
# SCOPE CAVEAT — this pair does NOT cover STB_GNU_UNIQUE data objects
# (Meyers singletons, inline variables, template statics, thread_local, and
# their `__cxa_guard_*` guards). By construction, unique symbols are a
# process-wide data lookup that glibc funnels through a namespace-scoped
# unique table (`do_lookup_unique` in glibc's `dl-lookup.c`), and
# -Bsymbolic-functions is a function-call rewrite that does not touch data
# bindings. Any vague-linkage static a first-party plugin's transitive deps
# instantiate (libstdc++'s own templates ship with default visibility, so
# `-fvisibility=hidden` on plugin source does not silence them either) still
# reaches `.dynsym` as UNIQUE. That is what causes a bundled DSO opened for
# discovery to stay resident once its unique names are entered in the
# process's unique table — see `pj_base/plugin_descriptor_section.hpp` for
# the mechanism, and the follow-up hardening (linker version scripts or
# `-fno-gnu-unique`) that closes the load-time gap this file cannot.
set_target_properties(${TARGET} PROPERTIES
CXX_VISIBILITY_PRESET hidden
C_VISIBILITY_PRESET hidden
Expand Down
2 changes: 1 addition & 1 deletion pj_base/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pj_base is the **Level 0** foundation and the **SDK boundary** for plugin author
- `include/pj_base/` — vocabulary primitives: `types.hpp`, `time.hpp` (absolute time spine: `Timepoint`/`Duration` + `fromRaw`/`toRaw`), `type_tree.hpp`, `dataset.hpp`, `expected.hpp`, `span.hpp`, `number_parse.hpp`, `assert.hpp`, `diagnostic_sink.hpp`, `buffer_anchor.hpp`.
- `include/pj_base/builtin/` — the 16 builtin object struct headers (`*.hpp`; 17 enum values in `BuiltinObjectType`, values 2 and 12 reserved) + their 15 wire codecs (`*_codec.hpp`; RobotDescription has none) + the `BuiltinObject` (`std::any`) type-erased holder.
- `include/pj_base/sdk/` — C++ SDK over the ABI: DataSource + Toolbox `*_plugin_base.hpp`, `service_registry.hpp`/`service_traits.hpp`, host views, Arrow RAII holders, `testing/`.
- `include/pj_base/*_protocol.h`, `plugin_data_api.h`, `builtin_object_abi.h`, `plugin_abi_export.hpp` — the stable C-ABI surface for DataSource/MessageParser/Toolbox (the Dialog protocol header lives in `pj_plugins/dialog_protocol/`).
- `include/pj_base/*_protocol.h`, `plugin_data_api.h`, `builtin_object_abi.h`, `plugin_abi_export.hpp`, `plugin_descriptor_section.hpp` (the statically discoverable manifest blob every `PJ_*_PLUGIN` macro emits) — the stable C-ABI surface for DataSource/MessageParser/Toolbox (the Dialog protocol header lives in `pj_plugins/dialog_protocol/`).
- `proto/pj/` — canonical `.proto` wire contracts for the builtin types (see its README).
- `src/`, `tests/` — codec/parse impls and gtests.
- `abi/baseline.abi` — golden libabigail dump; the ABI-stability regression baseline.
Expand Down
195 changes: 195 additions & 0 deletions pj_base/include/pj_base/plugin_descriptor_section.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#ifndef PJ_PLUGIN_DESCRIPTOR_SECTION_HPP
#define PJ_PLUGIN_DESCRIPTOR_SECTION_HPP

// Copyright 2026 Davide Faconti
// SPDX-License-Identifier: Apache-2.0

#include <cstddef>
#include <cstdint>

#include "pj_base/plugin_data_api.h"

// Statically discoverable plugin descriptor.
//
// The host reads a plugin's family and manifest straight out of the DSO image
// on disk, so discovery never has to dlopen the plugin. That matters because
// dlclose does not necessarily unmap what dlopen mapped: glibc pins a DSO
// NODELETE as soon as it is the FIRST PROVIDER of a name entered into the
// namespace's process-wide unique table (`do_lookup_unique` in glibc's
// `dl-lookup.c`). "First provider" is load-bearing here: a later copy of the
// same plugin whose unique names are already present in the table does not
// itself get pinned — it binds INTO the first copy's storage instead, which is
// what makes the duplicate-mapping bug so specific to the order things load in.
// Any vague-linkage static reaching `.dynsym` is a candidate for STB_GNU_UNIQUE
// binding (inline-function locals, template statics, Meyers singletons,
// thread_local, and their `__cxa_guard_*` guards). An inspect-then-close pass
// on a bundled plugin therefore leaves that plugin resident for the life of
// the process. If a second copy of the same plugin is later loaded from a
// different path, that copy binds its own references to the first copy's
// storage and finds its initialisation guards already set, so any layout drift
// between the two builds corrupts the process.
//
// Every DSO built with a PJ_*_PLUGIN macro carries one of these blobs per
// family it implements. The blob duplicates the manifest string that is also
// reachable through the vtable; the vtable copy stays authoritative once the
// plugin is genuinely loaded, and vtable-shape validation still happens there.
//
// The blob is located by SECTION, not by symbol name, so it survives stripping
// and needs no dynamic-symbol lookup.

// --- Section names -----------------------------------------------------------
//
// All three are defined on every platform: a plugin only ever emits into its
// own container's section, but the host-side reader is compiled everywhere so
// the object-file parsers can be tested on any machine.
//
// PE section names are capped at 8 bytes in the image section header, hence the
// abbreviated name on Windows. Mach-O needs the segment,section pair form.
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME_ELF ".pj_manifest"
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME_PE ".pjmani"
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME_MACHO "__PJ,__manifest"

// --- Section placement -------------------------------------------------------
#if defined(_MSC_VER)
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME PJ_PLUGIN_DESCRIPTOR_SECTION_NAME_PE
#pragma section(".pjmani", read)
#define PJ_PLUGIN_DESCRIPTOR_PLACEMENT __declspec(allocate(".pjmani"))
// dllexport is what keeps the object alive through /OPT:REF: an unreferenced
// global in a custom section is otherwise a valid link-time removal.
#define PJ_PLUGIN_DESCRIPTOR_KEEP __declspec(dllexport)
#elif defined(__APPLE__)
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME "__PJ,__manifest"
#define PJ_PLUGIN_DESCRIPTOR_PLACEMENT __attribute__((section("__PJ,__manifest")))
#define PJ_PLUGIN_DESCRIPTOR_KEEP __attribute__((visibility("default"), used))
#else
#define PJ_PLUGIN_DESCRIPTOR_SECTION_NAME ".pj_manifest"
#define PJ_PLUGIN_DESCRIPTOR_PLACEMENT __attribute__((section(".pj_manifest")))
// `used` keeps the compiler from dropping it; `retain` (SHF_GNU_RETAIN) keeps
// the linker from dropping it under --gc-sections. `visibility("default")`
// might look redundant next to those two, but it is deliberate belt-and-braces:
// on toolchains too old for `retain` / SHF_GNU_RETAIN, an exported symbol is
// itself a gc-root, and default visibility makes it exported. It is the same
// job `dllexport` does on MSVC.
#if defined(__has_attribute)
#if __has_attribute(retain)
#define PJ_PLUGIN_DESCRIPTOR_KEEP __attribute__((visibility("default"), used, retain))
#endif
#endif
#ifndef PJ_PLUGIN_DESCRIPTOR_KEEP
#define PJ_PLUGIN_DESCRIPTOR_KEEP __attribute__((visibility("default"), used))
#endif
#endif

namespace PJ::detail {

/// Family tags stored in the blob. Wire values — never renumber; the host's
/// PluginFamily mirrors them and static_asserts the correspondence.
enum PluginDescriptorFamily : uint32_t {
kDescriptorFamilyUnknown = 0,
kDescriptorFamilyDataSource = 1,
kDescriptorFamilyMessageParser = 2,
kDescriptorFamilyToolbox = 3,
kDescriptorFamilyDialog = 4,
};

/// Identifies a blob when scanning the raw section bytes. Not NUL-terminated.
inline constexpr char kPluginDescriptorMagic[8] = {'P', 'J', 'P', 'L', 'U', 'G', 'I', 'N'};

/// Bumped only if the header below changes shape. A reader that does not know
/// a blob's version skips that blob rather than misreading it.
inline constexpr uint32_t kPluginDescriptorBlobVersion = 1;

/// Fixed-size prologue of every blob. All fields are written in the DSO's
/// native byte order; a reader parsing a foreign-endian image byte-swaps them
/// using the endianness declared by the container format's own header.
struct PluginDescriptorBlobHeader {
char magic[8];
uint32_t blob_version;
/// Total bytes of this blob, header and trailing padding included. Lets a
/// reader walk a section holding several blobs without parsing each manifest.
uint32_t blob_size;
/// PJ_ABI_VERSION the plugin was compiled against.
uint32_t abi_version;
/// One of PluginDescriptorFamily.
uint32_t family;
/// Manifest length in bytes, excluding the NUL terminator.
uint32_t manifest_size;
uint32_t reserved;
};
static_assert(sizeof(PluginDescriptorBlobHeader) == 32, "descriptor blob header is a wire format");

/// A header immediately followed by the manifest text. 8-byte alignment makes
/// every blob_size a multiple of 8, so consecutive blobs contributed by
/// different translation units stay walkable without gaps.
template <std::size_t JsonBytes>
struct alignas(8) PluginDescriptorBlob {
PluginDescriptorBlobHeader header;
char manifest_json[JsonBytes];
};

/// Length of a NUL-terminated manifest, excluding the terminator.
///
/// Exists so the macro can size the blob exactly. Plugins spell their manifest
/// either as a `char[]` (the CMake-generated headers) or as a
/// `constexpr const char*` (hand-written ones), so the length cannot simply be
/// deduced from an array parameter.
constexpr std::size_t manifestLength(const char* manifest_json) {
std::size_t length = 0;
while (manifest_json[length] != '\0') {
++length;
}
return length;
}

/// Builds a blob from a NUL-terminated manifest. `JsonBytes` counts the
/// terminator, so it is manifestLength() + 1.
template <std::size_t JsonBytes>
constexpr PluginDescriptorBlob<JsonBytes> makePluginDescriptorBlob(uint32_t family, const char* manifest_json) {
static_assert(JsonBytes >= 2, "manifest must be a non-empty NUL-terminated string");
PluginDescriptorBlob<JsonBytes> blob{};
for (std::size_t i = 0; i < sizeof(kPluginDescriptorMagic); ++i) {
blob.header.magic[i] = kPluginDescriptorMagic[i];
}
blob.header.blob_version = kPluginDescriptorBlobVersion;
blob.header.blob_size = static_cast<uint32_t>(sizeof(PluginDescriptorBlob<JsonBytes>));
blob.header.abi_version = PJ_ABI_VERSION;
blob.header.family = family;
blob.header.manifest_size = static_cast<uint32_t>(JsonBytes - 1);
blob.header.reserved = 0;
for (std::size_t i = 0; i < JsonBytes; ++i) {
blob.manifest_json[i] = manifest_json[i];
}
return blob;
}

} // namespace PJ::detail

/// Emits one descriptor blob. `SymbolSuffix` keeps the names distinct when a
/// single DSO implements more than one family (a data source that also ships a
/// dialog, say).
///
/// The variable is non-const for the same reason `pj_plugin_abi_version` is: a
/// namespace-scope `const` has internal linkage in C++, which MSVC then refuses
/// to place with __declspec(dllexport). Nothing writes to it. Being a plain
/// global rather than a vague-linkage entity, it never acquires STB_GNU_UNIQUE
/// binding itself.
///
/// `constinit` is load-bearing, not decoration: the blob has to be present in
/// the image on disk. Without it, a manifest that is not a constant expression
/// would compile into a dynamic initialiser, leaving the section zero-filled on
/// disk and the whole static-discovery path silently reading nothing. It turns
/// that into a compile error instead.
#define PJ_EMBED_PLUGIN_DESCRIPTOR(SymbolSuffix, FamilyValue, ManifestJson) \
extern "C" { \
PJ_PLUGIN_DESCRIPTOR_KEEP PJ_PLUGIN_DESCRIPTOR_PLACEMENT constinit auto pj_plugin_descriptor_##SymbolSuffix = \
PJ::detail::makePluginDescriptorBlob<PJ::detail::manifestLength(ManifestJson) + 1>(FamilyValue, ManifestJson); \
}

// Statically linked builds have no DSO to inspect, and one blob symbol per
// family would collide across the plugins folded into the host binary.
#ifdef PJ_STATIC_PLUGINS
#undef PJ_EMBED_PLUGIN_DESCRIPTOR
#define PJ_EMBED_PLUGIN_DESCRIPTOR(SymbolSuffix, FamilyValue, ManifestJson)
#endif

#endif // PJ_PLUGIN_DESCRIPTOR_SECTION_HPP
4 changes: 3 additions & 1 deletion pj_base/include/pj_base/sdk/data_source_plugin_base.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#include "pj_base/data_source_protocol.h"
#include "pj_base/expected.hpp"
#include "pj_base/plugin_abi_export.hpp"
#include "pj_base/plugin_descriptor_section.hpp"
#include "pj_base/sdk/data_source_host_views.hpp"
#include "pj_base/sdk/plugin_data_api.hpp"
#include "pj_base/sdk/service_registry.hpp"
Expand Down Expand Up @@ -227,7 +228,8 @@ class DataSourcePluginBase {
}, \
manifest); \
return vt; \
}
} \
PJ_EMBED_PLUGIN_DESCRIPTOR(data_source, PJ::detail::kDescriptorFamilyDataSource, manifest)

// Variant for namespaced plugin classes. SymbolName must be an unqualified
// identifier and is used only to form the unique static getter name.
Expand Down
4 changes: 3 additions & 1 deletion pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

#include "pj_base/expected.hpp"
#include "pj_base/plugin_abi_export.hpp"
#include "pj_base/plugin_descriptor_section.hpp"
#include "pj_base/sdk/data_source_host_views.hpp" // ParserIngestHostView, errorToString
#include "pj_base/sdk/plugin_data_api.hpp"
#include "pj_base/sdk/service_registry.hpp"
Expand Down Expand Up @@ -251,7 +252,8 @@ class ToolboxPluginBase {
}, \
manifest); \
return vt; \
}
} \
PJ_EMBED_PLUGIN_DESCRIPTOR(toolbox, PJ::detail::kDescriptorFamilyToolbox, manifest)

// Variant for namespaced plugin classes. SymbolName must be an unqualified
// identifier and is used only to form the unique static getter name.
Expand Down
4 changes: 3 additions & 1 deletion pj_plugins/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ submodule-internal modules; `pj_base` carries none).
- `include/pj_plugins/testing/` — `ToolboxTestStore` (fake Arrow host for tests).
- `dialog_protocol/` — **nested module** (own CMake): the Dialog C ABI, C++
dialog SDK, and host dialog loader/handle. See `dialog_protocol/CLAUDE.md`.
- `src/` — loader/catalog `.cpp`; `src/detail/` vtable validation + dlopen.
- `src/` — loader/catalog `.cpp`; `src/detail/` vtable validation, dlopen, and
the object-file reader that pulls a plugin's descriptor section off disk so
discovery never has to map the DSO (`descriptor_section_reader.hpp`).
- `examples/` — mock plugins exercised by tests (`mock_data_source`, …).
- `tests/` — host-side loader + lifecycle tests.

Expand Down
14 changes: 13 additions & 1 deletion pj_plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
find_package(nlohmann_json REQUIRED)

add_library(pj_plugin_loader_detail STATIC
src/detail/descriptor_section_reader.cpp
src/detail/vtable_validation.cpp
)
target_include_directories(pj_plugin_loader_detail PUBLIC
Expand Down Expand Up @@ -362,6 +363,7 @@ target_compile_definitions(plugin_catalog_test PRIVATE
PJ_MISSING_ID_PLUGIN_PATH="$<TARGET_FILE:missing_id_data_source_plugin>"
PJ_INVALID_OPTIONAL_PLUGIN_PATH="$<TARGET_FILE:invalid_optional_manifest_data_source_plugin>"
PJ_MISSING_REQUIRED_SLOTS_PLUGIN_PATH="$<TARGET_FILE:missing_required_slots_plugin>"
PJ_MOCK_SOURCE_WITH_DIALOG_PLUGIN_PATH="$<TARGET_FILE:mock_source_with_dialog_plugin>"
)
target_compile_options(plugin_catalog_test PRIVATE ${PJ_WARNING_FLAGS})
target_link_libraries(plugin_catalog_test PRIVATE
Expand All @@ -372,9 +374,19 @@ add_dependencies(plugin_catalog_test mock_data_source_plugin
mock_toolbox_plugin mock_dialog_plugin missing_id_data_source_plugin
invalid_optional_manifest_data_source_plugin missing_required_slots_plugin
static_manifest_dialog_plugin legacy_macro_dialog_plugin
missing_dialog_required_slots_plugin)
missing_dialog_required_slots_plugin mock_source_with_dialog_plugin)
add_test(NAME plugin_catalog_test COMMAND plugin_catalog_test)

# Unit test: object-file parsing for the static plugin-descriptor reader.
# Builds synthetic ELF / PE / Mach-O containers, so it needs no fixture DSOs and
# covers the formats this host cannot itself produce.
add_executable(descriptor_section_reader_test tests/descriptor_section_reader_test.cpp)
target_compile_options(descriptor_section_reader_test PRIVATE ${PJ_WARNING_FLAGS})
target_link_libraries(descriptor_section_reader_test PRIVATE
pj_plugin_loader_detail pj_base GTest::gtest_main
)
add_test(NAME descriptor_section_reader_test COMMAND descriptor_section_reader_test)

endif() # PJ_BUILD_TESTS

# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <cstring>
#include <exception>
#include <pj_base/plugin_abi_export.hpp>
#include <pj_base/plugin_descriptor_section.hpp>
#include <string>
#include <string_view>
#include <utility>
Expand Down Expand Up @@ -287,8 +288,13 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept {
#define PJ_DIALOG_PLUGIN(...) \
PJ_DIALOG_PLUGIN_EXPAND( \
PJ_DIALOG_PLUGIN_SELECT(__VA_ARGS__, PJ_DIALOG_PLUGIN_WITH_MANIFEST, PJ_DIALOG_PLUGIN_LEGACY)(__VA_ARGS__))
#define PJ_DIALOG_PLUGIN_LEGACY(ClassName) PJ_DIALOG_PLUGIN_WITH_MANIFEST(ClassName, nullptr)
#define PJ_DIALOG_PLUGIN_WITH_MANIFEST(ClassName, ManifestJson) \
// A dialog declared without a manifest has nothing to embed, so it emits no
// descriptor blob and discovery falls back to loading the DSO.
#define PJ_DIALOG_PLUGIN_LEGACY(ClassName) PJ_DIALOG_PLUGIN_IMPL(ClassName, nullptr)
#define PJ_DIALOG_PLUGIN_WITH_MANIFEST(ClassName, ManifestJson) \
PJ_DIALOG_PLUGIN_IMPL(ClassName, ManifestJson) \
PJ_EMBED_PLUGIN_DESCRIPTOR(dialog, PJ::detail::kDescriptorFamilyDialog, ManifestJson)
#define PJ_DIALOG_PLUGIN_IMPL(ClassName, ManifestJson) \
PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_DIALOG_EXPORT) \
extern "C" PJ_DIALOG_EXPORT const PJ_dialog_vtable_t* PJ_get_dialog_vtable() noexcept { \
static const PJ_dialog_vtable_t* vt = PJ::DialogPluginBase::vtableWithCreate( \
Expand Down Expand Up @@ -349,4 +355,9 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept {
}
#define PJ_DIALOG_PLUGIN_WITH_MANIFEST(ClassName, ManifestJson) \
PJ_DIALOG_PLUGIN_NAMED(ClassName, ClassName, ManifestJson)
// The manifest-less form has to be rerouted too: its shared body emits the
// fixed `extern "C" PJ_get_dialog_vtable`, which collides across the plugins
// folded into one statically linked binary.
#undef PJ_DIALOG_PLUGIN_LEGACY
#define PJ_DIALOG_PLUGIN_LEGACY(ClassName) PJ_DIALOG_PLUGIN_NAMED(ClassName, ClassName, nullptr)
#endif // PJ_STATIC_PLUGINS
Loading
Loading