mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
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>
576 lines
21 KiB
C++
576 lines
21 KiB
C++
// Tearing down a PlainLogosObject with a call still in flight.
|
|
//
|
|
// The branch this sits on stopped callMethodAsync from DETACHING its waiter
|
|
// thread: the waiter captures `this` (it reads m_objectName and calls
|
|
// awaitCompletion), and release() used to `delete this` underneath it. Waiters
|
|
// are now registered in m_waiters and joined before the object dies.
|
|
//
|
|
// Joining alone only trades one bug for a stall. The join-only joinWaiters()
|
|
// had no way to ASK a waiter to stop, so both of the waiter's blocking sites
|
|
// ran to their deadline:
|
|
//
|
|
// * the std::future wait in callMethodAsyncWithError, and
|
|
// * the completion-event wait in awaitCompletion (a "multi" provider's
|
|
// deferred result).
|
|
//
|
|
// Destroying a handle with an in-flight call therefore blocked for the
|
|
// remainder of that call's timeout — up to 20s on the protocol default
|
|
// (logos_mode.h Timeout). A module unloading mid-call stalls the unload for
|
|
// that long, on whichever thread called release().
|
|
//
|
|
// What these tests pin, and why each one is here:
|
|
//
|
|
// 1. TEARDOWN LATENCY. release() with an in-flight call must return in about
|
|
// one wait slice, not one call timeout.
|
|
//
|
|
// 2. THE CALLBACK CONTRACT, which is the part a naive fix breaks.
|
|
// callMethodAsyncWithError (and lp_invoke_async above it) promise the
|
|
// callback fires EXACTLY ONCE. A waiter that simply RETURNS when asked to
|
|
// stop silently drops it — trading a bounded stall for an unbounded hang
|
|
// in any caller that awaits that callback. So the three outcomes are
|
|
// counted, not just observed: normal completion, timeout, and
|
|
// cancellation-by-teardown must each deliver exactly one callback. Zero
|
|
// and two are both failures.
|
|
//
|
|
// 3. THE UAF THAT MUST NOT COME BACK. Cancellation must not become "let the
|
|
// waiter go"; the join still has to happen. The release-during-call race
|
|
// is hammered here so an ASan/TSan build has something to catch.
|
|
//
|
|
// Everything runs against a live in-process PlainTransportHost over real TCP,
|
|
// and drives PlainLogosObject directly (PlainTransportConnection::requestObject)
|
|
// so release() is measured on its own rather than through lp_client_destroy.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "logos_async_dispatch.h"
|
|
#include "logos_call_error.h"
|
|
#include "logos_object.h"
|
|
#include "logos_provider_interface.h"
|
|
#include "logos_transport_config.h"
|
|
#include "module_proxy.h"
|
|
|
|
#include "plain_transport_connection.h"
|
|
#include "plain_transport_host.h"
|
|
|
|
#include "live_host_teardown.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QElapsedTimer>
|
|
#include <QJsonArray>
|
|
#include <QString>
|
|
#include <QThread>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QVariantMap>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstdint>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
|
|
using namespace logos::plain;
|
|
|
|
namespace {
|
|
|
|
// A provider with a method that parks until the test lets it go. The existing
|
|
// suites use a fixed sleep, which cannot express "in flight for as long as the
|
|
// test needs": a sleep that is shorter than the call timeout makes the future
|
|
// ready on its own and the teardown measurement then times the sleep instead of
|
|
// the timeout.
|
|
class BlockingProvider : public LogosProviderObject {
|
|
public:
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override
|
|
{
|
|
if (method == QLatin1String("ping")) return args.value(0, QVariant(1));
|
|
if (method == QLatin1String("block")) {
|
|
std::unique_lock<std::mutex> lk(m_mu);
|
|
m_cv.wait(lk, [this] { return m_released; });
|
|
return QVariant(42);
|
|
}
|
|
// The other in-flight shape: a "multi" provider that answers the
|
|
// pending sentinel straight away and then never pushes the completion
|
|
// event, so the consumer parks in awaitCompletion instead of on the
|
|
// future. Returns immediately, so unlike `block` it holds no thread.
|
|
if (method == QLatin1String("defer")) {
|
|
QVariantMap sentinel;
|
|
sentinel[logos::pendingCallKey()] = QStringLiteral("never-completes");
|
|
return sentinel;
|
|
}
|
|
return QVariant();
|
|
}
|
|
|
|
void letGo()
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
m_released = true;
|
|
}
|
|
m_cv.notify_all();
|
|
}
|
|
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
bool informModuleToken(const QString&, const QString&) override { return true; }
|
|
void setEventListener(EventCallback) override {}
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("blocker"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
|
|
private:
|
|
std::mutex m_mu;
|
|
std::condition_variable m_cv;
|
|
bool m_released = false;
|
|
};
|
|
|
|
QCoreApplication* ensureApp()
|
|
{
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
// A live host publishing `blocker_module` through a ModuleProxy on its own
|
|
// thread — the provider blocks, so it must not be the thread the consumer needs
|
|
// to deliver its callbacks on.
|
|
class LiveHost {
|
|
public:
|
|
LiveHost()
|
|
{
|
|
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("blocker_module", m_proxy);
|
|
|
|
const QString endpoint = m_host->endpoint();
|
|
m_port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
|
|
}
|
|
|
|
~LiveHost()
|
|
{
|
|
// Anything still parked in the provider would deadlock the thread quit
|
|
// below; let every blocked (and queued) call finish first.
|
|
m_provider.letGo();
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 200);
|
|
// On the PROXY's thread, not this one — see live_host_teardown.h.
|
|
logos::testing::destroyHostOnProxyThread(m_host, m_proxy);
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
|
|
m_thread->quit();
|
|
m_thread->wait();
|
|
delete m_proxy;
|
|
delete m_thread;
|
|
}
|
|
|
|
bool ok() const { return m_started && m_published && m_port != 0; }
|
|
uint16_t port() const { return m_port; }
|
|
BlockingProvider& provider() { return m_provider; }
|
|
|
|
private:
|
|
BlockingProvider 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;
|
|
};
|
|
|
|
// A consumer-side connection to that host. requestObject() hands back a bare
|
|
// PlainLogosObject, so release() is exercised directly.
|
|
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;
|
|
}
|
|
|
|
// Counts callbacks. `count` is the assertion that matters: the contract is
|
|
// EXACTLY ONE, so both 0 and 2 must fail.
|
|
struct Sink {
|
|
std::atomic<int> count{0};
|
|
std::mutex mu;
|
|
QVariant value;
|
|
logos::CallError err;
|
|
|
|
std::string code()
|
|
{
|
|
std::lock_guard<std::mutex> g(mu);
|
|
return err.code;
|
|
}
|
|
std::string message()
|
|
{
|
|
std::lock_guard<std::mutex> g(mu);
|
|
return err.message;
|
|
}
|
|
};
|
|
|
|
// The callback CO-OWNS its sink. A test that fails its "the callback fired"
|
|
// assertion returns with the delivery still queued on the Qt event loop, and a
|
|
// sink captured by reference would be a dead stack frame by then — a genuine
|
|
// regression would surface as a crash in the harness instead of the clean
|
|
// assertion failure that names it.
|
|
LogosObjectErrorChannel::AsyncResultErrorCallback cbFor(std::shared_ptr<Sink> sink)
|
|
{
|
|
return [sink](QVariant v, const logos::CallError& e) {
|
|
std::lock_guard<std::mutex> g(sink->mu);
|
|
sink->value = std::move(v);
|
|
sink->err = e;
|
|
sink->count.fetch_add(1);
|
|
};
|
|
}
|
|
|
|
void pump(int ms)
|
|
{
|
|
QElapsedTimer t;
|
|
t.start();
|
|
while (t.elapsed() < ms)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
|
|
}
|
|
|
|
bool pumpUntilFired(Sink& s, int budgetMs)
|
|
{
|
|
QElapsedTimer t;
|
|
t.start();
|
|
while (s.count.load() == 0 && t.elapsed() < budgetMs)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
|
|
return s.count.load() > 0;
|
|
}
|
|
|
|
LogosObjectErrorChannel* channelFor(LogosObject* obj)
|
|
{
|
|
return dynamic_cast<LogosObjectErrorChannel*>(obj);
|
|
}
|
|
|
|
const char* kToken = "live-token";
|
|
|
|
// Long enough that a full-timeout teardown is unmistakable next to a
|
|
// one-slice one, short enough that a regression doesn't wedge CI for 20s.
|
|
constexpr int kLongTimeoutMs = 8000;
|
|
|
|
// The budget release() must fit in. One wait slice is 25ms; this leaves an
|
|
// order of magnitude of headroom for a loaded CI box while still being ~10x
|
|
// below the call timeout above.
|
|
constexpr int kTeardownBudgetMs = 750;
|
|
|
|
} // namespace
|
|
|
|
class PlainObjectTeardownTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override { ensureApp(); }
|
|
};
|
|
|
|
// ── 1. teardown latency ─────────────────────────────────────────────────────
|
|
//
|
|
// The provider parks forever, the call is given 8s, and then the handle is
|
|
// released. Pre-fix release() sat inside joinWaiters() until the waiter's own
|
|
// future wait hit 8000ms, because nothing could tell it to stop.
|
|
TEST_F(PlainObjectTeardownTest, ReleaseWithACallInFlightDoesNotWaitOutTheTimeout)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("block"), {},
|
|
kLongTimeoutMs, cbFor(sink));
|
|
// Let the call reach the provider and park there, so the waiter really is
|
|
// mid-wait when release() lands.
|
|
pump(200);
|
|
EXPECT_EQ(sink->count.load(), 0) << "the provider answered; nothing was in flight";
|
|
|
|
QElapsedTimer timer;
|
|
timer.start();
|
|
obj->release();
|
|
const qint64 releaseMs = timer.elapsed();
|
|
|
|
std::cout << " release() with an in-flight " << kLongTimeoutMs
|
|
<< "ms call took " << releaseMs << "ms" << std::endl;
|
|
|
|
EXPECT_LT(releaseMs, kTeardownBudgetMs)
|
|
<< "release() waited out the call timeout instead of cancelling the waiter";
|
|
|
|
pumpUntilFired(*sink, 2000);
|
|
host.provider().letGo();
|
|
pump(200);
|
|
}
|
|
|
|
// ── 2. the callback contract, all three outcomes ────────────────────────────
|
|
|
|
TEST_F(PlainObjectTeardownTest, NormalCompletionFiresTheCallbackExactlyOnce)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("ping"),
|
|
QVariantList{ QVariant(7) }, 5000, cbFor(sink));
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 10000)) << "the callback never fired";
|
|
// Keep pumping: a second delivery would arrive here.
|
|
pump(300);
|
|
|
|
std::cout << " normal completion -> callbacks=" << sink->count.load()
|
|
<< " code='" << sink->code() << "'" << std::endl;
|
|
|
|
EXPECT_EQ(sink->count.load(), 1);
|
|
EXPECT_TRUE(sink->code().empty()) << "a successful call reported an error";
|
|
{
|
|
std::lock_guard<std::mutex> g(sink->mu);
|
|
EXPECT_EQ(sink->value.toInt(), 7);
|
|
}
|
|
|
|
obj->release();
|
|
}
|
|
|
|
TEST_F(PlainObjectTeardownTest, TimeoutFiresTheCallbackExactlyOnce)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("block"), {}, 400, cbFor(sink));
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 10000)) << "the callback never fired";
|
|
pump(400);
|
|
|
|
std::cout << " timeout -> callbacks=" << sink->count.load()
|
|
<< " code='" << sink->code() << "'" << std::endl;
|
|
|
|
EXPECT_EQ(sink->count.load(), 1);
|
|
EXPECT_EQ(sink->code(), "timeout")
|
|
<< "slicing the wait must not change what a real timeout reports";
|
|
|
|
obj->release();
|
|
host.provider().letGo();
|
|
pump(200);
|
|
}
|
|
|
|
// The one a "just return on stop" fix breaks: the call is abandoned, and the
|
|
// caller must still be told — once — and told the truth.
|
|
//
|
|
// "transport_error" is the honest code. logos_call_error.h defines it as "the
|
|
// connection failed or was torn down mid-call", which is exactly this: the
|
|
// consumer tore its own end of the call channel down while the call was in
|
|
// flight. The alternatives lie about who failed — "object_unavailable" means
|
|
// the module is not there (it is, and is very likely about to answer, and
|
|
// callers re-acquire on that code), and "call_failed" blames the peer for a
|
|
// dispatch it performed perfectly well. It is also what the wire already
|
|
// reports for the same event seen from the other side: callErrorFromWire maps
|
|
// TRANSPORT_CLOSED to transport_error.
|
|
TEST_F(PlainObjectTeardownTest, CancellationByTeardownFiresTheCallbackExactlyOnce)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("block"), {},
|
|
kLongTimeoutMs, cbFor(sink));
|
|
pump(200);
|
|
ASSERT_EQ(sink->count.load(), 0);
|
|
|
|
obj->release();
|
|
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 2000))
|
|
<< "the cancelled call dropped its callback — the caller waits forever";
|
|
pump(400); // a second delivery would land here
|
|
|
|
std::cout << " cancelled by release -> callbacks=" << sink->count.load()
|
|
<< " code='" << sink->code() << "' message='" << sink->message()
|
|
<< "'" << std::endl;
|
|
|
|
EXPECT_EQ(sink->count.load(), 1);
|
|
EXPECT_EQ(sink->code(), "transport_error");
|
|
EXPECT_FALSE(sink->message().empty());
|
|
|
|
host.provider().letGo();
|
|
pump(200);
|
|
}
|
|
|
|
// ── the SECOND blocking site: the deferred-completion wait ──────────────────
|
|
//
|
|
// A waiter has two places it can be parked, and cancelling only the first would
|
|
// be half a fix. Once a "multi" provider answers the pending sentinel, the
|
|
// waiter leaves the future wait entirely and blocks in awaitCompletion on
|
|
// m_completionCv until the completion event lands or the deadline passes. The
|
|
// provider here answers the sentinel and never completes, so release() lands
|
|
// while the waiter is in that second wait — not the first.
|
|
//
|
|
// This one interrupts with no latency floor at all: it is a condition variable,
|
|
// so the stop wakes it immediately rather than at the next slice boundary.
|
|
TEST_F(PlainObjectTeardownTest, ReleaseDuringADeferredCompletionCancelsThatWaitToo)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("defer"), {},
|
|
kLongTimeoutMs, cbFor(sink));
|
|
// The sentinel comes back fast; this is long enough for the waiter to have
|
|
// left the future wait and be sitting in awaitCompletion.
|
|
pump(300);
|
|
ASSERT_EQ(sink->count.load(), 0) << "the deferred call completed on its own";
|
|
|
|
QElapsedTimer timer;
|
|
timer.start();
|
|
obj->release();
|
|
const qint64 releaseMs = timer.elapsed();
|
|
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 2000)) << "the deferred call dropped its callback";
|
|
pump(300);
|
|
|
|
std::cout << " cancelled mid-defer -> release=" << releaseMs
|
|
<< "ms callbacks=" << sink->count.load()
|
|
<< " code='" << sink->code() << "'" << std::endl;
|
|
|
|
EXPECT_LT(releaseMs, kTeardownBudgetMs)
|
|
<< "release() waited out the deferred-completion deadline";
|
|
EXPECT_EQ(sink->count.load(), 1);
|
|
EXPECT_EQ(sink->code(), "transport_error");
|
|
}
|
|
|
|
// A call started on an already-released... there is no such thing (release
|
|
// deletes), but a handle CAN be torn down between the call being issued and the
|
|
// waiter starting. Same contract: one callback.
|
|
TEST_F(PlainObjectTeardownTest, ReleaseImmediatelyAfterTheCallStillDeliversOnce)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("block"), {},
|
|
kLongTimeoutMs, cbFor(sink));
|
|
obj->release(); // no pump: the waiter may not even have started
|
|
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 2000)) << "callback dropped";
|
|
pump(300);
|
|
|
|
std::cout << " released instantly -> callbacks=" << sink->count.load()
|
|
<< " code='" << sink->code() << "'" << std::endl;
|
|
|
|
EXPECT_EQ(sink->count.load(), 1);
|
|
|
|
host.provider().letGo();
|
|
pump(200);
|
|
}
|
|
|
|
// ── 3. the UAF must stay closed ─────────────────────────────────────────────
|
|
//
|
|
// The waiter must never outlive the object: it reads m_stopping and may call
|
|
// awaitCompletion (m_completionMu, m_completions) after the stop, so the join
|
|
// is what keeps `this` alive underneath it. Cancelling must not turn into
|
|
// detaching.
|
|
//
|
|
// Hammered with a varying gap between issuing the call and releasing, so the
|
|
// release lands at different points of the waiter's startup. Plain, this
|
|
// catches a dropped or doubled callback; run under a UAF detector it catches
|
|
// the freed `this` directly. Verified with macOS Guard Malloc
|
|
// (DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib): clean as written, SIGSEGV
|
|
// the moment the join is turned back into a detach. ASan/TSan are not usable
|
|
// on this toolchain — libclang_rt livelocks in its own init before main.
|
|
TEST_F(PlainObjectTeardownTest, ReleaseRacingTheWaiterIsSafeAndDeliversOnce)
|
|
{
|
|
LiveHost host;
|
|
ASSERT_TRUE(host.ok());
|
|
auto conn = connectTo(host.port());
|
|
ASSERT_NE(conn, nullptr);
|
|
|
|
constexpr int kRounds = 60;
|
|
int delivered = 0;
|
|
QElapsedTimer total;
|
|
total.start();
|
|
|
|
for (int i = 0; i < kRounds; ++i) {
|
|
LogosObject* obj = conn->requestObject(QStringLiteral("blocker_module"), 5000);
|
|
ASSERT_NE(obj, nullptr);
|
|
auto* ch = channelFor(obj);
|
|
ASSERT_NE(ch, nullptr);
|
|
|
|
auto sink = std::make_shared<Sink>();
|
|
ch->callMethodAsyncWithError(kToken, QStringLiteral("block"), {},
|
|
kLongTimeoutMs, cbFor(sink));
|
|
// 0..~1.5ms of drift across the rounds, sweeping the window between
|
|
// registering the waiter and the waiter reaching its first wait.
|
|
if (i % 3 != 0)
|
|
QThread::usleep(static_cast<unsigned long>((i % 30) * 50));
|
|
|
|
obj->release();
|
|
|
|
ASSERT_TRUE(pumpUntilFired(*sink, 3000)) << "round " << i << ": callback dropped";
|
|
pump(20);
|
|
ASSERT_EQ(sink->count.load(), 1) << "round " << i << ": callback fired twice";
|
|
++delivered;
|
|
}
|
|
|
|
const qint64 elapsed = total.elapsed();
|
|
std::cout << " " << delivered << "/" << kRounds
|
|
<< " release-during-call rounds delivered exactly once in "
|
|
<< elapsed << "ms" << std::endl;
|
|
EXPECT_EQ(delivered, kRounds);
|
|
// 60 rounds x kLongTimeoutMs is 8 minutes if the stop stops working, which
|
|
// would otherwise show up only as a suite that got mysteriously slower.
|
|
// Post-fix a round costs a slice plus a round trip (~40ms), so this is an
|
|
// order of magnitude of headroom.
|
|
EXPECT_LT(elapsed, 30000)
|
|
<< "rounds are waiting out call timeouts again, not cancelling";
|
|
|
|
host.provider().letGo();
|
|
pump(200);
|
|
}
|