feat(sdk): make the by-name call path a supported API

The dynamic (by-name) invoke path already existed and was already ungated at
every layer — lp_client_create / lp_invoke in the C ABI, logos::LpClient above
it, and the Qt client above that. Nothing checked a host service; there was no
gate to open. What was missing was the ERGONOMICS, which is what turned a
supported capability into something callers reached around the umbrella to get.

Three additive pieces, no gate touched:

1. LogosModuleContext::moduleName() — the module's own registry name, i.e. the
   origin it authenticates as. The typed wrappers bake their origin in at
   codegen time; a by-name call has to state one, and a wrong origin
   authenticates as nobody and fails far from the call site. Set through a NEW
   `_logosCoreSetModuleName_`, deliberately not a fourth parameter on
   `_logosCoreSetContext_`: every generated provider calls that signature, so
   widening it would break each one until regenerated, for a value the
   generator knows statically. Set before the context, so moduleName() is live
   inside onContextReady().

2. LogosModules::dynamic(target) on the origin-bound umbrella — the untyped
   client, with the origin baked in exactly as the typed members' is, and
   cached per target because LpClient owns a connection. The typed members over
   metadata.json#dependencies stay the ordinary way to call another module;
   this is for the cases whose target is a runtime value (a proxy, a router).

3. LpClient::getMethods() over the already-exported lp_get_methods. Invoke
   without introspect is guessing — a caller that cannot ask what exists can
   only hardcode, and a wrong guess fails at runtime like a typo.

Verified: #default builds, and both checks pass (tests, generator-cli).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-18 16:51:48 -03:00
co-authored by Claude Opus 5
parent 1afcf46f4d
commit 1be71bbbeb
4 changed files with 74 additions and 0 deletions
@@ -716,6 +716,9 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module,
// 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";
+25
View File
@@ -1649,6 +1649,31 @@ QString makeUmbrellaHeaderFromDeps(const QJsonArray& deps, const QStringList& in
s << " std::map<std::string, std::unique_ptr<" << className << "::State>> m_"
<< ifaceName << "_bound;\n";
}
// Untyped, BY-NAME access to a module this umbrella does not wrap.
//
// The typed members above cover `metadata.json#dependencies`, which is
// the right default and stays the ordinary way to call another module.
// But the by-name path already exists at every layer beneath this one
// (lp_client_create / lp_invoke, logos::LpClient), so a consumer that
// genuinely needs it — a proxy, a router, anything whose target is a
// runtime value — has been reaching around the umbrella to get it.
// Exposing it here is what makes that a supported surface rather than
// an accident.
//
// The origin is baked in, exactly as the typed members' is: an origin
// is asserted, never borrowed, and a wrong one authenticates as nobody
// and fails far from the call. Clients are cached per target, mirroring
// the bind_ state map above, because LpClient owns a connection.
//
// Pair it with LpClient::getMethods() — invoke without introspect is
// guessing.
s << " logos::LpClient& dynamic(const std::string& target) {\n";
s << " auto& _c = m_dynamic[target];\n";
s << " if (!_c) _c = std::make_unique<logos::LpClient>(target, \"" << originName << "\");\n";
s << " return *_c;\n";
s << " }\n";
s << " std::map<std::string, std::unique_ptr<logos::LpClient>> m_dynamic;\n";
s << "};\n";
return content;
}
+14
View File
@@ -164,6 +164,20 @@ public:
&LpClient::resultTrampoline, box);
}
// The target's method list, as the JSON the host reports. Empty on
// failure. Invoke-without-introspect is what makes a by-name call an
// escape hatch rather than an API: a caller that cannot ask what exists
// can only guess, and a wrong guess fails at runtime like a typo.
nlohmann::json getMethods() {
lp_client* c = ensure();
if (!c) return nlohmann::json();
char* out = lp_get_methods(c);
if (!out) return nlohmann::json();
auto parsed = nlohmann::json::parse(out, nullptr, /*allow_exceptions=*/false);
lp_string_free(out);
return parsed.is_discarded() ? nlohmann::json() : parsed;
}
// Subscribe to `event`. The payload is delivered as a JSON array. The
// returned handle owns the subscription — keep it alive (the generated
// wrapper stores it) for as long as you want the callback to fire.
+32
View File
@@ -85,6 +85,14 @@ public:
// resources bundled next to the plugin (icons, qml/, schema files…).
const std::string& modulePath() const { return m_modulePath; }
// This module's own registry name — the name other modules address it by,
// and the `origin` it authenticates as. Needed by any BY-NAME call: the
// typed wrappers bake their origin in at codegen time, but a dynamic call
// has to state it, and a wrong origin authenticates as nobody and fails far
// from the call site. Empty outside a framework-provisioned context, like
// the getters below.
const std::string& moduleName() const { return m_moduleName; }
// Short ID the host assigns to this instance. Stable across restarts
// for the same on-disk persistence directory; multiple side-by-side
// instances of the same module get distinct IDs.
@@ -152,6 +160,16 @@ public:
onContextReady();
}
// Framework-only — sets moduleName(). Separate from
// `_logosCoreSetContext_` on purpose: that signature is called by every
// generated provider, so widening it would break each one until
// regenerated, for a value the generator knows statically anyway. Called
// BEFORE the context setter, so moduleName() is already populated when
// onContextReady() fires.
void _logosCoreSetModuleName_(std::string moduleName) {
m_moduleName = std::move(moduleName);
}
// Framework-only — sets the typed `LogosModules` pointer that
// `logos<T>()` dereferences. Untyped (void*) at this layer because
// the SDK header is shared by every module; the codegen-generated
@@ -194,6 +212,7 @@ protected:
virtual void onContextReady() {}
private:
std::string m_moduleName;
std::string m_modulePath;
std::string m_instanceId;
std::string m_instancePersistencePath;
@@ -230,6 +249,19 @@ private:
// ---------------------------------------------------------------------------
namespace _logos_codegen_ {
template<class T>
inline auto maybeSetModuleName(T& impl, std::string moduleName)
-> std::enable_if_t<std::is_base_of_v<LogosModuleContext, T>>
{
static_cast<LogosModuleContext&>(impl)._logosCoreSetModuleName_(std::move(moduleName));
}
template<class T>
inline auto maybeSetModuleName(T&, std::string)
-> std::enable_if_t<!std::is_base_of_v<LogosModuleContext, T>>
{
}
template<class T>
inline auto maybeSetContext(T& impl,
std::string modulePath,