Files
logos-protocol/cpp/logos_module_impl.h
Dario LipicarandClaude Opus 5 480f40ff63 feat(abi): publish the module-impl export list as data, for backends to check against (#66)
* feat(abi): publish the module-impl export list as data, for backends to check against

logos-protocol DECLARES the module-impl C ABI; every language backend
(logos-cpp-sdk, logos-rust-sdk, and the Nim path now in flight) must
DEFINE every entry. Those are independent facts, and the gap between them
has shipped twice — grant_host_services at 0.3, the teardown pair at 0.5.
Each time it surfaced three repos downstream as an "undefined symbol" at
dlopen, on Linux only, and each time the runtime still reported the module
as LOADED, so what anyone actually saw was other modules timing out on a
replica that never appeared.

Both breakages happened at PERFECT version agreement between the caller
and the module. Version agreement is necessary and not sufficient: it says
nothing about which symbols a given backend's emitter happens to write.

So derive the list once, here, in the repo that owns the ABI, and ship it
as a build output:

  packages.<sys>.module-impl-abi
    exports.txt  — the declared names
    version      — the protocol version that header belongs to
    bin/logos-module-impl-diff — the assertion, and the explanation

Two properties follow from putting it here rather than in each backend.
There is ONE parser to keep working, rather than one regex per language
that can each silently stop matching. And the list is version-correct with
no version arithmetic anywhere: the header is itself versioned — at 0.4 it
declared eight exports, at 0.5 it declares ten — so "what this protocol
requires" is just "what this header declares". A backend pinning 0.4 reads
eight and is right to define eight. No @since tags, no MINOR comparisons,
nothing for a backend to get wrong.

The extractor parses LOGICAL declarations rather than lines (a reflowed
header must not silently drop one) and refuses to emit a list it is unsure
of: under-reporting is the dangerous direction, because a short list makes
every consumer's diff pass over an ABI nobody checked. The floor it checks
against is asserted rather than derived, so a broken parse cannot satisfy
it. logos-protocol failing to build is the right consequence of
logos-protocol being unable to state its own ABI.

checks.<sys>.module-impl-abi-tests proves all of that can still fail: empty
header, renamed macro, a founding export removed, an empty defined-set, and
a reflowed declaration — eleven cases, each a way this could have decayed
into a green check over nothing.

Also corrects the compatibility note above logos_module_about_to_unload.
It argued the pair was safe because "the glue is generated alongside the
module". That does not follow, and is the reasoning the 0.5 break rested on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: run the ABI manifest check — `nix build .#tests` never reaches `checks`

The step added here is not incidental. `nix build '.#tests'` builds the
PACKAGE; nothing in this workflow evaluated the `checks` attrset at all, so
the manifest self-test added in the previous commit would have sat there
green-by-absence — which is precisely the failure mode it exists to catch.

`ws test` is not a substitute either: it evaluates exactly one check per
repo (scripts/ws truncates the checks JSON at the first comma), so a green
`ws test logos-protocol` says nothing about whether this ran.

builtins.currentSystem rather than a literal, so one line is correct on both
matrix runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(abi): the helper must not depend on the consumer's PATH — and the suite must notice

Two bugs, and the second is the interesting one.

1. The installed helper carried `#!/usr/bin/env bash`. Consumers execute it
   from inside their own nix builds, whose PATH is whatever THEIR
   nativeBuildInputs provide. It resolved on macOS and not in the Linux
   sandbox, so the helper simply did not run there. patchShebangs pins an
   absolute interpreter.

2. The self-test did not notice, and the reason is worth keeping. expect_fail
   accepted ANY non-zero exit as a correct refusal — but a script that cannot
   be executed exits 126/127, so all five refusal cases reported PASS while
   proving nothing at all. Only the two POSITIVE cases failed, which is the
   only reason this surfaced.

   That is precisely the failure this whole change exists to prevent, one
   level up: a check that reports green over something it never examined. So
   expect_fail now asserts a deliberate refusal and rejects 126/127 by name.

Caught by CI on ubuntu-latest while macOS was green — the same
platform-asymmetry that let the original ABI break through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:06:48 -03:00

158 lines
7.8 KiB
C

#ifndef LOGOS_MODULE_IMPL_H
#define LOGOS_MODULE_IMPL_H
/* ===========================================================================
* logos_module_impl.h — the COMMON module-impl C ABI.
*
* ONE contract for module implementations in every language: a Logos module
* compiles to a cdylib exporting exactly these symbols. The C++ SDK emits
* this wrapper around a universal C++ impl class; the Rust SDK emits it
* around a Rust impl. The uniform generated Qt-plugin glue (and, later, a
* no-Qt host) talks to the cdylib ONLY through this ABI — the glue is
* identical regardless of the module's source language, which is what makes
* the eventual Qt-glue removal a host swap instead of a per-language change.
*
* Data model mirrors the lp_* consumer ABI (logos_protocol.h):
* - method args / event payloads: JSON array (UTF-8 const char*)
* - results: JSON value
* - bytes: the canonical {"_bytes":"<base64url>"} tagged form
* - errors from dispatch: NULL return, or a canonical error object
* {"code","message","origin"} returned as the result of a failed call
* when the implementation prefers structured errors.
*
* Ownership: every char* RETURNED by the module is heap-allocated and the
* CALLER frees it with logos_module_string_free (exported by the module so
* allocator domains never mix). Every const char* passed IN is borrowed.
*
* Threading: the host serializes dispatch calls (one at a time) unless a
* future capability negotiates otherwise. The emit callback may be invoked
* from any module thread; the host marshals.
*
* Versioning: logos_module_get_protocol_version() returns the
* logos-protocol semver the module was COMPILED against (forwarded from
* LOGOS_PROTOCOL_VERSION_STRING, never minted). Hosts apply the same rule
* as the metadata stamp: equal MAJOR ⇔ compatible. This runtime handshake
* complements the build-time metadata stamp and is what a no-Qt host (no
* Qt plugin metadata) negotiates with.
* =========================================================================== */
#ifdef __cplusplus
extern "C" {
#endif
#if defined(_WIN32)
#define LOGOS_MODULE_IMPL_EXPORT __declspec(dllexport)
#else
#define LOGOS_MODULE_IMPL_EXPORT __attribute__((visibility("default")))
#endif
/* Event-emission callback installed by the host/glue. `data_json` is a JSON
* array payload, borrowed for the duration of the call. */
typedef void (*logos_module_emit_cb)(const char* event_name,
const char* data_json,
void* user_data);
/* ---------------------------------------------------------------------------
* Exported by every module cdylib (generated by the SDK of the module's
* language; module authors never write these by hand).
* ------------------------------------------------------------------------- */
/* Dispatch a method call. Returns the result JSON value as a heap string
* (free with logos_module_string_free), or NULL when the method is unknown
* or dispatch failed structurally. */
LOGOS_MODULE_IMPL_EXPORT char* logos_module_dispatch(const char* method,
const char* args_json);
/* The module's method/event metadata as a JSON array — same shape as
* LogosProviderObject::getMethods() (entries tagged "method"/"event"). */
LOGOS_MODULE_IMPL_EXPORT char* logos_module_get_methods(void);
/* Module identity/context, stamped by the host before the first dispatch:
* module path, instance id, per-instance persistence path. Mirrors
* LogosModuleContext / RustModuleContext. Any argument may be NULL. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_context(
const char* module_path,
const char* instance_id,
const char* instance_persistence_path);
/* Install the host's event-emission callback. The module keeps (cb,
* user_data) and invokes cb once per emitted event. Passing NULL clears it;
* after the clearing call returns, the module must not invoke the old cb. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_emit_callback(
logos_module_emit_cb cb, void* user_data);
/* Deliver an auth token for `module_name` (the provider-side
* informModuleToken). Returns 0 on acceptance. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_accept_token(const char* module_name,
const char* token);
/* Grant the module the privileged host services named in `services_json` (a
* JSON array from the closed set lp_grant_host_services documents). Returns 0
* on acceptance; the generated implementation simply forwards to
* lp_grant_host_services.
*
* Called by the host AFTER it has verified the module's identity, and only for
* the modules its policy designates as a trust root — nothing about this ABI
* decides who deserves the grant.
*
* It has to travel this way, and that is the subtle part: the host binary and
* the module cdylib each link their own copy of logos-protocol, so each has its
* own process-global grant state, exactly as each has its own TokenManager. A
* grant the host records for itself is invisible to the gate the cdylib checks.
* Pushing it in over this ABI — the same route the auth token above already
* takes — is what puts the grant in the image whose gates it must open. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_grant_host_services(
const char* services_json);
/* Teardown completion callback, installed by the glue before it asks the module
* to unload. May be invoked from any module thread. */
typedef void (*logos_module_unload_done_cb)(void* user_data);
/* Install the teardown-completion callback. Called before
* logos_module_about_to_unload(); a NULL cb clears it. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_unload_done_callback(
logos_module_unload_done_cb cb, void* user_data);
/* Ask the module to prepare for teardown. Returns 0 when it is already
* quiescent, 1 when it has work to finish and will invoke the callback
* installed above exactly once when done.
*
* The wait is BOUNDED by the host: returning 1 buys a grace period, not a veto.
* A module that never signals delays every teardown by that period and is torn
* down anyway, so the deadline is real rather than a courtesy.
*
* CONDITIONAL on the protocol version, which is not the same as optional at
* load time. The glue emits a DIRECT call — no dlsym, no null check — so a
* module generated for >= 0.5 whose backend omits the definition links cleanly
* and then fails at dlopen(), on ELF, with "undefined symbol". The pair is
* skippable only where the CALLER was generated below 0.5 and emitted no call.
*
* An earlier version of this comment argued the arrangement was safe because
* "the glue is generated alongside the module". That does not follow, and the
* ABI has now been broken twice on the strength of it. Being generated in the
* same build makes the two agree on the protocol VERSION; it says nothing about
* which SYMBOLS a given language backend's emitter writes for that version,
* because each backend implements this ABI independently. Both breakages —
* grant_host_services at 0.3 and this pair at 0.5 — happened at perfect version
* agreement, and both were invisible on macOS (plugins link
* -undefined dynamic_lookup) and fatal on Linux (nixpkgs' -Wl,-z,now binds
* eagerly).
*
* So every backend owes a build-time check that its generated scaffold defines
* everything declared here. nix/module-impl-abi.nix publishes this file's
* export list as data for exactly that purpose. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_about_to_unload(void);
/* The logos-protocol semver this module was compiled against. Static
* string — do NOT free. */
LOGOS_MODULE_IMPL_EXPORT const char* logos_module_get_protocol_version(void);
/* Free a string returned by this module. Safe on NULL. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_string_free(char* s);
#ifdef __cplusplus
}
#endif
#endif /* LOGOS_MODULE_IMPL_H */