Files
logos-protocol/tests/protocol/test_lp_invoke_async_error.cpp

218 lines
8.2 KiB
C++
Raw Permalink Normal View History

// 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();
// Drain any QueuedConnection onCall invokes that PlainTransportHost
// posted to `proxy` before stop() returned. Without this, those
// slots can fire after `proxy` is destroyed at scope exit — a UAF
// that corrupts the heap and segfaults the *next* test (seen on
// macOS as CallErrorAfterAcquireTest.AsyncSuccessStillReportsTheValue
// crashing with no output of its own).
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}