mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
* fix(events): the event bridge converts through the canonical helper
setEventListenerStdBridge adapts the universal event callback (name + JSON
string) to the Qt EventCallback (name + QVariantList). It is the event-path
counterpart of callMethodStdBridge, but it did the conversion itself:
callMethodStdBridge -> logos::nlohmannToQVariant (canonical)
setEventListenerStdBridge -> QJsonDocument::fromJson
+ QJsonValue::toVariant (Qt's parser)
Two consequences, both measured by the LIDL conformance matrix as M6:
* a uint64 above int64max degraded to a double. Qt 6 backs QJsonValue with
QCborValue, so integers up to int64 DID survive — only values with no
integral representation there fell back to double. echoUint(2^64-1) was
exact while uintEvent(2^64-1) arrived as 1.8446744073709552e+19: same
value, same process, one hop later.
* canonical tagged bytes {"_bytes": ...} were not decoded, arriving as a
QVariantMap where the method path yields a QByteArray. This never showed up
end-to-end because the undecoded map round-trips to JSON and the python
client decodes the tag itself — but a C++ or QML event subscriber got a map.
Both now go through logos::nlohmannArgsToQVariantList, which the generated
cdylib emitTrampoline already used. Numbers and bytes no longer depend on
whether a value left the module as a return or as an event.
Not the residue of the codec convergence, despite how M6 was originally
registered. #29 converged six copies of the VALUE codec; this was a seventh
conversion inside an ADAPTER, which that scope never touched. It is also not on
the providers' own path — a Qt provider stores its callback verbatim and a
cdylib provider already converted correctly. The one live caller is the
logoscore daemon's CoreServiceImpl, which forwards every watched module event;
that is why C++ and Rust providers measured identically.
Why it survived: the bridge appeared in the test suite once, in
test_universal_provider_dispatch.cpp, purely to satisfy the pure virtual. No
test asserted anything about an event payload. The method path got 15 contract
tests in #29; the event path got none.
tests: 11 new cells pin the bridge directly — uint64 past int64max, 2^53+1,
int64::min, large integers nested in containers, tagged bytes at top level and
at depth, plus the shapes that already worked (multi-param order, double staying
double, null elements, empty payload, the non-array raw-string fallback) so a
future rewrite cannot quietly drop them. 210/210.
verified: logos-cpp-sdk, logos-qt-sdk, logos-liblogos and logos-logoscore-cli
all green against this build; the conformance matrix goes 156 -> 158 pass with
M6's two cells retired, and the ext table stays 40/40.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(events): pin the signedness rule the convergence brings with it
nlohmannArgsToQVariantList classifies every non-negative integer as unsigned, so
a LIDL `int` event argument now arrives as ULongLong where it used to be
LongLong. That matches what nlohmannToQVariant (the method path) and the cdylib
emitTrampoline already did — the surfaces now agree — but it is an observable
metatype change that nothing asserted.
Pinned in both directions (non-negative -> ULongLong, negative -> LongLong) so
it stays a decision rather than a side effect. Value-level reads are unaffected.
212/212.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(plain): RpcValue can represent a uint64 above int64max
The plain (tcp/tcp_ssl) wire squeezed every unsigned value through int64_t, so a
LIDL `uint` above int64max wrapped — independently in each direction:
outbound qvariant_rpc_value.cpp QMetaType::ULongLong -> int64_t(...)
inbound json_mapping.cpp is_number_unsigned -> get<int64_t>()
Neither wraps loudly: .get<int64_t>() past int64max returns -1 with no
exception. Two peers both running this code agreed on -1, so nothing looked
broken from inside — and no plain-tier test used an integer outside int32 range.
Measured over real tcp before the fix:
echoUint(2^63) -> -9223372036854775808
echoUint(2^64-1) -> -1
This was never a wire-format constraint. Both codecs carry uint64 natively (CBOR
emits major type 0, `1b ff..ff`) and the envelope's own `id` field already
crossed this wire as uint64_t. Only RpcValue *payloads* could not represent it.
RpcValue gains a uint64_t alternative, used through `makeInteger()` and ONLY for
values above int64max — the sole case where int64_t loses information. Anything
broader would change the representation of every non-negative integer already on
this wire, and since std::variant equality compares the alternative index it
would break comparisons against int64-built values, to fix nothing. Small
unsigned values keep crossing as signed, pinned by a test so the rule stays
visible.
Also fixes an off-by-one in the QJsonValue::Double -> int64 guard while here:
double(int64max) rounds UP to exactly 2^63, so `d <= double(int64max)` admitted
2^63 and then ran int64_t(d) out of range — undefined behaviour, saturating on
arm64 and INT64_MIN on x86-64. Now a strict `<` against 2^63.
tests: 14 new. Both codecs round-trip 2^64-1 flat and nested; negatives stay
signed; the Qt boundary is exact in both directions; the narrow representation
rule and the 2^63 guard are pinned. 226/226.
verified end-to-end, cross-process, with a negative control: the new 64-bit
boundary cases in logos-logoscore-py fail on the pinned protocol over tcp with
exactly the values above, and all 68 pass with this build — on local, tcp and
tcp_ssl alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
271 lines
10 KiB
C++
271 lines
10 KiB
C++
#include "qvariant_rpc_value.h"
|
|
|
|
#include "../../logos_types.h"
|
|
|
|
#include <QMetaType>
|
|
|
|
#include <cmath>
|
|
#include <limits>
|
|
|
|
namespace logos::plain {
|
|
|
|
namespace {
|
|
|
|
RpcValue fromJsonValue(const QJsonValue& v);
|
|
QJsonValue toJsonValue(const RpcValue& v);
|
|
|
|
RpcValue fromJsonValue(const QJsonValue& v)
|
|
{
|
|
switch (v.type()) {
|
|
case QJsonValue::Null: return RpcValue{std::monostate{}};
|
|
case QJsonValue::Bool: return RpcValue{v.toBool()};
|
|
case QJsonValue::Double: {
|
|
double d = v.toDouble();
|
|
double intPart = 0.0;
|
|
// Strict `<` on the upper bound: double(int64max) rounds UP to exactly
|
|
// 2^63, so `d <= double(int64max)` admitted d == 2^63, and int64_t(d) on
|
|
// an out-of-range double is undefined behaviour — saturating to int64max
|
|
// on arm64, INT64_MIN on x86-64. The lower bound needs no such care:
|
|
// double(int64min) is exactly -2^63 and representable.
|
|
if (std::modf(d, &intPart) == 0.0 &&
|
|
d >= double(std::numeric_limits<int64_t>::min()) &&
|
|
d < 9223372036854775808.0) // 2^63, i.e. int64max + 1
|
|
return RpcValue{int64_t(d)};
|
|
return RpcValue{d};
|
|
}
|
|
case QJsonValue::String: return RpcValue{v.toString().toStdString()};
|
|
case QJsonValue::Array: {
|
|
RpcList out;
|
|
const auto arr = v.toArray();
|
|
out.items.reserve(arr.size());
|
|
for (const QJsonValue& e : arr) out.items.push_back(fromJsonValue(e));
|
|
return RpcValue{std::move(out)};
|
|
}
|
|
case QJsonValue::Object: {
|
|
RpcMap out;
|
|
const auto obj = v.toObject();
|
|
for (auto it = obj.begin(); it != obj.end(); ++it)
|
|
out.emplace(it.key().toStdString(), fromJsonValue(it.value()));
|
|
return RpcValue{std::move(out)};
|
|
}
|
|
default:
|
|
return RpcValue{std::monostate{}};
|
|
}
|
|
}
|
|
|
|
QJsonValue toJsonValue(const RpcValue& v)
|
|
{
|
|
if (v.isNull()) return QJsonValue(QJsonValue::Null);
|
|
if (v.isBool()) return QJsonValue(v.asBool());
|
|
if (v.isInt()) return QJsonValue(static_cast<double>(v.asInt()));
|
|
// QJsonValue has no unsigned primitive and its double cannot hold the band
|
|
// above int64max exactly. This path only carries method-introspection
|
|
// METADATA (parameter descriptors), never payload values, so the lossy cast
|
|
// is acceptable here — but without this branch a uint64 would fall through
|
|
// to Null, which is worse than imprecise.
|
|
if (v.isUInt()) return QJsonValue(static_cast<double>(v.asUInt()));
|
|
if (v.isDouble()) return QJsonValue(v.asDouble());
|
|
if (v.isString()) return QJsonValue(QString::fromStdString(v.asString()));
|
|
if (v.isBytes()) {
|
|
// QJsonValue has no bytes primitive; encode as base64 string.
|
|
const auto& b = v.asBytes().data;
|
|
QByteArray ba(reinterpret_cast<const char*>(b.data()),
|
|
static_cast<int>(b.size()));
|
|
return QJsonValue(QString::fromLatin1(ba.toBase64(QByteArray::Base64UrlEncoding)));
|
|
}
|
|
if (v.isList()) {
|
|
QJsonArray arr;
|
|
for (const auto& e : v.asList().items) arr.append(toJsonValue(e));
|
|
return arr;
|
|
}
|
|
if (v.isMap()) {
|
|
QJsonObject obj;
|
|
for (const auto& kv : v.asMap().entries)
|
|
obj.insert(QString::fromStdString(kv.first), toJsonValue(kv.second));
|
|
return obj;
|
|
}
|
|
return QJsonValue(QJsonValue::Null);
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
RpcValue qvariantToRpcValue(const QVariant& v)
|
|
{
|
|
if (!v.isValid()) return RpcValue{std::monostate{}};
|
|
|
|
// LogosResult is a user-defined struct registered via qRegisterMetaType;
|
|
// its metatype id is assigned at runtime so we can't put it in the
|
|
// switch on QMetaType::Type below. Check it first — if we let it fall
|
|
// through to the default, we'd stringify it via QVariant::toString()
|
|
// (returning "" because LogosResult has no QString converter) or,
|
|
// earlier, lose it as std::monostate{} and the receiver would see null.
|
|
//
|
|
// Wire shape: {"success": bool, "value": <any>, "error": <any>}.
|
|
// That matches the struct's fields and recursively reuses the RpcValue
|
|
// conversion for `value` and `error`, which themselves are QVariants
|
|
// carrying primitives / QVariantMap / QVariantList / etc.
|
|
//
|
|
// Look up the metatype id per call (not cached in a `static`): the
|
|
// first `qvariantToRpcValue` call might land before any `LogosAPI`
|
|
// has called `qRegisterMetaType<LogosResult>`, and we don't want to
|
|
// permanently cache `UnknownType` in that case. The lookup is a
|
|
// hash probe — trivially cheap compared to the actual RPC work.
|
|
{
|
|
const int logosResultId = QMetaType::fromName("LogosResult").id();
|
|
if (logosResultId != QMetaType::UnknownType && v.userType() == logosResultId) {
|
|
const LogosResult r = v.value<LogosResult>();
|
|
RpcMap m;
|
|
m.emplace("success", RpcValue{r.success});
|
|
m.emplace("value", qvariantToRpcValue(r.value));
|
|
m.emplace("error", qvariantToRpcValue(r.error));
|
|
return RpcValue{std::move(m)};
|
|
}
|
|
}
|
|
|
|
// Fast path for the common scalar types.
|
|
switch (static_cast<QMetaType::Type>(v.userType())) {
|
|
case QMetaType::Bool: return RpcValue{v.toBool()};
|
|
case QMetaType::Int:
|
|
case QMetaType::Long:
|
|
case QMetaType::LongLong:
|
|
case QMetaType::Short:
|
|
case QMetaType::Char:
|
|
case QMetaType::SChar:
|
|
return RpcValue{int64_t(v.toLongLong())};
|
|
case QMetaType::UInt:
|
|
case QMetaType::ULong:
|
|
case QMetaType::ULongLong:
|
|
case QMetaType::UShort:
|
|
case QMetaType::UChar:
|
|
// makeInteger, not int64_t(): a LIDL `uint` above int64max used to wrap
|
|
// to -1 here, silently and in every direction. Values that fit int64_t
|
|
// still take the int64_t alternative, so nothing else changes.
|
|
return RpcValue::makeInteger(v.toULongLong());
|
|
case QMetaType::Float:
|
|
case QMetaType::Double:
|
|
return RpcValue{v.toDouble()};
|
|
case QMetaType::QString:
|
|
return RpcValue{v.toString().toStdString()};
|
|
case QMetaType::QByteArray: {
|
|
QByteArray ba = v.toByteArray();
|
|
RpcBytes b;
|
|
b.data.assign(reinterpret_cast<const uint8_t*>(ba.data()),
|
|
reinterpret_cast<const uint8_t*>(ba.data()) + ba.size());
|
|
return RpcValue{std::move(b)};
|
|
}
|
|
case QMetaType::QVariantList: {
|
|
RpcList list;
|
|
const QVariantList src = v.toList();
|
|
list.items.reserve(src.size());
|
|
for (const QVariant& e : src) list.items.push_back(qvariantToRpcValue(e));
|
|
return RpcValue{std::move(list)};
|
|
}
|
|
case QMetaType::QVariantMap: {
|
|
RpcMap map;
|
|
const QVariantMap src = v.toMap();
|
|
for (auto it = src.begin(); it != src.end(); ++it)
|
|
map.emplace(it.key().toStdString(), qvariantToRpcValue(it.value()));
|
|
return RpcValue{std::move(map)};
|
|
}
|
|
case QMetaType::QJsonValue:
|
|
return fromJsonValue(v.toJsonValue());
|
|
case QMetaType::QJsonArray: {
|
|
RpcList list;
|
|
const QJsonArray arr = v.toJsonArray();
|
|
list.items.reserve(arr.size());
|
|
for (const QJsonValue& e : arr) list.items.push_back(fromJsonValue(e));
|
|
return RpcValue{std::move(list)};
|
|
}
|
|
case QMetaType::QJsonObject: {
|
|
RpcMap map;
|
|
const QJsonObject obj = v.toJsonObject();
|
|
for (auto it = obj.begin(); it != obj.end(); ++it)
|
|
map.emplace(it.key().toStdString(), fromJsonValue(it.value()));
|
|
return RpcValue{std::move(map)};
|
|
}
|
|
default:
|
|
// Best-effort fallback: stringify.
|
|
if (v.canConvert<QString>()) return RpcValue{v.toString().toStdString()};
|
|
return RpcValue{std::monostate{}};
|
|
}
|
|
}
|
|
|
|
QVariant rpcValueToQVariant(const RpcValue& v)
|
|
{
|
|
if (v.isNull()) return QVariant();
|
|
if (v.isBool()) return QVariant(v.asBool());
|
|
if (v.isInt()) return QVariant(static_cast<qlonglong>(v.asInt()));
|
|
if (v.isUInt()) return QVariant(static_cast<qulonglong>(v.asUInt()));
|
|
if (v.isDouble()) return QVariant(v.asDouble());
|
|
if (v.isString()) return QVariant(QString::fromStdString(v.asString()));
|
|
if (v.isBytes()) {
|
|
const auto& b = v.asBytes().data;
|
|
return QVariant(QByteArray(reinterpret_cast<const char*>(b.data()),
|
|
static_cast<int>(b.size())));
|
|
}
|
|
if (v.isList()) return QVariant(rpcListToQVariantList(v.asList().items));
|
|
if (v.isMap()) {
|
|
QVariantMap map;
|
|
for (const auto& kv : v.asMap().entries)
|
|
map.insert(QString::fromStdString(kv.first), rpcValueToQVariant(kv.second));
|
|
return QVariant(std::move(map));
|
|
}
|
|
return QVariant();
|
|
}
|
|
|
|
std::vector<RpcValue> qvariantListToRpcList(const QVariantList& list)
|
|
{
|
|
std::vector<RpcValue> out;
|
|
out.reserve(list.size());
|
|
for (const QVariant& e : list) out.push_back(qvariantToRpcValue(e));
|
|
return out;
|
|
}
|
|
|
|
QVariantList rpcListToQVariantList(const std::vector<RpcValue>& list)
|
|
{
|
|
QVariantList out;
|
|
out.reserve(list.size());
|
|
for (const auto& e : list) out.append(rpcValueToQVariant(e));
|
|
return out;
|
|
}
|
|
|
|
QJsonArray methodsToJsonArray(const std::vector<MethodMetadata>& methods)
|
|
{
|
|
QJsonArray out;
|
|
for (const auto& m : methods) {
|
|
QJsonObject o;
|
|
o["name"] = QString::fromStdString(m.name);
|
|
o["signature"] = QString::fromStdString(m.signature);
|
|
o["returnType"] = QString::fromStdString(m.returnType);
|
|
o["isInvokable"] = m.isInvokable;
|
|
QJsonArray params;
|
|
for (const auto& p : m.parameters.items) params.append(toJsonValue(p));
|
|
o["parameters"] = std::move(params);
|
|
out.append(o);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<MethodMetadata> methodsFromJsonArray(const QJsonArray& arr)
|
|
{
|
|
std::vector<MethodMetadata> out;
|
|
out.reserve(arr.size());
|
|
for (const QJsonValue& v : arr) {
|
|
if (!v.isObject()) continue;
|
|
const auto o = v.toObject();
|
|
MethodMetadata m;
|
|
m.name = o.value("name").toString().toStdString();
|
|
m.signature = o.value("signature").toString().toStdString();
|
|
m.returnType = o.value("returnType").toString().toStdString();
|
|
m.isInvokable = o.value("isInvokable").toBool(true);
|
|
if (o.contains("parameters") && o.value("parameters").isArray()) {
|
|
for (const QJsonValue& p : o.value("parameters").toArray())
|
|
m.parameters.items.push_back(fromJsonValue(p));
|
|
}
|
|
out.push_back(std::move(m));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace logos::plain
|