Files
dpm-core-ng/docs/PROSE/MODULES.md
Christopher M. Punches 55c852586b Dispatch returns a result envelope
dpm_execute fills a dpm_result on every call: status and error_code
from the module's return value, and a payload the module hands back
through dpm_set_result. The payload's layout belongs to the module and
is documented per command; its release function is carried in the
envelope and invoked by dpm_result_release. The context keeps a stack
of the envelopes in progress, so a module calling a peer receives the
peer's payload in its own envelope and its caller sees only what the
module sets itself.

The good fixture returns a payload and counts its releases, and the
modules test covers the envelope fields, the payload round trip, single
release, discard on a NULL envelope, and a payload set outside any
dispatch. The prose documents describe the envelope and the payload
header a module ships.
2026-09-07 14:53:24 -04:00

10 KiB

Developing DPM Modules

A DPM module is one shared object in the module directory. libdpm-core.so loads it, validates it completely, and dispatches commands to it on behalf of whatever asked — the dpm binary, a build system, or another module. This document covers writing, building, testing, and installing a module.

The Module Contract

A module includes <dpm/core.h>, links -ldpm-core, and exports the following four symbols as extern "C". All returned strings must be non-NULL, static or module-owned, and valid for the lifetime of the loaded module; callers never free them.

int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv) The command entry point, and the only entry through which the module performs work. ctx is the host context that dispatched the call; the module reaches every service (dpm_log, dpm_config_get, dpm_get_resolved_module_path, ...) through it, and reaches peer modules through it as well. command is the subcommand name, equal to argv[0] when argc > 0. NULL or empty command must behave as the module's help command. Returns 0 on success, nonzero on failure; the return value becomes the caller's status and error_code. Where a nonzero return needs explaining, record the reason with dpm_set_last_error immediately before returning, and the caller reads it back with dpm_get_last_error. Where the command returns data, hand it to dpm_set_result before returning. It must be callable immediately after load with no other setup.

const char* dpm_module_version(void) The module's own version as an X.Y.Z string. libdpm-core.so reports this value to consumers, and each consumer decides for itself whether the version suits it.

const char* dpm_module_description(void) A one-line human-readable description, shown in module listings.

const char* dpm_module_aliases(void) A comma-separated list of alternate names your module answers to, or NULL for none:

extern "C" const char* dpm_module_aliases(void)
{
    return "installer, files";
}

NULL is a complete answer. The symbol itself is required, and a module that does not export it is refused at load with the other contract failures.

The dpm_ctx type and the service declarations all come from the installed public header:

#include <dpm/core.h>

You Determine Your Own Compatibility With the Library

Your module is built against the system-installed libdpm-core.so and is responsible for being correct against it. Where you need to act on what you are running under, dpm_core_version() reports the running version and you decide what to do:

const char* running = dpm_core_version();

Check it, proceed or fail on your own terms, and report through dpm_log and your return code.

Your Interface Is Your Command Vocabulary

Everything a module offers is reached through dpm_module_execute, addressed by command string, with arguments passed as an argument vector, a status returned as an int, and data returned as a payload.

A caller compiles against a module name, a command name, and the layout of the payload for the commands it reads. Document your commands, their arguments, their return codes, and the payload each returns — that documentation is your interface, and it is the only thing a consumer can depend on. No symbol of yours is reachable from a caller.

Returning Data

A command returns data by handing a payload to the library before the entry point returns:

dpm_set_result(ctx, payload, mymodule_release_payload);

payload is whatever the command returns, in a layout you define for that command. mymodule_release_payload frees it, and the caller invokes it through dpm_result_release when it is done. Modules are loaded RTLD_LOCAL, so that function pointer is the only way a caller can reach your release code. A command that returns nothing calls nothing, and the caller sees a NULL payload.

The layout of each command's payload is part of your interface. Ship it as a header from your module's repository, versioned with the module, and a caller includes it for the commands it reads:

struct mymodule_list_entry {
    const char* name;
    const char* version;
};

struct mymodule_list {
    size_t                    count;
    struct mymodule_list_entry* entries;
};

A caller that dispatched "list" to your module reads result.data as a struct mymodule_list*, because it asked for "list" and your documentation says that is what "list" returns.

The payload lands in the envelope of the dispatch in progress. When your command calls a peer, the peer's payload lands in the envelope you passed for that call, and your own caller sees only what you set yourself.

Symbol naming: every functional export is prefixed with the module's name (mymodule_*). The dpm_ prefix is reserved for the contract symbols and for libdpm-core.so.

Calling Another Module

A module reaches a peer by performing the same two steps its own caller performed — ask libdpm-core.so for the module by name, then ask libdpm-core.so to invoke it:

int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv)
{
    dpm_module* peer = dpm_require(ctx, "othermodule");
    if (!peer) {
        dpm_log(ctx, DPM_LOG_ERROR, dpm_get_last_error(ctx));
        return 1;
    }

    dpm_result result;
    int rc = dpm_execute(ctx, peer, "somecommand", argc, argv, &result);
    /* read result.data as the layout othermodule documents for "somecommand" */
    dpm_result_release(&result);
    return rc;
}

The ctx is the one handed to your entry point. Nothing else is needed to reach the library.

Never link or hardcode anything belonging to a peer. The one thing of a peer's you include is the header carrying the payload layouts of the commands you read. Modules are loaded with RTLD_LOCAL, so a peer's symbols are not reachable from your module even if you tried — libdpm-core.so is the only path, and the knowledge you hold about a peer is its name, the commands it documents, and the payloads those commands return.

A module that depends on a peer is the party that judges the peer's version. Require it, read its reported version with dpm_get_module_info, and decide whether it is suitable for the commands you intend to issue. libdpm-core.so reports; it does not rule.

Validation at Load

libdpm-core.so is the sole authority on module validity, and validation is all-or-nothing. Before a module is offered to anyone, it verifies that every reserved contract symbol resolves and that the version and description probes return well-formed values. A module failing either step is refused with an itemized reason, visible in the load-failure output. A module that loads is fully valid — consumers never defend against partial states.

Building

A module repository builds with CMake:

cmake_minimum_required(VERSION 3.22)
project(mymodule)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(mymodule MODULE mymodule.cpp)

set_target_properties(mymodule PROPERTIES
        PREFIX ""
        SUFFIX ".so"
)

target_link_libraries(mymodule PRIVATE dpm-core)

install(TARGETS mymodule LIBRARY DESTINATION lib/dpm/modules)
cmake -B <build-dir>
cmake --build <build-dir>

libdpm-core.so is the only DPM link dependency a module ever has. Dependencies on other modules are runtime concerns, resolved by name through require and dispatch — a peer module is never linked, never included, and never needs to be present to build or to test.

Running and Testing Locally

Load the freshly built module through a locally run dpm binary without installing anything:

dpm --module-path <build-dir> mymodule <command>

libdpm-core.so runs the full validation sequence on every load, so a contract mistake surfaces here, immediately and itemized, rather than after installation. The --config-dir flag points the module's configuration namespace at local files during development, and --root directs package operations at a scratch tree.

Where your module calls a peer, put a stub module in the fixture module path: a small .so exporting the three reserved symbols and answering the commands your module issues. libdpm-core.so validates and dispatches to it exactly as it would the real peer. Because a peer is addressed only by name and command string, the stub is a complete substitute — there is nothing else about the real peer your module could have depended on.

Installing

Modules install to lib/dpm/modules under the install prefix (/usr/lib/dpm/modules on a distribution install), and the package's install step then records the module:

dpm --install-module mymodule

That loads the module once, writes its version and description into /var/lib/dpm/metadata/mymodule.meta, and records each alias it declared whose name is free. From then on dpm --list-modules reports your module from that record and opens nothing, which is what keeps listing the modules on a system from executing them.

Removal is the counterpart:

dpm --uninstall-module mymodule

which deletes the record and every alias resolving to the module, leaving the .so where it is.

A module present in the module path with no record is reported in listings with a version of <uninstalled>. It still loads and runs when a caller requires it by name, with a warning, so a module put in place by hand works before anyone has installed it.

Aliases

An alias is a second name a module answers to. Your declared aliases are recorded at installation; an operator adds more with:

dpm --add-alias mymodule mm
dpm --remove-alias mm
dpm --list-aliases [mymodule]

A name is recorded once. Adding a name that is already an alias, or that is an installed module's own name, is refused rather than repointed, so an existing route to a module is never taken over silently. Module names resolve before aliases, so a module's own name always reaches that module.

A Working Example

The info module bundled with the dpm binary and libdpm-core.so, at src/bundled-modules/info/, tests and demonstrates full DPM system functionality, and in doing so shows the contract, command routing, and this build structure in working form.