feat(abi): define logos_module_set_call_caller, gated on protocol 0.6

The definition lands BEFORE the protocol declares the export. logos-protocol
only DECLARES the module-impl C ABI and every backend owes the definition;
that gap shipped twice, each time as an undefined symbol at dlopen, on Linux
only, invisible on macOS. Declaring first would turn this repo, logos-rust-sdk
and logos-module-builder red the night the bump merged. Defining first costs
nothing: the guard is MAJOR-aware >= 0.6 and the current pin is 0.5, so
nothing is emitted today and the ABI check sees declared == defined.

This is the case #146 made possible. The next-MAJOR probe resolves the
emitter at MAJOR+1, where a >= 6 guard IS true, so the emitted set there is
legitimately a SUPERSET of the declared one. The probe used to demand
equality and would have rejected this outright.

cpp/logos_caller.h carries the LogosCaller type (std-typed, Qt-free) and
logos::currentCaller(), reading a thread-local stack the generated export
pushes to.

Two things the audit corrected, both worth reading:

* A present-but-unreadable `instance` is DROPPED and the module still
  identified. This backend already did that; Rust returned Unknown, and each
  had a passing test pinning its own answer, so neither suite could see the
  divergence. The protocol header now states the rule normatively and Rust
  is aligned to it.

* The accessors are explicitly HIDDEN on ELF. The header argued this state
  must not be unified across images and then relied on being inline to
  achieve it — which is false: a function-local static in an inline function
  emits STB_GNU_UNIQUE at default visibility and the loader collapses every
  image's copy into one, even under RTLD_LOCAL. Measured across two dlopen'd
  images: default visibility let a push in A be read by B; hidden restored
  isolation. logos-module-builder sets no visibility anywhere, so real
  plugins were built the first way. An anonymous namespace would be worse —
  vague linkage is load-bearing WITHIN an image, since the generated TU
  pushes and the author's TU reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-22 16:59:13 -03:00
committed by Dario Lipicar
co-authored by Claude Opus 5
parent 43904ef1a3
commit dbe1d63677
8 changed files with 826 additions and 3 deletions
@@ -632,6 +632,10 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module,
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";
@@ -966,6 +970,41 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module,
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";