Per-module concurrent dispatch: async provider seam + transports (#5)

* 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>
This commit is contained in:
Dario Lipicar
2026-06-19 15:54:42 -03:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9de4165ab6
commit 4ea32a314a
11 changed files with 529 additions and 49 deletions
+27 -12
View File
@@ -44,12 +44,22 @@ bool RpcServerTcp::start()
void RpcServerTcp::stop()
{
std::lock_guard<std::mutex> g(m_mu);
m_stopped = true;
boost::system::error_code ignore;
m_acceptor.close(ignore);
for (auto& c : m_conns) c->stop("server stopped");
m_conns.clear();
// Move the connection list OUT under the lock, then release it before
// stopping each connection. conn->stop() fails the connection, which
// synchronously invokes its error handler (set in doAccept) — and that
// handler locks m_mu to erase itself from m_conns. Holding m_mu across the
// stop() call would re-enter this non-recursive mutex on the same thread and
// self-deadlock. The handler's erase is then a harmless no-op (the list it
// scans is already empty).
std::vector<std::shared_ptr<TcpConnection>> conns;
{
std::lock_guard<std::mutex> g(m_mu);
m_stopped = true;
boost::system::error_code ignore;
m_acceptor.close(ignore);
conns.swap(m_conns);
}
for (auto& c : conns) c->stop("server stopped");
}
void RpcServerTcp::doAccept()
@@ -118,12 +128,17 @@ bool RpcServerSsl::start()
void RpcServerSsl::stop()
{
std::lock_guard<std::mutex> g(m_mu);
m_stopped = true;
boost::system::error_code ignore;
m_acceptor.close(ignore);
for (auto& c : m_conns) c->stop("server stopped");
m_conns.clear();
// See RpcServerTcp::stop — release m_mu before stopping connections so the
// per-connection error handler (which re-locks m_mu) can't self-deadlock.
std::vector<std::shared_ptr<SslConnection>> conns;
{
std::lock_guard<std::mutex> g(m_mu);
m_stopped = true;
boost::system::error_code ignore;
m_acceptor.close(ignore);
conns.swap(m_conns);
}
for (auto& c : conns) c->stop("server stopped");
}
void RpcServerSsl::doAccept()