mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
* perf(plain): fold the per-call waiter thread into a call object with its own clock An async call on the plain transport used to be an OS thread whose entire job was to be blockable: std::future cannot be waited on with a deadline AND a cancel, so the waiter polled it in 25ms slices, parked on a condition variable for the deferred half, and delivered. Three costs came with that — one thread per pending RPC, a 25ms floor on teardown, and a registry-plus-reaping protocol to stop finished threads accumulating, because a thread cannot join itself. The TODO in callMethodAsyncWithError has said to fold it away since it was written. A call is now a shared_ptr<AsyncCall>: state that the reply (delivered as a handler rather than parked in a promise), a deadline, and cancellation race to finish. Nothing captures `this`. Handlers hold a shared_ptr to their AsyncCall and a weak_ptr to CallState, so "no handler touches a destroyed object" is true by construction rather than by a barrier, and the join is replaced by ownership. postToQtEventLoop is kept verbatim as the re-entrancy firebreak: all four completion sites route through it, so no user callback ever runs on an Asio stack. Measured against pristine0f26ffd, same probe compiled into both: * 32 calls parked in a provider that will not answer: threads 5 -> 37 on master, 6 -> 6 here (the 6th is the deadline clock below, not per-call); * release() with those 32 in flight: 26-29ms -> 0ms; * 24,000 calls resolved by their deadline and never answered: 8.5MB of resident memory on master, 353 bytes per call growing strictly linearly, against 0.95MB here that has stopped growing by 3,000 calls. THE DEADLINE GETS ITS OWN THREAD, and that is the one decision worth arguing with. Hanging the per-call timer off the connection's strand is the obvious move and it has a real regression: IoContextPool runs ONE thread for the whole process and this transport delivers user onEvent callbacks INLINE on it, so an event handler that calls another module — ordinary module code — holds every deadline in the process. Measured on that design: an onEvent handler making a 2000ms call delayed a 200ms deadline on a DIFFERENT connection to 2003ms, and a handler that never returns meant the deadline never fired at all. A second io thread does not fix it (user handlers are unbounded, so N blocked handlers need N+1 threads); moving inline event delivery off the strand is a much larger change to event ordering for every consumer; a Qt timer is strictly worse, because a synchronous call from the Qt thread blocks that loop too. So: DeadlineService, one thread process-wide, doing nothing but arming, cancelling and firing timers. Both shapes are back to 200ms and 250ms, and are pinned. Two things closed on the way, neither of them inherited: * RpcConnection::cancelPending(). m_pendingCalls was emptied only by a decoded reply and by fail()'s sweep, so a call resolved by its DEADLINE left its registration there for the life of the connection — which outlives every handle it hands out. That was true of the promise before this change too; the fold would have made the orphan bigger, so it is closed rather than passed on. The sync call path and getMethods withdraw theirs as well. * A sentinel that arrives after its own deadline used to be filed under CallState::deferred by a reply handler that had not noticed the call was already resolved. deliver() therefore leaves the registries BEFORE the exactly-once gate, not after it. TESTS. tests/protocol/test_iofold.cpp is the evidence, and its detectors are validated by an explicit inverted build rather than asserted: cmake -S tests -B build-broken -DLOGOS_PROTOCOL_DETECTOR_INVERSIONS=ON which removes the exactly-once CAS and puts the deadline back on the shared io_context. In that build the deadline tests fail at 2002ms and never-fires respectively, and the release-vs-replies race reports 6-16 double deliveries per 10,000 calls. Finding a race wide enough to be a RELIABLE exactly-once detector took three attempts and the two rejected candidates are documented in the file: the plain outcomes are weak detectors (one resolver, one deliver()), and release-against-a-single-completion caught one double in seven runs. Teardown against a burst of arriving replies is the one that works, because teardown snapshots the whole in-flight map and then delivers with the lock released. test_plain_waiter_publish_is_last.cpp is DELETED. It pinned exactly one rule — publishFinishedWaiter() is a waiter thread's last access to the object, which was the only reason stopAndJoinWaiters() could return while a reaper was still mid-join. There are no waiter threads, no reaper and no publish list, so there is no ordering left to pin; the property it protected is now structural. test_plain_waiter_reaping.cpp is retargeted at CallState::inflight, keeping its claims and losing its mechanism. The four guarantees from #41 all re-measured on this branch: no thread growth with in-flight calls, teardown 0ms with 32 outstanding, exactly one callback on normal/deferred/timeout/cancellation counted per call over 10,000 calls including a release race, and retention final 0 on both registries. Guard Malloc clean over the lifetime suites (27 tests), with the completion-subscription detector still faulting 3/3 on pristine master. ctest 296/296 (287 before, minus 3 deleted, plus 12); nix build .#tests 296/296. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plain): say what cancelPending() actually guarantees, and pin it rpc_connection.h claimed that "a caller that gives up before the reply arrives calls cancelPending() and is never called back at all". It is not true. dispatchIncoming copies the handler out of m_pendingCalls under m_mu and invokes it with the mutex RELEASED, so a cancelPending() landing in that gap erases an entry that is already gone and returns having stopped nothing — the handler then runs to completion, after cancelPending() has returned. THE CODE IS FINE; THE COMMENT WAS NOT, and the distinction it was blurring is the load-bearing one: * at-most-once INVOCATION of a registered handler is this layer's, and comes from the extract-and-erase under m_mu — three contenders (dispatchIncoming, fail()'s sweep, cancelPending) and only one can have it; * exactly-once DELIVERY to the user is NOT this layer's. It is AsyncCall::deliver()'s CAS, and nothing else. Both callers of sendCallAsync() were checked, because a non-idempotent one would have made this a bug rather than a comment. PlainLogosObject funnels every outcome into deliver(). RpcConnection::sendCall()'s promise handler has no CAS and needs none: only one contender ever reaches it, and fulfilling a future its caller has already walked away from is a no-op. Same for the two cancelPending() callers that are not deliver() — the sync callMethodWithError timeout and getMethods(). Three comment sites corrected (ResultHandler, dispatchIncoming's Result arm, cancelPending's own contract) and the claims moved out of prose into tests/protocol/test_plain_cancel_pending_race.cpp, which BUILDS the interleaving instead of racing for it: a stub connection reproduces dispatchIncoming's extract-then-invoke and lets the test stand between the halves. Four tests — the callback that fires after cancelPending() returns, the extracted reply racing teardown (exactly one delivery), the promise-shaped handler in the same gap, and a real RpcConnection pair proving the half of the old comment that IS true. The exactly-once one goes RED under -DLOGOS_PROTOCOL_DETECTOR_INVERSIONS=ON: 2 deliveries for 1 call, deterministically rather than probabilistically. Six tests now go red in that build, listed in tests/protocol/CMakeLists.txt. Full suite 302/302. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(plain): take the fold's detector inversions out of the transport too Three more compiled-in alternative implementations, same anti-pattern as the one the base branch just lost: production source holding a second, deliberately wrong version of its own contract so the suite could be built once with the mechanism removed. * deadlineContext() had LOGOS_PLAIN_DETECTOR_BREAK_DEADLINE_ISOLATION returning IoContextPool::shared().ioContext() — the rejected design DeadlineService exists to avoid. Kept DeadlineService::shared() .context(); the io_context_pool.h include the fold added for that branch alone goes with it. * AsyncCall::claim() had LOGOS_PLAIN_DETECTOR_BREAK_ONCE storing `delivered` and returning true unconditionally. Kept the CAS. * AsyncCall::takeCallback() had the same macro returning a COPY of the callback. Kept the swap. And the CMake option that defined all three, whose surviving content — which six tests are real detectors, and that the per-path exactly-once tests are PINS rather than detectors because a call resolved once calls deliver() once whatever guards it — moved into the note that replaces it. The comments now describe the validation that actually happened: a local edit in a throwaway checkout, with the numbers each run produced. The sub-order detector needs no edit at all, since it goes red on pristine master. 302 tests pass, unchanged in count: no test deleted or weakened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(iofold): count the provider's completion workers so none outlives the proxy Fixes the Linux SIGSEGV (exit 139) in the "Run protocol tests" step. `OmniProvider::defer` is a "multi" provider: it answers with a pending sentinel and pushes the real completion later from a worker of its own. That worker calls the EventCallback ModuleProxy handed the provider, and that listener captures `this` RAW (module_proxy.cpp) — its first act is `QMetaObject::invokeMethod(this, …, Qt::QueuedConnection)`, which dereferences the QObject. The worker was spawned DETACHED, so nothing proved it had finished, and `~LiveHost` runs `delete m_proxy` a couple of milliseconds after the last spawn. Nothing keeps the two apart. A round of ReleaseRacingAnInFlightCompletionIsSafe ends when the CALLER is answered, and a release()d call is answered by teardown — so the round can be over before the provider has run at all, and the proxy's thread is still working through a backlog of `defer` calls while ~LiveHost is already tearing down. Two to five workers were standing in the same frame at the moment of the fault: Thread "QThread" received signal SIGSEGV QObject::thread() const QMetaObject::invokeMethodImpl(QObject*, …) ModuleProxy::ModuleProxy(...)::<lambda(const QString&, const QVariantList&)> module_proxy.cpp:37 std::function<void(const QString&, const QList<QVariant>&)>::operator() OmniProvider::callMethod(...)::<lambda()> test_iofold.cpp, in defer std::thread::_State_impl<…>::_M_run() WHY ONLY THE WHOLE-BINARY RUN. The corpse is left by the test that spawned the worker and lands in whichever test runs NEXT — in CI, always ReleaseFromInsideAnIoThreadEventCallbackDoesNotWedge, which follows the 300-round ReleaseRacingAnInFlightCompletionIsSafe and its 300 `defer` calls. Under ctest every test is its own process, so the worker dies with the process that owned the proxy and there is nothing left to fault: `nix build '.#tests'` is green on the unfixed tree, which is exactly how this hid. Measured on Linux (aarch64, Qt 6.9.2, gcc 14.3, `nix build '.#tests'` artifact, QT_QPA_PLATFORM=offscreen, five concurrent copies): tree IoFoldTest.* whole binary master (03842db) n/a (no test) 0/10 feat/plain-async-io-fold 6/50 (12%) 2/20 (10%) + harness-host-thread-affinity 10/50 (20%) 5/20 (25%) this commit, on either tree 0/50 0/20 So it is latent HERE and merely widened by destroying the fixtures' host on the proxy's thread: that order lets the queued `defer` backlog RUN instead of discarding it with QThread::quit(), which is why the rate roughly doubles. The defect and the fix both belong to this commit's tree. macOS never opens the window — 3/3 clean whole-binary runs on the unfixed tree, and 4/4 clean under Guard Malloc (which unmaps the freed page, so a late worker would fault every time), which is why that half of the matrix stayed green. A COUNT, NOT A JOIN. Joinable workers would hold their 8MB stacks until reaped — 400 outstanding in NormalAndDeferredCompletionsDeliverExactlyOnceAtVolume — and reaping from the dispatch thread would block the very thread `defer` exists to free. So they stay detached and the provider counts them, with the decrement and its notify under one mutex so a drain() woken by it cannot return before the worker has released that mutex. ~LiveHost drains AFTER `m_thread->wait()`: the proxy's event loop has stopped, so no queued call can reach the provider any more and the worker set is FINAL — before that point a drain could pass and the backlog spawn more. ~OmniProvider drains too, because a worker's last act touches one of its members. Verification, all on the fixed tree: * `nix build '.#tests'` — 402/402, Linux and macOS (173s on macOS). * 20 whole-binary Linux runs: 402/402 every time. * Exactly-once, on the release-race shape rather than the per-path pins: 200,000 calls released mid-burst across those 20 runs (20 rounds x 500 each) reported DOUBLE deliveries=0 dropped=0, and all 20 runs of ReleaseRacingAnInFlightCompletionIsSafe were 300/300 with 0 doubles. * Teardown stays fast: `release()` 0-2ms with 32 calls in flight, and the added wait is on the FIXTURE, not on release() — it delays `delete m_proxy` by however long a completion worker still had to sleep (<=2.4ms here). * No production code touched, so "no user callback inline on an io thread" and the deadline guarantees are unchanged. Not touched: test_plain_completion_sub_order.cpp (InstantMultiModule) and test_concurrent_dispatch.cpp spawn the same detached completion worker, and read the callback member through a captured `this` on top of it. Neither has been observed to fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
903 lines
41 KiB
C++
903 lines
41 KiB
C++
#include "plain_logos_object.h"
|
|
|
|
#include "logos_async_dispatch.h"
|
|
#include "qvariant_rpc_value.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QDebug>
|
|
#include <QMetaObject>
|
|
#include <QTimer>
|
|
#include <QVariantMap>
|
|
|
|
#include <boost/asio/error.hpp>
|
|
#include <boost/asio/executor_work_guard.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/post.hpp>
|
|
#include <boost/asio/steady_timer.hpp>
|
|
#include <boost/asio/strand.hpp>
|
|
#include <boost/system/error_code.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <future>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace logos::plain {
|
|
|
|
namespace {
|
|
|
|
// The default the deferred half of a call falls back to when the caller gave a
|
|
// non-positive timeout — the value awaitCompletion has always used, kept so the
|
|
// async path and the sync path give up at the same moment.
|
|
constexpr int kDeferredFallbackMs = 30000;
|
|
|
|
// The honest code for "the object was released while your call was in flight".
|
|
//
|
|
// logos_call_error.h's vocabulary is part of the wire contract, so this reuses
|
|
// it rather than minting a code. "transport_error" is defined there as "the
|
|
// connection failed or was torn down mid-call", which is exactly what happened:
|
|
// the consumer tore its own end of the call channel down. Every alternative in
|
|
// that set misattributes the failure — "object_unavailable" says the module is
|
|
// not there (it is, and it is very likely about to answer; callers re-acquire
|
|
// on that code), "call_failed" blames the peer for a dispatch it performed
|
|
// perfectly well, and "timeout" — what this used to report, after waiting the
|
|
// deadline out — claims a deadline elapsed that did not. It is also already the
|
|
// code the wire produces for the same event seen from the other end:
|
|
// callErrorFromWire maps TRANSPORT_CLOSED / TRANSPORT_ERROR to transport_error.
|
|
logos::CallError callErrorReleased(const std::string& objectName,
|
|
const std::string& method)
|
|
{
|
|
return logos::callErrorTransport(
|
|
objectName,
|
|
"call to '" + objectName + "." + method + "' was abandoned: the object "
|
|
"was released while the call was in flight");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// DeadlineService — the clock the per-call deadlines hang off. ONE thread for
|
|
// the whole process, and deliberately NOT the one the connections run on.
|
|
//
|
|
// WHY IT IS SEPARATE, which is the single most important decision in this file.
|
|
// Folding the per-call waiter thread away means the deadline has to live
|
|
// somewhere else, and the obvious somewhere — the connection's own strand, on
|
|
// IoContextPool::shared() — makes every deadline in the process hostage to that
|
|
// one io thread. It is not a theoretical hostage: this transport delivers user
|
|
// onEvent callbacks INLINE on the io thread (rpc_connection.h dispatchIncoming),
|
|
// and an event handler that calls another module is ordinary module code. A
|
|
// handler making a 2000ms synchronous call while a 200ms deadline is outstanding
|
|
// on a COMPLETELY DIFFERENT connection made that deadline fire at 2003ms;
|
|
// measured, and the reason this class exists. With the ambient timeouts in this
|
|
// stack — 5s in getMethods, 30s in the deferred fallback — a 300ms deadline
|
|
// becomes multi-second, and a handler that blocks forever means the deadline
|
|
// never fires at all. The whole point of a timeout is that it is the thing that
|
|
// still works when everything else is stuck.
|
|
//
|
|
// The three alternatives, and why not:
|
|
//
|
|
// * A SECOND io thread in IoContextPool. Does not fix it — user handlers are
|
|
// unbounded, so N simultaneously-blocked handlers need N+1 threads, and the
|
|
// count is not knowable. It would also quietly break every "serialized by
|
|
// there being one thread" assumption in the transport, which is a far larger
|
|
// blast radius than this class.
|
|
// * MOVING INLINE EVENT DELIVERY OFF THE STRAND (post user callbacks to the
|
|
// Qt loop). Correct direction, much bigger change: it alters event ordering
|
|
// and re-entrancy for every existing consumer of this transport, and it does
|
|
// not help a deadline while the Qt loop itself is blocked.
|
|
// * A Qt TIMER on the Qt event loop. Strictly worse than either: a
|
|
// synchronous callMethod issued from the Qt thread — the most ordinary thing
|
|
// a module does — blocks that loop for the whole call, so the deadline would
|
|
// be hostage to exactly the calls it is supposed to bound.
|
|
//
|
|
// So: one dedicated thread, process-wide, that does nothing but arm, cancel and
|
|
// fire timers. It restores the independence the per-call waiter threads had, at
|
|
// one thread instead of one per pending call, which is the entire point of the
|
|
// fold. Nothing else may ever be posted here; user code reaches the Qt loop via
|
|
// postToQtEventLoop, and AsyncCall::deliver() is a flag, two map erases and a
|
|
// post.
|
|
// -----------------------------------------------------------------------------
|
|
class DeadlineService {
|
|
public:
|
|
static DeadlineService& shared()
|
|
{
|
|
// Lazy, like IoContextPool::shared(): a process that never makes an
|
|
// async plain call never starts this thread.
|
|
static DeadlineService svc;
|
|
return svc;
|
|
}
|
|
|
|
boost::asio::io_context& context() { return m_ioc; }
|
|
|
|
DeadlineService(const DeadlineService&) = delete;
|
|
DeadlineService& operator=(const DeadlineService&) = delete;
|
|
|
|
private:
|
|
DeadlineService()
|
|
: m_guard(boost::asio::make_work_guard(m_ioc))
|
|
, m_thread([this] { m_ioc.run(); })
|
|
{}
|
|
|
|
~DeadlineService()
|
|
{
|
|
m_guard.reset();
|
|
m_ioc.stop();
|
|
if (m_thread.joinable())
|
|
m_thread.join();
|
|
}
|
|
|
|
boost::asio::io_context m_ioc;
|
|
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> m_guard;
|
|
std::thread m_thread;
|
|
};
|
|
|
|
// The one place the choice above is made. Every per-call deadline in the process
|
|
// is armed on this context and nothing else is ever posted to it.
|
|
//
|
|
// The rejected design is one token different — IoContextPool::shared()
|
|
// .ioContext(), the connections' own thread — which is what makes the two tests
|
|
// in test_iofold.cpp that measure deadline accuracy under io-thread load worth
|
|
// having, and how they were validated. See that file for the numbers.
|
|
boost::asio::io_context& deadlineContext()
|
|
{
|
|
return DeadlineService::shared().context();
|
|
}
|
|
|
|
// Hand `callback(result)` over to the Qt event loop so PlainLogosObject's
|
|
// async path matches LogosObject's interface contract: callbacks are
|
|
// always delivered on a subsequent event-loop iteration, on the Qt
|
|
// thread, never synchronously and never racing with QObjects/UI code.
|
|
//
|
|
// Using QCoreApplication::instance() as the anchor means the queued
|
|
// invocation lands on whichever thread runs the Qt event loop in this
|
|
// process, regardless of which worker thread completed the call.
|
|
// If the application has shut down (instance() is null), we drop the
|
|
// callback rather than invoke it from an arbitrary thread.
|
|
//
|
|
// Deliberately a FREE function taking everything BY VALUE, and deliberately not
|
|
// a member: the queued lambda runs on a later event-loop iteration, which for a
|
|
// call cancelled by teardown is after the PlainLogosObject is already gone.
|
|
// Nothing it touches may belong to the object — which is why AsyncCall copies
|
|
// objectName/method up front instead of reading m_objectName from inside here.
|
|
// Do not give this a `this`.
|
|
//
|
|
// THE FOLD MADE THIS LOAD-BEARING TWICE OVER. It was already the reason a
|
|
// delivery could outlive the handle. It is now also the answer to the
|
|
// re-entrancy hazard: every one of the four places that can complete a call —
|
|
// the Asio read handler on the connection's strand, fail()'s sweep on an
|
|
// arbitrary thread, the deadline handler on the timer thread, and teardown on
|
|
// the caller's thread — routes its delivery through here, so NO user callback
|
|
// ever runs on an Asio handler stack. That is the class of bug that produced the
|
|
// deferred-multi SIGSEGV on the QtRO twin, whose fix (remote_transport.cpp) is
|
|
// the same move by a different vehicle: QTimer::singleShot(0).
|
|
void postToQtEventLoop(PlainLogosObject::AsyncResultErrorCallback callback,
|
|
QVariant result, logos::CallError err)
|
|
{
|
|
QCoreApplication* app = QCoreApplication::instance();
|
|
if (!app) return;
|
|
QMetaObject::invokeMethod(app,
|
|
[callback = std::move(callback), result = std::move(result),
|
|
err = std::move(err)]() mutable {
|
|
callback(result, err);
|
|
},
|
|
Qt::QueuedConnection);
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// AsyncCall — one in-flight asynchronous call. THIS IS WHAT REPLACED THE THREAD.
|
|
//
|
|
// The old design gave every pending RPC an OS thread whose only job was to be
|
|
// blockable: std::future cannot be waited on with a deadline AND a cancel, so
|
|
// the waiter polled it in 25ms slices, then parked on a condition variable for
|
|
// the deferred half, then delivered. Three costs came with that — a thread per
|
|
// pending call, a 25ms floor on teardown, and a registry-plus-reaping protocol
|
|
// to stop finished threads accumulating (a thread cannot join itself).
|
|
//
|
|
// Here the call is a piece of STATE that three events race to finish:
|
|
//
|
|
// * the reply, delivered by RpcConnection as a handler (its strand, or any
|
|
// thread via fail(), or inline when the connection is already stopped);
|
|
// * the deadline, an asio::steady_timer on the DeadlineService's own thread;
|
|
// * cancellation, from teardown on an arbitrary thread.
|
|
//
|
|
// EXACTLY ONCE is the `claim()` CAS below, and nothing else. That is a real
|
|
// change of mechanism and the thing most worth distrusting: the old guarantee
|
|
// was structural (one thread, one function, four returns, and a join proving it
|
|
// had finished), whereas three independent callers can all arrive here. The CAS
|
|
// is what makes the first one win and the other two no-ops, on every path.
|
|
//
|
|
// LIFETIME is ownership, not a barrier. Each of those three holds a shared_ptr
|
|
// to this object; the state it needs on the HANDLE is reached through a weak_ptr
|
|
// to CallState, and the connection through a weak_ptr too. Nothing here
|
|
// dereferences the PlainLogosObject, so release()'s `delete this` is none of its
|
|
// business and teardown has nothing to wait for.
|
|
// -----------------------------------------------------------------------------
|
|
struct AsyncCall : std::enable_shared_from_this<AsyncCall> {
|
|
using clock = std::chrono::steady_clock;
|
|
|
|
AsyncCall(std::weak_ptr<PlainLogosObject::CallState> st,
|
|
std::weak_ptr<RpcConnectionBase> cn,
|
|
std::uint64_t callNumber,
|
|
std::string obj, std::string meth, int timeout,
|
|
PlainLogosObject::AsyncResultErrorCallback cb)
|
|
// A strand of its own over the shared deadline thread. With one thread
|
|
// in that service the strand is redundant today; it is here so that
|
|
// "every touch of this timer is serialized" stays a property of the
|
|
// code rather than of the thread count, because asio timers are
|
|
// "Shared objects: Unsafe" and a second service thread would otherwise
|
|
// turn a re-arm racing its own handler into undefined behaviour.
|
|
: timer(boost::asio::make_strand(deadlineContext()))
|
|
, state(std::move(st))
|
|
, conn(std::move(cn))
|
|
, id(callNumber)
|
|
, objectName(std::move(obj))
|
|
, method(std::move(meth))
|
|
, timeoutMs(timeout)
|
|
, callback(std::move(cb))
|
|
{}
|
|
|
|
boost::asio::steady_timer timer;
|
|
std::weak_ptr<PlainLogosObject::CallState> state;
|
|
// Only ever used to withdraw this call's registration from the connection's
|
|
// pending map — see cancelPending(). weak, because the connection outlives
|
|
// the handle but not necessarily this call's last handler.
|
|
std::weak_ptr<RpcConnectionBase> conn;
|
|
const std::uint64_t id;
|
|
const std::string objectName;
|
|
const std::string method;
|
|
const int timeoutMs;
|
|
|
|
// Set under CallState::mu when a "multi" provider defers, read under it in
|
|
// deliver() — the one field two threads can reach.
|
|
QString callId;
|
|
|
|
std::mutex cbMu;
|
|
PlainLogosObject::AsyncResultErrorCallback callback;
|
|
std::atomic<bool> delivered{false};
|
|
|
|
// ── the exactly-once gate ────────────────────────────────────────────────
|
|
//
|
|
// Three independent callers race for the right to resolve a call — the
|
|
// reply handler, the deadline and teardown — and exactly one may reach the
|
|
// user's callback. Two halves, INDEPENDENTLY SUFFICIENT, which is worth
|
|
// recording because it means neither is redundant: the CAS is the one that
|
|
// also skips the registry erase, the pending withdrawal and the timer
|
|
// cancel, and the swap is what makes the callback itself unrepeatable.
|
|
//
|
|
// This replaced a structural guarantee — one waiter thread, one function
|
|
// body, and a join proving it had finished — so it is the guarantee in this
|
|
// file most worth distrusting, and the two tests that actually detect its
|
|
// absence are named in tests/protocol/CMakeLists.txt. The per-path
|
|
// exactly-once assertions are NOT among them: a call resolved once calls
|
|
// deliver() once whatever guards it.
|
|
bool claim()
|
|
{
|
|
bool expected = false;
|
|
return delivered.compare_exchange_strong(expected, true,
|
|
std::memory_order_acq_rel);
|
|
}
|
|
|
|
PlainLogosObject::AsyncResultErrorCallback takeCallback()
|
|
{
|
|
std::lock_guard<std::mutex> g(cbMu);
|
|
PlainLogosObject::AsyncResultErrorCallback cb;
|
|
cb.swap(callback);
|
|
return cb;
|
|
}
|
|
|
|
// Idempotent by construction: every later caller returns without touching
|
|
// the callback, the connection or the timer.
|
|
void deliver(QVariant value, logos::CallError err)
|
|
{
|
|
// LEAVE THE HANDLE'S REGISTRIES FIRST — before the exactly-once gate,
|
|
// and unconditionally, which is not where it reads most naturally.
|
|
//
|
|
// A duplicate deliver() has something to clean up. A provider can
|
|
// answer the pending sentinel AFTER this call's deadline has already
|
|
// passed: the timer resolves the call, and only then does the reply
|
|
// handler arrive, see a sentinel, and file this AsyncCall under
|
|
// CallState::deferred. Behind the gate, that entry would never be taken
|
|
// out again — one leaked map entry per slow-sentinel call, for the life
|
|
// of the handle, which is precisely the retention the fold exists to
|
|
// fix. (The reply handler also checks `delivered` before filing, so this
|
|
// only has to cover the instant between that check and the write; the
|
|
// re-armed deadline is what eventually runs this erase.)
|
|
//
|
|
// Erasing twice is free: ids come from RpcConnection::nextId() and are
|
|
// unique per call, so this can never take out somebody else's entry.
|
|
if (auto st = state.lock()) {
|
|
std::lock_guard<std::mutex> g(st->mu);
|
|
st->inflight.erase(id);
|
|
if (!callId.isEmpty()) st->deferred.erase(callId);
|
|
}
|
|
|
|
if (!claim()) return;
|
|
|
|
PlainLogosObject::AsyncResultErrorCallback cb = takeCallback();
|
|
|
|
// AND LEAVE THE CONNECTION'S. m_pendingCalls is erased by exactly two
|
|
// events on its own — a decoded reply with this id, and fail()'s
|
|
// teardown sweep — so a call resolved by its DEADLINE, or by teardown of
|
|
// the handle rather than of the connection, used to leave its
|
|
// registration there for the whole life of the connection, which
|
|
// outlives every handle it hands out. That was true of the promise this
|
|
// replaced too; it is closed here rather than inherited. When the reply
|
|
// IS what got us here, dispatchIncoming has already erased it and this
|
|
// is a lookup that finds nothing.
|
|
if (auto c = conn.lock()) c->cancelPending(id);
|
|
|
|
cancelTimer();
|
|
// Last, and never with a lock held: the delivery hop.
|
|
if (cb) postToQtEventLoop(std::move(cb), std::move(value), std::move(err));
|
|
}
|
|
|
|
// Callable from ANY thread: the arm is POSTED onto the timer's own strand,
|
|
// so the timer object itself is only ever touched from the deadline thread.
|
|
//
|
|
// `when` is an ABSOLUTE deadline computed by the caller, not a duration, so
|
|
// the post hop cannot stretch it — the timer fires when the caller said it
|
|
// would even if the deadline thread is momentarily busy. `reportMs` is only
|
|
// what the timeout REPORTS; it diverges from the wall time for the deferred
|
|
// half of a non-positive-timeout call, which falls back to 30s the way
|
|
// awaitCompletion always has.
|
|
void armTimer(clock::time_point when, int reportMs)
|
|
{
|
|
auto self = shared_from_this();
|
|
boost::asio::post(timer.get_executor(), [self, when, reportMs] {
|
|
self->timer.expires_at(when);
|
|
self->timer.async_wait([self, reportMs](const boost::system::error_code& ec) {
|
|
if (ec == boost::asio::error::operation_aborted) return;
|
|
self->deliver(QVariant(),
|
|
logos::callErrorTimeout(self->objectName,
|
|
self->method, reportMs));
|
|
});
|
|
});
|
|
}
|
|
|
|
// Also posted, for the same reason. Cancelling matters for retention rather
|
|
// than correctness (the CAS already makes a late timeout a no-op): without
|
|
// it, a call answered in 1ms with a 20s timeout would keep this object alive
|
|
// for the remaining 19.999s.
|
|
void cancelTimer()
|
|
{
|
|
auto self = shared_from_this();
|
|
boost::asio::post(timer.get_executor(), [self] {
|
|
try { self->timer.cancel(); } catch (...) {}
|
|
});
|
|
}
|
|
};
|
|
|
|
PlainLogosObject::PlainLogosObject(std::string objectName,
|
|
std::shared_ptr<RpcConnectionBase> conn)
|
|
: m_objectName(std::move(objectName))
|
|
, m_conn(std::move(conn))
|
|
{
|
|
}
|
|
|
|
PlainLogosObject::~PlainLogosObject()
|
|
{
|
|
disconnectEvents();
|
|
stopAndCancelCalls();
|
|
}
|
|
|
|
void PlainLogosObject::stopAndCancelCalls()
|
|
{
|
|
std::vector<std::shared_ptr<AsyncCall>> outstanding;
|
|
{
|
|
// The flag is published under the same mutex awaitCompletion evaluates
|
|
// its predicate under, so the parked synchronous caller cannot read
|
|
// `false`, decide to sleep, and only then miss the notify_all below.
|
|
std::lock_guard<std::mutex> g(m_state->mu);
|
|
m_state->stopping.store(true, std::memory_order_release);
|
|
outstanding.reserve(m_state->inflight.size());
|
|
for (auto& entry : m_state->inflight)
|
|
outstanding.push_back(entry.second);
|
|
m_state->inflight.clear();
|
|
m_state->deferred.clear();
|
|
// Buffered completions nobody can claim any more. They are dropped here
|
|
// rather than left to the state block's own destruction so that a
|
|
// handler still holding a share of it does not keep them alive.
|
|
m_state->completions.clear();
|
|
}
|
|
m_state->cv.notify_all();
|
|
|
|
// Cancelled OUTSIDE the lock, because deliver() takes it to erase its own
|
|
// registry entries. (It will find nothing — they were just cleared — which
|
|
// is fine and is why this cannot deadlock either way.)
|
|
//
|
|
// A cancelled call still DELIVERS, exactly once. Returning silently would
|
|
// honour the "stop fast" half and break the half that matters more:
|
|
// callMethodAsyncWithError (and lp_invoke_async above it) promise the
|
|
// callback fires exactly once, so a dropped one turns a bounded stall into
|
|
// an unbounded hang in every caller that awaits it.
|
|
for (auto& call : outstanding)
|
|
call->deliver(QVariant(), callErrorReleased(m_objectName, call->method));
|
|
|
|
// And that is the whole of teardown. NOTHING IS WAITED FOR: no thread to
|
|
// join, no io-thread barrier. The reply handler and the deadline handler for
|
|
// a cancelled call may still be queued; each holds its own shared_ptr to the
|
|
// AsyncCall, finds the gate already taken, and drops its share. None of them
|
|
// can reach this object, so it may be deleted the instant this returns.
|
|
//
|
|
// THE BARRIER THAT LOOKS RIGHT AND ISN'T: "post a no-op onto the connection
|
|
// strand and wait for it" would prove no handler is mid-flight, and it
|
|
// wedges the process — IoContextPool runs exactly ONE thread, this transport
|
|
// delivers user event callbacks inline on it, and release()-from-an-event-
|
|
// callback is shipped behaviour (remote_transport.cpp), so the caller can BE
|
|
// the only thread that could drain the barrier. Tried, deadlocked,
|
|
// discarded; ownership is what replaced the join, not a barrier.
|
|
// test_iofold.cpp pins the reentrant case with a watchdog.
|
|
}
|
|
|
|
QVariant PlainLogosObject::callMethod(const QString& authToken,
|
|
const QString& methodName,
|
|
const QVariantList& args,
|
|
int timeoutMs)
|
|
{
|
|
// Adapter over the error-carrying implementation: discards the diagnosis,
|
|
// which is exactly what this entry point has always done.
|
|
return callMethodWithError(authToken, methodName, args, timeoutMs, nullptr);
|
|
}
|
|
|
|
QVariant PlainLogosObject::callMethodWithError(const QString& authToken,
|
|
const QString& methodName,
|
|
const QVariantList& args,
|
|
int timeoutMs,
|
|
logos::CallError* err)
|
|
{
|
|
if (err) err->clear();
|
|
if (!m_conn || !m_conn->isOpen()) {
|
|
if (err)
|
|
*err = logos::callErrorTransport(
|
|
m_objectName, "connection to '" + m_objectName + "' is not open");
|
|
return QVariant();
|
|
}
|
|
|
|
// Subscribe to the completion channel BEFORE sending, so a "multi" provider's
|
|
// completion can't race ahead of the waiter (it's buffered either way).
|
|
ensureCompletionSub();
|
|
|
|
CallMessage msg;
|
|
msg.id = m_conn->nextId();
|
|
msg.authToken = authToken.toStdString();
|
|
msg.object = m_objectName;
|
|
msg.method = methodName.toStdString();
|
|
msg.args = qvariantListToRpcList(args);
|
|
|
|
const std::uint64_t callNumber = msg.id;
|
|
auto fut = m_conn->sendCall(std::move(msg));
|
|
|
|
if (fut.wait_for(std::chrono::milliseconds(timeoutMs)) != std::future_status::ready) {
|
|
qWarning() << "PlainLogosObject::callMethod: timeout for" << methodName;
|
|
// Withdraw the registration this call left in the connection. The sync
|
|
// path has the same orphan the async one does — nothing erases a
|
|
// pending entry whose reply never comes — and the promise behind it
|
|
// holds a future nobody will ever read again.
|
|
m_conn->cancelPending(callNumber);
|
|
if (err)
|
|
*err = logos::callErrorTimeout(m_objectName, methodName.toStdString(),
|
|
timeoutMs);
|
|
return QVariant();
|
|
}
|
|
auto res = fut.get();
|
|
if (!res.ok) {
|
|
qWarning() << "PlainLogosObject::callMethod:" << methodName
|
|
<< "failed:" << QString::fromStdString(res.err);
|
|
// res.errCode / res.err have been on the wire since the plain transport
|
|
// existed; this is the first caller to keep them. MODULE_NOT_LOADED in
|
|
// particular is how "the module isn't there" reaches us on this
|
|
// transport — requestObject never checks publication — so without this
|
|
// the single most common failure was reported as a null result.
|
|
if (err)
|
|
*err = logos::callErrorFromWire(m_objectName, res.errCode, res.err);
|
|
return QVariant();
|
|
}
|
|
const QVariant value = rpcValueToQVariant(res.value);
|
|
// A "multi" provider may have deferred: it returned a pending sentinel and
|
|
// pushes the real result as a completion event. Wait for it, keyed by callId.
|
|
{
|
|
QString callId;
|
|
if (logos::isPendingCallSentinel(value, &callId))
|
|
return awaitCompletion(callId, timeoutMs, methodName, err);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
void PlainLogosObject::ensureCompletionSub()
|
|
{
|
|
// Fast path. Every call after the first pays one acquire load and nothing
|
|
// else — and inherits, through it, the ordering the first caller
|
|
// established (see the header).
|
|
if (m_completionSubscribed.load(std::memory_order_acquire)) return;
|
|
|
|
// Serializing is the whole fix. A second caller that arrives while the
|
|
// first is still inside subscribeToCompletions() BLOCKS here instead of
|
|
// racing ahead with a Call the provider can answer before the Subscribe
|
|
// frame has been enqueued.
|
|
std::call_once(m_completionSubOnce, [this] {
|
|
subscribeToCompletions();
|
|
// Published last, so the fast path above cannot let a caller through on
|
|
// a subscription that is not yet on the strand.
|
|
m_completionSubscribed.store(true, std::memory_order_release);
|
|
});
|
|
}
|
|
|
|
void PlainLogosObject::subscribeToCompletions()
|
|
{
|
|
// Reuse the normal event subscription path (tracked in m_subs, so
|
|
// disconnectEvents() tears it down). The handler fires on the connection's
|
|
// IO thread.
|
|
//
|
|
// It captures a weak_ptr to CallState and nothing else — in particular NOT
|
|
// `this`. That unsubscribe is real (RpcConnection::sendUnsubscribe erases the
|
|
// entry under the connection's mutex) but it is not enough on its own:
|
|
// dispatchIncoming copies the handler out under that mutex and invokes it
|
|
// with the mutex dropped, so an erase racing an already-copied handler
|
|
// changes nothing about the invocation in flight, and nothing joins the io
|
|
// thread. With `this` captured, a completion arriving across a release()
|
|
// wrote into freed memory — see test_plain_completion_sub_lifetime.cpp.
|
|
//
|
|
// weak, not shared, deliberately: locking is what keeps the block alive for
|
|
// the length of one callback, and failing to lock is what makes a handler
|
|
// that outlives its owner — for this reason or any future one — a no-op
|
|
// instead of an append to a map nobody will ever drain.
|
|
std::weak_ptr<CallState> weak = m_state;
|
|
onEvent(logos::callCompleteEvent(), [weak](const QString&, const QVariantList& data) {
|
|
if (data.size() != 2) return;
|
|
const std::shared_ptr<CallState> st = weak.lock();
|
|
if (!st) return; // the handle and its state are both gone
|
|
const QString callId = data.at(0).toString();
|
|
|
|
std::shared_ptr<AsyncCall> call;
|
|
{
|
|
std::lock_guard<std::mutex> g(st->mu);
|
|
auto it = st->deferred.find(callId);
|
|
if (it != st->deferred.end()) {
|
|
call = it->second;
|
|
st->deferred.erase(it);
|
|
} else {
|
|
// Nobody is waiting on it yet: either a SYNCHRONOUS caller is
|
|
// about to park on it, or it arrived before its own sentinel
|
|
// was recorded. Buffer it, exactly as before.
|
|
st->completions[callId] = data.at(1);
|
|
}
|
|
}
|
|
if (call) {
|
|
// Resolves the async call HERE, on the io thread — but deliver()
|
|
// only takes a flag, drops two map entries and posts, so the user's
|
|
// callback still runs on the Qt loop. Called with st->mu released:
|
|
// deliver() takes it.
|
|
call->deliver(data.at(1), logos::CallError{});
|
|
return;
|
|
}
|
|
st->cv.notify_all();
|
|
});
|
|
}
|
|
|
|
QVariant PlainLogosObject::awaitCompletion(const QString& callId, int timeoutMs,
|
|
const QString& methodName,
|
|
logos::CallError* err)
|
|
{
|
|
// A LOCAL SHARE of the state, held for the whole wait. This function only
|
|
// ever runs on the SYNCHRONOUS caller's own thread, so that caller cannot
|
|
// be releasing the handle underneath it — but taking the share costs one
|
|
// atomic increment and removes the question entirely.
|
|
const std::shared_ptr<CallState> st = m_state;
|
|
std::unique_lock<std::mutex> lk(st->mu);
|
|
const auto effectiveMs = timeoutMs > 0 ? timeoutMs : kDeferredFallbackMs;
|
|
const auto deadline = std::chrono::steady_clock::now()
|
|
+ std::chrono::milliseconds(effectiveMs);
|
|
// Interruptible by construction: widen the predicate, and
|
|
// stopAndCancelCalls()' notify_all does the rest. No slicing, so no latency
|
|
// floor at all here — a stop wakes this wait immediately.
|
|
st->cv.wait_until(lk, deadline, [&] {
|
|
return st->completions.count(callId) > 0
|
|
|| st->stopping.load(std::memory_order_relaxed);
|
|
});
|
|
|
|
// AN ANSWER ALREADY IN HAND BEATS A CONCURRENT STOP: there is a real result
|
|
// here, so hand it over rather than manufacture a failure that did not
|
|
// happen. Callers re-acquire, retry and log on transport_error.
|
|
const auto it = st->completions.find(callId);
|
|
if (it != st->completions.end()) {
|
|
const QVariant result = it->second;
|
|
st->completions.erase(it);
|
|
return result;
|
|
}
|
|
if (st->stopping.load(std::memory_order_relaxed)) {
|
|
qWarning() << "PlainLogosObject: deferred call" << callId
|
|
<< "abandoned — object released while it was in flight";
|
|
if (err)
|
|
*err = callErrorReleased(m_objectName, methodName.toStdString());
|
|
return QVariant();
|
|
}
|
|
qWarning() << "PlainLogosObject: deferred call" << callId << "timed out";
|
|
if (err)
|
|
*err = logos::callErrorTimeout(m_objectName, methodName.toStdString(),
|
|
effectiveMs);
|
|
return QVariant();
|
|
}
|
|
|
|
void PlainLogosObject::callMethodAsync(const QString& authToken,
|
|
const QString& methodName,
|
|
const QVariantList& args,
|
|
int timeoutMs,
|
|
AsyncResultCallback callback)
|
|
{
|
|
// Adapter over the error-carrying implementation: discards the diagnosis,
|
|
// which is exactly what this entry point has always done.
|
|
if (!callback) return;
|
|
callMethodAsyncWithError(authToken, methodName, args, timeoutMs,
|
|
[cb = std::move(callback)](QVariant v, const logos::CallError&) mutable {
|
|
cb(std::move(v));
|
|
});
|
|
}
|
|
|
|
void PlainLogosObject::callMethodAsyncWithError(const QString& authToken,
|
|
const QString& methodName,
|
|
const QVariantList& args,
|
|
int timeoutMs,
|
|
AsyncResultErrorCallback callback)
|
|
{
|
|
if (!callback) return;
|
|
if (!m_conn || !m_conn->isOpen()) {
|
|
// Defer even the failure path — LogosObject's contract requires
|
|
// callbacks on a subsequent event-loop iteration, never inline.
|
|
postToQtEventLoop(std::move(callback), QVariant(),
|
|
logos::callErrorTransport(
|
|
m_objectName,
|
|
"connection to '" + m_objectName + "' is not open"));
|
|
return;
|
|
}
|
|
|
|
ensureCompletionSub();
|
|
|
|
CallMessage msg;
|
|
msg.id = m_conn->nextId();
|
|
msg.authToken = authToken.toStdString();
|
|
msg.object = m_objectName;
|
|
msg.method = methodName.toStdString();
|
|
msg.args = qvariantListToRpcList(args);
|
|
|
|
// Copied, not read from the object later: everything below this line may
|
|
// outlive the handle.
|
|
const std::uint64_t callNumber = msg.id;
|
|
const std::string objectName = m_objectName;
|
|
const std::string method = methodName.toStdString();
|
|
|
|
// ── the call, as state rather than as a thread ──────────────────────────
|
|
//
|
|
// There used to be a std::thread here whose entire job was to be blockable,
|
|
// and a TODO saying to fold it into the io_context the connection already
|
|
// runs on. This is that fold. Nothing below spawns, joins, sleeps or polls.
|
|
auto call = std::make_shared<AsyncCall>(m_state, m_conn, callNumber,
|
|
objectName, method, timeoutMs,
|
|
std::move(callback));
|
|
|
|
// The deadline is fixed HERE, before the send, and as an absolute point —
|
|
// so neither the post onto the timer thread nor anything the io thread is
|
|
// doing can stretch what the caller asked for.
|
|
call->armTimer(AsyncCall::clock::now() + std::chrono::milliseconds(timeoutMs),
|
|
timeoutMs);
|
|
|
|
bool refused = false;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_state->mu);
|
|
// Same branch, same honesty as before the fold: stopping is raised only
|
|
// by teardown, so a caller that can read it as true here is already
|
|
// calling a method on an object whose destructor is running — this very
|
|
// load is the use-after-free, and nothing in this function can repair
|
|
// that. It is kept because failing this way — one callback, with the
|
|
// error a cancelled call gets — is strictly better than registering a
|
|
// call nobody will ever cancel.
|
|
//
|
|
// What HAS changed is the blast radius if it ever became reachable: the
|
|
// state it would leak an entry into is shared-owned and would simply
|
|
// outlive the handle, instead of being a thread nobody joins.
|
|
if (m_state->stopping.load(std::memory_order_acquire))
|
|
refused = true;
|
|
else
|
|
m_state->inflight.emplace(callNumber, call);
|
|
}
|
|
// OUTSIDE the lock, and that is not a stylistic preference: deliver() takes
|
|
// CallState::mu to leave the registries, and this mutex is not recursive.
|
|
// Delivering from inside the scope above self-deadlocks — on the one branch
|
|
// whose whole purpose is to fail gracefully.
|
|
if (refused) {
|
|
call->deliver(QVariant(), callErrorReleased(objectName, method));
|
|
return;
|
|
}
|
|
|
|
// The reply. Handed over by RpcConnection as it arrives, on its strand for
|
|
// the normal path — and on an arbitrary thread from fail(), or inline right
|
|
// here if the connection is already stopped. All three are fine: every exit
|
|
// below funnels into AsyncCall::deliver, whose CAS makes the first one win
|
|
// and which hops to the Qt loop rather than running the user's callback on
|
|
// whatever stack it happens to be on.
|
|
std::weak_ptr<CallState> weakState = m_state;
|
|
m_conn->sendCallAsync(std::move(msg), [call, weakState](ResultMessage res) {
|
|
if (!res.ok) {
|
|
call->deliver(QVariant(),
|
|
logos::callErrorFromWire(call->objectName, res.errCode,
|
|
res.err));
|
|
return;
|
|
}
|
|
QVariant value = rpcValueToQVariant(res.value);
|
|
QString callId;
|
|
if (!logos::isPendingCallSentinel(value, &callId)) {
|
|
call->deliver(std::move(value), logos::CallError{});
|
|
return;
|
|
}
|
|
|
|
// ── the deferred ("multi") half ─────────────────────────────────────
|
|
auto st = weakState.lock();
|
|
if (!st) {
|
|
call->deliver(QVariant(),
|
|
callErrorReleased(call->objectName, call->method));
|
|
return;
|
|
}
|
|
|
|
// Already resolved — by the deadline, or by teardown — while this reply
|
|
// was in flight. Filing it under `deferred` now would register a call
|
|
// that only the re-armed deadline would ever take out again (see
|
|
// deliver()).
|
|
if (call->delivered.load(std::memory_order_acquire)) return;
|
|
|
|
QVariant buffered;
|
|
bool haveBuffered = false;
|
|
bool stopping = false;
|
|
{
|
|
std::lock_guard<std::mutex> g(st->mu);
|
|
stopping = st->stopping.load(std::memory_order_relaxed);
|
|
// The completion can be buffered ALREADY. On one ordered connection
|
|
// it cannot be — the provider writes the sentinel result before the
|
|
// completion event, and both are decoded on the same strand in order
|
|
// — but checking costs one lookup and removes the assumption.
|
|
const auto it = st->completions.find(callId);
|
|
if (it != st->completions.end()) {
|
|
buffered = it->second;
|
|
st->completions.erase(it);
|
|
haveBuffered = true;
|
|
} else if (!stopping) {
|
|
call->callId = callId; // written under st->mu, read under it
|
|
st->deferred[callId] = call;
|
|
}
|
|
}
|
|
if (haveBuffered) {
|
|
call->deliver(std::move(buffered), logos::CallError{});
|
|
return;
|
|
}
|
|
if (stopping) {
|
|
call->deliver(QVariant(),
|
|
callErrorReleased(call->objectName, call->method));
|
|
return;
|
|
}
|
|
// A SECOND full deadline, which is what the waiter thread gave it too:
|
|
// it ran the future wait for timeoutMs and then awaitCompletion for
|
|
// another timeoutMs. Preserved deliberately rather than tightened —
|
|
// changing how long a deferred call is allowed to take is a separate
|
|
// decision from removing the thread it used to take it on.
|
|
const int effective =
|
|
call->timeoutMs > 0 ? call->timeoutMs : kDeferredFallbackMs;
|
|
call->armTimer(AsyncCall::clock::now() + std::chrono::milliseconds(effective),
|
|
effective);
|
|
});
|
|
}
|
|
|
|
bool PlainLogosObject::informModuleToken(const QString& authToken,
|
|
const QString& moduleName,
|
|
const QString& token,
|
|
int /*timeoutMs*/)
|
|
{
|
|
if (!m_conn || !m_conn->isOpen()) return false;
|
|
TokenMessage msg;
|
|
msg.authToken = authToken.toStdString();
|
|
msg.moduleName = moduleName.toStdString();
|
|
msg.token = token.toStdString();
|
|
m_conn->sendToken(std::move(msg));
|
|
return true; // fire-and-forget
|
|
}
|
|
|
|
void PlainLogosObject::onEvent(const QString& eventName, EventCallback callback)
|
|
{
|
|
if (!m_conn || !m_conn->isOpen() || !callback) return;
|
|
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
m_subs.emplace_back(eventName, callback);
|
|
}
|
|
|
|
SubscribeMessage msg;
|
|
msg.object = m_objectName;
|
|
msg.eventName = eventName.toStdString();
|
|
|
|
// Bridge RPC event → Qt-flavored callback.
|
|
m_conn->sendSubscribe(std::move(msg), [callback](EventMessage evt) {
|
|
callback(QString::fromStdString(evt.eventName),
|
|
rpcListToQVariantList(evt.data));
|
|
});
|
|
}
|
|
|
|
void PlainLogosObject::disconnectEvents()
|
|
{
|
|
std::vector<std::pair<QString, EventCallback>> subs;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
subs.swap(m_subs);
|
|
}
|
|
if (!m_conn) return;
|
|
for (const auto& [name, _] : subs) {
|
|
UnsubscribeMessage msg;
|
|
msg.object = m_objectName;
|
|
msg.eventName = name.toStdString();
|
|
m_conn->sendUnsubscribe(std::move(msg));
|
|
}
|
|
}
|
|
|
|
void PlainLogosObject::emitEvent(const QString& eventName, const QVariantList& data)
|
|
{
|
|
if (!m_conn || !m_conn->isOpen()) return;
|
|
EventMessage msg;
|
|
msg.object = m_objectName;
|
|
msg.eventName = eventName.toStdString();
|
|
msg.data = qvariantListToRpcList(data);
|
|
m_conn->sendEvent(std::move(msg));
|
|
}
|
|
|
|
QJsonArray PlainLogosObject::getMethods()
|
|
{
|
|
if (!m_conn || !m_conn->isOpen()) return QJsonArray();
|
|
|
|
MethodsMessage msg;
|
|
msg.id = m_conn->nextId();
|
|
msg.object = m_objectName;
|
|
|
|
const std::uint64_t callNumber = msg.id;
|
|
auto fut = m_conn->sendMethods(std::move(msg));
|
|
if (fut.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
|
|
// Same orphan as the sync call path, on the map next door.
|
|
m_conn->cancelPending(callNumber);
|
|
return QJsonArray();
|
|
}
|
|
auto res = fut.get();
|
|
if (!res.ok) return QJsonArray();
|
|
return methodsToJsonArray(res.methods);
|
|
}
|
|
|
|
void PlainLogosObject::release()
|
|
{
|
|
// The RpcConnection is SHARED across every PlainLogosObject a single
|
|
// PlainTransportConnection hands out. Stopping it here would kill
|
|
// the connection for every other holder too, so just unsubscribe our
|
|
// own events and drop our reference — the connection stays alive
|
|
// until PlainTransportConnection itself is destroyed.
|
|
//
|
|
// stopAndCancelCalls() before delete, and it BLOCKS ON NOTHING. In-flight
|
|
// calls used to be threads that captured `this`, so teardown had to ask them
|
|
// to stop and then join them — a wait slice at best, the rest of the call's
|
|
// timeout before that, and a use-after-free if they were merely detached.
|
|
// Now they are shared-owned state that no longer refers to this object at
|
|
// all, so cancelling is a flag, a sweep of the in-flight map, and one
|
|
// callback per abandoned call.
|
|
//
|
|
// That also makes release() safe to call from inside an event callback
|
|
// running on the single io thread — the reentrant shape remote_transport.cpp
|
|
// documents — which a "post a no-op onto the strand and wait for it" barrier
|
|
// could not have been: it would have deadlocked against itself.
|
|
disconnectEvents();
|
|
stopAndCancelCalls();
|
|
m_conn.reset();
|
|
delete this;
|
|
}
|
|
|
|
quintptr PlainLogosObject::id() const
|
|
{
|
|
return reinterpret_cast<quintptr>(m_conn.get());
|
|
}
|
|
|
|
} // namespace logos::plain
|