Files
logos-protocol/cpp/implementations/plain/rpc_value.h
T
Dario LipicarandClaude Opus 5 8b8a358c8b fix: uint64 survives the event path and the plain wire (#30)
* 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>
2026-07-29 00:26:32 -03:00

154 lines
6.2 KiB
C++

#ifndef LOGOS_PLAIN_RPC_VALUE_H
#define LOGOS_PLAIN_RPC_VALUE_H
#include <algorithm>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <string>
#include <utility>
#include <variant>
#include <vector>
namespace logos::plain {
// -----------------------------------------------------------------------------
// RpcValue — plain C++ variant carried by the wire RPC layer.
//
// Covers the shapes we actually need (null / bool / int / double / string /
// bytes / list / map). No Qt types. The JSON/CBOR codec converts to/from
// `nlohmann::json`; Qt-side callers convert to/from `QVariant` at the Qt
// boundary (see plain_logos_object.cpp, plain_transport_host.cpp).
//
// Uses recursive std::variant via wrapper structs so list/map can hold
// RpcValue children without forward-declaration headaches.
// -----------------------------------------------------------------------------
struct RpcValue;
struct RpcList {
std::vector<RpcValue> items;
bool operator==(const RpcList& other) const { return items == other.items; }
bool operator!=(const RpcList& other) const { return !(*this == other); }
};
// std::map<string, RpcValue> would require RpcValue to be complete at this
// point, which is impossible (RpcValue contains RpcMap as a variant alt).
// Use a vector of pairs instead — also gives us deterministic encoding
// order for free, which matters when we move to CBOR.
//
// Method bodies that dereference RpcValue are defined out-of-line below,
// once RpcValue is complete.
struct RpcMap {
std::vector<std::pair<std::string, RpcValue>> entries;
void emplace(std::string key, RpcValue val);
const RpcValue* find(const std::string& key) const;
const RpcValue& at(const std::string& key) const;
bool operator==(const RpcMap& other) const;
bool operator!=(const RpcMap& other) const { return !(*this == other); }
};
struct RpcBytes {
std::vector<uint8_t> data;
bool operator==(const RpcBytes& other) const { return data == other.data; }
bool operator!=(const RpcBytes& other) const { return !(*this == other); }
};
struct RpcValue {
using Variant = std::variant<
std::monostate, // null
bool,
int64_t,
uint64_t, // ONLY for values above int64max — see makeInteger()
double,
std::string,
RpcBytes,
RpcList,
RpcMap
>;
Variant value;
RpcValue() = default;
RpcValue(std::monostate) : value(std::monostate{}) {}
RpcValue(bool b) : value(b) {}
RpcValue(int i) : value(static_cast<int64_t>(i)) {}
RpcValue(int64_t i) : value(i) {}
RpcValue(uint64_t u) : value(u) {}
RpcValue(double d) : value(d) {}
RpcValue(const char* s) : value(std::string(s)) {}
RpcValue(std::string s) : value(std::move(s)) {}
RpcValue(RpcBytes b) : value(std::move(b)) {}
RpcValue(RpcList l) : value(std::move(l)) {}
RpcValue(RpcMap m) : value(std::move(m)) {}
// Canonical way to build an integer from an unsigned source.
//
// The uint64_t alternative exists for exactly one reason: to carry values
// int64_t cannot. It is NOT used for every non-negative integer, and that is
// deliberate — std::variant equality compares the alternative index first,
// so representing 42 as uint64_t would make RpcValue{42} != decode("42") and
// silently change the metatype of every non-negative integer already
// crossing this wire, to fix nothing. Values that fit int64_t keep their
// existing representation; only the band above int64max is new.
static RpcValue makeInteger(uint64_t u) {
if (u <= static_cast<uint64_t>(std::numeric_limits<int64_t>::max()))
return RpcValue{static_cast<int64_t>(u)};
return RpcValue{u};
}
bool isNull() const { return std::holds_alternative<std::monostate>(value); }
bool isBool() const { return std::holds_alternative<bool>(value); }
bool isInt() const { return std::holds_alternative<int64_t>(value); }
bool isUInt() const { return std::holds_alternative<uint64_t>(value); }
bool isDouble() const { return std::holds_alternative<double>(value); }
bool isString() const { return std::holds_alternative<std::string>(value); }
bool isBytes() const { return std::holds_alternative<RpcBytes>(value); }
bool isList() const { return std::holds_alternative<RpcList>(value); }
bool isMap() const { return std::holds_alternative<RpcMap>(value); }
// True for either integer alternative — use this when you care about "is a
// whole number" rather than about signedness, so a uint64 above int64max is
// not mistaken for a non-integer.
bool isIntegral() const { return isInt() || isUInt(); }
bool asBool() const { return std::get<bool>(value); }
int64_t asInt() const { return std::get<int64_t>(value); }
uint64_t asUInt() const { return std::get<uint64_t>(value); }
double asDouble() const { return std::get<double>(value); }
const std::string& asString() const { return std::get<std::string>(value); }
const RpcBytes& asBytes() const { return std::get<RpcBytes>(value); }
const RpcList& asList() const { return std::get<RpcList>(value); }
const RpcMap& asMap() const { return std::get<RpcMap>(value); }
bool operator==(const RpcValue& other) const { return value == other.value; }
bool operator!=(const RpcValue& other) const { return !(*this == other); }
};
// ── RpcMap out-of-line methods (need complete RpcValue) ────────────────────
inline void RpcMap::emplace(std::string key, RpcValue val) {
entries.emplace_back(std::move(key), std::move(val));
}
inline const RpcValue* RpcMap::find(const std::string& key) const {
for (const auto& kv : entries) if (kv.first == key) return &kv.second;
return nullptr;
}
inline const RpcValue& RpcMap::at(const std::string& key) const {
const RpcValue* v = find(key);
if (!v) throw std::out_of_range("RpcMap::at: key not found: " + key);
return *v;
}
inline bool RpcMap::operator==(const RpcMap& other) const {
return entries == other.entries;
}
} // namespace logos::plain
#endif // LOGOS_PLAIN_RPC_VALUE_H