Files
logos-protocol/tests/protocol/test_plain_host_event_teardown.cpp
Dario LipicarandClaude Opus 5 5be3a84989 test(plain): destroy the fixtures' host on the thread that emits into it (#51)
Every LiveHost fixture in tests/protocol (five of them: test_iofold,
test_plain_object_teardown, test_plain_waiter_reaping,
test_plain_completion_sub_lifetime, test_call_error_after_acquire) puts a
ModuleProxy on a worker QThread and publishes it through a PlainTransportHost,
and every one of them freed that host from the TEST thread:

    m_host.reset();          // test thread
    m_thread->quit();
    m_thread->wait();

That is a use-after-free with a millisecond-wide window, in code five test
files share.

WHY. publishObject() connects a lambda to the proxy's eventResponse signal with
NO context object, so it is a direct connection and runs on whichever thread
emits. ModuleProxy always QUEUES that emission to its own thread — it must, or
QtRO source serialization races the reply socket — so the emitting thread is
always the worker. The lambda converts the payload and then calls fanOutEvent,
which locks the host's m_mu. Free the host on the test thread and that lock is
on a destroyed mutex. ~PlainTransportHost does disconnect the connection, which
covers an emission that has not started; a worker already inside the lambda is
not called back, it is simply running, and qvariantListToRpcList on a real
payload sits in front of the lock.

MOVING reset() AFTER quit()/wait() IS NOT THE FIX, and the obvious reason for
saying so is wrong, so here is the measured one. That order does stop the fault:
wait() joins the worker, so an in-flight lambda has finished, and it is clean
under Guard Malloc. It is clean because it throws the queue away —
QThread::quit() reaches QEventLoop::exit(), which sets the exit flag
SYNCHRONOUSLY from the calling thread instead of posting an event, so the
worker's loop stops at its next iteration and discards every emission still
queued behind it. Same specimen, same load: 273-383 of 960 events delivered,
silently. In a suite whose tests count deliveries that is the worse failure,
because nothing reports it. It also shuts the host down AFTER the proxy's
thread, which test_call_error_after_acquire needs the other way round.

THE FIX, in one shared place (live_host_teardown.h) rather than five copies,
because a copied pattern is what this was: destroy the host ON the proxy's
thread, via the SDK's own logos::runOnOwnerThread marshal. A QMetaCallEvent is
dispatched by the worker's event loop, so while it runs the worker is by
definition not inside any other slot. Qt dispatches equal-priority events FIFO,
so every emission queued before it runs first, against a live host; everything
after finds the connection already severed by ~PlainTransportHost. The io
thread, the third thread that reaches the host, is still covered by the drain
barrier ~PlainTransportHost already carries — the proxy thread is not the io
thread, so that barrier's running_in_this_thread() check still takes the
blocking path. The added blocking wait introduces no hang that was not already
there: the next two statements are quit()/wait() on the same thread, with no
timeout.

EVIDENCE. test_plain_host_event_teardown.cpp is the specimen, and it is a
detector: 24 wide events queued per round, teardown aimed at the trailing edge
of the first so the worker is inside the host's lambda. Validated the way this
directory validates detectors — against a real checkout of the code it replaces
(cf1b9b0), with the fixture's teardown as it was:

  pre-fix, no detector:    SIGABRT 3/3 runs   ("mutex lock failed: Invalid
                           argument" out of a Qt event handler)
  pre-fix, Guard Malloc:   SIGSEGV 3/3 runs   at plain_transport_host.cpp:354
                           in fanOutEvent, on the thread named "QThread", under
                           ModuleProxy::eventResponse
  naive (quit/wait/reset): NO fault, either detector — and 273-383 of 960
                           emissions delivered; the rest silently dropped
  fixed, no detector:      960/960 emitted, 0 after the free, 40/40 rounds
                           still draining when teardown began
  fixed, Guard Malloc:     same counts, clean

WHAT THIS IS NOT. The five fixtures do not fault on their own today: pre-fix,
their suites are clean under Guard Malloc (2/2 runs, 33 tests) because their
test bodies drain the burst before teardown. So this closes a live trap in
shared fixture code — one that two separate probes fell into by copying the
pattern — rather than a reproduced CI failure. It remains a candidate for the
suite's unexplained SIGSEGVs, not a proven cause, and it is stated that way in
the test file.

Full suite: 304/304 (302 before, +2 new). `nix build .#tests`: 304/304 via
ctest, 95s. The five affected suites are clean under Guard Malloc on the fixed
tree.

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

341 lines
14 KiB
C++

// Tearing down a live PlainTransportHost while its proxy thread is emitting.
//
// This is a test of the TEST HARNESS. Every LiveHost fixture in this directory
// (test_iofold, test_plain_object_teardown, test_plain_waiter_reaping,
// test_plain_completion_sub_lifetime, test_call_error_after_acquire) puts a
// ModuleProxy on a worker QThread and publishes it through a
// PlainTransportHost, and all of them used to free that host from the TEST
// thread. That is a use-after-free with a wide window, in shared fixture code,
// so it could corrupt any run in this binary — which makes it a candidate for
// the unexplained SIGSEGVs this suite has seen. live_host_teardown.h carries
// the mechanism and the reasoning; this file is the specimen that proves the
// mechanism is needed and that it works.
//
// THE PATH, which is three threads deep and none of them obvious:
//
// provider emits -> ModuleProxy queues the emission to ITS OWN thread
// (module_proxy.cpp:38 — it must, or QtRO source
// serialization races the reply socket)
// -> worker thread: emit eventResponse
// -> the lambda publishObject() connected with NO context
// object, so it is a DIRECT connection and runs right
// there on the worker
// -> qvariantListToRpcList(payload) <-- the window
// -> PlainTransportHost::fanOutEvent -> lock m_mu
//
// Free the host on the test thread and the last step locks a destroyed mutex.
// ~PlainTransportHost does disconnect the connection, which covers an emission
// that has not started; it does nothing for a worker already inside the lambda,
// and the payload conversion in front of the lock is milliseconds wide.
//
// HOW THIS TEST AIMS AT THAT WINDOW rather than sleeping towards it:
//
// * WIDENED. Each event carries kElems elements, so the conversion between
// lambda entry and the m_mu lock is real work rather than nanoseconds.
//
// * AIMED. The round queues a burst and then waits for the FIRST emission to
// complete before tearing down. Emissions run back to back on the worker,
// so an observation is the trailing edge of one and the worker is inside
// the next — i.e. inside the host's lambda — when teardown starts.
//
// * MEASURED, so it cannot pass vacuously. pendingRounds counts rounds that
// began teardown with emissions still queued; if a timing change ever makes
// that zero the test fails instead of silently stopping to test anything.
//
// WHAT IT ASSERTS, and why each is structural on fixed code:
//
// * afterHostGone == 0. No emission may run on the worker at or after the
// moment the host is freed. With the free ON the worker (a QMetaCallEvent)
// the worker is by definition not inside any other slot while it happens.
//
// * emitted == every event pushed. Qt dispatches equal-priority events FIFO,
// so a teardown queued behind a burst runs after the whole burst — against
// a host that is still alive. Nothing is dropped and nothing is late.
//
// NO SUBSCRIBER is connected, and that is deliberate: the fault is the m_mu
// lock at the TOP of fanOutEvent, which precedes the sink lookup, so a consumer
// would add a second connection's teardown to a teardown test without widening
// what is being tested.
//
// PRE-FIX EVIDENCE (this file with logos::testing::destroyHostOnProxyThread
// replaced by a plain m_host.reset(), everything else identical): a hard
// SIGSEGV on the worker thread, on the very first round, with or without a
// memory detector.
//
// EXC_BAD_ACCESS (SIGSEGV) KERN_INVALID_ADDRESS at 0x71de13fa0, thread "QThread"
// pthread_mutex_lock
// std::mutex::lock
// logos::plain::PlainTransportHost::fanOutEvent plain_transport_host.cpp:354
// PlainTransportHost::publishObject(...)::$_0 plain_transport_host.cpp:334
// doActivate<false>
// ModuleProxy::eventResponse
// ModuleProxy::ModuleProxy(...)::$_0 module_proxy.cpp:38
//
// Undetected, the same run reports `mutex lock failed: Invalid argument` out of
// a Qt event handler and aborts. The detector used here is macOS Guard Malloc
// (DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib), which unmaps freed pages so
// the dangling lock faults every time; ASan/TSan are unusable on this toolchain
// (libclang_rt livelocks in its own initializer before main), the same note that
// sits on test_plain_object_teardown.cpp. Fixed, this file is clean under Guard
// Malloc with pendingRounds == kRounds.
//
// THE THIRD ORDER, and why `emitted` is asserted rather than just printed. The
// obvious repair — quit(), wait(), THEN reset() — is memory-clean: wait() joins
// the worker, so an in-flight lambda has finished, and Guard Malloc says nothing
// (afterHostGone 0, twice). It is clean because QThread::quit() reaches
// QEventLoop::exit(), which sets the exit flag synchronously from the CALLING
// thread rather than posting an event, so the worker's loop stops at its next
// iteration and discards every emission still queued behind it. Same specimen,
// same load: 273-383 of 960 delivered. Nothing reports that on its own — which
// is why the count is an assertion here.
#include <gtest/gtest.h>
#include "logos_provider_interface.h"
#include "logos_transport_config.h"
#include "module_proxy.h"
#include "plain_transport_host.h"
#include "live_host_teardown.h"
#include <QCoreApplication>
#include <QJsonArray>
#include <QString>
#include <QThread>
#include <QVariant>
#include <QVariantList>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <memory>
#include <thread>
using namespace logos::plain;
namespace {
// Bookkeeping shared between the test thread and the proxy's worker thread.
struct Watch {
// Emissions that reached the worker's eventResponse.
std::atomic<int> emitted{0};
// Set on the worker, immediately after the host is destroyed there.
std::atomic<bool> hostGone{false};
// Emissions that ran with the host already gone. Must stay 0.
std::atomic<int> afterHostGone{0};
};
// Pushes events on demand through the real route: the EventCallback handed to
// setEventListener is ModuleProxy's, so every push takes the queued path a real
// provider's event takes.
class Pusher : public LogosProviderObject {
public:
QVariant callMethod(const QString&, const QVariantList& args) override
{
return args.value(0, QVariant(1));
}
QJsonArray getMethods() override { return QJsonArray{}; }
bool informModuleToken(const QString&, const QString&) override { return true; }
void setEventListener(EventCallback cb) override { m_emit = std::move(cb); }
void init(void*) override {}
QString providerName() const override { return QStringLiteral("pusher"); }
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
void push(const QVariantList& data)
{
if (m_emit) m_emit(QStringLiteral("teardown_probe"), data);
}
private:
EventCallback m_emit;
};
// The fixture shape this whole file is about, reduced to the parts that matter.
class LiveHost {
public:
explicit LiveHost(Watch& w) : m_watch(w)
{
LogosTransportConfig cfg;
cfg.protocol = LogosProtocol::Tcp;
cfg.host = "127.0.0.1";
cfg.port = 0; // ephemeral
m_host = std::make_unique<PlainTransportHost>(cfg);
m_started = m_host->start();
m_proxy = new ModuleProxy(&m_provider);
m_proxy->saveToken(QStringLiteral("origin"), QStringLiteral("live-token"));
m_thread = new QThread;
m_proxy->moveToThread(m_thread);
m_thread->start();
m_published = m_host->publishObject("pusher_module", m_proxy);
// Connected AFTER publishObject, so on each emission this runs on the
// worker thread immediately after the host's fan-out lambda — i.e. it
// observes the same emission that would have touched a freed host.
QObject::connect(m_proxy, &ModuleProxy::eventResponse,
[this](const QString&, const QVariantList&) {
if (m_watch.hostGone.load(std::memory_order_acquire))
m_watch.afterHostGone.fetch_add(1);
m_watch.emitted.fetch_add(1, std::memory_order_release);
});
const QString endpoint = m_host->endpoint();
m_port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
}
~LiveHost()
{
// The host dies on the proxy's thread. hostGone is set THERE, right
// after the free, so the observer above reads it with no race of its
// own: both run on that one thread.
QMetaObject::invokeMethod(m_proxy, [this] {
m_host.reset();
m_watch.hostGone.store(true, std::memory_order_release);
}, Qt::BlockingQueuedConnection);
m_thread->quit();
m_thread->wait();
delete m_proxy;
delete m_thread;
}
bool ok() const { return m_started && m_published && m_port != 0; }
Pusher& provider() { return m_provider; }
private:
Watch& m_watch;
Pusher m_provider;
std::unique_ptr<PlainTransportHost> m_host;
ModuleProxy* m_proxy = nullptr;
QThread* m_thread = nullptr;
bool m_started = false;
bool m_published = false;
uint16_t m_port = 0;
};
// The same teardown, expressed through the shared helper the five real fixtures
// call. Same object, same ordering — this one is here so the helper itself is
// exercised rather than only the shape it encodes.
class LiveHostViaHelper {
public:
LiveHostViaHelper()
{
LogosTransportConfig cfg;
cfg.protocol = LogosProtocol::Tcp;
cfg.host = "127.0.0.1";
cfg.port = 0;
m_host = std::make_unique<PlainTransportHost>(cfg);
m_started = m_host->start();
m_proxy = new ModuleProxy(&m_provider);
m_proxy->saveToken(QStringLiteral("origin"), QStringLiteral("live-token"));
m_thread = new QThread;
m_proxy->moveToThread(m_thread);
m_thread->start();
m_published = m_host->publishObject("pusher_module", m_proxy);
}
~LiveHostViaHelper()
{
logos::testing::destroyHostOnProxyThread(m_host, m_proxy);
m_thread->quit();
m_thread->wait();
delete m_proxy;
delete m_thread;
}
bool ok() const { return m_started && m_published; }
Pusher& provider() { return m_provider; }
private:
Pusher m_provider;
std::unique_ptr<PlainTransportHost> m_host;
ModuleProxy* m_proxy = nullptr;
QThread* m_thread = nullptr;
bool m_started = false;
bool m_published = false;
};
// Width of the window: the fan-out lambda runs qvariantListToRpcList() over
// this before it touches the host.
constexpr int kElems = 8000;
// Events per round. Enough that the burst is still draining when teardown
// starts, which is what pendingRounds checks.
constexpr int kBurst = 24;
constexpr int kRounds = 40;
QVariantList widePayload()
{
QVariantList l;
l.reserve(kElems);
for (int i = 0; i < kElems; ++i)
l.append(QVariant(QStringLiteral("xxxxxxxxxxxxxxxx")));
return l;
}
TEST(PlainHostEventTeardownTest, QueuedEmissionsNeverOutliveTheHost)
{
const QVariantList payload = widePayload();
int totalEmitted = 0;
int totalAfter = 0;
int pendingRounds = 0;
for (int r = 0; r < kRounds; ++r) {
Watch w;
auto host = std::make_unique<LiveHost>(w);
ASSERT_TRUE(host->ok()) << "round " << r;
for (int i = 0; i < kBurst; ++i) host->provider().push(payload);
// Aim: tear down on the trailing edge of the first emission, while the
// worker is inside the next one.
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (w.emitted.load(std::memory_order_acquire) < 1 &&
std::chrono::steady_clock::now() < deadline)
std::this_thread::yield();
ASSERT_GE(w.emitted.load(), 1) << "round " << r << ": nothing emitted";
if (w.emitted.load(std::memory_order_acquire) < kBurst) ++pendingRounds;
host.reset();
totalEmitted += w.emitted.load();
totalAfter += w.afterHostGone.load();
}
std::cout << " rounds=" << kRounds << " burst=" << kBurst
<< " emitted=" << totalEmitted
<< " afterHostGone=" << totalAfter
<< " pendingAtTeardown=" << pendingRounds << " rounds\n";
// The window was really open — otherwise the two assertions below are free.
EXPECT_GT(pendingRounds, 0)
<< "no round began teardown with emissions still queued; this test "
"stopped exercising the race it exists for";
EXPECT_EQ(totalAfter, 0)
<< "an event emission ran on the proxy thread after the host was freed";
EXPECT_EQ(totalEmitted, kRounds * kBurst)
<< "teardown swallowed queued emissions instead of running behind them";
}
// The helper on the path the fixtures use it on. Nothing to measure here beyond
// "a burst in flight at teardown is survivable"; the measured version is above.
TEST(PlainHostEventTeardownTest, TheSharedHelperTearsDownUnderTheSameLoad)
{
const QVariantList payload = widePayload();
for (int r = 0; r < 10; ++r) {
LiveHostViaHelper host;
ASSERT_TRUE(host.ok()) << "round " << r;
for (int i = 0; i < kBurst; ++i) host.provider().push(payload);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
SUCCEED();
}
} // namespace