mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
* fix(plain): give every handle its own event subscription on the shared connection
RpcConnection::m_eventCallbacks was keyed by (object, eventName) and ASSIGNED.
One RpcConnection is shared by every PlainLogosObject a PlainTransportConnection
hands out, and requestObject() mints a fresh handle per acquire, so the second
handle to subscribe to the same event on the same module silently took the first
one's channel — including the deferred ("multi") completion channel every handle
subscribes to on its first call.
No concurrency is needed to reach it. Measured on cf1b9b0: handle A's deferred
call answers 8/8 in 0 ms alone, and 0/8 in 1505 ms (timeout, INVALID) once a
second handle merely exists. Identical on master.
THE WIRE DOES NOT MOVE. Subscribe/Unsubscribe still carry (object, event) and
nothing else. The host keeps ONE sink per (object, event, connection) — the right
model, since every sink for a connection is the same "write this frame back down
that socket" — and the CONSUMER, the only end that knows how many of its own
handles want an event, does the demultiplexing: a list of registrations per key,
a subscription id to withdraw one of them, and an Unsubscribe frame only when the
last local registration for the pair is gone, because that frame is
connection-wide.
Also here, because the consumer-side fan-out makes it observable as a doubled
delivery: PlainTransportHost::fanOutEvent sent a connection subscribed both by
name and by wildcard two copies of the same event. One copy per connection now.
And the parked-completion staging area is gated. It exists for one ordering — a
"multi" worker that finishes before the sentinel it answers has been written, so
the completion event overtakes its own Result — and a completion arriving that
early is not attributable to a handle at all. Every handle now sees every
completion on the object, so parking is allowed only while this handle has a call
outstanding, the map is emptied the instant none is, and it is capped at 512 with
oldest-first eviction. Pre-fix the same leak existed with one victim instead of N:
the handle that had stolen the channel parked every other handle's completions and
never claimed one.
Detectors: nine tests in test_plain_event_sub_sharing.cpp, all validated RED on
cf1b9b0 and on master and green here, including both mixed-version directions (a
new consumer against a verbatim pre-fix host, and an old consumer's frame sequence
against the new host over a raw socket).
── REBASED ONTO #50, THE FIRST REPLAY IN THIS STACK WITH A REAL PRODUCTION
CONFLICT (and then onto #51, which lands cleanly) ──────────────────────────
the object (a live-reference count with deferred destruction) and to give async
callbacks somewhere to land in a Qt-free process (a never-destroyed
DeliveryService). Both of those and both of this commit's changes are kept; the
resolution picks no side anywhere.
* plain_logos_object.cpp, hunk 1 — #50 replaced postToQtEventLoop's comment
header with DeliveryService; this commit inserted SyncCallScope immediately
above it. The insert point survives, the replaced header does not:
SyncCallScope now sits above #50's DeliveryService block.
* plain_logos_object.cpp, hunk 2 — #50 split disconnectEvents() into a guarded
entry point plus an unguarded disconnectEventsImpl() (release() and the
destructor must not take a reference to what they are destroying); this
commit changed that body's local from (name, callback) pairs to subscription
ids. Kept as #50's split with this commit's body. onEvent() auto-merged the
same way: #50's EntryGuard declared FIRST, then the m_mu-across-the-subscribe
body from here.
* test_sync_call_release_race.cpp — not a text conflict and not visible to git:
#50's StalledConnection double implements RpcConnectionBase, and this commit
changes that interface, so it became abstract and the file stopped compiling.
Its stub now returns a DISTINCT id per subscribe, so it cannot hide a bug
that withdraws the wrong registration.
* tests/protocol/CMakeLists.txt — both detector lists are additive and both are
in. The prose is NOT concatenated: an earlier replay on this stack had
grafted a duplicated fragment ("per-path test as evidence the gate is there.
Nor is") into the middle of a paragraph, and the sentence it belonged to is
now where it was meant to go, at the end of the "what is NOT on that list"
paragraph. #50's "the CAS is TWO gates" correction is untouched. #51's own
additions to the same file merged without a conflict on the second replay.
gives the five LiveHost fixtures a teardown that destroys the host on the thread
that emits into it, and the host in test_plain_event_sub_sharing.cpp is not that
shape — its ModuleProxy stays on the test thread, so there is no worker to race.
RE-VERIFIED BY RUNNING, on macOS arm64, Debug, all after the rebase:
* THIS FIX still fixes the bug on the NEW master. The ten
PlainEventSubSharingTest cases compile unmodified on 5be3a84 and nine go RED
there: handle A alone 8/8 answered at 0 ms avg, handle A once B exists 0/8 at
1503 ms avg with 8 timeouts, and 0/4 with 4 timeouts through the shipping
host. On this commit: 8/8 and 4/4, 0 ms avg, 0 timeouts. So neither #50 nor
#51 caused or masked this defect, and this branch is not a no-op.
* #50's FIX still works through this change. SyncCallReleaseRaceTest 8/8 green
(release() returns in 0 ms with a call parked, destroyed=1 only after the
caller leaves; 400 release-wake races, one destruction each), the whole no-Qt
binary 7/7 plus the after-main() probe (delivered=1, off the issuing thread,
exit 0), and its mechanisms are still in the source: m_liveRefs with the
EntryGuard reference-first/reference-last ordering, release() dropping the
owner's reference instead of `delete this`, DeliveryService `new`-ed with
`~DeliveryService() = delete` and a detached thread, and NO m_conn.reset() in
release().
* EXACTLY-ONCE, in the release-race shape rather than the per-path pins. Qt
vehicle: 20 rounds x 500 calls released mid-burst, answered-by-reply=1034,
cancelled-by-teardown=8966, 0 doubles, 0 dropped. No-Qt twin: 1130 / 8870, 0
and 0. Both resolvers live in both. Re-validated as a DETECTOR on this merged
tree by removing BOTH gates (claim()'s CAS and takeCallback()'s swap) in a
throwaway build: 19 doubles Qt, 13 doubles no-Qt, both FAIL, while the
per-path exactly-once tests stay green — which is the difference the
CMakeLists note describes.
* NO USE-AFTER-FREE. Guard Malloc clean over SyncCallReleaseRace, IoFold,
PlainObjectTeardown, PlainCompletionSubLifetime, PlainCancelPendingRace,
PlainWaiterReaping, PlainHostEventTeardown, PlainEventSubSharing and
PlainParkedCompletionGate (52/52), and over the whole no-Qt binary (7/7,
exit 0).
* TEARDOWN AND DEADLINES. release() with 32 calls in flight: 0 ms. release()
after 100 reaped calls: 0 ms. A 200 ms deadline fires at 200 ms while an
onEvent handler holds the io thread for 2001 ms; a 250 ms deadline fires at
250 ms with the io thread blocked forever; 12 idle 200 ms deadlines
min=200 median=200 max=202 ms.
* NO USER CALLBACK INLINE ON AN IO THREAD: no-Qt replies 300/300 with
on-caller-thread=0 and delivery-thread!=io-thread=1; cancellations 20/20 with
inside-release=0 and on-releasing-thread=0.
* Full suite 433/433 (was 421 on master; this adds 12), and `nix build .#tests`
433/433.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(plain): make the subscription detectors barrier-driven instead of timed
Every "wait a bit then count" in the new tests is now an in-band round trip: a
Methods request travels the same socket and is dispatched on the same strand as
everything written before it, so its reply proves the earlier Subscribe /
Unsubscribe frames have been applied AND that the Event frames the peer wrote
before answering have been dispatched. The audits are exact counts rather than
polls with a timeout, which is what they have to be on a loaded CI runner — a
short sleep there fails the test rather than skipping it.
Still 9 of 10 RED on cf1b9b0 and on master, with the teardown pin green; the set
now runs in 9s instead of 110s.
* test(plain): drop the poll helper the barrier replaced
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1122 lines
46 KiB
C++
1122 lines
46 KiB
C++
// One RpcConnection, many handles: a subscription must belong to the HANDLE
|
|
// that made it, not to the (object, event) pair.
|
|
//
|
|
// RpcConnection::m_eventCallbacks is keyed by (object, eventName) and ASSIGNED.
|
|
// A single RpcConnection is shared by every PlainLogosObject a
|
|
// PlainTransportConnection hands out — and requestObject() mints a fresh handle
|
|
// per acquire — so the SECOND handle to subscribe to the same event on the same
|
|
// module silently takes the first one's channel away. That includes the deferred
|
|
// ("multi") completion channel every handle subscribes to on its first call, so
|
|
// the visible symptom is a call that used to answer in single-digit milliseconds
|
|
// waiting out its entire timeout and returning nothing.
|
|
//
|
|
// NO CONCURRENCY IS REQUIRED. Two handles and three sequential calls reproduce
|
|
// it, which is why these tests are written straight-line rather than as races.
|
|
//
|
|
// VALIDATED AGAINST THE REAL PRE-FIX CODE, not against an imitation of it. Every
|
|
// PlainEventSubSharingTest below compiles UNMODIFIED on feat/plain-async-io-fold
|
|
// (#46, cf1b9b0) and on master (c1b0a0f) — neither causes or fixes this — and
|
|
// nine of the ten go RED on both. Numbers from aarch64-darwin, Qt 6.9.2, Debug,
|
|
// on cf1b9b0 (master is within noise of it):
|
|
//
|
|
// ASecondHandleDoesNotStealTheFirstsCompletionChannel
|
|
// handle A alone -> 8/8 answered, 0 ms avg
|
|
// handle A once B exists -> 0/8 answered, 1508 ms avg, 8 timeouts
|
|
// TheSameTheftThroughTheRealHost
|
|
// handle A once B exists -> 0/4 answered, 1501 ms avg, 4 timeouts
|
|
// EveryHandleGetsEveryEmission A=0 of 5, B=5, wildcard=5
|
|
// ANamedAndAWildcardSubscriberEachGetOneCopy
|
|
// named=10 and wildcard=10 for 5 emissions
|
|
// ReleasingOneHandleLeavesTheOtherSubscribed
|
|
// B=0 of 5 after A left; host sinks=0
|
|
// SubscriptionsSurviveChurn 214 of 480 audited slots lost a delivery
|
|
// ACompletionThatOvertakesItsOwnResultIsStillDelivered
|
|
// sync 4/6, async 2/4
|
|
// ANewConsumerAgainstAnOldHost A=0 of 4; the old host was unsubscribed
|
|
// while B was still subscribed
|
|
// AnOldConsumersFrameSequenceAgainstTheNewHost
|
|
// 2 copies of one emission on the wire
|
|
//
|
|
// TeardownRemovesEverySubscription PASSES pre-fix — a PIN, not a detector
|
|
// PlainParkedCompletionGateTest.* unit tests of code this change adds, so
|
|
// there is no pre-fix tree to run them on
|
|
//
|
|
// The last three lines are the ones worth reading twice: a green teardown test
|
|
// proves nothing about this bug. Only the tests that assert a SECOND live
|
|
// subscriber still has its channel detect it.
|
|
//
|
|
// Reproducing the check costs a worktree at cf1b9b0, a copy of this file into
|
|
// tests/protocol with everything from PlainParkedCompletionGateTest onward cut,
|
|
// and one line in that directory's CMakeLists.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "incoming_call_handler.h"
|
|
#include "json_codec.h"
|
|
#include "logos_async_dispatch.h"
|
|
#include "logos_call_error.h"
|
|
#include "logos_provider_interface.h"
|
|
#include "logos_transport_config.h"
|
|
#include "module_proxy.h"
|
|
#include "plain_logos_object.h"
|
|
#include "plain_transport_connection.h"
|
|
#include "plain_transport_host.h"
|
|
#include "qvariant_rpc_value.h"
|
|
#include "rpc_connection.h"
|
|
#include "rpc_framing.h"
|
|
#include "rpc_message.h"
|
|
|
|
#include <boost/asio/connect.hpp>
|
|
#include <boost/asio/executor_work_guard.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/ip/tcp.hpp>
|
|
#include <boost/asio/local/connect_pair.hpp>
|
|
#include <boost/asio/local/stream_protocol.hpp>
|
|
|
|
#include <QCoreApplication>
|
|
#include <QElapsedTimer>
|
|
#include <QJsonArray>
|
|
#include <QString>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QVariantMap>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <set>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
using namespace logos::plain;
|
|
|
|
namespace {
|
|
|
|
using LocalSocket = boost::asio::local::stream_protocol::socket;
|
|
using LocalConnection = RpcConnection<LocalSocket>;
|
|
|
|
// An io_context with its own thread, so both ends of the socketpair really run
|
|
// concurrently instead of taking turns on one worker.
|
|
class Io {
|
|
public:
|
|
Io() : m_guard(boost::asio::make_work_guard(m_ioc)),
|
|
m_thread([this] { m_ioc.run(); }) {}
|
|
~Io() { m_guard.reset(); m_ioc.stop(); if (m_thread.joinable()) m_thread.join(); }
|
|
boost::asio::io_context& ctx() { return m_ioc; }
|
|
private:
|
|
boost::asio::io_context m_ioc;
|
|
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> m_guard;
|
|
std::thread m_thread;
|
|
};
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// MirrorProvider — a provider whose sink bookkeeping is a LINE-FOR-LINE mirror of
|
|
// PlainTransportHost's: sinks live in sinksByEvent[eventName][connectionId],
|
|
// assigned on Subscribe and erased by (eventName, connectionId) on Unsubscribe,
|
|
// and fan-out visits the named set followed by the wildcard set.
|
|
//
|
|
// Deliberately a mirror rather than the real host: these tests have to be able to
|
|
// count the Subscribe / Unsubscribe frames that actually reached a provider, and
|
|
// to push an event at a connection that is no longer subscribed. The real host is
|
|
// exercised separately by TheSameTheftThroughTheRealHost, so a divergence between
|
|
// this mirror and the shipping host cannot make a broken build look green.
|
|
// -----------------------------------------------------------------------------
|
|
class MirrorProvider : public IncomingCallHandler {
|
|
public:
|
|
// Fan out the way the host did BEFORE the per-connection de-duplication:
|
|
// every matching sink, so a connection subscribed both by name and by
|
|
// wildcard gets two copies. Used to stand in for an OLD host.
|
|
void setLegacyFanOut(bool on) { m_dedupePerConnection = !on; }
|
|
// Push the completion event BEFORE the Result it answers, which is what a
|
|
// "multi" module whose worker finishes inside callMethod really does.
|
|
void setCompletionBeforeResult(bool on) { m_completionFirst = on; }
|
|
|
|
// Every call answers with a pending sentinel and pushes the completion right
|
|
// after it, over the completion channel — the limiting case of a "multi"
|
|
// module whose worker finishes at once, and a legal one.
|
|
void onCall(const CallMessage& req, CallReply reply) override
|
|
{
|
|
const std::string callId = "cid-" + std::to_string(req.id);
|
|
QVariantMap pending;
|
|
pending[logos::pendingCallKey()] = QString::fromStdString(callId);
|
|
|
|
ResultMessage res;
|
|
res.id = req.id;
|
|
res.ok = true;
|
|
res.value = qvariantToRpcValue(QVariant(pending));
|
|
|
|
const auto pushCompletion = [&] {
|
|
emitEvent(req.object, logos::callCompleteEvent().toStdString(),
|
|
QVariantList{ QString::fromStdString(callId),
|
|
QVariant(kDeferredAnswer) });
|
|
};
|
|
if (m_completionFirst) {
|
|
pushCompletion();
|
|
reply(std::move(res));
|
|
} else {
|
|
reply(std::move(res));
|
|
pushCompletion();
|
|
}
|
|
}
|
|
|
|
void onMethods(const MethodsMessage& req, MethodsReply reply) override
|
|
{
|
|
MethodsResultMessage res; res.id = req.id; res.ok = true;
|
|
reply(std::move(res));
|
|
}
|
|
|
|
void onSubscribe(const SubscribeMessage& req, EventSink sink,
|
|
const void* connectionId) override
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
++m_subscribeFrames;
|
|
m_sinks[req.eventName][connectionId] = std::move(sink);
|
|
}
|
|
|
|
void onUnsubscribe(const UnsubscribeMessage& req, const void* connectionId) override
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
++m_unsubscribeFrames;
|
|
auto it = m_sinks.find(req.eventName);
|
|
if (it == m_sinks.end()) return;
|
|
it->second.erase(connectionId);
|
|
if (it->second.empty()) m_sinks.erase(it);
|
|
}
|
|
|
|
void onConnectionClosed(const void* connectionId) override
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
for (auto it = m_sinks.begin(); it != m_sinks.end(); ) {
|
|
it->second.erase(connectionId);
|
|
if (it->second.empty()) it = m_sinks.erase(it);
|
|
else ++it;
|
|
}
|
|
}
|
|
|
|
void onToken(const TokenMessage&) override {}
|
|
|
|
// Same walk as PlainTransportHost::fanOutEvent, de-duplication included: one
|
|
// copy per CONNECTION, however many of its sinks match. A connection
|
|
// subscribed both by name and by wildcard is one subscriber as far as the
|
|
// wire is concerned. ANamedAndAWildcardSubscriberEachGetOneCopy pins that on
|
|
// the real host rather than on this mirror.
|
|
void emitEvent(const std::string& object, const std::string& event,
|
|
const QVariantList& data)
|
|
{
|
|
std::vector<EventSink> sinks;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
std::set<const void*> seen;
|
|
for (const auto& which : { event, std::string{} }) {
|
|
auto it = m_sinks.find(which);
|
|
if (it == m_sinks.end()) continue;
|
|
for (auto& [id, sink] : it->second)
|
|
if (!m_dedupePerConnection || seen.insert(id).second)
|
|
sinks.push_back(sink);
|
|
if (event.empty()) break; // named == wildcard, don't visit twice
|
|
}
|
|
}
|
|
for (auto& sink : sinks) {
|
|
EventMessage evt;
|
|
evt.object = object;
|
|
evt.eventName = event;
|
|
evt.data = qvariantListToRpcList(data);
|
|
try { sink(std::move(evt)); } catch (...) {}
|
|
}
|
|
}
|
|
|
|
int sinkCount() const
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
int n = 0;
|
|
for (const auto& [event, byConn] : m_sinks) n += static_cast<int>(byConn.size());
|
|
return n;
|
|
}
|
|
|
|
int subscribeFrames() const
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu); return m_subscribeFrames;
|
|
}
|
|
int unsubscribeFrames() const
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu); return m_unsubscribeFrames;
|
|
}
|
|
|
|
static constexpr int kDeferredAnswer = 7;
|
|
|
|
private:
|
|
mutable std::mutex m_mu;
|
|
std::map<std::string, std::map<const void*, EventSink>> m_sinks;
|
|
int m_subscribeFrames = 0;
|
|
int m_unsubscribeFrames = 0;
|
|
bool m_dedupePerConnection = true;
|
|
bool m_completionFirst = false;
|
|
};
|
|
|
|
// The same "answer with a sentinel, complete at once" behaviour as a real module
|
|
// behind ModuleProxy, for the run through the shipping host.
|
|
class InstantMultiModule : public LogosProviderObject {
|
|
public:
|
|
QVariant callMethod(const QString& method, const QVariantList&) override
|
|
{
|
|
if (method != QLatin1String("work")) return QVariant();
|
|
const QString callId = QStringLiteral("mc-%1").arg(
|
|
static_cast<qulonglong>(m_counter.fetch_add(1)));
|
|
std::thread([this, callId]() {
|
|
if (m_cb) m_cb(logos::callCompleteEvent(),
|
|
QVariantList{ callId, QVariant(7) });
|
|
}).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_cb = std::move(cb); }
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("multimod"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
private:
|
|
EventCallback m_cb;
|
|
std::atomic<unsigned long long> m_counter{0};
|
|
};
|
|
|
|
// Emits one named event per call, from inside callMethod — the ordinary shape of
|
|
// a module that pushes on demand.
|
|
class EmittingModule : public LogosProviderObject {
|
|
public:
|
|
QVariant callMethod(const QString& method, const QVariantList&) override
|
|
{
|
|
if (method != QLatin1String("ping")) return QVariant();
|
|
if (m_cb) m_cb(QStringLiteral("tick"), QVariantList{ QVariant(1) });
|
|
return QVariant(true);
|
|
}
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
bool informModuleToken(const QString&, const QString&) override { return true; }
|
|
void setEventListener(EventCallback cb) override { m_cb = std::move(cb); }
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("emitmod"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
private:
|
|
EventCallback m_cb;
|
|
};
|
|
|
|
QCoreApplication* ensureApp()
|
|
{
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance()) new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
// A socketpair with a live RpcConnection on each end.
|
|
struct WirePair {
|
|
Io clientIo, serverIo;
|
|
std::shared_ptr<LocalConnection> client;
|
|
std::shared_ptr<LocalConnection> server;
|
|
|
|
explicit WirePair(IncomingCallHandler* provider)
|
|
{
|
|
auto codec = std::make_shared<JsonCodec>();
|
|
LocalSocket clientSock(clientIo.ctx());
|
|
LocalSocket serverSock(serverIo.ctx());
|
|
boost::system::error_code ec;
|
|
boost::asio::local::connect_pair(clientSock, serverSock, ec);
|
|
if (ec) throw std::runtime_error("connect_pair: " + ec.message());
|
|
server = std::make_shared<LocalConnection>(std::move(serverSock), codec, provider);
|
|
client = std::make_shared<LocalConnection>(std::move(clientSock), codec, nullptr);
|
|
server->start();
|
|
client->start();
|
|
}
|
|
~WirePair() { client->stop(); server->stop(); }
|
|
};
|
|
|
|
// A round trip that proves every frame written before it has been applied.
|
|
//
|
|
// Frames go out in post order on the connection's strand and are dispatched in
|
|
// arrival order on the peer's, so a reply to a request issued LAST cannot arrive
|
|
// before the effects of everything issued earlier — in either direction. getMethods
|
|
// blocks on that reply, so returning from it means both the Subscribe/Unsubscribe
|
|
// frames this side wrote AND the Event frames the peer wrote before answering have
|
|
// been processed. That is what lets the assertions below be exact counts rather
|
|
// than "wait a bit and hope"; a sleep here is a coin flip on a loaded CI runner.
|
|
//
|
|
// The probe is a handle that never subscribes to anything (getMethods does not
|
|
// touch the completion channel), so it changes nothing it is measuring.
|
|
void wireBarrier(PlainLogosObject* probe) { probe->getMethods(); }
|
|
|
|
// A counter the subscription callback owns a share of, so it stays valid after
|
|
// the handle that created it has been deleted.
|
|
using Counter = std::shared_ptr<std::atomic<int>>;
|
|
Counter makeCounter() { return std::make_shared<std::atomic<int>>(0); }
|
|
|
|
} // namespace
|
|
|
|
// ── the headline: the completion channel, over the raw wire ─────────────────
|
|
//
|
|
// Three sequential calls, no threads. Handle A answers, handle B is created and
|
|
// answers, and then handle A is asked the same question again.
|
|
TEST(PlainEventSubSharingTest, ASecondHandleDoesNotStealTheFirstsCompletionChannel)
|
|
{
|
|
constexpr int kTimeoutMs = 1500;
|
|
constexpr int kRounds = 8;
|
|
|
|
MirrorProvider provider;
|
|
WirePair wire(&provider);
|
|
|
|
int aloneAnswered = 0, sharedAnswered = 0, sharedTimeouts = 0;
|
|
qint64 aloneMs = 0, sharedMs = 0;
|
|
|
|
for (int r = 0; r < kRounds; ++r) {
|
|
auto* a = new PlainLogosObject("multimod", wire.client);
|
|
|
|
logos::CallError err;
|
|
QElapsedTimer t; t.start();
|
|
QVariant v = a->callMethodWithError(QStringLiteral("tok"),
|
|
QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
aloneMs += t.elapsed();
|
|
if (v.toInt() == MirrorProvider::kDeferredAnswer) ++aloneAnswered;
|
|
|
|
// B merely exists and makes one call of its own — which is all it takes,
|
|
// because the first call is what subscribes.
|
|
auto* b = new PlainLogosObject("multimod", wire.client);
|
|
err = logos::CallError{};
|
|
v = b->callMethodWithError(QStringLiteral("tok"), QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
EXPECT_EQ(v.toInt(), MirrorProvider::kDeferredAnswer)
|
|
<< "the SECOND handle should always work — it owns the channel pre-fix";
|
|
|
|
// Now ask A again.
|
|
err = logos::CallError{};
|
|
t.restart();
|
|
v = a->callMethodWithError(QStringLiteral("tok"), QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
sharedMs += t.elapsed();
|
|
if (v.toInt() == MirrorProvider::kDeferredAnswer) ++sharedAnswered;
|
|
if (err.code == "timeout") ++sharedTimeouts;
|
|
|
|
a->release();
|
|
b->release();
|
|
}
|
|
|
|
std::cout << " handle A alone: " << aloneAnswered << "/" << kRounds
|
|
<< " answered, " << (aloneMs / kRounds) << " ms avg\n"
|
|
<< " handle A once B exists: " << sharedAnswered << "/" << kRounds
|
|
<< " answered, " << (sharedMs / kRounds) << " ms avg, "
|
|
<< sharedTimeouts << " timeouts" << std::endl;
|
|
|
|
EXPECT_EQ(aloneAnswered, kRounds) << "the single-handle case is the baseline";
|
|
EXPECT_EQ(sharedTimeouts, 0)
|
|
<< sharedTimeouts << " of " << kRounds << " calls on handle A waited out "
|
|
"the whole timeout because handle B took its completion channel";
|
|
EXPECT_EQ(sharedAnswered, kRounds);
|
|
}
|
|
|
|
// ── the same theft, through the shipping stack ──────────────────────────────
|
|
//
|
|
// PlainTransportHost + ModuleProxy + a "multi" module, and handles that come from
|
|
// requestObject() rather than from a constructor — the shape the transport hands
|
|
// out in production, where a fresh handle per acquire is the norm.
|
|
TEST(PlainEventSubSharingTest, TheSameTheftThroughTheRealHost)
|
|
{
|
|
ensureApp();
|
|
constexpr int kTimeoutMs = 1500;
|
|
constexpr int kRounds = 4;
|
|
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0;
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
ASSERT_TRUE(host->start());
|
|
|
|
InstantMultiModule mod;
|
|
ModuleProxy proxy(&mod);
|
|
proxy.saveToken(QStringLiteral("core"), QStringLiteral("tok"));
|
|
ASSERT_TRUE(host->publishObject("multimod", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
LogosTransportConfig ccfg = cfg;
|
|
ccfg.port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
auto conn = std::make_unique<PlainTransportConnection>(ccfg);
|
|
ASSERT_TRUE(conn->connectToHost());
|
|
|
|
std::atomic<int> answered{0}, timeouts{0};
|
|
std::atomic<qint64> sharedMs{0};
|
|
|
|
for (int r = 0; r < kRounds; ++r) {
|
|
LogosObject* a = conn->requestObject(QStringLiteral("multimod"), 2000);
|
|
LogosObject* b = conn->requestObject(QStringLiteral("multimod"), 2000);
|
|
ASSERT_NE(a, nullptr);
|
|
ASSERT_NE(b, nullptr);
|
|
auto* ca = dynamic_cast<LogosObjectErrorChannel*>(a);
|
|
auto* cb = dynamic_cast<LogosObjectErrorChannel*>(b);
|
|
ASSERT_NE(ca, nullptr);
|
|
ASSERT_NE(cb, nullptr);
|
|
|
|
// The module's events and the host's dispatch both land on THIS thread,
|
|
// so the calls have to be made from another one while this one pumps.
|
|
std::atomic<bool> done{false};
|
|
std::thread driver([&]() {
|
|
logos::CallError err;
|
|
ca->callMethodWithError(QStringLiteral("tok"), QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
cb->callMethodWithError(QStringLiteral("tok"), QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
err = logos::CallError{};
|
|
QElapsedTimer t; t.start();
|
|
const QVariant v = ca->callMethodWithError(
|
|
QStringLiteral("tok"), QStringLiteral("work"),
|
|
QVariantList{}, kTimeoutMs, &err);
|
|
sharedMs.fetch_add(t.elapsed());
|
|
if (v.toInt() == 7) answered.fetch_add(1);
|
|
if (err.code == "timeout") timeouts.fetch_add(1);
|
|
done.store(true);
|
|
});
|
|
|
|
QElapsedTimer pump; pump.start();
|
|
while (!done.load() && pump.elapsed() < 3 * kTimeoutMs + 2000)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 1);
|
|
driver.join();
|
|
|
|
a->release();
|
|
b->release();
|
|
}
|
|
|
|
std::cout << " handle A once B exists (real host): " << answered.load() << "/"
|
|
<< kRounds << " answered, " << (sharedMs.load() / kRounds)
|
|
<< " ms avg, " << timeouts.load() << " timeouts" << std::endl;
|
|
|
|
EXPECT_EQ(timeouts.load(), 0)
|
|
<< "a handle acquired before another one lost its completion channel";
|
|
EXPECT_EQ(answered.load(), kRounds);
|
|
|
|
host.reset();
|
|
}
|
|
|
|
// ── the general case: any event, any number of handles ──────────────────────
|
|
TEST(PlainEventSubSharingTest, EveryHandleGetsEveryEmission)
|
|
{
|
|
constexpr int kEmissions = 5;
|
|
|
|
MirrorProvider provider;
|
|
WirePair wire(&provider);
|
|
|
|
auto* probe = new PlainLogosObject("evmod", wire.client);
|
|
auto* a = new PlainLogosObject("evmod", wire.client);
|
|
auto* b = new PlainLogosObject("evmod", wire.client);
|
|
auto* c = new PlainLogosObject("evmod", wire.client);
|
|
|
|
Counter na = makeCounter(), nb = makeCounter(), nc = makeCounter();
|
|
a->onEvent(QStringLiteral("tick"), [na](const QString&, const QVariantList&) { na->fetch_add(1); });
|
|
b->onEvent(QStringLiteral("tick"), [nb](const QString&, const QVariantList&) { nb->fetch_add(1); });
|
|
// A WILDCARD subscriber is a third shape on the same object and must not
|
|
// clobber, or be clobbered by, the two named ones.
|
|
c->onEvent(QString(), [nc](const QString&, const QVariantList&) { nc->fetch_add(1); });
|
|
|
|
wireBarrier(probe);
|
|
ASSERT_EQ(provider.sinkCount(), 2)
|
|
<< "expected one named sink and one wildcard sink for this connection";
|
|
|
|
for (int i = 0; i < kEmissions; ++i)
|
|
provider.emitEvent("evmod", "tick", QVariantList{ QVariant(i) });
|
|
wireBarrier(probe);
|
|
|
|
std::cout << " " << kEmissions << " emissions -> A=" << na->load()
|
|
<< " B=" << nb->load() << " wildcard=" << nc->load() << std::endl;
|
|
|
|
EXPECT_EQ(na->load(), kEmissions) << "the FIRST subscriber lost its channel";
|
|
EXPECT_EQ(nb->load(), kEmissions);
|
|
EXPECT_EQ(nc->load(), kEmissions);
|
|
|
|
a->release();
|
|
b->release();
|
|
c->release();
|
|
probe->release();
|
|
}
|
|
|
|
// ── one copy per connection, however many of its sinks match ────────────────
|
|
//
|
|
// The other half of "the consumer demultiplexes": once it fans a delivery out to
|
|
// every local subscriber, the HOST must stop sending a connection two copies of
|
|
// the same event because it matched both by name and by wildcard. Runs against
|
|
// the shipping PlainTransportHost, because that double-send is its own.
|
|
TEST(PlainEventSubSharingTest, ANamedAndAWildcardSubscriberEachGetOneCopy)
|
|
{
|
|
ensureApp();
|
|
constexpr int kPings = 5;
|
|
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0;
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
ASSERT_TRUE(host->start());
|
|
|
|
EmittingModule mod;
|
|
ModuleProxy proxy(&mod);
|
|
proxy.saveToken(QStringLiteral("core"), QStringLiteral("tok"));
|
|
ASSERT_TRUE(host->publishObject("emitmod", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
LogosTransportConfig ccfg = cfg;
|
|
ccfg.port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
auto conn = std::make_unique<PlainTransportConnection>(ccfg);
|
|
ASSERT_TRUE(conn->connectToHost());
|
|
|
|
LogosObject* named = conn->requestObject(QStringLiteral("emitmod"), 2000);
|
|
LogosObject* wildcard = conn->requestObject(QStringLiteral("emitmod"), 2000);
|
|
ASSERT_NE(named, nullptr);
|
|
ASSERT_NE(wildcard, nullptr);
|
|
|
|
Counter nn = makeCounter(), nw = makeCounter();
|
|
named->onEvent(QStringLiteral("tick"), [nn](const QString&, const QVariantList&) { nn->fetch_add(1); });
|
|
wildcard->onEvent(QString(), [nw](const QString&, const QVariantList&) { nw->fetch_add(1); });
|
|
|
|
std::atomic<int> sent{0};
|
|
std::thread driver([&]() {
|
|
auto* ch = dynamic_cast<LogosObjectErrorChannel*>(named);
|
|
for (int i = 0; i < kPings; ++i) {
|
|
logos::CallError err;
|
|
ch->callMethodWithError(QStringLiteral("tok"), QStringLiteral("ping"),
|
|
QVariantList{}, 2000, &err);
|
|
sent.fetch_add(1);
|
|
}
|
|
});
|
|
|
|
QElapsedTimer pump; pump.start();
|
|
while ((sent.load() < kPings || nn->load() < kPings || nw->load() < kPings)
|
|
&& pump.elapsed() < 8000)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 1);
|
|
driver.join();
|
|
// Give a duplicate copy time to show up before asserting there is none.
|
|
pump.restart();
|
|
while (pump.elapsed() < 300)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 1);
|
|
|
|
std::cout << " " << kPings << " emissions -> named=" << nn->load()
|
|
<< " wildcard=" << nw->load() << std::endl;
|
|
|
|
EXPECT_EQ(nn->load(), kPings)
|
|
<< "the host sent this connection more than one copy per emission";
|
|
EXPECT_EQ(nw->load(), kPings);
|
|
|
|
named->release();
|
|
wildcard->release();
|
|
host.reset();
|
|
}
|
|
|
|
// ── churn: one handle leaving must not take another's subscription with it ──
|
|
TEST(PlainEventSubSharingTest, ReleasingOneHandleLeavesTheOtherSubscribed)
|
|
{
|
|
constexpr int kEmissions = 5;
|
|
|
|
MirrorProvider provider;
|
|
WirePair wire(&provider);
|
|
|
|
auto* probe = new PlainLogosObject("evmod", wire.client);
|
|
auto* a = new PlainLogosObject("evmod", wire.client);
|
|
auto* b = new PlainLogosObject("evmod", wire.client);
|
|
|
|
Counter na = makeCounter(), nb = makeCounter();
|
|
a->onEvent(QStringLiteral("tick"), [na](const QString&, const QVariantList&) { na->fetch_add(1); });
|
|
b->onEvent(QStringLiteral("tick"), [nb](const QString&, const QVariantList&) { nb->fetch_add(1); });
|
|
|
|
wireBarrier(probe);
|
|
ASSERT_EQ(provider.subscribeFrames(), 2);
|
|
|
|
// A goes away. Its Unsubscribe must not evict the sink B is still using, and
|
|
// must not take B's callback out of the connection's table either.
|
|
a->release();
|
|
wireBarrier(probe);
|
|
ASSERT_EQ(provider.sinkCount(), 1)
|
|
<< "the host-side sink was removed while a handle was still subscribed";
|
|
|
|
for (int i = 0; i < kEmissions; ++i)
|
|
provider.emitEvent("evmod", "tick", QVariantList{ QVariant(i) });
|
|
wireBarrier(probe);
|
|
|
|
std::cout << " after A released: A=" << na->load() << " B=" << nb->load()
|
|
<< " host sinks=" << provider.sinkCount() << std::endl;
|
|
|
|
EXPECT_EQ(nb->load(), kEmissions)
|
|
<< "releasing one handle unsubscribed a DIFFERENT handle's event";
|
|
EXPECT_EQ(na->load(), 0) << "a released handle must not keep receiving events";
|
|
|
|
b->release();
|
|
probe->release();
|
|
}
|
|
|
|
// ── and teardown still removes everything ───────────────────────────────────
|
|
//
|
|
// A PIN, NOT A DETECTOR: this passes on the pre-fix tree too. It is here because
|
|
// the fix withholds the Unsubscribe frame until the LAST local subscriber goes,
|
|
// which is precisely the sort of change that leaks a host-side sink forever.
|
|
TEST(PlainEventSubSharingTest, TeardownRemovesEverySubscription)
|
|
{
|
|
MirrorProvider provider;
|
|
WirePair wire(&provider);
|
|
|
|
auto* probe = new PlainLogosObject("evmod", wire.client);
|
|
auto* a = new PlainLogosObject("evmod", wire.client);
|
|
auto* b = new PlainLogosObject("evmod", wire.client);
|
|
|
|
Counter na = makeCounter(), nb = makeCounter();
|
|
a->onEvent(QStringLiteral("tick"), [na](const QString&, const QVariantList&) { na->fetch_add(1); });
|
|
b->onEvent(QStringLiteral("tick"), [nb](const QString&, const QVariantList&) { nb->fetch_add(1); });
|
|
a->onEvent(QStringLiteral("tock"), [na](const QString&, const QVariantList&) { na->fetch_add(1); });
|
|
|
|
wireBarrier(probe);
|
|
ASSERT_EQ(provider.sinkCount(), 2);
|
|
|
|
a->release();
|
|
b->release();
|
|
wireBarrier(probe);
|
|
|
|
EXPECT_EQ(provider.sinkCount(), 0)
|
|
<< "the host still holds " << provider.sinkCount()
|
|
<< " sink(s) after every handle was released";
|
|
|
|
// Push an event at the connection anyway — a peer that never saw the
|
|
// Unsubscribe would — and prove the consumer side is empty too.
|
|
EventMessage evt;
|
|
evt.object = "evmod";
|
|
evt.eventName = "tick";
|
|
evt.data = qvariantListToRpcList(QVariantList{ QVariant(1) });
|
|
wire.server->sendEvent(std::move(evt));
|
|
wireBarrier(probe);
|
|
|
|
std::cout << " after teardown: sinks=" << provider.sinkCount()
|
|
<< " unsubscribe frames=" << provider.unsubscribeFrames()
|
|
<< " late deliveries A=" << na->load() << " B=" << nb->load()
|
|
<< std::endl;
|
|
|
|
EXPECT_EQ(na->load(), 0);
|
|
EXPECT_EQ(nb->load(), 0);
|
|
probe->release();
|
|
}
|
|
|
|
// ── no subscription is lost under subscribe / unsubscribe / release churn ───
|
|
//
|
|
// Rounds of concurrent churn on a shared connection, each followed by a QUIESCED
|
|
// audit: one emission, and every handle that is subscribed at that moment must
|
|
// receive exactly one, while every handle that is not must receive none. The
|
|
// audit is exact rather than statistical, so a single lost subscription in any
|
|
// interleaving fails the test.
|
|
TEST(PlainEventSubSharingTest, SubscriptionsSurviveChurn)
|
|
{
|
|
constexpr int kThreads = 4;
|
|
constexpr int kPerThread = 3;
|
|
constexpr int kRounds = 40;
|
|
|
|
MirrorProvider provider;
|
|
WirePair wire(&provider);
|
|
auto* probe = new PlainLogosObject("churn", wire.client);
|
|
|
|
struct Handle {
|
|
PlainLogosObject* obj = nullptr;
|
|
Counter hits;
|
|
bool subscribed = false;
|
|
int expected = 0;
|
|
};
|
|
std::vector<Handle> handles(kThreads * kPerThread);
|
|
for (auto& s : handles) s.hits = makeCounter();
|
|
|
|
std::atomic<int> arrived{0};
|
|
std::atomic<int> generation{0};
|
|
std::atomic<bool> stop{false};
|
|
|
|
auto worker = [&](int tid) {
|
|
std::mt19937 rng(1234 + tid);
|
|
int seen = 0;
|
|
while (true) {
|
|
// Wait for the auditor to open the next churn window.
|
|
while (generation.load(std::memory_order_acquire) == seen && !stop.load())
|
|
std::this_thread::yield();
|
|
if (stop.load()) return;
|
|
seen = generation.load(std::memory_order_acquire);
|
|
|
|
for (int i = 0; i < kPerThread; ++i) {
|
|
Handle& s = handles[tid * kPerThread + i];
|
|
const int roll = static_cast<int>(rng() % 3);
|
|
if (roll == 0) continue; // leave it alone
|
|
if (s.subscribed) { // drop it
|
|
s.obj->release();
|
|
s.obj = nullptr;
|
|
s.subscribed = false;
|
|
} else { // take it
|
|
s.obj = new PlainLogosObject("churn", wire.client);
|
|
Counter c = s.hits;
|
|
s.obj->onEvent(QStringLiteral("beat"),
|
|
[c](const QString&, const QVariantList&) { c->fetch_add(1); });
|
|
s.subscribed = true;
|
|
}
|
|
}
|
|
arrived.fetch_add(1, std::memory_order_release);
|
|
}
|
|
};
|
|
|
|
std::vector<std::thread> threads;
|
|
for (int t = 0; t < kThreads; ++t) threads.emplace_back(worker, t);
|
|
|
|
int lost = 0, spurious = 0, audited = 0;
|
|
for (int r = 0; r < kRounds; ++r) {
|
|
arrived.store(0, std::memory_order_release);
|
|
generation.fetch_add(1, std::memory_order_release);
|
|
while (arrived.load(std::memory_order_acquire) < kThreads)
|
|
std::this_thread::yield();
|
|
|
|
// Quiesced: nothing is churning now, and the barrier proves every frame
|
|
// the churn produced has reached the provider. The audit that follows is
|
|
// therefore an EXACT count, not a poll with a timeout.
|
|
wireBarrier(probe);
|
|
for (auto& s : handles) if (s.subscribed) ++s.expected;
|
|
|
|
provider.emitEvent("churn", "beat", QVariantList{ QVariant(r) });
|
|
wireBarrier(probe);
|
|
|
|
for (auto& s : handles) {
|
|
++audited;
|
|
const int got = s.hits->load();
|
|
if (got < s.expected) { ++lost; s.expected = got; }
|
|
else if (got > s.expected) { ++spurious; s.expected = got; }
|
|
}
|
|
}
|
|
|
|
stop.store(true);
|
|
for (auto& t : threads) t.join();
|
|
|
|
std::cout << " " << kRounds << " audited rounds over " << handles.size()
|
|
<< " handles -> lost=" << lost << " spurious=" << spurious
|
|
<< " (audited slots: " << audited << ")" << std::endl;
|
|
|
|
EXPECT_EQ(lost, 0) << lost << " subscriptions missed an emission they were "
|
|
"subscribed for";
|
|
EXPECT_EQ(spurious, 0) << spurious << " handles received an emission they were "
|
|
"not subscribed for";
|
|
|
|
for (auto& s : handles) if (s.obj) s.obj->release();
|
|
probe->release();
|
|
}
|
|
|
|
// ── a completion that overtakes its own Result is still delivered ────────────
|
|
//
|
|
// The reason the staging area in CallState exists, and the reason it cannot
|
|
// simply drop everything it fails to attribute. A "multi" module whose worker
|
|
// finishes inside callMethod pushes the completion event before the host has
|
|
// written the sentinel, so the completion is decoded FIRST — at which point the
|
|
// callId names a call whose Result the consumer has not seen and cannot match.
|
|
// It has to be parked; the gate that keeps parking bounded must not lose it.
|
|
TEST(PlainEventSubSharingTest, ACompletionThatOvertakesItsOwnResultIsStillDelivered)
|
|
{
|
|
ensureApp();
|
|
constexpr int kTimeoutMs = 1500;
|
|
|
|
MirrorProvider provider;
|
|
provider.setCompletionBeforeResult(true);
|
|
WirePair wire(&provider);
|
|
|
|
auto* a = new PlainLogosObject("multimod", wire.client);
|
|
auto* b = new PlainLogosObject("multimod", wire.client);
|
|
|
|
// Synchronous, on both handles, alternating — the path that has to park.
|
|
int answered = 0;
|
|
for (int i = 0; i < 6; ++i) {
|
|
PlainLogosObject* obj = (i % 2) ? b : a;
|
|
logos::CallError err;
|
|
const QVariant v = obj->callMethodWithError(
|
|
QStringLiteral("tok"), QStringLiteral("work"), QVariantList{},
|
|
kTimeoutMs, &err);
|
|
if (v.toInt() == MirrorProvider::kDeferredAnswer) ++answered;
|
|
}
|
|
|
|
// And asynchronously, which parks nothing (the reply handler files the callId
|
|
// under `deferred` on the strand) but must survive the same reordering.
|
|
std::atomic<int> asyncAnswered{0};
|
|
for (int i = 0; i < 4; ++i) {
|
|
PlainLogosObject* obj = (i % 2) ? b : a;
|
|
obj->callMethodAsyncWithError(
|
|
QStringLiteral("tok"), QStringLiteral("work"), QVariantList{}, kTimeoutMs,
|
|
[&asyncAnswered](QVariant v, const logos::CallError&) {
|
|
if (v.toInt() == MirrorProvider::kDeferredAnswer)
|
|
asyncAnswered.fetch_add(1);
|
|
});
|
|
}
|
|
QElapsedTimer pump; pump.start();
|
|
while (asyncAnswered.load() < 4 && pump.elapsed() < 5000)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 1);
|
|
|
|
std::cout << " completion-before-result: sync " << answered << "/6, async "
|
|
<< asyncAnswered.load() << "/4" << std::endl;
|
|
|
|
EXPECT_EQ(answered, 6);
|
|
EXPECT_EQ(asyncAnswered.load(), 4);
|
|
|
|
a->release();
|
|
b->release();
|
|
}
|
|
|
|
// ── mixed versions, direction 1: a NEW consumer against an OLD host ─────────
|
|
//
|
|
// The wire does not move, so this is exercisable in one process: the provider
|
|
// below is the host's PRE-FIX bookkeeping — assign the sink on Subscribe, erase
|
|
// it by (event, connection) on Unsubscribe, and fan out every matching sink with
|
|
// no per-connection de-duplication. Everything the consumer half of the fix does
|
|
// has to work against it, because that is what an unpatched daemon looks like.
|
|
TEST(PlainEventSubSharingTest, ANewConsumerAgainstAnOldHost)
|
|
{
|
|
constexpr int kEmissions = 4;
|
|
|
|
MirrorProvider oldHost;
|
|
oldHost.setLegacyFanOut(true);
|
|
WirePair wire(&oldHost);
|
|
|
|
auto* probe = new PlainLogosObject("evmod", wire.client);
|
|
auto* a = new PlainLogosObject("evmod", wire.client);
|
|
auto* b = new PlainLogosObject("evmod", wire.client);
|
|
|
|
Counter na = makeCounter(), nb = makeCounter();
|
|
a->onEvent(QStringLiteral("tick"), [na](const QString&, const QVariantList&) { na->fetch_add(1); });
|
|
b->onEvent(QStringLiteral("tick"), [nb](const QString&, const QVariantList&) { nb->fetch_add(1); });
|
|
|
|
wireBarrier(probe);
|
|
ASSERT_EQ(oldHost.subscribeFrames(), 2);
|
|
// An old host holds ONE sink for the pair however many Subscribes arrive —
|
|
// which is exactly why the consumer, not the host, has to demultiplex.
|
|
EXPECT_EQ(oldHost.sinkCount(), 1);
|
|
|
|
for (int i = 0; i < kEmissions; ++i)
|
|
oldHost.emitEvent("evmod", "tick", QVariantList{ QVariant(i) });
|
|
wireBarrier(probe);
|
|
EXPECT_EQ(na->load(), kEmissions);
|
|
EXPECT_EQ(nb->load(), kEmissions);
|
|
|
|
// A leaves; the old host is told nothing, so B keeps its sink.
|
|
a->release();
|
|
wireBarrier(probe);
|
|
for (int i = 0; i < kEmissions; ++i)
|
|
oldHost.emitEvent("evmod", "tick", QVariantList{ QVariant(i) });
|
|
wireBarrier(probe);
|
|
|
|
std::cout << " new consumer / old host: A=" << na->load() << " B=" << nb->load()
|
|
<< " sinks=" << oldHost.sinkCount()
|
|
<< " unsubscribe frames=" << oldHost.unsubscribeFrames() << std::endl;
|
|
|
|
EXPECT_EQ(nb->load(), 2 * kEmissions);
|
|
EXPECT_EQ(na->load(), kEmissions);
|
|
EXPECT_EQ(oldHost.unsubscribeFrames(), 0);
|
|
|
|
// B leaves: now the old host hears about it, and its table is empty.
|
|
b->release();
|
|
wireBarrier(probe);
|
|
EXPECT_EQ(oldHost.sinkCount(), 0);
|
|
EXPECT_EQ(oldHost.unsubscribeFrames(), 1);
|
|
probe->release();
|
|
}
|
|
|
|
// ── mixed versions, direction 2: an OLD consumer against the NEW host ───────
|
|
//
|
|
// An old consumer cannot be linked into this binary alongside the new one, so
|
|
// this speaks its wire directly: a raw TCP socket to a real PlainTransportHost,
|
|
// hand-built Subscribe / Unsubscribe frames in the sequence a pre-fix consumer
|
|
// emits (one of each per handle, no subscription id — there never was one), and
|
|
// a count of the Event frames that come back.
|
|
TEST(PlainEventSubSharingTest, AnOldConsumersFrameSequenceAgainstTheNewHost)
|
|
{
|
|
ensureApp();
|
|
|
|
LogosTransportConfig cfg;
|
|
cfg.protocol = LogosProtocol::Tcp;
|
|
cfg.host = "127.0.0.1";
|
|
cfg.port = 0;
|
|
|
|
auto host = std::make_unique<PlainTransportHost>(cfg);
|
|
ASSERT_TRUE(host->start());
|
|
|
|
EmittingModule mod;
|
|
ModuleProxy proxy(&mod);
|
|
proxy.saveToken(QStringLiteral("core"), QStringLiteral("tok"));
|
|
ASSERT_TRUE(host->publishObject("emitmod", &proxy));
|
|
|
|
const QString endpoint = host->endpoint();
|
|
const unsigned short port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
|
|
boost::asio::io_context ioc;
|
|
boost::asio::ip::tcp::socket sock(ioc);
|
|
boost::asio::ip::tcp::resolver resolver(ioc);
|
|
boost::system::error_code ec;
|
|
boost::asio::connect(sock, resolver.resolve("127.0.0.1", std::to_string(port)), ec);
|
|
ASSERT_FALSE(ec) << ec.message();
|
|
sock.non_blocking(true);
|
|
|
|
auto codec = std::make_shared<JsonCodec>();
|
|
FrameReader reader;
|
|
std::vector<uint8_t> buf(4096);
|
|
|
|
const auto writeMsg = [&](AnyMessage m) {
|
|
const auto frame = encodeFrame(*codec, std::move(m));
|
|
std::size_t off = 0;
|
|
while (off < frame.size()) {
|
|
boost::system::error_code wec;
|
|
const auto n = sock.write_some(
|
|
boost::asio::buffer(frame.data() + off, frame.size() - off), wec);
|
|
if (wec == boost::asio::error::would_block) continue;
|
|
ASSERT_FALSE(wec) << wec.message();
|
|
off += n;
|
|
}
|
|
};
|
|
std::vector<EventMessage> events;
|
|
std::atomic<uint64_t> lastMethodsResult{0};
|
|
|
|
// Pump both sides for a while, collecting Event frames and noting the id of
|
|
// any MethodsResult that comes back (the barrier below reads that).
|
|
const auto pumpFor = [&](int ms) {
|
|
QElapsedTimer t; t.start();
|
|
while (t.elapsed() < ms) {
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 1);
|
|
boost::system::error_code rec;
|
|
const auto n = sock.read_some(boost::asio::buffer(buf), rec);
|
|
if (rec == boost::asio::error::would_block || n == 0) continue;
|
|
reader.append(buf.data(), n);
|
|
MessageType tag;
|
|
std::vector<uint8_t> payload;
|
|
while (reader.next(tag, payload)) {
|
|
auto any = codec->decode(tag, payload.data(), payload.size());
|
|
if (tag == MessageType::Event)
|
|
events.push_back(std::get<EventMessage>(any));
|
|
else if (tag == MessageType::MethodsResult)
|
|
lastMethodsResult.store(std::get<MethodsResultMessage>(any).id);
|
|
}
|
|
}
|
|
};
|
|
|
|
// AN IN-BAND BARRIER instead of a sleep, because a sleep here is a coin flip
|
|
// on a loaded runner and a wrong one fails the test rather than skipping it.
|
|
// A Methods request travels the same socket and is dispatched on the same
|
|
// strand as everything written before it, and its reply comes back only after
|
|
// the host's Qt thread has answered — so a MethodsResult in hand proves every
|
|
// earlier Subscribe/Unsubscribe frame has been applied.
|
|
uint64_t barrierId = 1000;
|
|
const auto barrier = [&]() {
|
|
const uint64_t id = ++barrierId;
|
|
writeMsg(MethodsMessage{id, "tok", "emitmod"});
|
|
QElapsedTimer t; t.start();
|
|
while (lastMethodsResult.load() != id && t.elapsed() < 10000) pumpFor(10);
|
|
return lastMethodsResult.load() == id;
|
|
};
|
|
|
|
// Emitting straight through the module, so no Call frame (and no token
|
|
// handshake) is needed to make the host fan an event out.
|
|
const auto emitAndCount = [&]() {
|
|
events.clear();
|
|
mod.callMethod(QStringLiteral("ping"), QVariantList{});
|
|
// One more barrier: the Event frame is written on the same strand and
|
|
// therefore lands ahead of the MethodsResult that follows it.
|
|
barrier();
|
|
pumpFor(50);
|
|
return events;
|
|
};
|
|
|
|
// Two "handles", each sending its own Subscribe frame for the same pair.
|
|
writeMsg(SubscribeMessage{"emitmod", "tick"});
|
|
writeMsg(SubscribeMessage{"emitmod", "tick"});
|
|
ASSERT_TRUE(barrier()) << "the host never answered — nothing below is meaningful";
|
|
auto got = emitAndCount();
|
|
ASSERT_EQ(got.size(), 1u)
|
|
<< "an old peer must still get exactly one copy per emission";
|
|
// The frame itself is byte-for-byte what it always was.
|
|
EXPECT_EQ(got[0].object, "emitmod");
|
|
EXPECT_EQ(got[0].eventName, "tick");
|
|
ASSERT_EQ(got[0].data.size(), 1u);
|
|
EXPECT_TRUE(got[0].data[0].isIntegral());
|
|
|
|
// The one place an old peer sees a DIFFERENT number of frames: subscribed
|
|
// both by name and by wildcard, it used to be sent two copies of the same
|
|
// event and delivered each to both of its callbacks. Now it is sent one.
|
|
writeMsg(SubscribeMessage{"emitmod", ""});
|
|
ASSERT_TRUE(barrier());
|
|
got = emitAndCount();
|
|
EXPECT_EQ(got.size(), 1u)
|
|
<< "a connection matching twice must still be sent one copy";
|
|
|
|
// And its per-handle Unsubscribe frames still mean what they always meant.
|
|
writeMsg(UnsubscribeMessage{"emitmod", ""});
|
|
writeMsg(UnsubscribeMessage{"emitmod", "tick"});
|
|
ASSERT_TRUE(barrier());
|
|
got = emitAndCount();
|
|
EXPECT_EQ(got.size(), 0u) << "the host kept sending after Unsubscribe";
|
|
// A second, redundant Unsubscribe (an old consumer sends one per handle)
|
|
// must be harmless.
|
|
writeMsg(UnsubscribeMessage{"emitmod", "tick"});
|
|
ASSERT_TRUE(barrier());
|
|
got = emitAndCount();
|
|
EXPECT_EQ(got.size(), 0u);
|
|
|
|
sock.close(ec);
|
|
host.reset();
|
|
}
|
|
|
|
// ── the staging area's gate, directly ───────────────────────────────────────
|
|
//
|
|
// CallState::parkCompletion is the one piece of this change that DROPS data on
|
|
// purpose, so it is pinned on its own rather than only through a transport.
|
|
TEST(PlainParkedCompletionGateTest, ParkingIsGatedOnHavingSomethingOutstanding)
|
|
{
|
|
PlainLogosObject::CallState st;
|
|
QVariant out;
|
|
|
|
// Idle: a completion arriving now belongs to another handle on the shared
|
|
// connection and is dropped rather than kept for the life of the connection.
|
|
st.parkCompletion(QStringLiteral("foreign"), QVariant(1));
|
|
EXPECT_FALSE(st.takeCompletion(QStringLiteral("foreign"), &out));
|
|
|
|
// A synchronous call outstanding: park it, because the sentinel that names it
|
|
// may be the very next frame.
|
|
++st.syncOutstanding;
|
|
st.parkCompletion(QStringLiteral("mine"), QVariant(42));
|
|
EXPECT_TRUE(st.takeCompletion(QStringLiteral("mine"), &out));
|
|
EXPECT_EQ(out.toInt(), 42);
|
|
|
|
// Anything left over when the last call finishes is provably unclaimable.
|
|
st.parkCompletion(QStringLiteral("leftover"), QVariant(7));
|
|
--st.syncOutstanding;
|
|
st.dropUnclaimedIfIdle();
|
|
EXPECT_FALSE(st.takeCompletion(QStringLiteral("leftover"), &out));
|
|
EXPECT_TRUE(st.completions.empty());
|
|
EXPECT_TRUE(st.completionOrder.empty());
|
|
}
|
|
|
|
TEST(PlainParkedCompletionGateTest, TheStagingAreaIsCappedAndEvictsOldestFirst)
|
|
{
|
|
PlainLogosObject::CallState st;
|
|
++st.syncOutstanding; // busy for the whole test
|
|
|
|
constexpr int kFlood = 4000;
|
|
for (int i = 0; i < kFlood; ++i)
|
|
st.parkCompletion(QStringLiteral("id-%1").arg(i), QVariant(i));
|
|
|
|
std::cout << " " << kFlood << " unclaimed completions parked -> "
|
|
<< st.completions.size() << " retained" << std::endl;
|
|
|
|
EXPECT_LE(st.completions.size(), 512u)
|
|
<< "the staging area grew without bound";
|
|
EXPECT_EQ(st.completions.size(), st.completionOrder.size())
|
|
<< "the map and the eviction order drifted apart";
|
|
|
|
// Newest survives, oldest is gone.
|
|
QVariant out;
|
|
EXPECT_TRUE(st.takeCompletion(QStringLiteral("id-%1").arg(kFlood - 1), &out));
|
|
EXPECT_EQ(out.toInt(), kFlood - 1);
|
|
EXPECT_FALSE(st.takeCompletion(QStringLiteral("id-0"), &out));
|
|
// And the pair stayed in step across a claim.
|
|
EXPECT_EQ(st.completions.size(), st.completionOrder.size());
|
|
}
|