mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
lp_result_cb has always been documented as carrying an outcome —
"ok != 0 -> `json` is the result JSON value; ok == 0 -> `json` is the
canonical error object" — and the synchronous twin lp_invoke has always
honoured it (LP_ERR_UNAVAILABLE + out_error_json). lp_invoke_async did
not: it subscribed with the VALUE-ONLY invokeRemoteMethodAsync overload
and called back `cb(1, json, user_data)` with ok hard-coded to 1, so a
call to a module that cannot be acquired reached the callback as a
SUCCESS carrying a default-constructed value.
The fix is to pass a TWO-argument lambda, which is invocable only as
LogosAPIClient::AsyncResultErrorCallback and so binds to the
CallError-aware overload that already exists next to the value-only one.
The failure is then rendered with the same makeErrorJson the sync path
uses, so both entry points report the same event in the same shape.
The ABI is unchanged. lp_result_cb's (ok, json, user_data) signature
already reserves ok == 0 for exactly this; nothing had to grow a new
entry point, and every in-tree consumer already branches on `ok`
(logos-rust-sdk's async_call_trampoline even parses `message` out of the
canonical error object — code written against a contract the
implementation never kept).
Regression test: a matched pair over a REAL transport (plain TCP), not
the mock.
FAILING async call -> ok=0 {"code":"object_unavailable", ...}
SUCCEEDING async call -> ok=1 7
The first fails on the unfixed tree (ok=1, json "null"); the second
passes on both, so an over-eager "report failure everywhere" fix cannot
sneak through.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
211 lines
7.7 KiB
C++
211 lines
7.7 KiB
C++
// The ASYNC half of the call-error channel.
|
|
//
|
|
// lp_result_cb has always been documented as carrying an outcome —
|
|
// "ok != 0 → `json` is the result JSON value; ok == 0 → `json` is the
|
|
// canonical error object"
|
|
// — and lp_invoke, its synchronous twin, has always honoured that shape via
|
|
// LP_ERR_UNAVAILABLE + out_error_json. lp_invoke_async did not: it subscribed
|
|
// with the VALUE-ONLY invokeRemoteMethodAsync overload and called back
|
|
// `cb(1, json, user_data)` with ok hard-coded, so a call to a module that
|
|
// cannot be acquired reached the callback as a SUCCESS carrying a
|
|
// default-constructed value. An error channel that always says "fine" is worse
|
|
// than none — it is why logos-cpp-sdk#132 shipped `fooAsyncResult` on the Qt
|
|
// surface but deliberately withheld it on the Qt-free (Lp) one.
|
|
//
|
|
// These two tests are a matched pair and only mean something together:
|
|
//
|
|
// 1. a genuinely failing call must reach the callback with ok == 0 and the
|
|
// canonical {code, message, origin} object — the case that used to lie;
|
|
// 2. a genuinely succeeding call must still reach it with ok == 1 and its
|
|
// value — the control that an over-eager "report failure everywhere" fix
|
|
// would break.
|
|
//
|
|
// Both run against a REAL transport (plain TCP), not the mock: (1) dials a
|
|
// port nothing listens on, (2) dials a live in-process PlainTransportHost
|
|
// publishing a real provider through ModuleProxy.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "logos_protocol.h"
|
|
|
|
#include "logos_provider_interface.h"
|
|
#include "logos_transport_config.h"
|
|
#include "module_proxy.h"
|
|
|
|
#include "plain_transport_host.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QElapsedTimer>
|
|
#include <QJsonArray>
|
|
#include <QString>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
using namespace logos::plain;
|
|
|
|
namespace {
|
|
|
|
// Minimal live provider: compute() -> 7.
|
|
class EchoProvider : public LogosProviderObject {
|
|
public:
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override
|
|
{
|
|
if (method == QLatin1String("compute")) return QVariant(7);
|
|
if (method == QLatin1String("echo")) return args.value(0);
|
|
return QVariant();
|
|
}
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
bool informModuleToken(const QString&, const QString&) override { return true; }
|
|
void setEventListener(EventCallback) override {}
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("echo"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
};
|
|
|
|
QCoreApplication* ensureApp()
|
|
{
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
// What lp_result_cb handed us.
|
|
struct Capture {
|
|
std::atomic<bool> fired{false};
|
|
int ok = -1;
|
|
std::string json;
|
|
};
|
|
|
|
void captureCb(int ok, const char* json, void* user_data)
|
|
{
|
|
auto* c = static_cast<Capture*>(user_data);
|
|
c->ok = ok;
|
|
c->json = json ? json : "";
|
|
c->fired = true;
|
|
}
|
|
|
|
// Pump the owner thread's loop until the callback lands (or we give up).
|
|
bool pumpUntilFired(Capture& c, int budgetMs)
|
|
{
|
|
QElapsedTimer timer;
|
|
timer.start();
|
|
while (!c.fired && timer.elapsed() < budgetMs)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
|
|
return c.fired;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class LpInvokeAsyncErrorTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override { ensureApp(); }
|
|
};
|
|
|
|
// ── 1. The case that used to lie ────────────────────────────────────────────
|
|
//
|
|
// Plain TCP to a port nothing listens on: the object cannot be acquired, which
|
|
// the protocol reports as "object_unavailable" — exactly what lp_invoke puts in
|
|
// out_error_json for the same call (test_call_error.cpp pins that half).
|
|
// Pre-fix this delivered ok=1 with json "null".
|
|
TEST_F(LpInvokeAsyncErrorTest, UnreachableTargetDeliversTheErrorToTheCallback)
|
|
{
|
|
const char* deadTarget =
|
|
"{\"protocol\":\"tcp\",\"host\":\"127.0.0.1\",\"port\":9}";
|
|
|
|
// Pre-save a token so the capability requestModule handshake is skipped —
|
|
// this exercises transport acquisition only.
|
|
ASSERT_EQ(lp_token_save("async_missing_module", "test-token"), LP_OK);
|
|
|
|
lp_client* client = lp_client_create("async_missing_module", "origin",
|
|
deadTarget, deadTarget);
|
|
ASSERT_NE(client, nullptr);
|
|
|
|
Capture c;
|
|
ASSERT_EQ(lp_invoke_async(client, "anyMethod", "[1,2]", 1500,
|
|
&captureCb, &c),
|
|
LP_OK);
|
|
ASSERT_TRUE(pumpUntilFired(c, 10000)) << "async callback never fired";
|
|
|
|
std::cout << " FAILING async call -> ok=" << c.ok
|
|
<< " json=" << c.json << std::endl;
|
|
|
|
// The point of the whole fix.
|
|
EXPECT_EQ(c.ok, 0) << "a failed async call reported success";
|
|
|
|
nlohmann::json e = nlohmann::json::parse(c.json, nullptr, false);
|
|
ASSERT_TRUE(e.is_object()) << "ok==0 must carry the canonical error object";
|
|
EXPECT_EQ(e.value("code", std::string{}), "object_unavailable");
|
|
EXPECT_EQ(e.value("origin", std::string{}), "async_missing_module");
|
|
EXPECT_FALSE(e.value("message", std::string{}).empty());
|
|
|
|
lp_client_destroy(client);
|
|
}
|
|
|
|
// ── 2. The control ─────────────────────────────────────────────────────────
|
|
//
|
|
// A live provider over the same real transport must still report ok=1 with its
|
|
// value. Without this, "always report failure" would pass test 1.
|
|
TEST_F(LpInvokeAsyncErrorTest, LiveTargetStillReportsOkWithItsValue)
|
|
{
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0; // ephemeral
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
ASSERT_TRUE(host->start());
|
|
|
|
EchoProvider provider;
|
|
ModuleProxy proxy(&provider);
|
|
// The token the consumer will present (pre-saved below), so ModuleProxy
|
|
// authorizes the call without a capability_module handshake.
|
|
ASSERT_TRUE(proxy.saveToken(QStringLiteral("origin"),
|
|
QStringLiteral("live-token")));
|
|
ASSERT_TRUE(host->publishObject("async_live_module", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
ASSERT_TRUE(endpoint.startsWith("tcp://"));
|
|
const uint16_t port =
|
|
endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
ASSERT_NE(port, 0);
|
|
|
|
const std::string liveTarget =
|
|
"{\"protocol\":\"tcp\",\"host\":\"127.0.0.1\",\"port\":"
|
|
+ std::to_string(port) + "}";
|
|
|
|
ASSERT_EQ(lp_token_save("async_live_module", "live-token"), LP_OK);
|
|
|
|
lp_client* client = lp_client_create("async_live_module", "origin",
|
|
liveTarget.c_str(),
|
|
liveTarget.c_str());
|
|
ASSERT_NE(client, nullptr);
|
|
|
|
Capture c;
|
|
ASSERT_EQ(lp_invoke_async(client, "compute", "[]", 5000, &captureCb, &c),
|
|
LP_OK);
|
|
ASSERT_TRUE(pumpUntilFired(c, 10000)) << "async callback never fired";
|
|
|
|
std::cout << " SUCCEEDING async call -> ok=" << c.ok
|
|
<< " json=" << c.json << std::endl;
|
|
|
|
EXPECT_EQ(c.ok, 1) << "a successful async call reported failure";
|
|
nlohmann::json v = nlohmann::json::parse(c.json, nullptr, false);
|
|
ASSERT_TRUE(v.is_number());
|
|
EXPECT_DOUBLE_EQ(v.get<double>(), 7.0);
|
|
|
|
lp_client_destroy(client);
|
|
// Let the deferred client teardown run before the host goes away.
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
|
|
host.reset();
|
|
}
|