Files
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

229 lines
7.7 KiB
C++

#include <gtest/gtest.h>
#include "json_codec.h"
#include "cbor_codec.h"
#include "qvariant_rpc_value.h"
#include <QJsonValue>
#include <QMetaType>
#include <QVariant>
#include <cstdint>
#include <limits>
// ---------------------------------------------------------------------------
// uint64 across the plain (tcp / tcp_ssl) wire.
//
// RpcValue's variant had no unsigned alternative, so every uint64 above
// int64max was squeezed through int64_t and arrived as -1 — independently in
// both directions:
//
// 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.
//
// This was NOT a wire-format limitation. 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 a uint64_t. Only RpcValue *payloads* could not
// represent it.
//
// The plain tier had no integer test outside int32 range before this file:
// test_json_codec and test_cbor_codec used 42 and 3.
// ---------------------------------------------------------------------------
using namespace logos::plain;
namespace {
constexpr uint64_t kUint64Max = std::numeric_limits<uint64_t>::max();
constexpr uint64_t kInt64Max = static_cast<uint64_t>(std::numeric_limits<int64_t>::max());
AnyMessage roundtrip(IWireCodec& codec, const AnyMessage& msg)
{
auto bytes = codec.encode(msg);
return codec.decode(messageTypeOf(msg), bytes.data(), bytes.size());
}
EventMessage eventCarrying(std::vector<RpcValue> data)
{
EventMessage e;
e.object = "test_fullapi";
e.eventName = "uintEvent";
e.data = std::move(data);
return e;
}
} // namespace
// --- The representation rule ----------------------------------------------
// The unsigned alternative is used ONLY where int64_t loses information.
// Anything broader would change the representation of every non-negative
// integer already crossing this wire — and std::variant equality compares the
// alternative index, so it would also break comparisons against int64-built
// values, to fix nothing.
TEST(PlainUint64, MakeIntegerUsesSignedAlternativeWhenItFits)
{
EXPECT_TRUE(RpcValue::makeInteger(0).isInt());
EXPECT_TRUE(RpcValue::makeInteger(42).isInt());
EXPECT_TRUE(RpcValue::makeInteger(kInt64Max).isInt());
EXPECT_EQ(RpcValue::makeInteger(kInt64Max).asInt(),
std::numeric_limits<int64_t>::max());
// Unchanged representation means unchanged equality.
EXPECT_EQ(RpcValue::makeInteger(42), RpcValue{int64_t(42)});
}
TEST(PlainUint64, MakeIntegerUsesUnsignedAlternativeOnlyPastInt64Max)
{
EXPECT_TRUE(RpcValue::makeInteger(kInt64Max + 1).isUInt());
EXPECT_TRUE(RpcValue::makeInteger(kUint64Max).isUInt());
EXPECT_EQ(RpcValue::makeInteger(kUint64Max).asUInt(), kUint64Max);
}
TEST(PlainUint64, IsIntegralCoversBothAlternatives)
{
EXPECT_TRUE(RpcValue::makeInteger(42).isIntegral());
EXPECT_TRUE(RpcValue::makeInteger(kUint64Max).isIntegral());
EXPECT_FALSE(RpcValue{3.5}.isIntegral());
EXPECT_FALSE(RpcValue{std::string("7")}.isIntegral());
}
// --- JSON codec ------------------------------------------------------------
TEST(PlainUint64, JsonCodecRoundTripsUint64Max)
{
JsonCodec codec;
const AnyMessage out = roundtrip(
codec, eventCarrying({RpcValue::makeInteger(kUint64Max)}));
const auto* evt = std::get_if<EventMessage>(&out);
ASSERT_NE(evt, nullptr);
ASSERT_EQ(evt->data.size(), 1u);
ASSERT_TRUE(evt->data[0].isUInt()) << "decoded into the wrong alternative";
EXPECT_EQ(evt->data[0].asUInt(), kUint64Max);
}
TEST(PlainUint64, JsonCodecRoundTripsUint64NestedInContainers)
{
RpcMap m;
m.emplace("n", RpcValue::makeInteger(kUint64Max));
RpcList l;
l.items.push_back(RpcValue::makeInteger(kInt64Max + 1));
JsonCodec codec;
const AnyMessage out = roundtrip(
codec, eventCarrying({RpcValue{std::move(m)}, RpcValue{std::move(l)}}));
const auto* evt = std::get_if<EventMessage>(&out);
ASSERT_NE(evt, nullptr);
ASSERT_EQ(evt->data.size(), 2u);
EXPECT_EQ(evt->data[0].asMap().at("n").asUInt(), kUint64Max);
EXPECT_EQ(evt->data[1].asList().items.at(0).asUInt(), kInt64Max + 1);
}
TEST(PlainUint64, JsonCodecKeepsNegativeIntegersSigned)
{
JsonCodec codec;
const AnyMessage out = roundtrip(
codec, eventCarrying({RpcValue{std::numeric_limits<int64_t>::min()}}));
const auto* evt = std::get_if<EventMessage>(&out);
ASSERT_NE(evt, nullptr);
ASSERT_TRUE(evt->data[0].isInt());
EXPECT_EQ(evt->data[0].asInt(), std::numeric_limits<int64_t>::min());
}
// --- CBOR codec ------------------------------------------------------------
TEST(PlainUint64, CborCodecRoundTripsUint64Max)
{
CborCodec codec;
const AnyMessage out = roundtrip(
codec, eventCarrying({RpcValue::makeInteger(kUint64Max)}));
const auto* evt = std::get_if<EventMessage>(&out);
ASSERT_NE(evt, nullptr);
ASSERT_EQ(evt->data.size(), 1u);
ASSERT_TRUE(evt->data[0].isUInt());
EXPECT_EQ(evt->data[0].asUInt(), kUint64Max);
}
TEST(PlainUint64, CborCodecRoundTripsUint64NestedInContainers)
{
RpcMap m;
m.emplace("n", RpcValue::makeInteger(kUint64Max));
CborCodec codec;
const AnyMessage out = roundtrip(codec, eventCarrying({RpcValue{std::move(m)}}));
const auto* evt = std::get_if<EventMessage>(&out);
ASSERT_NE(evt, nullptr);
EXPECT_EQ(evt->data[0].asMap().at("n").asUInt(), kUint64Max);
}
// --- The Qt boundary, both directions --------------------------------------
TEST(PlainUint64, QVariantToRpcValuePreservesUint64)
{
const QVariant v = QVariant::fromValue<qulonglong>(kUint64Max);
const RpcValue r = qvariantToRpcValue(v);
ASSERT_TRUE(r.isUInt()) << "wrapped to int64 again";
EXPECT_EQ(r.asUInt(), kUint64Max);
}
TEST(PlainUint64, RpcValueToQVariantPreservesUint64)
{
const QVariant v = rpcValueToQVariant(RpcValue::makeInteger(kUint64Max));
EXPECT_EQ(v.typeId(), QMetaType::ULongLong) << "got " << v.typeName();
EXPECT_EQ(v.toULongLong(), kUint64Max);
}
TEST(PlainUint64, QtBoundaryRoundTripIsExact)
{
const QVariant in = QVariant::fromValue<qulonglong>(kUint64Max);
const QVariant out = rpcValueToQVariant(qvariantToRpcValue(in));
EXPECT_EQ(out.toULongLong(), kUint64Max);
}
// A uint that fits int64 keeps crossing as signed, exactly as before. Pinned so
// the narrow rule stays visible rather than assumed.
TEST(PlainUint64, SmallUnsignedStillCrossesAsSigned)
{
const QVariant in = QVariant::fromValue<qulonglong>(42);
const RpcValue r = qvariantToRpcValue(in);
EXPECT_TRUE(r.isInt());
EXPECT_EQ(rpcValueToQVariant(r).typeId(), QMetaType::LongLong);
}
// --- The off-by-one in the QJsonValue::Double guard ------------------------
// double(int64max) rounds UP to exactly 2^63, so `d <= double(int64max)` used to
// admit 2^63 and then run int64_t(d) out of range — undefined behaviour,
// saturating on arm64 and INT64_MIN on x86-64.
TEST(PlainUint64, DoubleAtTwoPow63DoesNotEnterTheIntegerBranch)
{
const QVariant v = QVariant(QJsonValue(9223372036854775808.0)); // 2^63
const RpcValue r = qvariantToRpcValue(v);
EXPECT_FALSE(r.isInt()) << "2^63 is not representable as int64_t";
ASSERT_TRUE(r.isDouble());
EXPECT_DOUBLE_EQ(r.asDouble(), 9223372036854775808.0);
}
TEST(PlainUint64, DoubleAtInt64MinStillTakesTheIntegerBranch)
{
const QVariant v = QVariant(QJsonValue(-9223372036854775808.0)); // -2^63, exact
const RpcValue r = qvariantToRpcValue(v);
ASSERT_TRUE(r.isInt());
EXPECT_EQ(r.asInt(), std::numeric_limits<int64_t>::min());
}