Files
Dario LipicarandClaude Opus 5 e6d5b575c2 fix(plain): a second handle must not steal the first's event channel (#52)
* 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>
2026-08-12 19:06:16 -03:00

540 lines
22 KiB
C++

#include "plain_transport_host.h"
#include "cbor_codec.h"
#include "io_context_pool.h"
#include "json_codec.h"
#include "qvariant_rpc_value.h"
#include "../../module_proxy.h"
#include <QDebug>
#include <QMetaObject>
#include <atomic>
#include <chrono>
#include <future>
#include <set>
#include <boost/asio/post.hpp>
#include <boost/asio/ssl/context.hpp>
#include <boost/version.hpp>
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <openssl/opensslv.h>
#include <openssl/x509.h>
#include <openssl/evp.h>
namespace logos::plain {
namespace {
std::shared_ptr<IWireCodec> makeCodec(LogosWireCodec kind)
{
switch (kind) {
case LogosWireCodec::Cbor: return std::make_shared<CborCodec>();
case LogosWireCodec::Json:
default: return std::make_shared<JsonCodec>();
}
}
// Print the last OpenSSL error to qWarning, clearing the error stack.
// Used after any SSL_CTX_set_* call that returned 0 — silent failures
// were how we missed the cipher-list misconfig for multiple rounds.
void dumpSslErrors(const char* where)
{
unsigned long e;
while ((e = ERR_get_error()) != 0) {
char buf[256];
ERR_error_string_n(e, buf, sizeof(buf));
qWarning() << "buildSslCtx/" << where << ":" << buf;
}
}
boost::asio::ssl::context buildSslCtx(const LogosTransportConfig& cfg, bool server)
{
// One-time diagnostic: print Boost + OpenSSL build-vs-runtime
// versions on first call. We've spent multiple rounds on
// TLS configuration that *appeared* to take effect (strings
// baked into the binary) but didn't change runtime behaviour;
// a version mismatch between compile-time headers and runtime
// libs would do that, and this prints the smoking gun.
static bool versionsLogged = false;
if (!versionsLogged) {
versionsLogged = true;
qInfo().nospace()
<< "buildSslCtx versions: "
<< "Boost build=" << BOOST_VERSION
<< " (" << BOOST_LIB_VERSION << "), "
<< "OpenSSL build=0x" << Qt::hex << OPENSSL_VERSION_NUMBER
<< Qt::dec << " (" << OPENSSL_VERSION_TEXT << "), "
<< "runtime=" << OpenSSL_version(OPENSSL_VERSION);
}
qInfo().nospace() << "buildSslCtx: cfg.certFile='"
<< QString::fromStdString(cfg.certFile)
<< "' cfg.keyFile='"
<< QString::fromStdString(cfg.keyFile)
<< "' cfg.caFile='"
<< QString::fromStdString(cfg.caFile)
<< "' role=" << (server ? "server" : "client");
boost::asio::ssl::context ctx(server
? boost::asio::ssl::context::tls_server
: boost::asio::ssl::context::tls_client);
ctx.set_options(boost::asio::ssl::context::default_workarounds
| boost::asio::ssl::context::no_sslv2
| boost::asio::ssl::context::no_sslv3
| boost::asio::ssl::context::single_dh_use);
// Require TLS 1.2+. Check return values on every set_* below and
// dump any pending OpenSSL errors — previous silent-fail behaviour
// was the root cause of multiple debugging rounds landing no fix.
if (!SSL_CTX_set_min_proto_version(ctx.native_handle(), TLS1_2_VERSION)) {
qWarning() << "buildSslCtx: SSL_CTX_set_min_proto_version(TLS1_2) failed";
dumpSslErrors("set_min_proto_version");
}
if (!SSL_CTX_set_max_proto_version(ctx.native_handle(), TLS1_3_VERSION)) {
qWarning() << "buildSslCtx: SSL_CTX_set_max_proto_version(TLS1_3) failed";
dumpSslErrors("set_max_proto_version");
}
if (!SSL_CTX_set1_groups_list(ctx.native_handle(),
"X25519:P-256:P-384:P-521")) {
qWarning() << "buildSslCtx: SSL_CTX_set1_groups_list failed";
dumpSslErrors("set1_groups_list");
}
if (!SSL_CTX_set_ciphersuites(ctx.native_handle(),
"TLS_AES_128_GCM_SHA256:"
"TLS_AES_256_GCM_SHA384:"
"TLS_CHACHA20_POLY1305_SHA256")) {
qWarning() << "buildSslCtx: SSL_CTX_set_ciphersuites failed";
dumpSslErrors("set_ciphersuites");
}
if (!SSL_CTX_set_cipher_list(ctx.native_handle(),
"ECDHE+AESGCM:ECDHE+CHACHA20:"
"DHE+AESGCM:DHE+CHACHA20:"
"!aNULL:!MD5:!DSS:!RC4:!3DES")) {
qWarning() << "buildSslCtx: SSL_CTX_set_cipher_list failed";
dumpSslErrors("set_cipher_list");
}
// Log what stuck. If min/max_proto read back as 0, the platform
// doesn't support bounded proto versions (very old OpenSSL) and
// nothing we did above will have capped anything. Cipher count is
// the second-most-likely silent failure: a non-zero count from
// SSL_CTX_get_ciphers means the TLS 1.2 cipher list got applied.
{
auto* sk = SSL_CTX_get_ciphers(ctx.native_handle());
const int n = sk ? sk_SSL_CIPHER_num(sk) : 0;
QString first;
if (n > 0) {
const SSL_CIPHER* c = sk_SSL_CIPHER_value(sk, 0);
first = QString::fromLatin1(SSL_CIPHER_get_name(c));
}
qInfo().nospace() << "buildSslCtx: role="
<< (server ? "server" : "client")
<< " min_proto=0x" << Qt::hex
<< SSL_CTX_get_min_proto_version(ctx.native_handle())
<< " max_proto=0x"
<< SSL_CTX_get_max_proto_version(ctx.native_handle())
<< " options=0x"
<< SSL_CTX_get_options(ctx.native_handle())
<< Qt::dec
<< " cipher_count=" << n
<< " first_cipher=" << first;
}
if (!cfg.certFile.empty()) {
ctx.use_certificate_chain_file(cfg.certFile);
dumpSslErrors("use_certificate_chain_file");
}
if (!cfg.keyFile.empty()) {
ctx.use_private_key_file(cfg.keyFile, boost::asio::ssl::context::pem);
dumpSslErrors("use_private_key_file");
}
if (!cfg.caFile.empty()) {
ctx.load_verify_file(cfg.caFile);
dumpSslErrors("load_verify_file");
}
if (cfg.verifyPeer && !server) {
ctx.set_verify_mode(boost::asio::ssl::verify_peer);
} else if (!server) {
ctx.set_verify_mode(boost::asio::ssl::verify_none);
}
// Final check: is a cert + matching key actually attached to the
// SSL_CTX? "no shared cipher" / "unsupported protocol" can both
// result from a server that has no usable cert at all (no PKI
// cipher suites can negotiate without one). use_certificate_*
// / use_private_key_* throw on outright failure but can leave the
// ctx in a "loaded but the CTX-level slot is empty" state if the
// file's first PEM block was something other than a CERTIFICATE.
{
X509* serverCert = SSL_CTX_get0_certificate(ctx.native_handle());
EVP_PKEY* serverKey = SSL_CTX_get0_privatekey(ctx.native_handle());
const int checkOk = SSL_CTX_check_private_key(ctx.native_handle());
qInfo().nospace() << "buildSslCtx: cert_attached="
<< (serverCert ? "yes" : "no")
<< " key_attached=" << (serverKey ? "yes" : "no")
<< " check_private_key=" << checkOk;
if (!checkOk) dumpSslErrors("SSL_CTX_check_private_key");
}
return ctx;
}
} // anonymous namespace
PlainTransportHost::PlainTransportHost(LogosTransportConfig cfg)
: m_cfg(std::move(cfg))
{
}
PlainTransportHost::~PlainTransportHost()
{
// The two stop() calls below are blocking: they tear down all
// open RpcConnection sessions, each of which calls
// IncomingCallHandler::onConnectionClosed() — which re-acquires
// `m_mu` to remove its publisher mapping. Holding m_mu across
// stop() therefore self-deadlocks.
//
// Move the published map and the listeners out under the lock,
// then drop it before driving the shutdowns. Once we've moved
// them, no other thread can reach this object's data through
// m_published / m_tcp / m_ssl.
decltype(m_published) published;
decltype(m_tcp) tcp;
decltype(m_ssl) ssl;
{
std::lock_guard<std::mutex> g(m_mu);
published = std::move(m_published);
tcp = std::move(m_tcp);
ssl = std::move(m_ssl);
}
for (auto& [name, pub] : published) {
QObject::disconnect(pub.eventConn);
}
if (tcp) tcp->stop();
if (ssl) ssl->stop();
// Quiesce the I/O thread before this host (an IncomingCallHandler) is
// destroyed. Server-side RpcConnections hold a RAW `IncomingCallHandler*`
// back to us; a read completion racing this teardown runs
// RpcConnection::fail() on the I/O thread, which calls
// m_handler->onConnectionClosed(this). stop() above closes the sockets but
// does NOT wait for an already-executing fail() — so without this barrier
// the handler can be freed mid-call (a use-after-free that surfaced as a
// flaky SIGSEGV, including on macOS CI in CallErrorAfterAcquireTest right
// after a preceding live-host test tore its PlainTransportHost down).
// There is a single shared I/O thread, so a task posted now runs only after
// every in-flight/queued connection handler has completed; blocking on it
// guarantees no callback still references this host. Skip when we're ON
// the I/O thread (the in-flight handler is our own caller) to avoid
// self-deadlock.
if (tcp || ssl) {
auto& ioc = IoContextPool::shared().ioContext();
if (!ioc.get_executor().running_in_this_thread()) {
// The promise is shared, NOT captured by reference. The wait below is
// bounded, so on timeout this frame returns while the posted task is
// still queued — a by-reference capture would then set_value() on a
// destroyed stack object, which is the very failure mode this barrier
// exists to prevent.
auto drained = std::make_shared<std::promise<void>>();
auto fut = drained->get_future();
boost::asio::post(ioc, [drained] { drained->set_value(); });
// Bounded so a wedged I/O thread cannot hang teardown — but a timeout
// means the barrier did NOT hold and we are about to free an
// IncomingCallHandler a connection may still call back into. Say so:
// silently proceeding is how this class of crash stays unexplained.
if (fut.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
qWarning() << "PlainTransportHost: I/O drain timed out after 5s;"
<< "tearing down anyway — a connection callback may still"
<< "reference this host (see the barrier comment above)";
}
}
}
}
bool PlainTransportHost::start()
{
std::lock_guard<std::mutex> g(m_mu);
if (m_started) return true;
auto codec = makeCodec(m_cfg.codec);
auto& ioc = IoContextPool::shared().ioContext();
if (m_cfg.protocol == LogosProtocol::Tcp) {
m_tcp = std::make_shared<RpcServerTcp>(ioc, m_cfg.host, m_cfg.port, codec, this);
if (!m_tcp->start()) {
qCritical() << "PlainTransportHost: TCP bind failed on"
<< QString::fromStdString(m_cfg.host) << m_cfg.port;
m_tcp.reset();
return false;
}
m_boundPort = m_tcp->boundPort();
} else if (m_cfg.protocol == LogosProtocol::TcpSsl) {
try {
auto ctx = buildSslCtx(m_cfg, /*server=*/true);
m_ssl = std::make_shared<RpcServerSsl>(ioc, m_cfg.host, m_cfg.port,
std::move(ctx), codec, this);
if (!m_ssl->start()) {
qCritical() << "PlainTransportHost: TLS bind failed";
m_ssl.reset();
return false;
}
m_boundPort = m_ssl->boundPort();
} catch (const std::exception& e) {
qCritical() << "PlainTransportHost: SSL context setup failed:" << e.what();
return false;
}
} else {
qCritical() << "PlainTransportHost: unsupported protocol";
return false;
}
m_started = true;
return true;
}
QString PlainTransportHost::endpoint() const
{
std::lock_guard<std::mutex> g(m_mu);
if (m_boundPort == 0) return QString();
return QString("tcp://%1:%2")
.arg(QString::fromStdString(m_cfg.host))
.arg(m_boundPort);
}
QString PlainTransportHost::bindUrl(const QString& /*instanceId*/,
const QString& /*moduleName*/)
{
// One PlainTransportHost listens on a single host:port and serves every
// published module over the same socket; URL is independent of module.
return endpoint();
}
bool PlainTransportHost::publishObject(const QString& name, QObject* object)
{
if (!object) return false;
auto* proxy = qobject_cast<ModuleProxy*>(object);
if (!proxy) {
qWarning() << "PlainTransportHost::publishObject: expected ModuleProxy for"
<< name << "(plain transport only publishes ModuleProxy for now)";
return false;
}
std::lock_guard<std::mutex> g(m_mu);
Published pub;
pub.object = object;
const std::string stdName = name.toStdString();
// Hook the QObject's eventResponse(QString, QVariantList) signal so every
// Q_INVOKABLE-style event emission fans out to subscribed connections.
pub.eventConn = QObject::connect(proxy, &ModuleProxy::eventResponse,
[this, stdName](const QString& eventName, const QVariantList& data) {
EventMessage msg;
msg.object = stdName;
msg.eventName = eventName.toStdString();
msg.data = qvariantListToRpcList(data);
fanOutEvent(stdName, std::move(msg));
});
m_published[stdName] = std::move(pub);
return true;
}
void PlainTransportHost::unpublishObject(const QString& name)
{
std::lock_guard<std::mutex> g(m_mu);
auto it = m_published.find(name.toStdString());
if (it == m_published.end()) return;
QObject::disconnect(it->second.eventConn);
m_published.erase(it);
}
void PlainTransportHost::fanOutEvent(const std::string& name, EventMessage msg)
{
// ONE COPY PER CONNECTION, not one per matching sink. A connection subscribed
// BOTH to an event by name and to the wildcard matches twice here, and every
// sink for a given connection does the same thing (write this frame back down
// that socket), so pushing both put the SAME event on the wire twice and the
// consumer delivered it twice to each of its subscribers. Measured before this
// guard: 5 emissions arriving as 10 deliveries at both a named and a wildcard
// subscriber. Keying the collection by connection is what makes event delivery
// exactly-once; the consumer decides which of its own handles a delivery goes
// to (RpcConnection::sendSubscribe).
std::vector<EventSink> sinks;
{
std::lock_guard<std::mutex> g(m_mu);
auto it = m_sinks.find(name);
if (it == m_sinks.end()) return;
std::set<const void*> seen;
// Named subscribers + wildcard ("") subscribers get the event.
for (auto which : {msg.eventName, std::string{}}) {
auto evtIt = it->second.find(which);
if (evtIt == it->second.end()) continue;
for (auto& [key, sink] : evtIt->second)
if (seen.insert(key).second) sinks.push_back(sink);
if (msg.eventName.empty()) break; // named IS wildcard here
}
}
for (auto& sink : sinks) {
try { sink(msg); } catch (...) {}
}
}
void PlainTransportHost::onCall(const CallMessage& req, CallReply reply)
{
QObject* obj = nullptr;
{
std::lock_guard<std::mutex> g(m_mu);
auto it = m_published.find(req.object);
if (it != m_published.end()) obj = it->second.object;
}
if (!obj) {
ResultMessage res; res.id = req.id; res.ok = false;
res.err = "object not published: " + req.object;
res.errCode = "MODULE_NOT_LOADED";
reply(std::move(res));
return;
}
QString authToken = QString::fromStdString(req.authToken);
QString methodName = QString::fromStdString(req.method);
QVariantList args = rpcListToQVariantList(req.args);
uint64_t id = req.id;
// The wire this call arrived on, so the provider can enforce local_only
// tokens. This host only ever serves the remote plain protocols (LocalSocket
// is handled by RemoteTransportHost). Default to the non-local label so an
// unexpected protocol fails closed for a local_only token.
QString transportProtocol = QStringLiteral("tcp");
if (m_cfg.protocol == LogosProtocol::TcpSsl)
transportProtocol = QStringLiteral("tcp_ssl");
QMetaObject::invokeMethod(obj, [obj, authToken, methodName, args, transportProtocol, id, reply]() {
QVariant ret;
bool ok = QMetaObject::invokeMethod(obj, "callRemoteMethod",
Qt::DirectConnection,
Q_RETURN_ARG(QVariant, ret),
Q_ARG(QString, authToken),
Q_ARG(QString, methodName),
Q_ARG(QVariantList, args),
Q_ARG(QString, transportProtocol));
ResultMessage res;
res.id = id;
if (ok) {
res.ok = true;
res.value = qvariantToRpcValue(ret);
} else {
res.ok = false;
res.err = "callRemoteMethod failed";
res.errCode = "METHOD_FAILED";
}
reply(std::move(res));
}, Qt::QueuedConnection);
}
void PlainTransportHost::onMethods(const MethodsMessage& req, MethodsReply reply)
{
QObject* obj = nullptr;
{
std::lock_guard<std::mutex> g(m_mu);
auto it = m_published.find(req.object);
if (it != m_published.end()) obj = it->second.object;
}
if (!obj) {
MethodsResultMessage res; res.id = req.id; res.ok = false;
res.err = "object not published";
reply(std::move(res));
return;
}
uint64_t id = req.id;
QMetaObject::invokeMethod(obj, [obj, id, reply]() {
QJsonArray arr;
QMetaObject::invokeMethod(obj, "getPluginMethods",
Qt::DirectConnection,
Q_RETURN_ARG(QJsonArray, arr));
MethodsResultMessage res;
res.id = id;
res.ok = true;
res.methods = methodsFromJsonArray(arr);
reply(std::move(res));
}, Qt::QueuedConnection);
}
void PlainTransportHost::onSubscribe(const SubscribeMessage& req, EventSink sink,
const void* connectionId)
{
std::lock_guard<std::mutex> g(m_mu);
// Recorded whether or not `req.object` is published YET. A consumer
// subscribes exactly when it most likely is not — the host is listening but
// the module has not published — and refusing here loses the subscription
// silently: the consumer's requestObject() already succeeded, so nothing
// upstream knows to retry. The sink simply waits for the object to appear,
// which is the same contract onEventWhenAvailable() offers a layer up.
//
// ASSIGNING over an existing sink for the same pair is correct and not a
// clobber: every sink this host builds for one connection is the identical
// "write it back down that socket", so a repeated Subscribe is an idempotent
// re-assertion (a consumer sends one per handle). The consumer owns the
// demultiplexing — see incoming_call_handler.h.
m_sinks[req.object][req.eventName][connectionId] = std::move(sink);
}
void PlainTransportHost::onUnsubscribe(const UnsubscribeMessage& req,
const void* connectionId)
{
std::lock_guard<std::mutex> g(m_mu);
auto it = m_sinks.find(req.object);
if (it == m_sinks.end()) return;
auto evtIt = it->second.find(req.eventName);
if (evtIt == it->second.end()) return;
// Only drop the requesting connection's sink — other clients
// subscribed to the same (object, event) keep theirs. Previously
// this erased the entire eventName entry, taking every other
// subscriber down with it.
evtIt->second.erase(connectionId);
if (evtIt->second.empty()) it->second.erase(evtIt);
if (it->second.empty()) m_sinks.erase(it);
}
void PlainTransportHost::onConnectionClosed(const void* connectionId)
{
std::lock_guard<std::mutex> g(m_mu);
// Sweep every published object's per-event sink table and drop any
// entries belonging to the closed connection. Without this, a
// crashing/disconnecting client (which never sends Unsubscribe)
// leaves dead sinks in the table forever.
for (auto objIt = m_sinks.begin(); objIt != m_sinks.end(); ) {
for (auto evtIt = objIt->second.begin(); evtIt != objIt->second.end(); ) {
evtIt->second.erase(connectionId);
if (evtIt->second.empty()) evtIt = objIt->second.erase(evtIt);
else ++evtIt;
}
if (objIt->second.empty()) objIt = m_sinks.erase(objIt);
else ++objIt;
}
}
void PlainTransportHost::onToken(const TokenMessage& req)
{
QObject* obj = nullptr;
{
std::lock_guard<std::mutex> g(m_mu);
// Route token to the module matching req.moduleName if we host
// it; otherwise the first published module (matches today's behavior
// for the single-published-object provider pattern).
auto it = m_published.find(req.moduleName);
if (it != m_published.end()) obj = it->second.object;
else if (!m_published.empty()) obj = m_published.begin()->second.object;
}
if (!obj) return;
QString authToken = QString::fromStdString(req.authToken);
QString moduleName = QString::fromStdString(req.moduleName);
QString token = QString::fromStdString(req.token);
QMetaObject::invokeMethod(obj, "informModuleToken",
Qt::QueuedConnection,
Q_ARG(QString, authToken),
Q_ARG(QString, moduleName),
Q_ARG(QString, token));
}
} // namespace logos::plain