mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 09:41:06 +00:00
* fix(generator): defer generated event subscriptions instead of acquiring a replica
Both C++ generators emitted, at all three subscription sites (the generic
on(QString, RawEventCallback), its EventCallback overload, and every typed
on<Event>):
LogosObject* origin = ensureReplica(); // blocking requestObject
if (!origin) return false; // PERMANENT -- never retried
m_client->onEvent(origin, eventName, callback);
That asks "is the module reachable right now" at the one moment the answer is
no. Every C++ consumer subscribes from init(), onContextReady() or a backend
constructor, all of which run while the dependency's host has been spawned but
has not called listen() yet. The guard inside requestObject was dead code for
years -- isConnected() returned a latch that was always true -- so the call fell
through to a blocking wait that usually succeeded, slowly. Making isConnected()
truthful turns the same code into an instant, permanent, silent failure: the
wrapper compiles, returns a bool, and never delivers.
All three sites now route through the deferred channel:
return m_client->onEventWhenAvailable(m_moduleName, eventName, callback) != 0;
and ensureReplica() / m_eventReplica are deleted from both generators. Keeping a
per-wrapper replica would reintroduce both halves at once -- a blocking acquire
on the subscriber's thread, and a permanent failure when the module had simply
not started yet.
The return becomes ACCEPTED rather than live, false only for errors no retry can
fix. That is stated in the emitted comment so it reaches every generated file
rather than only this message.
VERIFIED AT THREE LEVELS, because the first two prove less than they look:
emits -- 265/265 cpp-sdk tests. The goldens now pin the emitted CALL SITE and
EXPECT_FALSE the removed symbols; they are string comparisons and
would pass on code that does not compile, which is exactly how this
defect survived.
compiles-- both generators' output compiled against the local protocol branch
(EXIT=0), including a 15-event contract with a 3-parameter event.
defers -- real A/B on a live qt_remote transport with real generated code:
7/7 green on the migrated generator, 3/3 red in 0-8 ms on the
pristine one, with published-first controls green in both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): bump logos-protocol to 0183e8c for onEventWhenAvailable
The generator change in this PR emits `m_client->onEventWhenAvailable(...)` at
all three subscription sites. This repo pinned logos-protocol 0f26ffd, which has
zero occurrences of that symbol -- compiling the emitted wrapper against it gave
EXIT=1 and 16 errors, every one "no member named 'onEventWhenAvailable' in
'LogosAPIClient'". That is why this PR was opened as a draft and why the bump has
to ride in the SAME commit range as the emission change: split them and cpp-sdk
master is red for every Qt-api-style consumer.
0183e8c is logos-protocol master with #47, #53 and #55 in. It is deliberately not
the first commit that introduces onEventWhenAvailable: #47's tip also carries the
use-after-free fix for tryAcquireNow (09f684f), without which a consumer that
subscribes more than once to a not-yet-reachable module frees a QtRO facade that
is still registered in a shared replica implementation's connect list. Generated
Qt consumers subscribe exactly that way -- one on<Event> per declared event, from
init() -- so pinning below that commit would make this change crash rather than
merely fail to compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
410 lines
16 KiB
C++
410 lines
16 KiB
C++
#include <gtest/gtest.h>
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include "generator_lib.h"
|
|
|
|
static QJsonObject makeMethod(const QString& name, const QString& retType, int paramCount)
|
|
{
|
|
QJsonObject m;
|
|
m["name"] = name;
|
|
m["returnType"] = retType;
|
|
m["isInvokable"] = true;
|
|
QJsonArray params;
|
|
for (int i = 0; i < paramCount; ++i) {
|
|
QJsonObject p;
|
|
p["type"] = "int";
|
|
p["name"] = QString("p%1").arg(i);
|
|
params.append(p);
|
|
}
|
|
m["parameters"] = params;
|
|
return m;
|
|
}
|
|
|
|
TEST(MakeSourceTest, ContainsInclude)
|
|
{
|
|
QString src = makeSource("mod", "Mod", "mod_api.h", QJsonArray());
|
|
EXPECT_TRUE(src.contains("#include \"mod_api.h\""));
|
|
}
|
|
|
|
TEST(MakeSourceTest, ConstructorInitializesClient)
|
|
{
|
|
QString src = makeSource("my_mod", "MyMod", "my_mod_api.h", QJsonArray());
|
|
EXPECT_TRUE(src.contains("MyMod::MyMod(LogosAPI* api)"));
|
|
EXPECT_TRUE(src.contains("api->getClient(\"my_mod\")"));
|
|
}
|
|
|
|
// Subscriptions must be DEFERRED, not acquired synchronously. The generated
|
|
// wrapper used to call requestObject() and return false forever when the module
|
|
// was not reachable -- which is the normal state at the moment a consumer
|
|
// subscribes, so "calls work, events never arrive" was the result. Pinning the
|
|
// emitted call site because a regression here is silent: the wrapper still
|
|
// compiles, still returns a bool, and simply never delivers.
|
|
TEST(MakeSourceTest, SubscribesViaDeferredChannel)
|
|
{
|
|
QString src = makeSource("mod", "Mod", "mod.h", QJsonArray());
|
|
EXPECT_TRUE(src.contains("m_client->onEventWhenAvailable(m_moduleName, eventName, callback)"));
|
|
EXPECT_FALSE(src.contains("ensureReplica"));
|
|
EXPECT_FALSE(src.contains("requestObject"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, ZeroParams)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("doStuff", "int", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("m_client->invokeRemoteMethod(\"mod\", \"doStuff\", QVariantList{}, timeout, &_err)"));
|
|
EXPECT_TRUE(src.contains("return _result.toInt()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, OneParam)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "bool", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("m_client->invokeRemoteMethod(\"mod\", \"fn\", QVariantList{QVariant::fromValue(p0)}, timeout, &_err)"));
|
|
EXPECT_TRUE(src.contains("return _result.toBool()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, TwoParams)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "void", 2));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("m_client->invokeRemoteMethod(\"mod\", \"fn\", QVariantList{QVariant::fromValue(p0), QVariant::fromValue(p1)}, timeout, &_err)"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, ThreeParams)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QString", 3));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("QVariant::fromValue(p0), QVariant::fromValue(p1), QVariant::fromValue(p2)"));
|
|
EXPECT_TRUE(src.contains("return _result.toString()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, FourParams)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "double", 4));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("QVariant::fromValue(p0), QVariant::fromValue(p1), QVariant::fromValue(p2), QVariant::fromValue(p3)"));
|
|
EXPECT_TRUE(src.contains("return _result.toDouble()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, FiveParams)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "float", 5));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("QVariant::fromValue(p0), QVariant::fromValue(p1), QVariant::fromValue(p2), QVariant::fromValue(p3), QVariant::fromValue(p4)"));
|
|
EXPECT_TRUE(src.contains("return _result.toFloat()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, MoreThanFiveParamsUsesVariantList)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QVariant", 6));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("QVariantList{QVariant::fromValue(p0), QVariant::fromValue(p1), QVariant::fromValue(p2), QVariant::fromValue(p3), QVariant::fromValue(p4), QVariant::fromValue(p5)}"));
|
|
EXPECT_TRUE(src.contains("return _result"));
|
|
}
|
|
|
|
// Regression: a QVariantList-typed ([any]/[int]/...) argument must be wrapped as
|
|
// ONE element via QVariant::fromValue. A bare `QVariantList{v}` concatenates the
|
|
// list's elements into the args list, sending [1,2,3] as three positional args
|
|
// — the "typed arrays empty over the Qt path" bug.
|
|
TEST(MakeSourceTest, ListArgWrappedAsOneElement)
|
|
{
|
|
QJsonObject m;
|
|
m["name"] = "echoList";
|
|
m["returnType"] = "QVariantList";
|
|
m["isInvokable"] = true;
|
|
QJsonObject p;
|
|
p["type"] = "QVariantList";
|
|
p["name"] = "v";
|
|
QJsonArray params;
|
|
params.append(p);
|
|
m["parameters"] = params;
|
|
QJsonArray methods;
|
|
methods.append(m);
|
|
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("invokeRemoteMethod(\"mod\", \"echoList\", QVariantList{QVariant::fromValue(v)}, timeout, &_err)"));
|
|
EXPECT_TRUE(src.contains("invokeRemoteMethodAsync(\"mod\", \"echoList\", QVariantList{QVariant::fromValue(v)}"));
|
|
// The bare (spreading) form must not appear.
|
|
EXPECT_FALSE(src.contains("QVariantList{v}"));
|
|
}
|
|
|
|
// Regression: in the Qt-free (lp) wrapper, an `any` (QVariant) return must pass
|
|
// the raw json value through, NOT force it to an object. `any` shares the
|
|
// LogosMap std type with the `{tstr:any}` map, and forcing `any` to an object
|
|
// collapsed every non-object value to `{}` (e.g. a proxy forwarding echoAny
|
|
// returned {} for the string "x"). The map keeps its object coercion.
|
|
TEST(MakeSourceTest, LpAnyReturnPassesThroughButMapForcesObject)
|
|
{
|
|
QJsonObject any;
|
|
any["name"] = "echoAny";
|
|
any["returnType"] = "QVariant";
|
|
any["isInvokable"] = true;
|
|
{
|
|
QJsonObject p; p["type"] = "QVariant"; p["name"] = "v";
|
|
QJsonArray ps; ps.append(p); any["parameters"] = ps;
|
|
}
|
|
QJsonObject mp;
|
|
mp["name"] = "echoMap";
|
|
mp["returnType"] = "QVariantMap";
|
|
mp["isInvokable"] = true;
|
|
{
|
|
QJsonObject p; p["type"] = "QVariantMap"; p["name"] = "v";
|
|
QJsonArray ps; ps.append(p); mp["parameters"] = ps;
|
|
}
|
|
QJsonArray methods;
|
|
methods.append(any);
|
|
methods.append(mp);
|
|
|
|
QString src = makeSourceLp("mod", "Mod", "mod.h", methods);
|
|
// `any` return: raw passthrough (return _r;), no is_object coercion.
|
|
EXPECT_TRUE(src.contains("return _r;"));
|
|
// `{tstr:any}` map return: still forced to an object.
|
|
EXPECT_TRUE(src.contains("_r.is_object() ? _r : LogosMap::object()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, VoidReturnNoConversion)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("doIt", "void", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_FALSE(src.contains("return _result"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QStringListReturn)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getNames", "QStringList", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("return _result.toStringList()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QJsonArrayReturn)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getData", "QJsonArray", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("qvariant_cast<QJsonArray>(_result)"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantListReturn)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getItems", "QVariantList", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("return _result.toList()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantMapReturn)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getData", "QVariantMap", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("return _result.toMap()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantListAsync)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getItems", "QVariantList", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("Mod::getItemsAsync("));
|
|
EXPECT_TRUE(src.contains("std::function<void(QVariantList)> callback"));
|
|
EXPECT_TRUE(src.contains("QVariantList()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantMapAsync)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("getData", "QVariantMap", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("Mod::getDataAsync("));
|
|
EXPECT_TRUE(src.contains("std::function<void(QVariantMap)> callback"));
|
|
EXPECT_TRUE(src.contains("QVariantMap()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantListConstRefParam)
|
|
{
|
|
QJsonArray methods;
|
|
{
|
|
QJsonObject m;
|
|
m["name"] = "process";
|
|
m["returnType"] = "void";
|
|
m["isInvokable"] = true;
|
|
QJsonArray params;
|
|
QJsonObject p;
|
|
p["type"] = "QVariantList";
|
|
p["name"] = "items";
|
|
params.append(p);
|
|
m["parameters"] = params;
|
|
methods.append(m);
|
|
}
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("const QVariantList& items"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QVariantMapConstRefParam)
|
|
{
|
|
QJsonArray methods;
|
|
{
|
|
QJsonObject m;
|
|
m["name"] = "update";
|
|
m["returnType"] = "void";
|
|
m["isInvokable"] = true;
|
|
QJsonArray params;
|
|
QJsonObject p;
|
|
p["type"] = "QVariantMap";
|
|
p["name"] = "data";
|
|
params.append(p);
|
|
m["parameters"] = params;
|
|
methods.append(m);
|
|
}
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("const QVariantMap& data"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, LogosResultReturn)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("query", "LogosResult", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("_result.value<LogosResult>()"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, AsyncImplementation)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "int", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("Mod::fnAsync("));
|
|
EXPECT_TRUE(src.contains("invokeRemoteMethodAsync"));
|
|
EXPECT_TRUE(src.contains("callback"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, NonInvokableSkipped)
|
|
{
|
|
QJsonArray methods;
|
|
QJsonObject m;
|
|
m["name"] = "hidden";
|
|
m["returnType"] = "void";
|
|
m["isInvokable"] = false;
|
|
m["parameters"] = QJsonArray();
|
|
methods.append(m);
|
|
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_FALSE(src.contains("hidden"));
|
|
}
|
|
|
|
// ─── The provider REJECTION envelope on the return path ─────────────────────
|
|
//
|
|
// A provider that refuses a call answers the canonical
|
|
// {"code":"dispatch_failed", "message":…, "origin":…} object as its RESULT. The
|
|
// Qt return table converts it like any other value, which ERASES it — a rejected
|
|
// `[uint]` call answered `[]`, indistinguishable from "the provider returned
|
|
// nothing". These pin the consumer folding it into the CallError out-channel the
|
|
// wrapper already uses for a failed call.
|
|
|
|
TEST(MakeSourceTest, QtEmitsRejectionDetector)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QVariantList", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("bool logosDispatchRejection(const QVariant& v, logos::CallError& out)"));
|
|
// Exact match only: an `any` / map return carrying user data must not
|
|
// false-match (same discipline as logos_rpc_status.h's sentinel).
|
|
EXPECT_TRUE(src.contains("if (m.size() != 3) return false;"));
|
|
EXPECT_TRUE(src.contains("if (code.toString() != QStringLiteral(\"dispatch_failed\")) return false;"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QtSyncFoldsRejectionIntoCallError)
|
|
{
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("echoUintList", "QVariantList", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
// Folded BEFORE the return table converts the value, and before *err is
|
|
// written, so a caller passing err sees the rejection.
|
|
const int fold = src.indexOf("if (_err.ok()) logosDispatchRejection(_result, _err);");
|
|
const int assign = src.indexOf("if (err) *err = _err;");
|
|
const int convert = src.indexOf("return _result.toList();");
|
|
EXPECT_NE(fold, -1);
|
|
EXPECT_NE(assign, -1);
|
|
EXPECT_NE(convert, -1);
|
|
EXPECT_LT(fold, assign);
|
|
EXPECT_LT(assign, convert);
|
|
}
|
|
|
|
TEST(MakeSourceTest, QtVoidReturnStillCapturesResult)
|
|
{
|
|
// A void method can be rejected too, and the rejection object is the only
|
|
// place that says so — so the result has to be captured even when it is
|
|
// never returned.
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("doVoid", "void", 0));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("QVariant _result = m_client->invokeRemoteMethod(\"mod\", \"doVoid\""));
|
|
EXPECT_TRUE(src.contains("if (_err.ok()) logosDispatchRejection(_result, _err);"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QtAsyncLogsRejection)
|
|
{
|
|
// The async callback takes the value alone — there is no CallError to fill
|
|
// without changing the generated public surface — so the rejection has to at
|
|
// least reach the module log instead of vanishing into the conversion.
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QVariantList", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("{ logos::CallError _rej; if (logosDispatchRejection(v, _rej))"));
|
|
EXPECT_TRUE(src.contains("Mod::fnAsync: remote call failed:"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QtNoInvokableMethodsEmitsNoDetector)
|
|
{
|
|
// Unreachable from any body: emitting it would be an unused function in an
|
|
// anonymous namespace (-Wunused-function), and such a contract's wrapper
|
|
// stays byte-identical to what it generated before.
|
|
QJsonArray methods;
|
|
QJsonObject m;
|
|
m["name"] = "hidden";
|
|
m["returnType"] = "void";
|
|
m["isInvokable"] = false;
|
|
m["parameters"] = QJsonArray();
|
|
methods.append(m);
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_FALSE(src.contains("logosDispatchRejection"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, LpSurfaceIsUntouched)
|
|
{
|
|
// The fix is Qt-consumer-only; the lp wrapper must generate exactly as
|
|
// before (byte-identical output is the negative control for the change).
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QVariantList", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods, ApiStyle::Lp);
|
|
EXPECT_FALSE(src.contains("logosDispatchRejection"));
|
|
}
|
|
|
|
TEST(MakeSourceTest, QtRejectionDetectorIsPreprocessorGuarded)
|
|
{
|
|
// The umbrella (logos_sdk.cpp) textually #includes EVERY generated
|
|
// `<dep>_api.cpp`, so a module with more than one dependency puts several
|
|
// copies in ONE translation unit — "redefinition of logosDispatchRejection".
|
|
// Internal linkage does not help there; only the guard does.
|
|
QJsonArray methods;
|
|
methods.append(makeMethod("fn", "QVariantList", 1));
|
|
QString src = makeSource("mod", "Mod", "mod.h", methods);
|
|
EXPECT_TRUE(src.contains("#ifndef LOGOS_GENERATED_DISPATCH_REJECTION"));
|
|
EXPECT_TRUE(src.contains("#define LOGOS_GENERATED_DISPATCH_REJECTION"));
|
|
EXPECT_TRUE(src.contains("#endif // LOGOS_GENERATED_DISPATCH_REJECTION"));
|
|
// Concatenating two generated wrappers, as the umbrella does, must compile:
|
|
// the second copy is preprocessed away.
|
|
QString other = makeSource("dep", "Dep", "dep.h", methods);
|
|
EXPECT_EQ(other.count("bool logosDispatchRejection"), 1);
|
|
EXPECT_EQ((src + other).count("#ifndef LOGOS_GENERATED_DISPATCH_REJECTION"), 2);
|
|
}
|