mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 05:51:08 +00:00
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:
co-authored by
Claude Opus 4.8
parent
9de4165ab6
commit
4ea32a314a
+42
-29
@@ -168,43 +168,56 @@ void LogosAPIClient::invokeRemoteMethodAsync(const QString& objectName, const QS
|
||||
QString token = getToken(objectName);
|
||||
|
||||
if (token.isEmpty() && objectName != "capability_module" && m_capability_consumer) {
|
||||
// Async-chain: dispatch the requestModule call asynchronously,
|
||||
// and only fire the real method's invokeRemoteMethodAsync from
|
||||
// its callback. The previous version called `requestModule`
|
||||
// synchronously here, which made the "async" entry point
|
||||
// block its caller for the full requestModule round-trip
|
||||
// (a real perf hit when capability_module has any latency).
|
||||
// Async-chain: dispatch the requestModule call asynchronously, and only
|
||||
// fire the real method's invokeRemoteMethodAsync from its callback. The
|
||||
// previous version called `requestModule` synchronously here, which made
|
||||
// the "async" entry point block its caller for the full round-trip.
|
||||
//
|
||||
// Lifetime: the inner callback captures m_consumer through a
|
||||
// QPointer guard. If the LogosAPIClient (and thus its
|
||||
// QObject-parented m_consumer) is destroyed while the
|
||||
// requestModule round-trip is still in flight, the QPointer
|
||||
// goes null and the inner dispatch is suppressed instead of
|
||||
// dereferencing dangling memory.
|
||||
// COALESCE concurrent first-calls behind ONE handshake. A driver that
|
||||
// fans out N async calls to an un-tokened target before any completes
|
||||
// would otherwise fire N separate requestModule handshakes; each mints a
|
||||
// distinct token and informs the target, and the later inform OVERWRITES
|
||||
// the earlier token there (the target stores one token per caller). The
|
||||
// already-dispatched calls then carry a superseded token and the target
|
||||
// rejects them as unauthorized. So only the first caller starts the
|
||||
// handshake; the rest queue and all drain with the single minted token.
|
||||
// (The sync path can't hit this — it blocks per call, so handshakes
|
||||
// never overlap.) m_pendingHandshakes is touched only on the owner
|
||||
// thread, reached above, so no lock is needed.
|
||||
m_pendingHandshakes[objectName].push_back(
|
||||
[this, objectName, methodName, args, timeout, cb = std::move(callback)]
|
||||
(const QString& tok) mutable {
|
||||
m_consumer->invokeRemoteMethodAsync(tok, objectName, methodName, args,
|
||||
std::move(cb), timeout);
|
||||
});
|
||||
if (m_pendingHandshakes[objectName].size() > 1)
|
||||
return; // a handshake for this target is already in flight
|
||||
|
||||
const QString capabilityToken = getToken("capability_module");
|
||||
const QString origin = m_origin_module;
|
||||
QPointer<LogosAPIConsumer> consumer = m_consumer;
|
||||
auto outerCallback = std::move(callback);
|
||||
// Lifetime: capture the client through a QPointer guard. If it (and its
|
||||
// QObject-parented consumers + the pending queue) is destroyed while the
|
||||
// requestModule round-trip is in flight, the guard goes null and we drop
|
||||
// the queued continuations instead of dereferencing dangling memory.
|
||||
QPointer<LogosAPIClient> self(this);
|
||||
m_capability_consumer->invokeRemoteMethodAsync(
|
||||
capabilityToken,
|
||||
QStringLiteral("capability_module"),
|
||||
QStringLiteral("requestModule"),
|
||||
QVariantList() << origin << objectName,
|
||||
[consumer, objectName, methodName, args, timeout,
|
||||
outerCallback = std::move(outerCallback)]
|
||||
(const QVariant& tokenResult) mutable {
|
||||
if (!consumer) {
|
||||
// Client was destroyed mid-flight. Honour the
|
||||
// contract by firing the outer callback with an
|
||||
// invalid QVariant so callers don't deadlock
|
||||
// waiting for a result that'll never come.
|
||||
if (outerCallback) outerCallback(QVariant{});
|
||||
return;
|
||||
}
|
||||
consumer->invokeRemoteMethodAsync(
|
||||
tokenResult.toString(),
|
||||
objectName, methodName, args,
|
||||
std::move(outerCallback), timeout);
|
||||
[self, objectName](const QVariant& tokenResult) mutable {
|
||||
if (!self) return; // client destroyed mid-flight
|
||||
const QString tok = tokenResult.toString();
|
||||
// Drain every continuation queued for this target with the one
|
||||
// minted token — the target was informed of exactly this token.
|
||||
// An empty tok (handshake failed) still flows through: the
|
||||
// consumer call is then rejected and each callback fires with an
|
||||
// invalid QVariant, so callers never hang.
|
||||
auto it = self->m_pendingHandshakes.find(objectName);
|
||||
if (it == self->m_pendingHandshakes.end()) return;
|
||||
std::vector<std::function<void(const QString&)>> calls = std::move(it.value());
|
||||
self->m_pendingHandshakes.erase(it);
|
||||
for (auto& c : calls) c(tok);
|
||||
},
|
||||
timeout);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user