Files
logos-protocol/tests/protocol/test_delivery_without_qt.cpp
Dario LipicarandClaude Opus 5 0fef299362 fix(plain): deliver async callbacks in a Qt-free host, and make release()-racing-a-call diagnosable (#50)
* fix(plain): deliver async callbacks in a Qt-free host, and make release()-racing-a-call diagnosable

Two pre-existing defects in the plain transport's async surface. Both are
older than #45/#46 and neither is caused by the io_context fold; the fold is
just what this is stacked on.

DEFECT 5 — the async surface promised exactly-once and delivered ZERO in a
Qt-free host. Every completion went through one hop, and the hop was:

    QCoreApplication* app = QCoreApplication::instance();
    if (!app) return;                       // <- the callback, dropped

In a Qt host that branch only fires at shutdown, which is why it read as a
reasonable guard. In a process that never had a QCoreApplication — the
deployment the plain transport exists for — it fires for EVERY call, forever,
on all four resolvers (reply, deferred completion, deadline, cancellation).
Not an error, not a timeout: silence, which turns a bounded call into an
unbounded wait in every caller that awaits it, including lp_invoke_async and
every generated async wrapper.

Fixed with a dedicated DELIVERY THREAD, used only when the process has no Qt
loop. NOT inline on the completing stack: inline delivery on an Asio read
handler is the re-entrancy class that already cost this codebase a SIGSEGV
(deferred-multi completion on the QtRO read stack), so a fix that delivers by
removing the hop is not a fix. NOT the deadline thread either — user callbacks
there would make every deadline in the process hostage to user code, which is
exactly the coupling DeadlineService was extracted to prevent.

The Qt-loop check LATCHES, so Qt hosts see no behavioural difference at all:
instance() also goes null inside ~QCoreApplication, and module teardown after
the application is gone is what static-destruction ordering produces — with
stopAndCancelCalls() handing every in-flight call a cancellation callback at
exactly that moment. Running user code on a side thread into half-destroyed
module state would be a NEW failure mode introduced by a bug-fix change, so a
process that has ever been seen with an event loop keeps the old shutdown
behaviour. logos_object.h now states that residue instead of glossing it.

DEFECT 3 — release() racing a call on another thread. NOT FIXED, because it
cannot be, and the honest answer is a contract plus a detector.

release() ends in `delete this`, so a synchronous call parked in its future
wait dereferences freed memory when it comes back. Reproduced deterministically
on master (exit 139 under Guard Malloc, 3/3) and on cf1b9b0 (exit 139 with AND
without Guard Malloc, 3/3), faulting in callMethodWithError one line after the
wait.

It is not fixable from inside the object: every mechanism that could make the
racing call safe — a refcount, a flag, a lock, an epoch — is a MEMBER, so the
racing thread's first act would be to read it out of storage that has just been
freed. There is no synchronising with a destruction you can only learn about by
reading the destroyed object. Three alternatives were considered and rejected,
each for a stated reason (an atomic alive-flag is check-then-use on freed
memory; a blocking release() breaks the fast-teardown guarantee and deadlocks
in the shipped reentrant shape; an immortal forwarding handle works but trades
the crash for permanent retention proportional to requestObject count, in a
transport whose two preceding changes were spent proving retention does not
grow with call count — and would fix one of four transports). The reasoning is
in the note over PlainLogosObject::release().

So: the contract is stated (logos_object.h, plain_logos_object.h), and the
object counts entries into its public methods and REPORTS when release() or
the destructor finds the count non-zero — aborting in debug builds. The misuse
becomes a named diagnostic at the line that committed it instead of a SIGSEGV
somewhere else. It is a diagnostic, not a rescue, and it is deliberately biased
to under-report rather than ever accuse a correct program.

EVIDENCE, all by running:

  * Defect 5: six detectors in a NEW binary (protocol_noqt_tests) that never
    constructs a QCoreApplication — the state protocol_tests can never reach,
    since its main() constructs one first. All six red on cf1b9b0 (0/300
    replies, 0/40 deferred, 0/20 deadlines, 0/20 cancellations delivered),
    all six green after, including under Guard Malloc.
  * Defect 3: a death test red on BOTH pre-fix trees, 3/3 each, with and
    without Guard Malloc ("died but not with expected error"), green after.
    Its three companion tests prove the detector never fires on a correct
    program, and were themselves validated by deleting the decrement from
    EntryGuard's destructor in a throwaway build: all three then abort.
  * Exactly-once still holds via the release-race shape — the only one that
    detects a broken gate — on both delivery vehicles: 20 rounds x 500 calls
    released mid-burst, 0 double deliveries, 0 dropped, with both resolvers
    live, under Guard Malloc too.
  * nix build '.#tests': 312/312 ctest cases pass. Both installed binaries run
    clean through the exact CI commands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(plain): bound the concurrent-callers wait, and fail the harness loudly

Two ways this file could have reported something other than what it measures.

An unbounded `while (ok < N) processEvents()` does not fail when it goes
wrong — it hangs the CI job until the job timeout, and a hang says nothing
about what broke. Bounded at 60s; the assertion below it then reports the
actual count.

And the death test's harness setup checked the host and the connection but
not the handle, so a failed acquire would have crashed on a null pointer and
been reported as "died but not with expected error" — indistinguishable from
the defect the test is looking for. It now exits 9 with a message, like the
other two harness paths.

Re-validated after the change: still red on cf1b9b0 (3/3), green here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plain): the detector must not touch the object after dropping its count

CI caught this, on Linux, in the shape this whole change is about — the
detector inventing the use-after-free it exists to report.

EntryGuard's destructor restored m_lastEntryPoint AFTER decrementing
m_callsInFlight. That opens a window exactly one store wide: the count reaches
zero, a release() racing on another thread reads zero, concludes nothing is in
flight and runs `delete this`, and the store lands in freed memory. Exit 139
in IoFoldTest.ReleaseFromInsideAnIoThreadEventCallbackDoesNotWedge on
ubuntu-latest; macOS was green in the same run, and the retry was green too,
which is exactly how a one-store window behaves.

The count is now the FIRST and LAST thing either the constructor or the
destructor touches. Between them the object is covered — a concurrent release()
sees a non-zero count and reports. Outside them the guard touches nothing. The
cost is a vaguer message across threads (the restore now happens before the
decrement, so a reader can see the outer frame's name); a diagnostic string is
worth less than not storing into freed memory.

AND THE REASON IT WAS REACHABLE AT ALL: that test really does violate the
contract this PR documents. It issued its triggering `fire` call on the same
handle its io-thread event callback releases, so release() ran while the main
thread was still inside that handle's callMethodAsyncWithError. The violation
was always UB and always silent — the pre-existing code touches no member after
sendCallAsync() returns, so losing the race cost nothing observable — which is
why it stayed green for seven runs on #46. Adding bookkeeping to the epilogue
made it visible.

Both tests with that shape now fire the event through a SECOND handle, which
changes nothing about what they pin: the event still arrives on the io thread,
the handler still releases the handle it was delivered through, and that handle
still has an outstanding call for teardown to cancel.

Verified by running:

  * With a 300ms sleep injected into callMethodAsyncWithError's epilogue — a
    window the old code lost every time — both tests reported
    "LOGOS FATAL: ... callMethodAsyncWithError()" before the fix and are clean
    after it. That is the violation demonstrated and then removed, not narrowed.
  * The same injection at 5ms across the WHOLE suite produces zero LOGOS FATAL
    reports: no other test has this shape. (The one failure it causes,
    IoFoldTest.ReleaseRacingRepliesInFlightDeliversEachCallOnce, is that test's
    own "the race did not run" guard firing because a 5ms-per-call sleep lets
    every reply land before the release — 10000 answered-by-reply, 0
    by-teardown, 0 doubles, 0 drops. Correct behaviour from the test.)
  * Full suite green again: 306/306 Qt, 6/6 no-Qt, and the UAF-sensitive subset
    green under Guard Malloc.
  * Detectors re-validated on cf1b9b0 after the edits: death test still red 3/3.

Also fixes a fragility this found in the new no-Qt race test. In that binary
the provider shares the process's single io thread with the consumer, so under
the nix sandbox the issuing thread enqueued all 500 calls and released before
one reply came back: answered-by-reply=0, cancelled-by-teardown=10000. Zero
doubles and zero drops — but only ONE resolver ran, so the exactly-once
assertion was proving nothing, which is precisely why the "both resolvers were
live" guards are in the test. It now waits for the first reply before
releasing; both resolvers are live every run (byReply 496-744, byTeardown
9256-9504 over six runs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plain): make release() safe against a call already inside the object

The defect this PR reported as unfixable is fixable, and the argument that
said otherwise conflated two different races.

That argument ran: every mechanism that could save the racing call is a
member of the object, so the racing thread's first act would be to read
freed storage. That is true of a call that ENTERS after destruction. It is
false of a call ALREADY INSIDE the object, which is the defect actually
reproduced — a synchronous callMethod parked in its future wait, released
from a second thread, faulting on the next line it executes. That call took
its bookkeeping on the way in, while the object was provably alive, so
release() cannot fail to see it.

So PlainLogosObject carries a live-reference count next to the counter the
detector already added: 1 for the owner plus one per caller inside a public
entry point. release() tears down and then drops THE OWNER'S reference
instead of `delete this`; whoever drops the count to zero destroys the
object, which for a racing call is that call's own thread on its way out.
EntryGuard takes the reference before it touches anything else and drops it
after everything else, because the drop may BE the delete.

  SAFE now: release() concurrent with any call that entered first, sync or
  async, any number of threads; and release() re-entered from inside a call
  or an event callback on the same thread (shipped behaviour, io thread).

  STILL a caller error, and still diagnosed: STARTING a call at or after
  release() — its first act is to increment a counter that may already be
  freed, so nothing in the object can save it — and `delete obj` in place of
  release() with a call in flight, where there is no destruction left to
  defer. Both report and abort in debug builds whenever the object still
  exists to notice; when the storage is already freed there is nothing left
  to look at, and that residue is the documented contract.

Two consequences worth naming. m_conn is no longer reset by release(): the
parked caller's next act is `m_conn->cancelPending(...)`, and resetting a
shared_ptr while another thread reads it is a data race on the shared_ptr
itself. And the object — with its share of the connection — now outlives
release() by however long the slowest call still inside it takes, which is
bounded by that call's own timeout. release() itself still blocks on
nothing: 0ms with an 8000ms call in flight, unchanged.

release() and the destructor call an unguarded disconnectEventsImpl(),
because taking a reference during destruction would drop it again and
recurse into the delete.

VERIFIED by running, on macOS arm64, debug:

  * The reproduction now exits 0 through the real host stack; on cf1b9b0 the
    child dies by signal, 3 runs of 3.
  * The deterministic twin (a connection double that never answers, so the
    park needs no timing assumption): release() returns in 0ms with the call
    parked, destroyed=0 at that moment, destroyed=1 after the caller leaves,
    and the caller reaches its post-wait cancelPending. On cf1b9b0: exit 139,
    with and without Guard Malloc.
  * The tight version — the double answers with a pending sentinel so
    release()'s notify wakes the parked caller inside the window — 400 rounds,
    one destruction each, 0 double deletes. On cf1b9b0 that one is SILENT
    without Guard Malloc and 139 with it, which is noted in the test.
  * Both remaining misuses die with their named diagnostic; both fail on
    cf1b9b0, where no diagnostic exists to match.
  * No false alarms: 310/310 protocol_tests, and with the detector's
    decrement removed by hand all four "not accused" tests abort on a
    correct program (rc=134), which is what makes them detectors.
  * Guard Malloc clean over SyncCallReleaseRace, IoFold, PlainObjectTeardown,
    PlainCompletionSubLifetime, PlainCancelPendingRace, PlainWaiterReaping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plain): keep the Qt-free delivery vehicle alive as long as its callers

The delivery thread this PR added fixed the drop and introduced a new
use-after-free one moment later in the process's life.

DeliveryService was an ordinary function-local static, so it was constructed
on the FIRST async delivery — which means every object with static storage
constructed before that (i.e. everything constructed during dynamic
initialisation) is destroyed AFTER it. A delivery issued from such a
destructor posted into an io_context that had already run its own destructor,
on a thread that had already been joined. Reproduced with nothing but the
null-connection early-return path: SIGSEGV under Guard Malloc inside
scheduler::post_immediate_completion, reached from __cxa_finalize, 3 runs of
3; and without Guard Malloc, silently, as delivered=0 — the exact drop this
class exists to prevent, moved to a later moment. So "exactly once holds for
the whole life of a Qt-free process" was still untrue.

FIX: the service is never destroyed and registers no destructor — a
`new`-ed pointer behind the function-local static, with the destructor
DELETED so no future edit can reintroduce one — and its thread is detached.
There is now no state in which the vehicle is gone but callers remain. The
old destructor's own comment worried about a user callback blocking the join
at static-destruction time; with no join there is no such hang, and exit()
does not wait for a detached thread. Costs: one io_context and one thread in
a process that is ending, and a callback that is RUNNING at process exit can
be cut off — the same exposure a Qt slot has when the loop's thread goes.

The alternative (detect the destroyed service and deliver inline) was
rejected: inline delivery is the re-entrancy class this hop exists to
prevent, and "we are at static destruction, so no io thread is running" is
not knowable from inside postDelivery — the completing thread there can be
IoContextPool's.

ALSO IN THIS COMMIT, because it is the same file and the same claim:

  * THE INLINE CHECK NOW MEASURES NESTING. Test 1 read d.total after
    callMethodAsyncWithError returned and asserted it was zero, which is a
    race against the delivery thread and not an inline check: 7 failures in
    200 runs here (the review reported 3/200 plain, 2/40 under Guard Malloc),
    every one of them with on-caller-thread=0 — i.e. nothing had actually run
    inline. This file already says as much about its own tests 2 and 5. The
    replacement is a thread-local depth marker raised around the issuing call
    and read BY THE DELIVERING THREAD at delivery time: a callback that runs
    inline is nested on the issuing thread and says so from inside itself,
    with no shared state and no timing. Applied to tests 1, 2 and 5, where it
    also strengthens 5 — "did a cancellation run from inside release()" is now
    nesting rather than a thread comparison.
  * A HARNESS LIFETIME BUG in the same file: QtFreeHost held its
    IncomingCallHandler as a member, RpcServer keeps a raw pointer to it and
    nothing joins the io thread, so a frame already read from the socket could
    be dispatched into freed storage. SIGBUS on the io thread inside
    dispatchIncoming, 1 run in 25 (1 in 5 under Guard Malloc) once the run got
    long enough for the io thread to reach the queued frames. The handler is
    now deliberately leaked, which is the shape that cannot lose that race.

CONTRACT WORDING. logos_object.h promised exactly-once unconditionally. It
now promises AT MOST once always, EXACTLY once whenever the callback has
somewhere to run, and enumerates the three process-level cases where it does
not: after ~QCoreApplication in a Qt process; in a process that constructs a
QCoreApplication and never RUNS its loop (queued onto a loop that never
turns — unfixable here, and it was covered by the old unconditional promise);
and in a process whose QCoreApplication was TRANSIENT, where the latch keeps
dropping for the rest of that process's life. That last one is the price of
the first: from inside postDelivery "the app is gone because we are shutting
down" and "a helper's app object went out of scope" are the same observation,
and guessing the other way would run user callbacks on a side thread during
every Qt host's teardown. A process with no QCoreApplication in its life is
NOT on the list — there delivery now holds through static destruction, with
the only residue being the process exiting before the delivery thread runs.

VERIFIED by running, on macOS arm64, debug:

  * The after-main window is now a TEST: a static destructor issues a delivery
    and reports through the process exit code, because no test case runs
    there. On the pre-fix delivery service it fails 3/3 (exit 70,
    delivered=0) and 3/3 under Guard Malloc (139). On this commit:
    delivered=1, off the issuing thread, exit 0.
  * The de-flaked test: 0 failures in 250 runs plain, 0 in 60 under Guard
    Malloc (was 7/200 before).
  * Whole no-Qt binary: 40/40 clean plain, 10/10 clean under Guard Malloc
    (was 1/25 and 1/5 with the SIGBUS above). Process exit adds ~50ms and does
    not hang.
  * All 7 no-Qt tests still fail on cf1b9b0 (0 deliveries), so defect 5 is
    still what it was.
  * 310/310 protocol_tests, 3 runs of 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(noqt): record that the exactly-once gate is TWO gates, not one

Re-validating the release-race exactly-once test against the no-Qt delivery
vehicle turned up a correction to what this suite says about its own
mechanism. AsyncCall guards a duplicate delivery twice — claim()'s
compare-exchange, and the swap in takeCallback() that leaves a second caller
holding an empty std::function — and the note in tests/protocol/CMakeLists.txt
describes only the first.

Measured, on the no-Qt twin (20 rounds x 500 calls released mid-burst):

  * CAS removed, swap intact:  0 doubled deliveries. This test, its Qt twin
    and PlainCancelPendingRaceTest all stay GREEN. So a validation that
    removes only the CAS proves nothing about the gate.
  * both removed:              22 doubled deliveries, this test FAILS — while
    the three per-path exactly-once tests stay green, which is the difference
    between a detector and a pin.

Neither half is redundant: the CAS is what stops a second caller from also
erasing registries and cancelling timers, and the swap is what protects the
callback itself. Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:13:09 -03:00

876 lines
38 KiB
C++

// Async delivery in a process that has NO Qt event loop.
//
// WHAT WAS BROKEN. Every async completion in the plain transport goes through
// one hop, and that hop was:
//
// QCoreApplication* app = QCoreApplication::instance();
// if (!app) return; // <- the callback, dropped
//
// In a Qt-free host — the deployment the plain transport exists for — that
// branch is taken for EVERY call, so `callMethodAsyncWithError` (and
// `lp_invoke_async` above it, and every generated async wrapper above that)
// promised a callback exactly once and delivered zero, forever, silently. The
// caller does not get an error; it gets nothing, which turns a bounded call
// into an unbounded hang.
//
// It could not be observed from protocol_tests, whose main() constructs a
// QCoreApplication before the first test runs — hence this second binary. See
// test_main_noqt.cpp.
//
// WHAT IS PINNED HERE:
//
// 1. The callback ARRIVES, on all four outcomes: an immediate reply, a
// deferred ("multi") completion, a deadline, and a call cancelled by
// release(). Each of these reaches the delivery hop from a different
// thread, and the drop was in the hop, so every one of them was affected.
// 2. It does NOT arrive inline. The constraint on any fix here is that
// delivery must not move onto the Asio stack: this transport delivers user
// event callbacks inline on its single io thread already, and a user
// callback running on an Asio read handler is the re-entrancy class that
// produced a SIGSEGV on the QtRO twin. So the tests assert the callback
// did not run inside the call, did not run on the caller's thread, and did
// not run on the io thread. "Inside the call" is measured as NESTING, by
// the delivering thread, at delivery time — see IssuingScope, and the note
// there about why counting deliveries after the call returns is a race
// rather than a check.
// 3. Exactly once still holds with no Qt loop in the process, including when
// release() races a burst of arriving replies — the same shape
// test_iofold.cpp uses, run here against the delivery thread instead of
// the Qt loop.
// 4. It still arrives AFTER main() HAS RETURNED, from a static destructor —
// the window in which a lazily-constructed delivery vehicle is already
// dead. No test case can run there, so that one is a static destructor
// whose verdict is this binary's exit code; see LateDeliveryProbe.
//
// HOW THE DETECTOR WAS VALIDATED: by running this file against the pre-fix
// tree (feat/plain-async-io-fold, cf1b9b0). Every test below that waits for a
// callback fails there — the waits run out with zero deliveries — which is the
// bug, stated as a test. The numbers are in the PR.
//
// The provider is deliberately Qt-FREE too: an IncomingCallHandler on an
// RpcServerTcp, no ModuleProxy and no QObject, because a QObject provider
// needs a thread with a Qt event loop to dispatch into and this process has
// none. That is also the honest shape — a Qt-free consumer talking to a host
// over a socket.
#include <gtest/gtest.h>
#include "logos_call_error.h"
#include "logos_object.h"
#include "logos_transport_config.h"
#include "io_context_pool.h"
#include "json_codec.h"
#include "plain_logos_object.h"
#include "plain_transport_connection.h"
#include "rpc_server.h"
#include "logos_async_dispatch.h"
#include <QCoreApplication>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using namespace logos::plain;
namespace {
// ── a Qt-free provider ──────────────────────────────────────────────────────
//
// Handles exactly the four shapes the tests need:
// ping — answers immediately
// sink — never answers at all (the caller's deadline resolves it)
// defer — answers with the pending sentinel, then pushes the completion
// event, which is the "multi" provider protocol
// never — answers with a sentinel it never completes (cancellation fodder)
//
// onCall runs on the connection's strand, i.e. the process's single io thread,
// which is exactly the thread the delivery hop must NOT run user callbacks on.
// It is recorded here so the tests can assert that.
class QtFreeHandler : public IncomingCallHandler {
public:
void onCall(const CallMessage& req, CallReply reply) override
{
m_ioThread.store(std::this_thread::get_id());
m_sawCall.store(true);
if (req.method == "sink") return; // no reply, ever
ResultMessage res;
res.id = req.id;
res.ok = true;
if (req.method == "defer" || req.method == "never") {
const std::string callId =
(req.method == "defer" ? "cid-" : "never-")
+ std::to_string(m_counter.fetch_add(1));
RpcMap pending;
pending.emplace(logos::pendingCallKey().toStdString(), RpcValue(callId));
res.value = RpcValue(std::move(pending));
reply(std::move(res));
if (req.method == "never") return;
// The completion is pushed from a worker, the way a real "multi"
// provider does it, so it arrives as an Event frame rather than
// inline in the reply.
EventSink sink = eventSink();
std::thread([sink, callId] {
if (!sink) return;
EventMessage evt;
evt.object = "omni";
evt.eventName = logos::callCompleteEvent().toStdString();
evt.data.push_back(RpcValue(callId));
evt.data.push_back(RpcValue(static_cast<int64_t>(7)));
sink(std::move(evt));
}).detach();
return;
}
res.value = req.args.empty() ? RpcValue(static_cast<int64_t>(1))
: req.args.front();
reply(std::move(res));
}
void onMethods(const MethodsMessage& req, MethodsReply reply) override
{
MethodsResultMessage res;
res.id = req.id;
res.ok = true;
reply(std::move(res));
}
void onSubscribe(const SubscribeMessage&, EventSink sink,
const void* connectionId) override
{
std::lock_guard<std::mutex> g(m_mu);
m_sinks[connectionId] = std::move(sink);
}
void onUnsubscribe(const UnsubscribeMessage&, const void* connectionId) override
{
std::lock_guard<std::mutex> g(m_mu);
m_sinks.erase(connectionId);
}
void onConnectionClosed(const void* connectionId) override
{
std::lock_guard<std::mutex> g(m_mu);
m_sinks.erase(connectionId);
}
void onToken(const TokenMessage&) override {}
std::thread::id ioThread() const { return m_ioThread.load(); }
bool sawCall() const { return m_sawCall.load(); }
private:
EventSink eventSink()
{
std::lock_guard<std::mutex> g(m_mu);
return m_sinks.empty() ? EventSink{} : m_sinks.begin()->second;
}
std::mutex m_mu;
std::map<const void*, EventSink> m_sinks;
std::atomic<std::thread::id> m_ioThread{};
std::atomic<bool> m_sawCall{false};
std::atomic<std::uint64_t> m_counter{0};
};
class QtFreeHost {
public:
QtFreeHost()
{
m_server = std::make_shared<RpcServerTcp>(
IoContextPool::shared().ioContext(), "127.0.0.1", 0,
std::make_shared<JsonCodec>(), m_handler);
m_started = m_server->start();
}
~QtFreeHost() { if (m_server) m_server->stop(); }
bool ok() const { return m_started && m_server->boundPort() != 0; }
uint16_t port() const { return m_server->boundPort(); }
QtFreeHandler& handler() { return *m_handler; }
private:
// DELIBERATELY LEAKED, and this is a harness lifetime bug that was worth
// finding rather than a style choice. RpcServer keeps a RAW
// IncomingCallHandler*, hands it to every connection, and nothing joins the
// io thread — not stop(), not the server's destruction. So a frame that has
// already been read from the socket can be dispatched into the handler after
// this object's members would have been destroyed, and a handler that was a
// member died first: SIGBUS on the io thread, inside
// RpcConnection::dispatchIncoming calling a virtual on freed storage.
//
// Observed at 1 run in 25 (and 1 in 5 under Guard Malloc) once the run got
// slightly longer — a burst test leaves frames queued, and the process has to
// stay alive long enough for the io thread to reach them. It is the test's
// bug, not the transport's, and the fix is to let the handler outlive the io
// thread: one small object per host, in a test binary that is about to exit.
QtFreeHandler* m_handler = new QtFreeHandler();
std::shared_ptr<RpcServerTcp> m_server;
bool m_started = false;
};
std::unique_ptr<PlainTransportConnection> connectTo(uint16_t port)
{
LogosTransportConfig cfg;
cfg.protocol = LogosProtocol::Tcp;
cfg.host = "127.0.0.1";
cfg.port = port;
auto conn = std::make_unique<PlainTransportConnection>(cfg);
if (!conn->connectToHost()) return nullptr;
return conn;
}
LogosObjectErrorChannel* channelFor(LogosObject* obj)
{
return dynamic_cast<LogosObjectErrorChannel*>(obj);
}
// ── the inline check, done properly ─────────────────────────────────────────
//
// "Did the callback run INLINE inside the call that issued it" is a question
// about NESTING, and the only place it can be answered is at the moment of
// delivery, on the delivering thread. This thread-local depth counter is that
// answer: IssuingScope raises it around an issuing call, and a callback that
// runs nested inside that call — which can only happen on the issuing thread,
// because inline means on this stack — sees its own thread's copy raised. A
// callback on the delivery thread reads that thread's copy, which is zero.
// Nothing races: no shared state, no ordering, no timing.
//
// WHAT THIS REPLACES, because it was wrong in a way worth remembering. The first
// version of test 1 read `d.total` AFTER callMethodAsyncWithError returned and
// asserted it was still zero. That is not an inline check, it is a race with the
// delivery thread — which is allowed to deliver the instant the call returns —
// and it failed 3 in 200 plain / 2 in 40 under Guard Malloc, always with
// on-caller-thread=0, i.e. always with nothing whatsoever having run inline.
// This file already says as much about tests 2 and 5 ("that measures scheduling
// luck"); test 1 was doing it anyway. A post-hoc count read cannot distinguish
// "ran inline" from "ran promptly, elsewhere". Nesting can.
thread_local int t_insideIssue = 0;
struct IssuingScope {
IssuingScope() { ++t_insideIssue; }
~IssuingScope() { --t_insideIssue; }
IssuingScope(const IssuingScope&) = delete;
IssuingScope& operator=(const IssuingScope&) = delete;
};
// Per-call delivery counts, plus where the delivery happened. Both 0 and 2 are
// failures and both are counted per call, because a double on one call and a
// drop on another cancel out in a total.
struct Deliveries {
explicit Deliveries(int n) : counts(n) {}
std::vector<std::atomic<int>> counts;
std::atomic<int> total{0};
// Deliveries that ran nested inside the call that issued them. Observed by
// the delivering thread itself, at delivery time — see IssuingScope.
std::atomic<int> inlineDeliveries{0};
// A thread no delivery may ever run on, counted rather than sampled.
// Set before the first call is issued and only read after the last has
// landed, so it needs no synchronisation of its own.
//
// Sampling the LAST delivery's thread is not enough for this claim: 300
// calls delivered correctly and one delivered on the caller's stack is
// still the bug, and a last-writer-wins field would miss it 299 times out
// of 300.
std::thread::id forbiddenThread{};
std::atomic<int> onForbiddenThread{0};
std::mutex mu;
std::string lastCode;
QVariant lastValue;
std::thread::id lastThread;
void record(int i, QVariant v, const logos::CallError& e)
{
// FIRST, and on the delivering thread: nesting is only observable from
// inside the delivery.
if (t_insideIssue > 0) inlineDeliveries.fetch_add(1);
if (forbiddenThread != std::thread::id{}
&& std::this_thread::get_id() == forbiddenThread)
onForbiddenThread.fetch_add(1);
{
std::lock_guard<std::mutex> g(mu);
lastCode = e.code;
lastValue = std::move(v);
lastThread = std::this_thread::get_id();
}
counts[i].fetch_add(1);
total.fetch_add(1);
}
std::string code() { std::lock_guard<std::mutex> g(mu); return lastCode; }
std::thread::id thread() { std::lock_guard<std::mutex> g(mu); return lastThread; }
int worst() const
{
int w = 0;
for (const auto& c : counts) w = std::max(w, c.load());
return w;
}
int missing() const
{
int m = 0;
for (const auto& c : counts) if (c.load() == 0) ++m;
return m;
}
};
// There is no event loop to pump: the only thing to do is wait. A budget rather
// than a fixed sleep so the passing case is fast and the failing case is
// unambiguous.
bool waitFor(Deliveries& d, int target, int budgetMs)
{
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(budgetMs);
while (d.total.load() < target
&& std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(2));
return d.total.load() >= target;
}
void settle(int ms)
{
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
const char* kToken = "noqt-token";
// ── the delivery that happens AFTER main() has returned ─────────────────────
//
// THE DEFECT THIS PROBE EXISTS FOR. The delivery vehicle used to be an ordinary
// function-local static, constructed on the FIRST async delivery in the process.
// Anything with static storage constructed before that — which is everything
// constructed during dynamic initialisation — is therefore destroyed AFTER it,
// so a delivery issued from such a destructor posted into an io_context that had
// already run its own destructor, on a thread that had already been joined. Under
// Guard Malloc: SIGSEGV in scheduler::post_immediate_completion, reached through
// __cxa_finalize, three runs out of three. Without it: silence, delivered=0 —
// which is the exact bug this file was written to kill, moved to a later moment
// in the process's life.
//
// WHY IT IS A STATIC OBJECT AND NOT A TEST CASE. The moment under test is "after
// main() returned", and no test case runs there. So the check is a destructor,
// and its verdict is the PROCESS EXIT CODE: a failure calls _Exit with a
// distinctive status, which is what CI (and `ninja test`) sees, because gtest has
// long since printed its summary and returned. There is no other way to assert
// on this window.
//
// ORDERING, which is the whole point and is easy to break by accident: this
// object must be constructed BEFORE the first delivery in the process, so that on
// a tree that destroys the vehicle it is destroyed AFTER it. Dynamic
// initialisation gives that for free — and it is why this probe must NOT warm the
// vehicle up in its own constructor: doing so would register the vehicle's
// destructor first and hand it the LONGER life, quietly turning the reproduction
// into a no-op. TheVehicleOutlivesStaticDestructors below does the warm-up, from
// inside a test, where it lands after all dynamic initialisation.
std::atomic<int> g_lateDeliveries{0};
std::atomic<bool> g_lateOffTheIssuingThread{false};
std::atomic<bool> g_lateProbeArmed{false};
struct LateDeliveryProbe {
LateDeliveryProbe() { g_lateProbeArmed.store(true); }
~LateDeliveryProbe()
{
const std::thread::id issuing = std::this_thread::get_id();
std::fprintf(stderr,
"\n[post-main] issuing an async call from a static destructor "
"(after main() returned)\n");
std::fflush(stderr);
// The null-connection early return: the smallest path that reaches the
// delivery hop, with no socket, no host and no io thread of its own.
PlainLogosObject obj("nobody", nullptr);
obj.callMethodAsyncWithError(kToken, QStringLiteral("ping"), {}, 1000,
[issuing](QVariant, const logos::CallError&) {
if (std::this_thread::get_id() != issuing)
g_lateOffTheIssuingThread.store(true);
g_lateDeliveries.fetch_add(1);
});
// Bounded, like every other wait in this file: the passing case is fast
// and the failing case is unambiguous rather than a hang at exit.
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(5000);
while (g_lateDeliveries.load() == 0
&& std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(2));
const int delivered = g_lateDeliveries.load();
const bool offThread = g_lateOffTheIssuingThread.load();
std::fprintf(stderr,
"[post-main] delivered=%d off-the-issuing-thread=%d\n",
delivered, static_cast<int>(offThread));
std::fflush(stderr);
if (delivered != 1 || !offThread) {
std::fprintf(stderr,
"\nPOST-MAIN DELIVERY FAILED: callMethodAsyncWithError promises "
"its callback exactly once, and a delivery issued after main() "
"returned got %d of them%s.\n"
" The delivery vehicle must outlive every possible caller, "
"which for a process-wide static means it must never be "
"destroyed. See DeliveryService in plain_logos_object.cpp.\n",
delivered,
(delivered == 1 && !offThread)
? " (and ran on the issuing thread, which is the inline "
"delivery the hop exists to prevent)"
: "");
std::fflush(stderr);
std::_Exit(70);
}
}
};
// Constructed during dynamic initialisation; destroyed after main(). See above.
LateDeliveryProbe g_lateDeliveryProbe;
} // namespace
class NoQtLoopTest : public ::testing::Test {
protected:
// The premise of the whole file, checked on every test rather than assumed:
// if something ever constructs a QCoreApplication in this process, these
// tests silently stop testing anything.
void SetUp() override
{
ASSERT_EQ(QCoreApplication::instance(), nullptr)
<< "this binary must run with NO QCoreApplication — otherwise it is "
"just protocol_tests with fewer tests";
}
};
// ── 1. the smallest possible statement of the bug ───────────────────────────
//
// No host, no socket, no threads of our own: a handle whose connection is not
// open takes the early-return branch in callMethodAsyncWithError, which posts
// the failure through the same delivery hop as everything else. Pre-fix the
// callback is dropped there and this waits out its whole budget.
TEST_F(NoQtLoopTest, AFailedCallDeliversItsCallbackWithNoQtLoop)
{
PlainLogosObject obj("nobody", nullptr);
Deliveries d(1);
const std::thread::id caller = std::this_thread::get_id();
d.forbiddenThread = caller;
{
// The nesting marker, raised for exactly the duration of the call. A
// callback that runs inline runs INSIDE this scope, on this thread, and
// says so from inside itself — which is a fact about the stack and not
// about who won a race. Reading d.total after the call returns would be
// the race; see IssuingScope.
IssuingScope inCall;
obj.callMethodAsyncWithError(kToken, QStringLiteral("ping"), {}, 1000,
[&d](QVariant v, const logos::CallError& e) {
d.record(0, std::move(v), e);
});
}
const bool arrived = waitFor(d, 1, 5000);
settle(50); // a duplicate would land here
std::cout << " no-Qt failure path: delivered=" << d.total.load()
<< " code='" << d.code() << "'"
<< " inline=" << d.inlineDeliveries.load()
<< " on-caller-thread=" << d.onForbiddenThread.load() << std::endl;
ASSERT_TRUE(arrived)
<< "the callback was never delivered: with no QCoreApplication the "
"delivery hop dropped it, and callMethodAsyncWithError's "
"exactly-once promise became exactly-never";
EXPECT_EQ(d.total.load(), 1);
EXPECT_EQ(d.code(), "transport_error");
EXPECT_EQ(d.inlineDeliveries.load(), 0)
<< "the callback ran inline inside callMethodAsyncWithError";
EXPECT_EQ(d.onForbiddenThread.load(), 0)
<< "the callback ran on the caller's thread — delivery must stay off "
"the issuing stack";
}
// ── 2. a real call over a real socket ───────────────────────────────────────
//
// The normal path, at volume, with the delivery thread as the only place a
// callback can land. Also the place to pin the constraint that shapes the fix:
// the callback must not run on the io thread, which is where the reply is
// decoded.
TEST_F(NoQtLoopTest, RepliesDeliverExactlyOnceWithNoQtLoop)
{
QtFreeHost host;
ASSERT_TRUE(host.ok());
auto conn = connectTo(host.port());
ASSERT_NE(conn, nullptr);
LogosObject* obj = conn->requestObject(QStringLiteral("omni"), 5000);
ASSERT_NE(obj, nullptr);
auto* ch = channelFor(obj);
ASSERT_NE(ch, nullptr);
constexpr int kCalls = 300;
Deliveries d(kCalls);
const std::thread::id caller = std::this_thread::get_id();
// NOT "no callback had arrived by the time the loop finished" — with a real
// delivery thread, callbacks for the early calls legitimately land while the
// later ones are still being issued, so that measures scheduling luck. The
// claim is per delivery and it is about the THREAD: no callback may run on
// the stack that issued the call.
d.forbiddenThread = caller;
for (int i = 0; i < kCalls; ++i) {
// Per call, so the nesting claim covers all 300 issue points and not
// just the loop as a whole.
IssuingScope inCall;
ch->callMethodAsyncWithError(kToken, QStringLiteral("ping"),
QVariantList{ QVariant(i) }, 10000,
[&d, i](QVariant v, const logos::CallError& e) {
d.record(i, std::move(v), e);
});
}
const bool arrived = waitFor(d, kCalls, 20000);
settle(200); // duplicates would land here
std::cout << " no-Qt replies: " << d.total.load() << "/" << kCalls
<< " worst=" << d.worst() << " missing=" << d.missing()
<< " on-caller-thread=" << d.onForbiddenThread.load()
<< " delivery-thread!=io-thread="
<< (d.thread() != host.handler().ioThread()) << std::endl;
ASSERT_TRUE(arrived) << "only " << d.total.load() << " of " << kCalls
<< " callbacks were delivered with no Qt loop";
EXPECT_EQ(d.inlineDeliveries.load(), 0)
<< d.inlineDeliveries.load() << " callbacks ran nested inside the call "
"that issued them";
EXPECT_EQ(d.onForbiddenThread.load(), 0)
<< d.onForbiddenThread.load() << " callbacks ran on the thread that "
"issued the call";
EXPECT_EQ(d.worst(), 1);
EXPECT_EQ(d.missing(), 0);
EXPECT_TRUE(d.code().empty());
ASSERT_TRUE(host.handler().sawCall());
EXPECT_NE(d.thread(), host.handler().ioThread())
<< "the callback ran on the transport's io thread — that is the "
"re-entrancy the delivery hop exists to prevent, and delivering "
"inline there would be a worse bug than the drop";
obj->release();
settle(50);
}
// ── 3. the deferred ("multi") completion ────────────────────────────────────
//
// Reaches the hop from the completion-event handler on the io thread, which is
// a different resolver from the reply path above and was dropped just as hard.
TEST_F(NoQtLoopTest, DeferredCompletionsDeliverWithNoQtLoop)
{
QtFreeHost host;
ASSERT_TRUE(host.ok());
auto conn = connectTo(host.port());
ASSERT_NE(conn, nullptr);
LogosObject* obj = conn->requestObject(QStringLiteral("omni"), 5000);
ASSERT_NE(obj, nullptr);
auto* ch = channelFor(obj);
ASSERT_NE(ch, nullptr);
constexpr int kCalls = 40;
Deliveries d(kCalls);
for (int i = 0; i < kCalls; ++i) {
ch->callMethodAsyncWithError(kToken, QStringLiteral("defer"), {}, 10000,
[&d, i](QVariant v, const logos::CallError& e) {
d.record(i, std::move(v), e);
});
}
const bool arrived = waitFor(d, kCalls, 20000);
settle(200);
std::cout << " no-Qt deferred completions: " << d.total.load() << "/"
<< kCalls << " worst=" << d.worst() << " code='" << d.code()
<< "'" << std::endl;
ASSERT_TRUE(arrived) << "only " << d.total.load() << " of " << kCalls
<< " deferred completions were delivered";
EXPECT_EQ(d.worst(), 1);
EXPECT_EQ(d.missing(), 0);
EXPECT_TRUE(d.code().empty());
{
std::lock_guard<std::mutex> g(d.mu);
EXPECT_EQ(d.lastValue.toInt(), 7)
<< "the deferred call delivered the sentinel, not the completion";
}
obj->release();
settle(50);
}
// ── 4. the deadline ─────────────────────────────────────────────────────────
//
// Reaches the hop from the DeadlineService thread — the third distinct
// resolver. A dropped timeout is the worst of the four: the call is never going
// to be answered, so the caller waits forever on a callback that was the only
// thing that could have told it so.
TEST_F(NoQtLoopTest, TimeoutsDeliverWithNoQtLoop)
{
QtFreeHost host;
ASSERT_TRUE(host.ok());
auto conn = connectTo(host.port());
ASSERT_NE(conn, nullptr);
LogosObject* obj = conn->requestObject(QStringLiteral("omni"), 5000);
ASSERT_NE(obj, nullptr);
auto* ch = channelFor(obj);
ASSERT_NE(ch, nullptr);
constexpr int kCalls = 20;
Deliveries d(kCalls);
for (int i = 0; i < kCalls; ++i) {
ch->callMethodAsyncWithError(kToken, QStringLiteral("sink"), {}, 200,
[&d, i](QVariant v, const logos::CallError& e) {
d.record(i, std::move(v), e);
});
}
const bool arrived = waitFor(d, kCalls, 10000);
settle(200);
std::cout << " no-Qt timeouts: " << d.total.load() << "/" << kCalls
<< " worst=" << d.worst() << " code='" << d.code() << "'"
<< std::endl;
ASSERT_TRUE(arrived) << "only " << d.total.load() << " of " << kCalls
<< " deadlines were delivered";
EXPECT_EQ(d.worst(), 1);
EXPECT_EQ(d.missing(), 0);
EXPECT_EQ(d.code(), "timeout");
obj->release();
settle(50);
}
// ── 5. cancellation by release() ────────────────────────────────────────────
//
// The fourth resolver: teardown, on the caller's own thread. This one is the
// reason the fix cannot be "call it inline when there is no Qt loop" — inline
// here means running user code from inside release(), i.e. from inside a
// destructor path, which is exactly the re-entrancy that has already produced a
// SIGSEGV in this codebase on the QtRO twin.
TEST_F(NoQtLoopTest, CancelledCallsDeliverWithNoQtLoopAndNotInsideRelease)
{
QtFreeHost host;
ASSERT_TRUE(host.ok());
auto conn = connectTo(host.port());
ASSERT_NE(conn, nullptr);
LogosObject* obj = conn->requestObject(QStringLiteral("omni"), 5000);
ASSERT_NE(obj, nullptr);
auto* ch = channelFor(obj);
ASSERT_NE(ch, nullptr);
constexpr int kCalls = 20;
Deliveries d(kCalls);
const std::thread::id caller = std::this_thread::get_id();
d.forbiddenThread = caller;
for (int i = 0; i < kCalls; ++i) {
IssuingScope inCall;
ch->callMethodAsyncWithError(kToken, QStringLiteral("never"), {}, 30000,
[&d, i](QVariant v, const logos::CallError& e) {
d.record(i, std::move(v), e);
});
}
settle(300); // let every sentinel come back, so the calls are parked
ASSERT_EQ(d.total.load(), 0) << "the calls resolved before the release";
{
// The same nesting marker, around release() this time: a cancellation
// callback that ran from inside release() would run inside this scope,
// on this thread, and would count itself.
IssuingScope inRelease;
obj->release();
}
const bool arrived = waitFor(d, kCalls, 10000);
settle(200);
std::cout << " no-Qt cancellations: " << d.total.load() << "/" << kCalls
<< " worst=" << d.worst() << " code='" << d.code()
<< "' inside-release=" << d.inlineDeliveries.load()
<< " on-releasing-thread=" << d.onForbiddenThread.load()
<< std::endl;
ASSERT_TRUE(arrived) << "only " << d.total.load() << " of " << kCalls
<< " cancellations were delivered";
// "How many had arrived by the time release() returned" is NOT the inline
// check, and measuring it that way is how this test first went red under
// Guard Malloc: with everything slowed down, the delivery thread finished
// all twenty before the releasing thread executed its next statement, which
// is correct behaviour and reads as a violation. The claim is about NESTING,
// and the marker above measures exactly that — plus the thread, since the
// releasing thread is the forbidden one here.
EXPECT_EQ(d.inlineDeliveries.load(), 0)
<< "a cancellation callback ran from inside release()";
EXPECT_EQ(d.worst(), 1);
EXPECT_EQ(d.missing(), 0);
EXPECT_EQ(d.code(), "transport_error");
EXPECT_EQ(d.onForbiddenThread.load(), 0)
<< "a cancellation callback ran on the releasing thread";
}
// ── 6. exactly once, with the delivery thread as the hop ────────────────────
//
// The release-racing-replies shape from test_iofold.cpp — the only one that
// actually detects a broken exactly-once gate — re-run here so that the gate is
// pinned against the OTHER delivery vehicle too. Teardown snapshots the
// in-flight map and then delivers one call at a time with the lock released,
// so the io thread has a window N deliveries wide in which to answer a call
// teardown has already claimed.
//
// VALIDATED AS A DETECTOR HERE, and the validation turned up something about the
// mechanism that the note in tests/protocol/CMakeLists.txt does not say. There
// are TWO gates in AsyncCall, not one: claim()'s compare-exchange, and the
// swap in takeCallback() which leaves a second caller holding an empty
// std::function. Removing the CAS alone changes nothing measurable — this test,
// its Qt twin and PlainCancelPendingRaceTest all stay green, 0 doubles — because
// the swap still absorbs the duplicate. With BOTH removed this test reports 22
// doubled deliveries in 20 rounds x 500 calls, while the three PER-PATH tests
// above stay green, which is the difference between a detector and a pin.
//
// So: a validation that removes one of the two gates proves nothing, and the
// exactly-once guarantee is stronger than the CAS on its own. Neither half is
// redundant — the CAS is what stops the second caller from also erasing
// registries and cancelling timers — but the CALLBACK is protected by the swap.
TEST_F(NoQtLoopTest, ReleaseRacingRepliesDeliversEachCallOnceWithNoQtLoop)
{
QtFreeHost host;
ASSERT_TRUE(host.ok());
auto conn = connectTo(host.port());
ASSERT_NE(conn, nullptr);
constexpr int kRounds = 20;
constexpr int kCalls = 500;
int doubled = 0;
int dropped = 0;
int byReply = 0;
int byTeardown = 0;
for (int r = 0; r < kRounds; ++r) {
LogosObject* obj = conn->requestObject(QStringLiteral("omni"), 5000);
ASSERT_NE(obj, nullptr);
auto* ch = channelFor(obj);
ASSERT_NE(ch, nullptr);
auto d = std::make_shared<Deliveries>(kCalls);
auto codes = std::make_shared<std::vector<std::atomic<int>>>(2);
for (int i = 0; i < kCalls; ++i) {
ch->callMethodAsyncWithError(kToken, QStringLiteral("ping"),
QVariantList{ QVariant(i) }, 20000,
[d, codes, i](QVariant v, const logos::CallError& e) {
(*codes)[e.code.empty() ? 0 : 1].fetch_add(1);
d->record(i, std::move(v), e);
});
}
// WAIT FOR THE FIRST REPLY, then release — and this is not a tidy-up,
// it is what makes the test a race at all.
//
// In this binary the provider and the consumer share the process's ONE
// io thread (the QtFreeHandler runs on the same strand that writes the
// client's frames), unlike the Qt twin in test_iofold.cpp whose provider
// sits on its own QThread. So the issuing thread can enqueue all 500
// calls and release before the io thread has drained a single one: on a
// slow sandbox that produced answered-by-reply=0,
// cancelled-by-teardown=10000 — one resolver, no race, and the
// exactly-once assertions below reduced to decoration. (It still
// reported 0 doubles and 0 drops, which is exactly why the
// "both resolvers were live" guards further down have to exist.)
//
// Waiting for one delivery proves the reply path is running; with 500
// calls in the burst, hundreds are still outstanding for teardown to
// cancel. The jittered nudge then sweeps where in the burst it lands.
ASSERT_TRUE(waitFor(*d, 1, 20000))
<< "round " << r << ": no reply came back at all before the release";
std::this_thread::sleep_for(std::chrono::microseconds((r % 6) * 120));
obj->release();
// Per round rather than in aggregate, so a run against a tree that
// drops callbacks stops on the first round instead of waiting out
// twenty budgets.
ASSERT_TRUE(waitFor(*d, kCalls, 20000))
<< "round " << r << ": only " << d->total.load() << " of " << kCalls
<< " callbacks were delivered";
settle(50);
for (const auto& c : d->counts) {
if (c.load() > 1) doubled += c.load() - 1;
if (c.load() == 0) ++dropped;
}
byReply += (*codes)[0].load();
byTeardown += (*codes)[1].load();
}
std::cout << " no-Qt: " << kRounds << " rounds x " << kCalls
<< " calls released mid-burst -> answered-by-reply=" << byReply
<< " cancelled-by-teardown=" << byTeardown
<< " DOUBLE deliveries=" << doubled << " dropped=" << dropped
<< std::endl;
EXPECT_GT(byReply, 0) << "no call was answered by its reply";
EXPECT_GT(byTeardown, 0) << "no call was cancelled by teardown — the race "
"did not happen";
EXPECT_EQ(doubled, 0) << doubled << " calls were delivered more than once";
EXPECT_EQ(dropped, 0) << dropped << " calls were never delivered at all";
}
// ── 7. the delivery vehicle outlives static destruction ─────────────────────
//
// The visible half of the post-main probe above: this test WARMS THE VEHICLE UP
// — constructing it here, i.e. after all dynamic initialisation, is what puts it
// on the wrong side of the destruction order from the probe — and states where
// the verdict will appear. The assertion itself cannot live in a test case,
// because the moment it is about is after main() returns; it lives in
// LateDeliveryProbe::~LateDeliveryProbe and reports through the process exit
// code. See the note over that struct.
//
// On the tree this was written against (the delivery service as an ordinary
// function-local static) the run ends in `[post-main] delivered=0` followed by
// exit 70, or SIGSEGV under Guard Malloc. Both are what a failure looks like
// here, and both were observed before the fix.
TEST_F(NoQtLoopTest, TheVehicleOutlivesStaticDestructors)
{
ASSERT_TRUE(g_lateProbeArmed.load())
<< "the post-main probe was not constructed during dynamic "
"initialisation, so nothing will check the after-main() window";
PlainLogosObject obj("nobody", nullptr);
Deliveries d(1);
{
IssuingScope inCall;
obj.callMethodAsyncWithError(kToken, QStringLiteral("ping"), {}, 1000,
[&d](QVariant v, const logos::CallError& e) {
d.record(0, std::move(v), e);
});
}
ASSERT_TRUE(waitFor(d, 1, 5000))
<< "the warm-up delivery never arrived, so the vehicle was never "
"constructed and the probe below tests nothing";
EXPECT_EQ(d.inlineDeliveries.load(), 0);
std::cout << " delivery vehicle constructed inside the run; the after-main "
"delivery is checked by LateDeliveryProbe (exit code 70 on "
"failure)" << std::endl;
}