mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
* feat: per-module concurrent dispatch (concurrency:"multi") — zero ABI change
A "multi" module serves calls concurrently behind the ORDINARY callMethod — no
new provider/host vtable method, so LogosProviderObject's ABI is byte-identical
to before and an old host/daemon loads and forwards a multi module unmodified.
Mechanism: a multi module's generated glue returns a pending sentinel
({"__logos_pending_call__": callId}) from callMethod and pushes the real result
back later as a __logos_call_complete__ event keyed by callId, over the existing
event channel. The consumer transport detects the sentinel and awaits the
completion transparently, so generated clients are unchanged.
- logos_async_dispatch.h: shared wire constants + the contract.
- remote_transport.cpp (QtRO) / plain_logos_object.{h,cpp} (plain): consumer
sentinel detection + await keyed by callId. The host is a pure forwarder.
- logos_protocol.h + nix/default.nix: protocol 0.2.0 (additive minor; same MAJOR
stays compatible, so an old host accepts a 0.2 "multi" module).
- rpc_server.cpp: fix a teardown self-deadlock (stop() held m_mu while invoking a
per-connection error handler that re-locks m_mu) that the new in-process
subscription path exposed.
- tests/protocol/test_concurrent_dispatch.cpp: proves a multi provider overlaps
two concurrent calls (peak 2) while single serializes (peak 1), over the plain
transport, with the host unchanged from master.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: coalesce concurrent async requestModule handshakes (+ async fan-out test)
A driver that fans out N async calls to an un-tokened target before any
completes used to fire N separate requestModule handshakes. Each mints a
distinct capability token and informs the target, and the later inform
OVERWRITES the earlier token there (the target stores one token per caller),
so the already-dispatched calls carried a superseded token and the target
rejected them as unauthorized ("auth token not recognized"). The sync path
never hit this — it blocks per call, so handshakes never overlap.
Coalesce in LogosAPIClient::invokeRemoteMethodAsync: the first async call to
an un-tokened target starts ONE handshake; concurrent calls to the same
target queue behind it and all drain with the single minted token when it
resolves. m_pendingHandshakes is touched only on the owner thread, so no lock
(appended last per the class's ABI note). This is what lets a concurrency:
"multi" worker actually run a single-threaded driver's fan-out concurrently —
otherwise the fanned-out calls are rejected before reaching dispatch.
Also add MultiProviderOverlapsAsync / SingleProviderSerializesAsync to the
concurrent-dispatch gtest: they fire N concurrent callMethodAsync() calls (the
fan-out pattern over the async consumer path, which the sync tests don't
exercise) and assert peak overlap 4 for "multi", 1 for "single".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
9.0 KiB
C++
248 lines
9.0 KiB
C++
// Proves per-module concurrent dispatch (concurrency:"multi") with NO provider
|
|
// ABI change — concurrency is owned by the module, behind the ordinary
|
|
// callMethod, exactly as a generated "multi" glue does it:
|
|
//
|
|
// - multi : callMethod does NOT block. It hands slow() to a worker and
|
|
// returns a PENDING SENTINEL ({pendingCallKey: callId}) at once, so
|
|
// the dispatch thread is free to take the next call. The worker
|
|
// records peak overlap, then pushes the result as a
|
|
// callCompleteEvent([callId, result]) over the event listener.
|
|
// The plain consumer detects the sentinel and awaits the
|
|
// completion. ⇒ overlap (peak 2)
|
|
// - single : callMethod runs slow() inline, blocking the dispatch thread until
|
|
// it returns the result directly (no sentinel). ⇒ serial (peak 1)
|
|
//
|
|
// The host (ModuleProxy / PlainTransportHost) is unchanged from master: it just
|
|
// returns whatever callMethod returned and forwards whatever events the provider
|
|
// emits. The callers run on their own threads (blocking in the consumer) while
|
|
// the main thread pumps the event loop so the worker's completion event — which
|
|
// ModuleProxy marshals onto the source thread — is delivered.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "logos_async_dispatch.h"
|
|
#include "logos_object.h"
|
|
#include "logos_provider_interface.h"
|
|
#include "logos_transport_config.h"
|
|
#include "module_proxy.h"
|
|
|
|
#include "plain_transport_connection.h"
|
|
#include "plain_transport_host.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QJsonArray>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QVariantMap>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <memory>
|
|
#include <thread>
|
|
|
|
using namespace logos::plain;
|
|
|
|
namespace {
|
|
|
|
// Records the peak number of slow() handlers running at the same time.
|
|
class SlowProvider : public LogosProviderObject {
|
|
public:
|
|
explicit SlowProvider(bool multi) : m_multi(multi) {}
|
|
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override
|
|
{
|
|
if (method != QLatin1String("slow")) return QVariant();
|
|
const int ms = args.value(0).toInt();
|
|
|
|
// single: run inline → blocks the dispatch thread → calls serialize.
|
|
if (!m_multi) return runSlow(ms);
|
|
|
|
// multi: defer. Spawn a worker, return a pending sentinel immediately so
|
|
// the dispatch thread is freed; the worker emits the completion event.
|
|
const QString callId = QStringLiteral("lc-%1").arg(
|
|
static_cast<qulonglong>(m_callCounter.fetch_add(1, std::memory_order_relaxed)));
|
|
std::thread([this, callId, ms]() {
|
|
const int result = runSlow(ms);
|
|
if (m_eventCb)
|
|
m_eventCb(logos::callCompleteEvent(), QVariantList{ callId, QVariant(result) });
|
|
}).detach();
|
|
QVariantMap pending;
|
|
pending[logos::pendingCallKey()] = callId;
|
|
return pending;
|
|
}
|
|
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
bool informModuleToken(const QString&, const QString&) override { return true; }
|
|
void setEventListener(EventCallback cb) override { m_eventCb = std::move(cb); }
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("slow"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
|
|
int maxConcurrent() const { return m_maxSeen.load(); }
|
|
|
|
private:
|
|
int runSlow(int ms)
|
|
{
|
|
const int now = ++m_inFlight;
|
|
int prev = m_maxSeen.load();
|
|
while (now > prev && !m_maxSeen.compare_exchange_weak(prev, now)) { /* retry */ }
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
|
--m_inFlight;
|
|
return ms;
|
|
}
|
|
|
|
bool m_multi;
|
|
EventCallback m_eventCb;
|
|
std::atomic<std::uint64_t> m_callCounter{0};
|
|
std::atomic<int> m_inFlight{0};
|
|
std::atomic<int> m_maxSeen{0};
|
|
};
|
|
|
|
QCoreApplication* ensureApp()
|
|
{
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class ConcurrentDispatchTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override { ensureApp(); }
|
|
|
|
// Fire two concurrent slow() calls and return the peak observed overlap.
|
|
int peakOverlap(bool multi)
|
|
{
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0;
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
EXPECT_TRUE(host->start());
|
|
|
|
SlowProvider provider(multi);
|
|
ModuleProxy proxy(&provider);
|
|
proxy.saveToken(QStringLiteral("core"), QStringLiteral("tok"));
|
|
EXPECT_TRUE(host->publishObject("slow_mod", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
const uint16_t port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
|
|
LogosTransportConfig ccfg = cfg;
|
|
ccfg.port = port;
|
|
auto conn = std::make_unique<PlainTransportConnection>(ccfg);
|
|
EXPECT_TRUE(conn->connectToHost());
|
|
|
|
LogosObject* obj = conn->requestObject("slow_mod", 2000);
|
|
EXPECT_NE(obj, nullptr);
|
|
if (!obj) return -1;
|
|
|
|
std::atomic<int> done{0};
|
|
auto caller = [&]() {
|
|
obj->callMethod(QStringLiteral("tok"), QStringLiteral("slow"),
|
|
QVariantList{ 300 }, 5000);
|
|
done.fetch_add(1);
|
|
};
|
|
std::thread t1(caller), t2(caller);
|
|
|
|
// Pump the host event loop until both callers return (or a generous cap).
|
|
for (int i = 0; i < 800 && done.load() < 2; ++i) {
|
|
QCoreApplication::processEvents();
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
t1.join();
|
|
t2.join();
|
|
EXPECT_EQ(done.load(), 2);
|
|
|
|
const int peak = provider.maxConcurrent();
|
|
obj->release();
|
|
host.reset();
|
|
return peak;
|
|
}
|
|
|
|
// Fire N concurrent calls from a SINGLE thread via the ASYNC consumer path
|
|
// (callMethodAsync) — the fan-out pattern a real driver module uses through
|
|
// the generated work_async() client: it fires N non-blocking calls without
|
|
// waiting between them, so all N are in flight before any completes. This
|
|
// exercises PlainLogosObject::callMethodAsync, which resolves a "multi"
|
|
// provider's pending sentinel on its waiter thread (the sync peakOverlap
|
|
// above only covers the blocking callMethod path). Returns the peak overlap.
|
|
int peakOverlapAsync(bool multi, int n)
|
|
{
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0;
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
EXPECT_TRUE(host->start());
|
|
|
|
SlowProvider provider(multi);
|
|
ModuleProxy proxy(&provider);
|
|
proxy.saveToken(QStringLiteral("core"), QStringLiteral("tok"));
|
|
EXPECT_TRUE(host->publishObject("slow_mod", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
const uint16_t port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
|
|
LogosTransportConfig ccfg = cfg;
|
|
ccfg.port = port;
|
|
auto conn = std::make_unique<PlainTransportConnection>(ccfg);
|
|
EXPECT_TRUE(conn->connectToHost());
|
|
|
|
LogosObject* obj = conn->requestObject("slow_mod", 2000);
|
|
EXPECT_NE(obj, nullptr);
|
|
if (!obj) return -1;
|
|
|
|
// Fire n async calls back-to-back from this one thread. None blocks, so
|
|
// all n reach the provider before any returns — a "multi" provider runs
|
|
// them at once; a "single" one serializes them.
|
|
std::atomic<int> done{0};
|
|
for (int i = 0; i < n; ++i) {
|
|
obj->callMethodAsync(QStringLiteral("tok"), QStringLiteral("slow"),
|
|
QVariantList{ 300 }, 5000,
|
|
[&done](const QVariant&) { done.fetch_add(1); });
|
|
}
|
|
|
|
// Pump the host event loop until every async callback has fired.
|
|
for (int i = 0; i < 1000 && done.load() < n; ++i) {
|
|
QCoreApplication::processEvents();
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
EXPECT_EQ(done.load(), n);
|
|
|
|
const int peak = provider.maxConcurrent();
|
|
obj->release();
|
|
host.reset();
|
|
return peak;
|
|
}
|
|
};
|
|
|
|
TEST_F(ConcurrentDispatchTest, MultiProviderOverlaps)
|
|
{
|
|
EXPECT_EQ(peakOverlap(/*multi=*/true), 2);
|
|
}
|
|
|
|
TEST_F(ConcurrentDispatchTest, SingleProviderSerializes)
|
|
{
|
|
EXPECT_EQ(peakOverlap(/*multi=*/false), 1);
|
|
}
|
|
|
|
TEST_F(ConcurrentDispatchTest, MultiProviderOverlapsAsync)
|
|
{
|
|
// The fan-out pattern over the async consumer path: one thread fires 4
|
|
// non-blocking calls; the "multi" provider runs all 4 concurrently.
|
|
EXPECT_EQ(peakOverlapAsync(/*multi=*/true, /*n=*/4), 4);
|
|
}
|
|
|
|
TEST_F(ConcurrentDispatchTest, SingleProviderSerializesAsync)
|
|
{
|
|
// The same fan-out at a "single" provider serializes — peak 1.
|
|
EXPECT_EQ(peakOverlapAsync(/*multi=*/false, /*n=*/4), 1);
|
|
}
|