Files
Dario LipicarandClaude Opus 5 937f17ed01 feat(cdylib): define logos_module_accept_inbound_token (#151)
* feat(cdylib): define logos_module_accept_inbound_token

logos-protocol only DECLARES the module-impl C ABI; every language backend
owes each definition. A missing one links clean and dies at dlopen, on Linux
only — macOS links plugins -undefined dynamic_lookup and hides it entirely.

Also fixes a misrouted diagnostic in the ABI check. The at-nextmaj MAJOR probe
ran before the export diff, so an export that NOTHING defines was reported as
"a version guard testing MINOR without MAJOR" — the wrong lesson, sending the
reader to fix a guard that is not there. The probe is now gated on the symbol
being present at the current MINOR, with a self-test over synthetic sets
because both diagnostics are inline shell and otherwise untestable.

Requires logos-protocol fix/token-direction-key-namespace (59b27ef).

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

* chore(deps): relock logos-protocol to 42460e5b (0.8.0), which is what makes the ABI check non-vacuous

WHAT MOVED

  logos-protocol  480f40ff (0.5.0)  ->  42460e5b (0.8.0)

Nothing else in the lock changed. The input tracks a branch rather than a
rev, so the update moved it on its own.

WHY IT HAD TO

This PR's definition of logos_module_accept_inbound_token is guarded on
protocol >= 0.8, so at the locked 0.5 the emitter wrote NOTHING and
checks.<sys>.module-impl-abi passed with the feature entirely absent. That
check diffs the emitted export set against the list the PINNED logos-protocol
declares, and 0.5 declares ten exports, none of them the inbound door. The PR
was green because the check could not see the thing the PR adds.

Measured rather than argued. Deleting the whole emitter block from
cpp-generator/experimental/lidl_gen_cdylib.cpp:

  at 480f40ff (0.5.0) -- GREEN, i.e. the check was vacuous
      logos-protocol 0.5.0 declares 10 module-impl exports
      [A: --from-header, header-first] defines all 10 declared module-impl exports.
      /nix/store/mlqs29va7lx4m49cclni6pf01yg7n2js-logos-cpp-sdk-module-impl-abi-tests

  at 42460e5b (0.8.0) -- RED, naming the missing export
      FAIL: [A: --from-header, header-first] does not define every module-impl
      C ABI export.
        DECLARED by logos-protocol but NOT DEFINED by this backend:
            - logos_module_accept_inbound_token
      (version probe: 7 exports at MINOR=0, 11 at MINOR=8 -- one short)

Restoring the block returns the check to green at the SAME store path it had
before the deletion, so the red is attributable to the emitter and to nothing
else in the tree.

CHECKS

Every check the flake exposes, built individually on x86_64-linux at the new
lock. Substituters restricted to cache.nixos.org because cache.nix.logos.co
is returning 502, so these are builds rather than cache hits.

  checks.x86_64-linux.generator-cli
    /nix/store/ndimz2vnkrqlms1pl6bi0jhci8snzh82-logos-cpp-sdk-generator-cli-tests
  checks.x86_64-linux.module-impl-abi
    /nix/store/dncpnlql0jlk1yhzygnfchcjrxh1ndf7-logos-cpp-sdk-module-impl-abi-tests
  checks.x86_64-linux.tests
    /nix/store/za8digicp97vd1aqis3x5ypnbwbnqz5r-logos-cpp-sdk-tests-0.2.0

module-impl-abi now reports, for all four generator configurations
(--from-header, --lidl, zero-method, records+events):

  logos-protocol 0.8.0 declares 12 module-impl exports; resolving generated
  code at LOGOS_PROTOCOL_VERSION_MINOR=8
  version probe: 7 exports at MINOR=0, 12 at MINOR=8
  defines all 12 declared module-impl exports.

The Darwin checks were not built; no macOS builder was available.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:02:14 -03:00

1178 lines
62 KiB
C++

#include "lidl_gen_cdylib.h"
#include "lidl_emit_common.h"
#include <QTextStream>
#include <functional>
#include <set>
#include <string>
QString lidlToPascalCase(const QString& name);
QString lidlTypeToQt(const TypeExpr& te);
bool lidlIsStdConvertible(const TypeExpr& te);
namespace {
// The cdylib-supported subset: std-convertible LIDL types only — the same
// Qt-free set the std apiStyle handled, so any universal module that built
// under std also builds as a header-first cdylib.
// The records a contract DECLARES. A `Named` type is a record only if it is in
// here: `void` is not a LIDL builtin, so `-> void` arrives as Named("void") and
// treating every Named as a record is how the Rust generator once emitted
// `-> Void`. Same trap, same guard.
std::set<std::string> recordNames(const ModuleDecl& module)
{
std::set<std::string> out;
for (const TypeDecl& t : module.types) out.insert(t.name);
return out;
}
bool isRecord(const TypeExpr& te, const std::set<std::string>& recs)
{
return te.kind == TypeExpr::Named && recs.count(te.name) > 0;
}
bool typeSupported(const TypeExpr& te, bool isReturn, const std::set<std::string>& recs)
{
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr" || te.name == "bstr" || te.name == "int"
|| te.name == "uint" || te.name == "float64" || te.name == "bool")
return true;
// any (LogosMap/LogosList/json) routes through nlohmann in either
// direction; result (StdLogosResult) and void only make sense as a
// return. All Qt-free.
if (te.name == "any")
return true;
if (isReturn && (te.name == "result" || te.name == "void"))
return true;
return false;
}
// A declared record is a generated struct with a generated codec.
if (isRecord(te, recs))
return true;
// `?T` — supported exactly when its VALUE type is.
//
// The value type is checked as a NON-return position on purpose: `result`
// and `void` are the two spellings that only make sense as a return, and
// neither can be optional. `void` is the absence of a value, so `?void` is
// meaningless; `result` already carries its own success/error discriminant,
// so `?result` would be a second one. `-> ?Point` and `-> ?tstr` are the
// real optional returns and stay eligible.
if (te.kind == TypeExpr::Optional) {
if (te.elements.empty()) return false;
return typeSupported(optionalValueType(te), /*isReturn=*/false, recs);
}
// Recurse rather than whitelisting element names: that admits [bstr],
// [[int]], [Record] and [{tstr: T}] in one rule, and keeps the gate and
// the spelling function agreeing about what is expressible.
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
return typeSupported(te.elements[0], false, recs);
// Only tstr keys: the generated codec spells a map as
// std::map<std::string, T>, so a non-tstr key has no C++ spelling. This
// used to `return true` for ANY map, which admitted `{int: tstr}` and then
// silently produced a LogosMap that lost the key type.
if (te.kind == TypeExpr::Map) {
if (te.elements.size() != 2) return false;
const TypeExpr& k = te.elements[0];
if (!(k.kind == TypeExpr::Primitive && k.name == "tstr")) return false;
return typeSupported(te.elements[1], false, recs);
}
return false;
}
// Qt-free spelling of a LIDL type (defined below). Forward-declared so the
// method-param decoder can spell composite `any` containers as their nlohmann
// aliases instead of Qt containers in this Qt-free TU.
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs);
// json arg expression -> std-typed C++ expression
// A method argument, decoded into the author's C++ type.
//
// EVERY typed value goes through the generated codec, which recurses — so a bstr
// keeps its canonical tag at ANY depth, a record decodes field by field with a
// path in the error, and a scalar is checked against its declared type.
//
// The scalars used to keep their nlohmann accessor verbatim, and that was the
// last hole in the type contract on this backend: `.get<uint64_t>()` on -1 wraps
// to 18446744073709551615 with no exception, so `echoUint(-1)` answered
// 18446744073709551615 here and `dispatch_failed` on the Rust provider — a
// silent sign flip on a nominal type, in a contract both providers share.
// `.get<int64_t>()` on 3.7 likewise truncated to 3 instead of rejecting.
//
// The comment that used to sit here justified the leniency by pointing at the
// conformance matrix cells that pinned it. That was circular: those cells exist
// to DOCUMENT the divergence, and their own `why` text says the strict behaviour
// is the correct one. The expectations moved with this change.
//
// `any` still passes through untouched — it is the one LIDL type that declares
// nothing, so there is nothing to check it against.
QString jsonArgToStd(const TypeExpr& te, const QString& expr, const QString& path,
const std::set<std::string>& recs)
{
// `?T` — decode is LIBERAL, and only by exactly one inhabitant.
//
// null decodes to empty; anything else is decoded as T by the SAME decoder a
// required T would get, so a present-but-wrong value fails with the same
// message at the same path. Optional widens the domain, it does not switch
// type checking off.
if (te.kind == TypeExpr::Optional && !te.elements.empty()) {
const QString cpp = lidlTypeToStdCdylib(te, recs);
const TypeExpr& vt = optionalValueType(te);
// `?any` collapses onto `any` (see lidlTypeToStdCdylib): untyped JSON
// already carries null, so there is no wrapper to build.
if (!cpp.startsWith("std::optional<"))
return jsonArgToStd(vt, expr, path, recs);
// A scalar `bstr` argument does NOT go through the codec — it gets the
// lenient bytes decode, so a caller may send the tagged form, a plain
// string, a number or a byte array. `?bstr` has to keep that, or the
// identical value would be accepted in a required slot and rejected in
// an optional one. Test for the empty inhabitant here and wrap.
if (vt.kind == TypeExpr::Primitive && vt.name == "bstr")
return "(" + expr + ".is_null() ? " + cpp + "() : " + cpp + "("
+ jsonArgToStd(vt, expr, path, recs) + "))";
// Everything else names std::optional<T> and lets
// Codec<std::optional<T>> map null -> nullopt in one expression.
return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")";
}
if (te.kind == TypeExpr::Primitive) {
if (te.name == "bstr")
return "logos::bytesFromJsonLenient(" + expr + ", \"" + path + "\")";
if (te.name == "any") return expr;
}
const QString cpp = lidlTypeToStdCdylib(te, recs);
// `[any]` / `{tstr:any}`. The ELEMENT type is unconstrained, so there is
// nothing to decode — but the SHAPE is declared, and it used to pass through
// unchecked ("as it always has"). That let a scalar reach a LogosList
// parameter, and a proxy forwarding it through a Qt-typed consumer turned
// "notalist" into ["n","o","t","a","l","i","s","t"] — qvariant_cast reads a
// QString as a sequential container. The downstream provider then saw a
// well-formed array and had nothing to refuse.
//
// Checked here rather than deeper: LogosList and LogosMap are both aliases
// of nlohmann::json, so no codec specialization can tell them apart. The
// value is still handed on unchanged, and the throw lands in the dispatch's
// existing catch as {"code":"dispatch_failed"} — the same answer, with the
// same message, that every non-Qt surface already gives.
if (cpp == "LogosList")
return "logos::jsonRequireArray(" + expr + ", \"" + path + "\")";
if (cpp == "LogosMap")
return "logos::jsonRequireObject(" + expr + ", \"" + path + "\")";
// A TYPED map does not NAME its C++ type — it hands the compiler a proxy and
// lets the author's own declaration pick it.
//
// `{tstr: T}` has two C++ spellings, std::map and std::unordered_map, and
// logos_codec.h specializes Codec for both. Naming one of them here would
// silently make the other a compile error in generated code the author never
// wrote: `logos::fromJson<std::map<...>>` returns a std::map, and a std::map
// does not convert to an unordered_map parameter. logos::JsonArg instantiates
// the conversion with the EXACT parameter type instead, so both spellings
// decode — through the same Codec, with the same path in the same error.
//
// Only maps: every other LIDL type has exactly one C++ spelling here, and
// JsonArg documents one type it cannot serve (std::optional<X>, whose own
// converting constructor out-ranks the proxy's conversion operator) — the
// Optional branch above returns before reaching this line.
if (te.kind == TypeExpr::Map)
return "logos::JsonArg(" + expr + ", \"" + path + "\")";
return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")";
}
// std-typed return variable -> json expression
QString stdReturnToJson(const MethodDecl& md, const QString& var,
const std::set<std::string>& recs)
{
const TypeExpr& te = md.returnType;
if (md.resultReturn) {
// StdLogosResult -> the canonical {success, value, error} object
// (same shape logos_json_convert emits for Qt LogosResult).
return "lidlResultToJson(" + var + ")";
}
// `jsonReturn` is set by the front end for any map/list return, but that no
// longer implies the C++ type IS nlohmann::json: a TYPED map now spells
// std::map<std::string, T>. Checking the flag before the spelling emitted
// `result.dump()` on a std::map. The spelling decides.
const QString cppRet = lidlTypeToStdCdylib(te, recs);
if (md.jsonReturn && (cppRet == "LogosMap" || cppRet == "LogosList")) {
return var; // LogosMap / LogosList are nlohmann::json already
}
if (te.kind == TypeExpr::Primitive) {
if (te.name == "bstr") return "logos::bytesToJson(" + var + ")";
if (te.name == "any") return var;
return "nlohmann::json(" + var + ")";
}
if (cppRet == "LogosMap" || cppRet == "LogosList")
return var;
// Same reason the map ARGUMENT does not name its type: `{tstr: T}` is both
// std::map and std::unordered_map, so let the return variable's own type be
// deduced rather than asserting one of them.
if (te.kind == TypeExpr::Map)
return "logos::toJson(" + var + ")";
// `nlohmann::json(v)` would serialize a vector<uint8_t> as a plain number
// array and a record not at all; the codec keeps bytes tagged at depth.
return "logos::toJson<" + cppRet + ">(" + var + ")";
}
// Qt-free spelling of a LIDL type. lidlTypeToStd() falls back to Qt containers
// (QVariant / QVariantMap / QVariantList) for the composite types, but a cdylib
// TU is Qt-free by definition and typeSupported() admits `any` and maps — so
// spell those as their nlohmann aliases (LogosMap / LogosList) instead. Without
// this the events sidecar emits a bare `QVariant` parameter and does not
// compile.
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs)
{
// `?T` -> std::optional<T>, EXCEPT over the untyped-JSON aliases.
//
// LogosMap / LogosList are nlohmann::json, and json already has `null` among
// its inhabitants — so std::optional<LogosMap> would give `?any` TWO empty
// spellings (nullopt and json(null)) and make it three-state, which is
// exactly what R1 forbids. `?any` therefore collapses onto `any`: same two
// states, one C++ type. (logos-lidl's validator warns on `?any` for the same
// reason, and the warning is about the spelling, not about this mapping.)
if (te.kind == TypeExpr::Optional && !te.elements.empty()) {
const QString inner = lidlTypeToStdCdylib(optionalValueType(te), recs);
if (inner == "LogosMap" || inner == "LogosList")
return inner;
return "std::optional<" + inner + ">";
}
if (te.kind == TypeExpr::Primitive && te.name == "any")
return "LogosMap";
// `{tstr: any}` and `[any]` keep their nlohmann aliases: every existing
// universal module spells them that way, and narrowing them would be a
// source break for no gain (they ARE untyped JSON).
if (te.kind == TypeExpr::Map && te.elements.size() == 2
&& te.elements[1].kind == TypeExpr::Primitive && te.elements[1].name == "any")
return "LogosMap";
if (te.kind == TypeExpr::Array && te.elements.size() == 1
&& te.elements[0].kind == TypeExpr::Primitive
&& te.elements[0].name == "any")
return "LogosList";
// A declared record is its generated struct.
if (isRecord(te, recs))
return qs(te.name);
// Recurse, so [bstr] is std::vector<std::vector<uint8_t>> and {tstr: Blob}
// is std::map<std::string, Blob>. lidlTypeToStd() would answer QVariantList
// / QVariantMap here — a Qt name in a Qt-FREE translation unit, which only
// failed to appear because the gate used to reject these types. Widening
// the gate makes that fallback a live leak, so composites must never reach
// it.
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
return "std::vector<" + lidlTypeToStdCdylib(te.elements[0], recs) + ">";
if (te.kind == TypeExpr::Map && te.elements.size() == 2)
return "std::map<std::string, " + lidlTypeToStdCdylib(te.elements[1], recs) + ">";
return lidlTypeToStd(te);
}
// The C++ spelling of a RECORD FIELD, honouring both optionality spellings.
//
// `? name: T` and `name: ?T` are the same declaration and must produce
// byte-identical code (logos-lidl docs/spec.md, "Optionality"). That only holds
// because fieldIsOptional()/fieldValueType() reconcile them in the frontend —
// spelling one of the two out here would reintroduce the drift they exist to
// prevent. Never write `f.optional` or `f.type.kind == Optional` in a backend.
QString lidlFieldTypeCdylib(const FieldDecl& f, const std::set<std::string>& recs)
{
if (!fieldIsOptional(f))
return lidlTypeToStdCdylib(f.type, recs);
const QString inner = lidlTypeToStdCdylib(fieldValueType(f), recs);
// Same collapse as lidlTypeToStdCdylib: untyped JSON already has null.
if (inner == "LogosMap" || inner == "LogosList")
return inner;
return "std::optional<" + inner + ">";
}
// True when anything in the contract is optional — a record field by either
// spelling, a method parameter or return, or an event parameter. Gates the
// `#include <optional>` in the generated TUs, so a contract that declares no
// optional keeps its output byte-for-byte unchanged.
bool moduleUsesOptional(const ModuleDecl& module)
{
std::function<bool(const TypeExpr&)> mentions = [&](const TypeExpr& t) -> bool {
if (t.kind == TypeExpr::Optional) return true;
for (const TypeExpr& e : t.elements)
if (mentions(e)) return true;
return false;
};
for (const TypeDecl& t : module.types)
for (const FieldDecl& f : t.fields)
if (fieldIsOptional(f) || mentions(f.type)) return true;
for (const MethodDecl& md : module.methods) {
if (mentions(md.returnType)) return true;
for (const ParamDecl& pd : md.params)
if (mentions(pd.type)) return true;
}
for (const EventDecl& ed : module.events)
for (const ParamDecl& pd : ed.params)
if (mentions(pd.type)) return true;
return false;
}
// True when the module declares at least one `bstr` event parameter — the only
// reason the events sidecar needs the bytes encoder. Emitting it unconditionally
// leaves an unused static function (a -Wunused-function warning) in every module
// whose events carry no binary data.
// ── The generated codec ─────────────────────────────────────────────────────
//
// Emitted into the module's types header so the author's impl class and the
// generated dispatch share one definition of how a value crosses the wire.
//
// This is deliberately the same SHAPE as logos-protocol's logos_codec.h — and
// it exists as generated code only because that header cannot currently be
// included here: logos_json.h (which every universal module pulls in for
// LogosMap) and logos_codec.h both define logos::b64UrlEncode /
// b64UrlDecode / bytesToJson as inline, so including both in one translation
// unit is a redefinition error. Unify when that is resolved; the emitted
// specializations would then be the only generated part.
//
// The primary template is intentionally left UNDEFINED: an unsupported T is a
// compile error naming the type, never a silent default-constructed value.
// Emits ONE specialization per record the module declares — and nothing else.
//
// The generic half (scalars, bstr, the vector/map composition, the error paths)
// used to be emitted here too, ~186 lines of C++-emitting-C++ that mirrored
// logos-protocol's logos_codec.h by hand. It no longer is: logos_json.h stopped
// defining byte helpers that collided with that header, so a module TU can now
// include the canonical codec directly.
//
// That duplication was not free. The two copies had drifted (the emitted integer
// decode gated on is_number() where the canonical one checked
// is_number_integer() || is_number_unsigned()), they disagreed on padded base64,
// and every codec fix had to be written twice or it silently only half-applied.
//
// What remains is irreducible: a LIDL `type` is a per-contract struct whose field
// names and member types exist only in this module's header, and C++17 has no
// field reflection. Nesting composes for free — Codec<std::vector<Blob>> and
// deeper come from the shared generic half once Codec<::Blob> exists.
void emitRecordCodecs(QTextStream& s, const ModuleDecl& module,
const std::set<std::string>& recs)
{
if (module.types.empty()) return;
// Reopened so the specializations land beside the primary template they
// specialize. `::Name` because the author's record types are at global
// scope, while this is namespace logos::detail — without the qualifier the
// name would resolve inside logos::.
s << "namespace logos { namespace detail {\n\n";
// One specialization per declared record. Field order follows the contract.
for (const TypeDecl& t : module.types) {
const QString name = qs(t.name);
s << "template <> struct Codec<::" << name << ", void> {\n";
s << " static nlohmann::json to(const " << name << "& v) {\n";
s << " nlohmann::json out = nlohmann::json::object();\n";
for (const FieldDecl& f : t.fields) {
const QString ft = lidlFieldTypeCdylib(f, recs);
const QString fn = qs(f.name);
if (ft.startsWith("std::optional<")) {
// ENCODE: a record field is a NAMED slot, so empty is spelled by
// OMITTING the key — never by writing null. This is the half of
// the rule Codec<std::optional<T>> deliberately cannot do: a
// codec only ever sees a VALUE, so it emits the positional
// spelling (null) and leaves key omission to the one place that
// knows there IS a key. That place is here.
//
// The round trip is therefore CANONICALISING, not identity: a
// peer that sent `"f": null` gets the key back omitted, and both
// spellings mean the same state.
const QString vt = lidlTypeToStdCdylib(fieldValueType(f), recs);
s << " if (v." << fn << ".has_value())\n";
s << " out[\"" << fn << "\"] = Codec<" << vt << ">::to(*v."
<< fn << ");\n";
} else {
s << " out[\"" << fn << "\"] = Codec<" << ft << ">::to(v."
<< fn << ");\n";
}
}
s << " return out;\n }\n";
s << " static " << name << " from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (!j.is_object()) detail::typeError(path, \"object\", j);\n";
s << " " << name << " out;\n";
for (const FieldDecl& f : t.fields) {
const QString ft = lidlFieldTypeCdylib(f, recs);
const QString fn = qs(f.name);
// A missing field is reported at its own path rather than
// default-constructed: a record that silently loses a field is the
// failure mode this whole layer exists to prevent.
//
// DECODE needs no optional branch, and that is the point: an absent
// key is already materialised as null right here, so absent and
// explicit null arrive at the codec indistinguishable. In an
// optional field Codec<std::optional<T>> answers nullopt for both;
// in a required one Codec<T> still rejects both. One expression,
// both halves of the rule.
s << " out." << fn << " = Codec<" << ft << ">::from(\n";
s << " j.contains(\"" << fn << "\") ? j.at(\"" << fn
<< "\") : nlohmann::json(),\n";
s << " path + \"." << fn << "\");\n";
}
s << " return out;\n }\n};\n\n";
}
s << "}} // namespace logos::detail\n\n";
}
// The type name a method's PUBLISHED metadata carries — getMethods()'s
// `returnType`, `parameters[].type` and `signature`.
//
// It is the LIDL CONTRACT spelling: `tstr`, `uint`, `[Point]`, `{tstr: uint}`,
// `? tstr`. That is the only vocabulary in which this question has one right
// answer. A module's published surface is its contract, and every consumer of
// this JSON — `lm`, `logoscore`'s method listing, basecamp's module inspector —
// is showing a human what the module offers. Answering in Qt names made a
// Qt-free cdylib module describe itself in the types of a language it does not
// use, and answered three different LIDL types (`[uint]`, `[bstr]`, `[any]`)
// with one word, QVariantList, so the listing could not be read back.
//
// NOT lidlTypeToQt, and no longer a near-copy of it. That function answers the
// CONSUMER's question — "what C++ type does the caller hold?" — and its answers
// are now typed C++ spellings (QList<qulonglong>, std::optional<QString>) that
// are meaningless outside a generated wrapper.
//
// WHY THIS IS SAFE, checked rather than assumed. The historical objection was
// that these strings are read as METATYPES: emitting a record's struct name
// here once made the host SIGSEGV. Nothing in the current runtime does that.
// The dispatch paths key on QMetaObject types instead — logos-plugin-qt's
// QtProviderObject reads `method.returnMetaType()` / `parameterMetaType(i)` and
// never touches this JSON — and every reader of these fields that remains
// (logos-module's `lm`, logoscore's `output.cpp`, basecamp's CoreModuleManager,
// the plain wire's json_mapping round-trip) treats them as opaque text.
//
// The `recs` parameter is gone with the Qt spelling: a record publishes its
// declared NAME, which is what the contract calls it.
QString lidlTypeToPublishedName(const TypeExpr& te)
{
return lidlTypeToLidlText(te);
}
// True when any event parameter is spelled LogosMap / LogosList, so the sidecar
// needs <logos_json.h> for those aliases.
bool hasJsonEventParam(const ModuleDecl& module)
{
const std::set<std::string> recs = recordNames(module);
for (const EventDecl& ed : module.events)
for (const ParamDecl& pd : ed.params) {
const QString t = lidlTypeToStdCdylib(pd.type, recs);
if (t == "LogosMap" || t == "LogosList")
return true;
}
return false;
}
// The generated base64 codec is GONE — all of it.
//
// #117 replaced the emitted generic codec with logos-protocol's logos_codec.h,
// but left behind the base64 pair it had grown around: an encoder
// (lidlB64UrlEncode / lidlBytesToJson) and a decoder (lidlB64Idx /
// lidlBytesFromJson), ~89 emitted lines in every module's export TU. The decoder
// had no call site at all — every byte parameter had already moved to
// logos::bytesFromJsonLenient — and the encoder was a byte-for-byte reimplementation
// of logos::bytesToJson, which is included via <logos_codec.h> in the very same
// translation unit.
//
// A second copy of an encoder is not free: this is the arrangement that let the
// emitted and canonical halves drift over padded base64 once already, and it is
// exactly the duplication #117's own comment set out to end. Scalar `bstr` slots
// now call logos::bytesToJson directly, which is what every composite slot
// (`[bstr]`, `{tstr: bstr}`, records) has been doing through logos::Codec since
// #117.
void emitInterfaceJson(QTextStream& s, const ModuleDecl& module)
{
s << "static nlohmann::json lidlInterfaceJson()\n{\n";
s << " nlohmann::json methods = nlohmann::json::array();\n";
for (const MethodDecl& md : module.methods) {
s << " {\n nlohmann::json obj;\n";
s << " obj[\"name\"] = \"" << md.name << "\";\n";
if (!md.description.empty()) {
QString esc = qs(md.description);
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
s << " obj[\"description\"] = \"" << esc << "\";\n";
}
QString sig = qs(md.name) + "(";
for (int i = 0; i < md.params.size(); ++i) {
sig += lidlTypeToPublishedName(md.params[i].type);
if (i + 1 < md.params.size()) sig += ",";
}
sig += ")";
s << " obj[\"signature\"] = \"" << sig << "\";\n";
s << " obj[\"returnType\"] = \"" << lidlTypeToPublishedName(md.returnType) << "\";\n";
s << " obj[\"isInvokable\"] = true;\n";
if (!md.params.empty()) {
s << " nlohmann::json params = nlohmann::json::array();\n";
for (const ParamDecl& pd : md.params) {
s << " params.push_back({{\"type\", \"" << lidlTypeToPublishedName(pd.type)
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
}
s << " obj[\"parameters\"] = params;\n";
}
s << " methods.push_back(obj);\n }\n";
}
for (const EventDecl& ed : module.events) {
s << " {\n nlohmann::json obj;\n";
s << " obj[\"type\"] = \"event\";\n";
s << " obj[\"name\"] = \"" << ed.name << "\";\n";
if (!ed.description.empty()) {
QString esc = qs(ed.description);
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
s << " obj[\"description\"] = \"" << esc << "\";\n";
}
QString sig = qs(ed.name) + "(";
for (int i = 0; i < ed.params.size(); ++i) {
sig += lidlTypeToPublishedName(ed.params[i].type);
if (i + 1 < ed.params.size()) sig += ",";
}
sig += ")";
s << " obj[\"signature\"] = \"" << sig << "\";\n";
if (!ed.params.empty()) {
s << " nlohmann::json params = nlohmann::json::array();\n";
for (const ParamDecl& pd : ed.params) {
s << " params.push_back({{\"type\", \"" << lidlTypeToPublishedName(pd.type)
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
}
s << " obj[\"parameters\"] = params;\n";
}
s << " methods.push_back(obj);\n }\n";
}
s << " return methods;\n}\n\n";
}
} // namespace
bool lidlCdylibSupported(const ModuleDecl& module, QString* error)
{
const std::set<std::string> recs = recordNames(module);
for (const MethodDecl& md : module.methods) {
for (const ParamDecl& pd : md.params) {
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
if (error)
*error = QString("method '%1': parameter '%2' has a type outside the "
"cdylib-supported (Qt-free) subset")
.arg(qs(md.name), qs(pd.name));
return false;
}
}
// `void` is not a lidlBuiltinType, so the .lidl parser yields it as a
// Named type "void" (the impl-header parser writes "-> void"); an empty
// name is the in-memory void from the header path. Treat both as void.
const bool voidReturn =
md.returnType.name == "void"
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty());
if (!voidReturn && !md.jsonReturn && !md.resultReturn
&& !typeSupported(md.returnType, /*isReturn=*/true, recs)) {
if (error)
*error = QString("method '%1': return type outside the cdylib-supported "
"(Qt-free) subset").arg(qs(md.name));
return false;
}
}
for (const EventDecl& ed : module.events) {
for (const ParamDecl& pd : ed.params) {
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
if (error)
*error = QString("event '%1': parameter '%2' has a type outside the "
"cdylib-supported (Qt-free) subset")
.arg(qs(ed.name), qs(pd.name));
return false;
}
}
}
return true;
}
QString lidlMakeTypesHeaderCdylib(const ModuleDecl& module)
{
const std::set<std::string> recs = recordNames(module);
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --backend cdylib -- do not edit\n";
s << "//\n";
s << "// The record types `" << module.name << "` declares, plus the codec that moves\n";
s << "// them across the wire. Qt-FREE. The author's impl header includes this and\n";
s << "// writes the structs directly:\n";
s << "//\n";
s << "// Blob echoBlob(const Blob& v);\n";
s << "//\n";
s << "// rather than picking fields out of a LogosMap.\n";
s << "#pragma once\n";
s << "#include <logos_json.h>\n"; // LogosMap / LogosList aliases
s << "#include <logos_codec.h>\n"; // logos::Codec — the ONE definition
s << "#include <cstdint>\n";
s << "#include <map>\n";
// Only when the contract actually declares an optional: logos_codec.h
// already pulls <optional> in, so this is documentation of what the emitted
// codec names — and emitting it unconditionally would rewrite the types
// header of every contract that has no optional at all.
if (moduleUsesOptional(module))
s << "#include <optional>\n";
s << "#include <string>\n";
s << "#include <vector>\n\n";
// The structs themselves are the AUTHOR's: this file is included after the
// impl header, and the contract was derived from those very declarations,
// so emitting them again is a redefinition error. Only forward
// declarations, so the codec below can name them in any order.
if (!module.types.empty()) {
for (const TypeDecl& t : module.types)
s << "struct " << qs(t.name) << ";\n";
s << "\n";
}
emitRecordCodecs(s, module, recs);
return c;
}
QString lidlMakeModuleImplExports(const ModuleDecl& module,
const QString& implClass,
const QString& implHeader)
{
const std::set<std::string> recs = recordNames(module);
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "//\n";
s << "// The common module-impl C ABI exports (logos_module_impl.h) around the\n";
s << "// universal impl class `" << implClass << "`. Qt-FREE: compiled into the\n";
s << "// module's cdylib; the uniform Qt-plugin glue (or a future no-Qt host)\n";
s << "// drives it exclusively through these symbols.\n";
s << "#include \"" << implHeader << "\"\n";
s << "#include \"" << module.name << "_types.h\"\n";
s << "#include \"logos_module_impl.h\"\n";
s << "#include \"logos_protocol.h\"\n";
s << "#include \"logos_module_context.h\"\n";
s << "#include \"logos_result.h\"\n";
// The caller-of-a-dispatch reader. Unconditional: it is a logos-cpp-sdk
// header with no protocol dependency of its own, so it costs nothing on an
// older protocol where the export below is not emitted.
s << "#include \"logos_caller.h\"\n";
s << "#include <nlohmann/json.hpp>\n";
s << "#include <cstdlib>\n";
s << "#include <cstring>\n";
s << "#include <atomic>\n";
s << "#include <map>\n";
s << "#include <mutex>\n";
if (moduleUsesOptional(module))
s << "#include <optional>\n";
s << "#include <string>\n";
s << "#include <vector>\n";
// The Qt-free typed dependency surface: LogosModules (behind modules())
// built from this module's dependencies (metadata.json#dependencies),
// calling the lp_* C ABI — no Qt in the cdylib. The umbrella codegen
// emits logos_sdk.h for every cdylib module (empty when there are no
// dependencies), so this include is always available.
s << "#include \"logos_sdk.h\"\n";
s << "\n";
// -- shared statics ------------------------------------------------------
s << "namespace {\n\n";
s << implClass << "& lidlImpl()\n{\n static " << implClass << " impl;\n return impl;\n}\n\n";
s << "logos_module_emit_cb g_emitCb = nullptr;\n";
s << "void* g_emitUd = nullptr;\n";
s << "std::mutex g_emitMutex;\n";
// Guarded on the protocol MINOR that introduced the teardown surface (0.5),
// exactly like the trust-root surface below. The emitted module must still
// COMPILE against an older logos-protocol, which has neither the callback
// typedef nor the two logos_module_impl.h declarations -- a module built
// against 0.4 simply has no teardown entry point, which is the same state
// as a module that never overrode the hook. Without this an older protocol
// is a hard compile error in generated code the author never sees.
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && "
"(LOGOS_PROTOCOL_VERSION_MAJOR > 0 || "
"(LOGOS_PROTOCOL_VERSION_MAJOR == 0 && "
"LOGOS_PROTOCOL_VERSION_MINOR >= 5))\n";
s << "logos_module_unload_done_cb g_unloadCb = nullptr;\n";
s << "void* g_unloadUd = nullptr;\n";
s << "std::mutex g_unloadMutex;\n";
s << "#endif\n";
s << "std::mutex g_ctxMutex;\n";
s << "bool g_ctxStored = false;\n";
s << "std::string g_ctxPath, g_ctxId, g_ctxPersist;\n";
s << "std::atomic<bool> g_hookFired{false};\n\n";
s << "char* lidlStrdup(const std::string& str)\n{\n";
s << " char* out = static_cast<char*>(std::malloc(str.size() + 1));\n";
s << " if (out) std::memcpy(out, str.data(), str.size() + 1);\n";
s << " return out;\n}\n\n";
s << "nlohmann::json lidlResultToJson(const StdLogosResult& r)\n{\n";
s << " nlohmann::json obj;\n";
s << " obj[\"success\"] = r.success;\n";
s << " obj[\"value\"] = r.value;\n";
s << " obj[\"error\"] = r.error.empty() ? nlohmann::json() : nlohmann::json(r.error);\n";
s << " return obj;\n}\n\n";
emitInterfaceJson(s, module);
s << "} // namespace\n\n";
// -- event wiring (install once, lazily) ---------------------------------
s << "static void lidlEnsureEmitWiring()\n{\n";
s << " static std::once_flag once;\n";
s << " std::call_once(once, []() {\n";
s << " _logos_codegen_::maybeSetEmitEvent(lidlImpl(),\n";
s << " [](const std::string& name, void* args) {\n";
s << " // cdylib events sidecar marshals into nlohmann::json\n";
s << " const nlohmann::json* payload = static_cast<const nlohmann::json*>(args);\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " if (g_emitCb) {\n";
s << " const std::string dumped = payload ? payload->dump() : \"[]\";\n";
s << " g_emitCb(name.c_str(), dumped.c_str(), g_emitUd);\n";
s << " }\n";
s << " });\n";
s << " });\n}\n\n";
// -- typed dependency surface (modules().<dep>...) -----------------------
// Wire modules() INDEPENDENTLY of the persistence context. Each dependency
// client bakes its target+origin at codegen time and creates its lp client
// lazily on first call, so modules() needs nothing from the context. A
// module with deps but no STORED context still must have it wired — gating
// it on the context latch (as it used to be) left m_logosModulesPtr null and
// segfaulted the first cross-module call when the daemon never delivered a
// context. No-op for impls that don't derive LogosModuleContext. Fired once
// from the FIRST lidlTryFireContext (i.e. the first dispatch / set_context /
// set_emit_callback), before the context-gated early return below.
s << "static void lidlEnsureModulesWired()\n{\n";
s << " static std::once_flag once;\n";
s << " std::call_once(once, []() {\n";
s << " _logos_codegen_::maybeSetLogosModules(lidlImpl(), new LogosModules());\n";
s << " });\n}\n\n";
// The context ready-latch: stamp the context + fire onContextReady ONCE,
// as soon as the module is fully wired (context stored AND the emit
// callback delivered) — at module load, before publication. Hosts that
// never wire an emit callback still get the hook before first dispatch
// (requireEmit = false fallback).
s << "static void lidlTryFireContext(bool requireEmit)\n{\n";
s << " lidlEnsureEmitWiring();\n";
s << " lidlEnsureModulesWired();\n";
s << " if (g_hookFired.load(std::memory_order_acquire)) return;\n";
s << " std::string path, id, persist;\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
s << " if (!g_ctxStored) return;\n";
s << " path = g_ctxPath; id = g_ctxId; persist = g_ctxPersist;\n";
s << " }\n";
s << " if (requireEmit) {\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " if (!g_emitCb) return;\n";
s << " }\n";
s << " g_hookFired.store(true, std::memory_order_release);\n";
// modules() was already wired by lidlEnsureModulesWired() above (before this
// context-gated early return), so onContextReady can safely call
// modules().<dep>... / subscribe to dependency events from the hook.
// The module's own registry name, which the generator knows statically.
// Set BEFORE the context so moduleName() is live inside onContextReady().
s << " _logos_codegen_::maybeSetModuleName(lidlImpl(), \"" << module.name << "\");\n";
s << " _logos_codegen_::maybeSetContext(lidlImpl(), path, id, persist);\n";
s << "}\n\n";
// -- exports -------------------------------------------------------------
s << "extern \"C\" {\n\n";
s << "char* logos_module_dispatch(const char* method, const char* args_json)\n{\n";
s << " if (!method) return nullptr;\n";
s << " lidlTryFireContext(false);\n";
s << " nlohmann::json args = nlohmann::json::array();\n";
s << " if (args_json && *args_json) {\n";
s << " args = nlohmann::json::parse(args_json, nullptr, false);\n";
s << " if (args.is_discarded() || !args.is_array()) return nullptr;\n";
s << " }\n";
s << " const std::string m(method);\n";
s << " try {\n";
for (const MethodDecl& md : module.methods) {
// The arity gate, and the one place the LIBERAL half of the decode rule
// reaches a POSITIONAL slot.
//
// A canonical encoder never changes arity: an empty positional slot is
// spelled null and still occupies its position. But absent and null are
// the same state on decode, so an optional trailing argument may also
// simply not be there. The gate therefore admits anything from the last
// REQUIRED parameter onwards, and each optional beyond it materialises
// as null exactly the way an absent record field already does. Below
// that point nothing changes: a missing required argument is still a
// hard reject, and a contract with no optional parameters emits the
// byte-identical `args.size() < <count>` it always did.
size_t minArgs = 0;
for (size_t i = 0; i < md.params.size(); ++i)
if (!paramIsOptional(md.params[i])) minArgs = i + 1;
s << " if (m == \"" << md.name << "\") {\n";
// A wrong argument COUNT is reported, not swallowed.
//
// This used to be `return nullptr`, and the Qt glue turns a NULL reply
// into an empty QVariant — indistinguishable from a method that
// legitimately returned nothing. "You passed 2 of 4 arguments" looked
// like a successful empty answer.
//
// The shape is the one logos-rust-sdk's args::invalid_args() already
// emits (src/args.rs), so a C++ and a Rust provider answer a malformed
// call identically — which is what that module's
// invalid_args_shape_matches_cpp test claims, and what was not true
// until now. Same three keys, same message text, same `origin`.
//
// Emitted only when the method has at least one REQUIRED parameter:
// `args.size() < 0` is unsigned-compared and always false, so a zero-arg
// method carried a dead branch (the Rust generator skips it for the same
// reason).
if (minArgs > 0) {
s << " if (args.size() < " << minArgs << ") {\n";
s << " nlohmann::json err{{\"code\", \"invalid_args\"},\n";
s << " {\"message\", \"expected " << minArgs
<< " arguments, got \" + std::to_string(args.size())},\n";
s << " {\"origin\", \"" << module.name << "\"}};\n";
s << " return lidlStrdup(err.dump());\n";
s << " }\n";
}
// ...and so is a wrong count in the OTHER direction, which nothing
// checked until now: an EXTRA argument was silently dropped and the
// call succeeded. `args.size() < minArgs` bounds one side only.
//
// Arity is the one part of a contract a caller cannot verify for
// itself. A method that gains or loses a parameter upstream answered a
// stale caller with a plausible value instead of a refusal, and the
// caller had no way to tell which contract it had just talked to.
//
// Emitted UNCONDITIONALLY, including for a zero-parameter method
// (`args.size() > 0`). That case is not the lower bound with maxArgs=0
// — it is the arm that had no gate at all, and it is the arm the
// derived identity methods take: `version("junk")` answered "1.0.0"
// with status ok, a correct-looking answer to a call that should have
// been refused. This sits ABOVE the `md.derived` branch below so the
// generated identity dispatch inherits it rather than needing its own.
const size_t maxArgs = md.params.size();
s << " if (args.size() > " << maxArgs << ") {\n";
s << " nlohmann::json err{{\"code\", \"invalid_args\"},\n";
s << " {\"message\", \"expected "
<< (minArgs == maxArgs ? "" : "at most ") << maxArgs
<< " arguments, got \" + std::to_string(args.size())},\n";
s << " {\"origin\", \"" << module.name << "\"}};\n";
s << " return lidlStrdup(err.dump());\n";
s << " }\n";
// A derived method (lidl/identity.hpp) has no member on the impl class
// to call — the generator owns its body. name()/version() answer from
// the module declaration, which the builder derives from metadata.json,
// so the reported value cannot drift from the built one.
if (md.derived && lidl::isIdentityMethod(md.name)) {
const QString literal = md.name == lidl::kIdentityName
? qs(module.name)
: (module.version.empty() ? QStringLiteral("1.0.0") : qs(module.version));
s << " auto result = std::string(\"" << literal << "\");\n";
s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs)
<< ".dump());\n";
s << " }\n";
continue;
}
QString call = "lidlImpl()." + qs(md.name) + "(";
for (size_t i = 0; i < md.params.size(); ++i) {
const QString expr = (i < minArgs)
? QString("args.at(%1)").arg(i)
: QString("(args.size() > %1 ? args.at(%1) : nlohmann::json())").arg(i);
call += jsonArgToStd(md.params[i].type, expr,
QString("arg%1").arg(i), recs);
if (i + 1 < md.params.size()) call += ", ";
}
call += ")";
// `void` parses as a Named type "void" from a .lidl (it isn't a
// lidlBuiltinType); empty name is the header path's in-memory void.
const bool voidReturn =
md.returnType.name == "void"
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty())
|| lidlTypeToQt(md.returnType) == "void";
if (voidReturn) {
s << " " << call << ";\n";
s << " return lidlStrdup(\"true\");\n";
} else {
s << " auto result = " << call << ";\n";
s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs) << ".dump());\n";
}
s << " }\n";
}
s << " } catch (const std::exception& e) {\n";
s << " nlohmann::json err{{\"code\", \"dispatch_failed\"}, {\"message\", e.what()},\n";
s << " {\"origin\", \"" << module.name << "\"}};\n";
s << " return lidlStrdup(err.dump());\n";
s << " }\n";
s << " return nullptr; // unknown method\n";
s << "}\n\n";
s << "char* logos_module_get_methods(void)\n{\n";
s << " return lidlStrdup(lidlInterfaceJson().dump());\n}\n\n";
s << "void logos_module_set_context(const char* module_path,\n";
s << " const char* instance_id,\n";
s << " const char* instance_persistence_path)\n{\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
s << " g_ctxPath = module_path ? module_path : \"\";\n";
s << " g_ctxId = instance_id ? instance_id : \"\";\n";
s << " g_ctxPersist = instance_persistence_path ? instance_persistence_path : \"\";\n";
s << " g_ctxStored = true;\n";
s << " }\n";
s << " lidlTryFireContext(true);\n";
s << "}\n\n";
s << "void logos_module_set_emit_callback(logos_module_emit_cb cb, void* user_data)\n{\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " g_emitCb = cb;\n";
s << " g_emitUd = user_data;\n";
s << " }\n";
s << " lidlTryFireContext(true);\n";
s << "}\n\n";
s << "int logos_module_accept_token(const char* module_name, const char* token)\n{\n";
s << " if (!module_name || !token) return -1;\n";
s << " // THE OUTBOUND DOOR. Seed the protocol's shared TokenManager so this\n";
s << " // module's OUTBOUND lp_client (modules().<dep>...) can authenticate\n";
s << " // calls. In particular the capability_module bootstrap token the\n";
s << " // host delivers at load lets the automatic requestModule flow fetch\n";
s << " // a per-target token on the first cross-module call. lp_token_save\n";
s << " // writes the same TokenManager::instance() the lp_client reads.\n";
s << " //\n";
s << " // ONE MEANING ONLY, as of protocol 0.8. The Qt glue used to call\n";
s << " // this from onInit (the module's own anchor -- outbound, correct)\n";
s << " // AND from informModuleToken (a CALLER's token -- inbound, filed\n";
s << " // here as an outbound credential). The caller path now goes through\n";
s << " // logos_module_accept_inbound_token below. Do not merge them.\n";
s << " return lp_token_save(module_name, token);\n}\n\n";
// THE INBOUND DOOR (protocol 0.8). logos-protocol only DECLARES it; this
// backend owes the definition, and so does logos-rust-sdk, IN THE SAME
// WAVE. A module generated for >= 0.8 whose backend omits this links
// cleanly and then fails at dlopen() on ELF with "undefined symbol" --
// invisible on macOS, which links plugins -undefined dynamic_lookup. That
// has now shipped three times (grant_host_services at 0.3, the teardown
// pair at 0.5, set_call_caller at 0.6), every time at perfect version
// agreement, because agreeing on the VERSION says nothing about which
// SYMBOLS a backend's emitter writes. checks.module-impl-abi is what makes
// it fail here instead of at a user's dlopen.
//
// Guarded MAJOR-aware, not on the MINOR alone, for the reason spelled out
// at set_call_caller below: at 1.0 the MINOR resets to 0, a `MINOR >= 8`
// guard goes false, and the definition disappears together with the glue's
// call -- so nothing fails to build, nothing fails to load, and every
// module silently goes back to filing its callers as outbound credentials.
// Written expanded because unifdef must be able to evaluate it.
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && "
"(LOGOS_PROTOCOL_VERSION_MAJOR > 0 || "
"(LOGOS_PROTOCOL_VERSION_MAJOR == 0 && "
"LOGOS_PROTOCOL_VERSION_MINOR >= 8))\n";
s << "int logos_module_accept_inbound_token(const char* caller, const char* token)\n{\n";
s << " if (!caller || !token) return -1;\n";
s << " // THE INBOUND DOOR: `caller` is the module that will CALL US and\n";
s << " // `token` is what it will present. This is NOT a credential this\n";
s << " // module may present to anyone, and lp_token_save_inbound writes a\n";
s << " // key namespace lp_token_get and lp_token_keys cannot read -- which\n";
s << " // is what stops a grant one way from being a grant the other way.\n";
s << " //\n";
s << " // One line, deliberately: the token-registry carve-out (a granted\n";
s << " // registry ALSO gets the outbound entry, because for it the same\n";
s << " // wire message means \"here is X's token, present it when you call\n";
s << " // X\") lives in logos-protocol, where a unit test reaches it by\n";
s << " // value. Logic that lives in emitted text is logic no test ever\n";
s << " // executes, only greps.\n";
s << " return lp_token_save_inbound(caller, token);\n}\n";
s << "#endif\n\n";
// Guarded on the protocol MINOR that introduced the trust-root surface
// (0.3). The emitted module must still COMPILE against an older
// logos-protocol, which has neither lp_grant_host_services nor the
// logos_module_impl.h declaration — a module built against 0.2 simply has
// no grant entry point, which is the same fail-closed state as never being
// granted. Without this an older protocol is a hard compile error in
// generated code the author never sees.
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && "
"(LOGOS_PROTOCOL_VERSION_MAJOR > 0 || "
"(LOGOS_PROTOCOL_VERSION_MAJOR == 0 && "
"LOGOS_PROTOCOL_VERSION_MINOR >= 3))\n";
s << "int logos_module_grant_host_services(const char* services_json)\n{\n";
s << " // Route the host's grant into THIS image's gate state.\n";
s << " //\n";
s << " // The grant has to travel over the C ABI rather than being\n";
s << " // recorded once by the host, and that is the whole reason this\n";
s << " // export exists: the host binary and this cdylib each link their\n";
s << " // own copy of logos-protocol, so each has its own process-global\n";
s << " // grant state, exactly as each has its own TokenManager. A grant\n";
s << " // the host records for itself is invisible to the gate a\n";
s << " // lp_token_keys() call checks HERE, so a gate 'simplified' into\n";
s << " // the host would silently never fire.\n";
s << " //\n";
s << " // Emitted unconditionally, for every module, rather than behind a\n";
s << " // codegen flag: which modules are privileged is the HOST's\n";
s << " // decision (it chooses what to push, and pushes nothing to an\n";
s << " // ordinary module), and lp_grant_host_services itself validates\n";
s << " // the names and fails closed. A per-module flag would only add a\n";
s << " // second place for the two to disagree.\n";
s << " //\n";
s << " // NOTE this is a declaration-and-audit boundary, NOT a defence\n";
s << " // against a hostile module: this cdylib links logos-protocol, so\n";
s << " // its own code can call lp_grant_host_services() directly and\n";
s << " // self-grant. What the gate buys is that the privilege is\n";
s << " // explicit, greppable and off by default, so no module acquires\n";
s << " // it by accident. Isolation between modules rests on process\n";
s << " // separation, the auth token and the target's allowedCallers.\n";
s << " return lp_grant_host_services(services_json);\n}\n";
s << "#endif\n\n";
// Teardown. The callback is stored under its own mutex rather than reusing
// the emit one: it is installed on the host's thread and fired from
// whichever thread the module finishes its work on, and those are the same
// two threads the emit path already keeps apart.
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && "
"(LOGOS_PROTOCOL_VERSION_MAJOR > 0 || "
"(LOGOS_PROTOCOL_VERSION_MAJOR == 0 && "
"LOGOS_PROTOCOL_VERSION_MINOR >= 5))\n";
s << "void logos_module_set_unload_done_callback(logos_module_unload_done_cb cb,\n";
s << " void* user_data)\n{\n";
s << " std::lock_guard<std::mutex> lock(g_unloadMutex);\n";
s << " g_unloadCb = cb;\n";
s << " g_unloadUd = user_data;\n";
s << "}\n\n";
s << "int logos_module_about_to_unload(void)\n{\n";
// Hand the impl a way to say "done" BEFORE asking it to unload: an impl
// that finishes inline would otherwise signal into an empty slot and the
// host would wait out the whole grace period for a module already done.
s << " _logos_codegen_::maybeSetUnloadFinished(lidlImpl(), [] {\n";
s << " logos_module_unload_done_cb cb = nullptr;\n";
s << " void* ud = nullptr;\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_unloadMutex);\n";
s << " cb = g_unloadCb;\n";
s << " ud = g_unloadUd;\n";
s << " }\n";
s << " if (cb) cb(ud);\n";
s << " });\n";
s << " return _logos_codegen_::maybeAboutToUnload(lidlImpl())\n";
s << " == LogosShutdown::Asynchronous ? 1 : 0;\n";
s << "}\n";
s << "#endif\n\n";
// THE CALLER OF A DISPATCH (protocol 0.6). The glue wraps one
// logos_module_dispatch in one push/pop pair on the dispatching thread; a
// non-NULL argument pushes, NULL pops the innermost.
//
// WHY THIS CROSSES THE C ABI AT ALL, since a thread_local the host set
// would be so much simpler. It would not be the same object. Measured with
// nm on built binaries rather than assumed, on both object formats: the
// host image and the module plugin EACH define
// ModuleProxy::callRemoteMethod and TokenManager::instance; the
// function-local static behind the latter is a LOCAL bss symbol in each,
// at a different address; and neither image holds an undefined reference
// to the other's copy. The Mach-O plugin is MH_NOUNDEFS | MH_TWOLEVEL. So
// the identity has to be handed over explicitly, exactly as the trust-root
// grant above is. cpp/logos_caller.h carries the full measurement.
//
// Guarded MAJOR-aware, not on the MINOR alone. At 1.0 the MINOR resets to
// 0 and a `MINOR >= 6` guard would go false, taking the definition and the
// generated call away TOGETHER — everything would still build and load,
// and modules would just silently stop being able to name their caller.
// checks.module-impl-abi resolves this text at one MAJOR up for that
// reason. Written expanded rather than behind a function-like macro
// because unifdef has to be able to evaluate it.
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && "
"(LOGOS_PROTOCOL_VERSION_MAJOR > 0 || "
"(LOGOS_PROTOCOL_VERSION_MAJOR == 0 && "
"LOGOS_PROTOCOL_VERSION_MINOR >= 6))\n";
s << "void logos_module_set_call_caller(const char* caller_json)\n{\n";
// One line, deliberately. Parsing the document, the per-thread stack and
// the nesting rule all live in cpp/logos_caller.h where a unit test can
// reach them BY VALUE; logic that lives in emitted text is logic no test
// ever executes, only greps.
s << " logos::detail::setCallCaller(caller_json);\n";
s << "}\n";
s << "#endif\n\n";
s << "const char* logos_module_get_protocol_version(void)\n{\n";
s << " return LOGOS_PROTOCOL_VERSION_STRING;\n}\n\n";
s << "void logos_module_string_free(char* str)\n{\n";
s << " std::free(str);\n}\n\n";
s << "} // extern \"C\"\n";
return c;
}
QString lidlMakeEventsSourceCdylib(const ModuleDecl& module,
const QString& implClass,
const QString& implHeader)
{
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "// Typed `logos_events:` bodies, cdylib flavor: marshal into\n";
s << "// nlohmann::json and route through LogosModuleContext::emitEventImpl_\n";
s << "// (the export wrapper forwards to the host's emit callback).\n";
const std::set<std::string> recsEv = recordNames(module);
s << "#include \"" << implHeader << "\"\n";
s << "#include \"" << module.name << "_types.h\"\n";
s << "#include <nlohmann/json.hpp>\n\n";
s << "#include <cstdint>\n";
s << "#include <map>\n";
if (moduleUsesOptional(module))
s << "#include <optional>\n";
s << "#include <string>\n";
s << "#include <vector>\n";
// LogosMap / LogosList (nlohmann aliases) appear in the emitted signatures
// whenever an event carries a map or an `any` payload.
if (hasJsonEventParam(module))
s << "#include <logos_json.h>\n";
s << "\n";
// No local bytes encoder any more, and so no hasBytesEventParam() gate for
// it either: a `bstr` event parameter calls logos::bytesToJson, which the
// <logos_codec.h> pulled in by "<module>_types.h" above already provides.
// The gate existed only to keep the emitted copy from sitting unused in
// modules whose events carry no binary data.
for (const EventDecl& ed : module.events) {
s << "void " << implClass << "::" << ed.name << "(";
for (int i = 0; i < ed.params.size(); ++i) {
const QString stdType = lidlTypeToStdCdylib(ed.params[i].type, recsEv);
// Must match the author's declaration in the `logos_events:` block:
// the non-scalar types are conventionally taken by const-ref there.
// Records and std::map belong in that set too — they are structs and
// containers, and emitting them BY VALUE makes the generated
// definition not match the author's declaration, which is a compile
// error naming a parameter type mismatch rather than anything
// helpful.
if (stdType == "std::string" || stdType.startsWith("std::vector")
|| stdType.startsWith("std::map")
|| stdType.startsWith("std::optional")
|| isRecord(ed.params[i].type, recsEv)
|| stdType == "LogosMap" || stdType == "LogosList")
s << "const " << stdType << "& " << ed.params[i].name;
else
s << stdType << " " << ed.params[i].name;
if (i + 1 < ed.params.size()) s << ", ";
}
s << ")\n{\n";
s << " nlohmann::json args = nlohmann::json::array();\n";
for (const ParamDecl& pd : ed.params) {
const QString evStd = lidlTypeToStdCdylib(pd.type, recsEv);
// A record or a composite carrying bytes rides the generated codec,
// exactly like a method return — otherwise an event payload would be
// the one place a bstr silently loses its tag.
//
// An optional joins them: an event parameter is a POSITIONAL slot,
// so empty is spelled null and the argument list keeps its length.
// Codec<std::optional<T>>::to answers exactly that. (`?any` collapsed
// to LogosMap above and is excluded by the same guard the untyped
// aliases always were.)
if (evStd != "LogosMap" && evStd != "LogosList"
&& (isRecord(pd.type, recsEv)
|| pd.type.kind == TypeExpr::Array || pd.type.kind == TypeExpr::Map
|| pd.type.kind == TypeExpr::Optional)) {
s << " args.push_back(logos::toJson<" << evStd << ">("
<< pd.name << "));\n";
continue;
}
if (pd.type.kind == TypeExpr::Primitive && pd.type.name == "bstr")
s << " args.push_back(logos::bytesToJson(" << pd.name << "));\n";
else
s << " args.push_back(" << pd.name << ");\n";
}
s << " emitEventImpl_(\"" << ed.name << "\", &args);\n";
s << "}\n\n";
}
return c;
}