// A CALL REGISTERED AS THE CONNECTION FAILS — the one send that was never // answered at all. // // sendCallAsync() reads m_stopped and THEN registers its handler under m_mu. // fail() flips m_stopped and THEN sweeps the pending map under the same mutex. // Those two orders are opposed, so there is an interleaving in which nobody // answers the call: // // caller fail() // ------------------------------ ------------------------------- // m_stopped.load() -> false // CAS m_stopped -> true // lock(m_mu); swap(m_pendingCalls) // ... the map is EMPTY ... // unlock(m_mu) // lock(m_mu); m_pendingCalls[id] = h // writeFrame() -> drops, stopped // // The handler is now parked in the pending map of a connection that has already // been torn down. Nothing will ever take it out: fail() runs once and has been, // no reply can arrive on a closed socket, and the frame was never written. The // caller is not told anything. // // WHAT THAT COSTS THE CALLER, which is the part worth being precise about, // because "a call is lost" understates it. Every caller of this function has a // deadline, and the deadline is what answers instead: // // * callMethodAsyncWithError arms a timer before sending, so the user's // callback fires — after the FULL timeoutMs — with code "timeout". A // connection that is provably gone is reported as a peer that was merely // slow, and callers key their retry / re-acquire behaviour off that code. // * callMethodWithError blocks its own thread on the future for the whole // timeout and reports the same wrong thing. // * getMethods() has no caller-supplied timeout at all: it waits out a // hard-coded 5 seconds and returns an empty method list. // // This predates the io_context fold — master has the identical shape on the // promise-based path — so it is not a regression of #46; #46's cancelPending() // only made the orphaned entry self-cleaning rather than permanent. // // ── HOW THE INTERLEAVING IS BUILT, rather than waited for ──────────────────── // // The window is a handful of instructions wide, so these tests place the two // threads in it instead of racing for it. m_mu is the lever: the test takes the // connection's own mutex (through the explicit-instantiation access hole // test_iofold.cpp already uses to read the pending map), which parks the caller // AFTER its m_stopped check and BEFORE its registration — exactly the gap. The // test then drops the mutex and calls stop() from the hot thread, while the // caller is still coming back from a futex wait, so fail() usually reaches the // mutex first and sweeps a map the caller has not written to yet. // // That last handoff is the one part these tests do not control, so nothing is // asserted about a single round: each runs many and asserts on the aggregate. // It leans on std::mutex BARGING — a hot thread taking a just-released mutex // ahead of a waiter the kernel is still waking — which is how both libc++ on // Darwin (measured: 39-40 of 40 rounds) and glibc's default non-PI mutex behave, // neither of which promises it. An implementation that instead handed ownership // straight to the queued waiter would reach the interleaving in NO round, and // the "was never reached" assertions below are there so that shows up as a // failure that says exactly that, rather than as four quietly vacuous passes. // // Every test asserts that the target interleaving was actually reached at least // once. Post-fix it is visible in what the call reports — the reclaim answers // with TRANSPORT_CLOSED / "connection stopped", and that answer is unreachable // in these rounds by any other route, because the caller demonstrably read // m_stopped as FALSE (it was parked in the gap before stop() was called at all) // and so cannot have taken the pre-existing already-stopped early-out. A run in // which the interleaving was never reached fails, rather than passing quietly. // // ── VALIDATED AGAINST THE PRE-FIX TREE ─────────────────────────────────────── // // Per tests/protocol/CMakeLists.txt: a detector is checked by running it on the // code the fix replaced — a checkout of commit cf1b9b0, the head of #46 — and // not against a switch, a build option or a getenv() probe in this tree. The // numbers those runs produced on an aarch64-darwin box are recorded on each // test below. Summarised: the parking trick reaches the target interleaving in // 39-40 of 40 rounds, all four tests are RED on cf1b9b0, and they take 159 // seconds there against 9 here — almost all of the difference is callers // sitting out deadlines that had already been decided. // // WHICH OF THESE ARE DETECTORS OF WHAT, because it is not uniform: // // * tests 1-3 detect the DROPPED call. Each is red on cf1b9b0 by a wide // margin (40/40 calls never answered; 40/40 answered as "timeout" at // 828ms; a 5049ms getMethods). // * test 4 detects it only weakly — the unaided race is a few instructions // wide, and cf1b9b0 loses 14 calls in 10,000. What test 4 is a strong // detector of is the DOUBLE, which is the failure the fix could newly // introduce; see the note on it. #include #include "incoming_call_handler.h" #include "json_codec.h" #include "logos_call_error.h" #include "plain_logos_object.h" #include "rpc_connection.h" #include "rpc_message.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace logos::plain; namespace { using LocalSocket = boost::asio::local::stream_protocol::socket; using LocalConn = RpcConnection; // Reads the connection's own mutex and pending maps through the // explicit-instantiation access hole ([temp.spec] does not check access on the // template arguments of an explicit instantiation), so the code under test is // observed — and stalled — exactly as it ships: no friend, no test-only hook, // no `#define private public`. test_iofold.cpp uses the same idiom to size the // pending map. template struct Rob { friend typename Tag::type get(Tag) { return Member; } }; struct ConnMuTag { using type = std::mutex LocalConn::*; friend type get(ConnMuTag); }; template struct Rob; struct PendingCallsTag { using type = std::map LocalConn::*; friend type get(PendingCallsTag); }; template struct Rob; struct PendingMethodsTag { using type = std::map>> LocalConn::*; friend type get(PendingMethodsTag); }; template struct Rob; // A provider on the far end of the socketpair that answers everything at once. // It exists so the warm-up call in the object-level test completes for real; // the racing call never reaches it, because the connection dies first. // // It can also be told to HOLD its replies. That is what lets the volume test // below guarantee — rather than hope — that fail()'s sweep has something to // sweep at the moment it runs. class EagerProvider : public IncomingCallHandler { public: void onCall(const CallMessage& req, CallReply reply) override { if (m_hold.load()) { std::lock_guard g(m_mu); m_held.push_back(std::move(reply)); return; } ResultMessage res; res.id = req.id; res.ok = true; res.value = RpcValue{static_cast(1)}; reply(std::move(res)); } void hold(bool on) { m_hold.store(on); } void dropHeld() { std::vector gone; { std::lock_guard g(m_mu); gone.swap(m_held); } // Destroyed here, outside the lock: each closure holds a share of its // connection, so this is also what lets that connection die. } void onMethods(const MethodsMessage& req, MethodsReply reply) override { MethodsResultMessage r; r.id = req.id; r.ok = true; reply(std::move(r)); } void onSubscribe(const SubscribeMessage&, EventSink, const void*) override {} void onUnsubscribe(const UnsubscribeMessage&, const void*) override {} void onConnectionClosed(const void*) override {} void onToken(const TokenMessage&) override {} private: std::atomic m_hold{false}; std::mutex m_mu; std::vector m_held; }; // One io_context and worker thread, shared by every connection a test builds. class IoWorker { public: IoWorker() : m_guard(boost::asio::make_work_guard(m_ioc)) , m_thread([this] { m_ioc.run(); }) {} ~IoWorker() { m_guard.reset(); m_ioc.stop(); if (m_thread.joinable()) m_thread.join(); } boost::asio::io_context& ioc() { return m_ioc; } private: boost::asio::io_context m_ioc; boost::asio::executor_work_guard m_guard; std::thread m_thread; }; // A live client/provider pair over a connected socketpair. Fresh per round, // because each round kills its connection. struct Wire { std::shared_ptr client; std::shared_ptr provider; }; // A HARNESS DETAIL WITH NOTHING TO SAY ABOUT THE PRODUCT, recorded so nobody // reads it as one. On Darwin a write to a socket whose peer has closed raises // SIGPIPE, and the default disposition kills the process — which is what a // provider still answering a burst does the instant the client half is stopped // (observed: one run in six died with signal 13 before this). // // It is specific to connect_pair: asio sets SO_NOSIGPIPE itself in // socket_ops::socket() and socket_ops::accept(), which is how every socket in // the shipped transports is created, but socketpair() descriptors are handed to // basic_socket::assign() and assign() does not. (On Linux the question does not // arise — asio passes MSG_NOSIGNAL on every send.) So this sets the option asio // would have set, rather than touching the process-wide signal disposition out // from under the rest of the suite. void suppressSigpipe(LocalSocket& s) { #ifdef SO_NOSIGPIPE int on = 1; ::setsockopt(s.native_handle(), SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); #else (void)s; #endif } Wire makeWire(boost::asio::io_context& ioc, IncomingCallHandler* handler) { LocalSocket a(ioc), b(ioc); boost::system::error_code ec; boost::asio::local::connect_pair(a, b, ec); if (ec) return {}; suppressSigpipe(a); suppressSigpipe(b); auto codec = std::make_shared(); Wire w; w.client = std::make_shared(std::move(a), codec, nullptr); w.provider = std::make_shared(std::move(b), codec, handler); w.client->start(); w.provider->start(); return w; } // Everything a result handler touches, SHARED-OWNED. A handler that loses its // race still runs — on the io thread, at a moment this test does not control — // so nothing it reads may live in a round's stack frame. struct Outcome { std::atomic calls{0}; std::mutex mu; std::string errCode; std::string errText; logos::CallError err; }; QCoreApplication* ensureApp() { static int argc = 0; static char* argv[] = { nullptr }; if (!QCoreApplication::instance()) new QCoreApplication(argc, argv); return QCoreApplication::instance(); } void pump(int ms) { QElapsedTimer t; t.start(); while (t.elapsed() < ms) QCoreApplication::processEvents(QEventLoop::AllEvents, 5); } void pumpUntil(std::atomic& counter, int target, int budgetMs) { QElapsedTimer t; t.start(); while (counter.load() < target && t.elapsed() < budgetMs) QCoreApplication::processEvents(QEventLoop::AllEvents, 5); } // Park `work` in sendCallAsync's gap, then let fail() through ahead of it. // // On entry the connection's mutex is taken, so `work` — which must be something // that ends in a registration under that mutex — runs its m_stopped check // (false: the connection is live) and then blocks. Dropping the mutex and // calling stop() from THIS thread, which is hot and is not returning from a // futex wait, is what usually gets fail() to the mutex first. // // Returns once both halves are done. Whether the target order was reached is // decided by the caller, from what the call reported. void raceRegistrationAgainstFail(const std::shared_ptr& conn, std::function work) { std::mutex& mu = conn.get()->*get(ConnMuTag{}); std::unique_lock hold(mu); std::thread caller(std::move(work)); // Long enough that the caller is provably parked ON THE MUTEX: it cannot // have got past the registration, because this thread holds it. std::this_thread::sleep_for(std::chrono::milliseconds(30)); hold.unlock(); conn->stop("peer vanished"); caller.join(); } size_t pendingCalls(const std::shared_ptr& conn) { std::lock_guard g(conn.get()->*get(ConnMuTag{})); return (conn.get()->*get(PendingCallsTag{})).size(); } size_t pendingMethods(const std::shared_ptr& conn) { std::lock_guard g(conn.get()->*get(ConnMuTag{})); return (conn.get()->*get(PendingMethodsTag{})).size(); } const char* kToken = "tok"; } // namespace class PlainSendAfterFailTest : public ::testing::Test { protected: void SetUp() override { ensureApp(); } }; // ── 1. the transport-level claim: the handler is invoked, once, always ─────── // // Straight at RpcConnection, with no PlainLogosObject above it, so what is // measured is the registration itself rather than anything the handle does to // compensate for it. // // PRE-FIX (cf1b9b0), 40 rounds: ALL 40 reached the interleaving and in all 40 // the handler was NEVER INVOKED, each leaving its registration parked in the // pending map of a connection that had already been torn down (unanswered=40, // leaked=40). POST-FIX: 39-40 answered by the reclaim, unanswered 0, leaked 0. TEST_F(PlainSendAfterFailTest, ACallRegisteredAsTheConnectionFailsIsStillAnswered) { IoWorker io; EagerProvider provider; constexpr int kRounds = 40; int reached = 0; // fail() swept before the registration landed int sweptFirst = 0; // the registration landed first; fail()'s sweep took it int unanswered = 0; // nobody answered at all — the defect int doubled = 0; int leaked = 0; // registration still parked on the dead connection for (int r = 0; r < kRounds; ++r) { Wire w = makeWire(io.ioc(), &provider); ASSERT_NE(w.client, nullptr); auto out = std::make_shared(); const std::uint64_t id = w.client->nextId(); auto client = w.client; raceRegistrationAgainstFail(client, [client, out, id] { CallMessage msg; msg.id = id; msg.object = "probe"; msg.method = "ping"; client->sendCallAsync(std::move(msg), [out](ResultMessage res) { { std::lock_guard g(out->mu); out->errCode = res.errCode; } out->calls.fetch_add(1); }); }); // Everything that could still answer this call has run: fail() is // complete (stop() returned) and the caller has returned from // sendCallAsync. std::this_thread::sleep_for(std::chrono::milliseconds(20)); const int n = out->calls.load(); std::string seen; { std::lock_guard g(out->mu); seen = out->errCode; } if (n == 0) ++unanswered; else if (n > 1) ++doubled; if (seen == "TRANSPORT_CLOSED") ++reached; if (seen == "TRANSPORT_ERROR") ++sweptFirst; leaked += static_cast(pendingCalls(client)); w.provider->stop(); } std::cout << " " << kRounds << " rounds parked in sendCallAsync's gap -> " << "answered-by-reclaim=" << reached << " answered-by-sweep=" << sweptFirst << " NEVER ANSWERED=" << unanswered << " doubled=" << doubled << " registrations left on a dead connection=" << leaked << std::endl; EXPECT_EQ(unanswered, 0) << unanswered << " of " << kRounds << " calls registered on a connection " "fail() had already swept, and were never answered by anything. The " "caller is left to its deadline."; EXPECT_EQ(doubled, 0) << "a handler was invoked more than once"; EXPECT_EQ(leaked, 0) << "a handler is still parked in the pending map of a stopped connection"; EXPECT_GT(reached, 0) << "the interleaving this test exists for was never reached in " << kRounds << " rounds — fail() always lost the mutex to the caller, so " "this run proved nothing. It is not evidence of a fix."; } // ── 2. what the CALLER is told, and how long it waits to hear it ──────────── // // The same interleaving one layer up, through callMethodAsyncWithError, which // is where the cost actually lands: the deadline is the only thing left that // can resolve the call, so the caller waits it out in full and is then told // "timeout" — a diagnosis that is not merely imprecise but names the wrong // party, since the transport knew the connection was gone before the call was // ever written. // // PRE-FIX (cf1b9b0), 40 rounds with an 800ms deadline: all 40 were answered only // by the deadline — worst 828ms — with code "timeout". POST-FIX: 0 reported as a // timeout, worst latency 30-88ms (which is this test's own 30ms parking sleep // plus the hop through the Qt loop). TEST_F(PlainSendAfterFailTest, TheCallerIsToldTheTransportClosedInsteadOfWaitingOutItsDeadline) { IoWorker io; EagerProvider provider; constexpr int kRounds = 40; constexpr int kTimeoutMs = 800; int reached = 0; int timedOut = 0; int missing = 0; int doubled = 0; std::vector latencies; for (int r = 0; r < kRounds; ++r) { Wire w = makeWire(io.ioc(), &provider); ASSERT_NE(w.client, nullptr); auto* obj = new PlainLogosObject("probe_module", w.client); // ONE COMPLETED CALL FIRST, and it is load-bearing rather than tidy: // the first callMethodAsyncWithError on a handle also runs // ensureCompletionSub(), whose Subscribe takes the connection mutex. // Without the warm-up the racing caller would park on that mutex — // BEFORE its m_stopped check rather than after it — and the round would // exercise the pre-existing already-stopped early-out instead of the gap. { auto warm = std::make_shared(); obj->callMethodAsyncWithError(kToken, QStringLiteral("ping"), QVariantList{}, 5000, [warm](QVariant, const logos::CallError&) { warm->calls.fetch_add(1); }); pumpUntil(warm->calls, 1, 5000); ASSERT_EQ(warm->calls.load(), 1) << "the warm-up call was not answered"; } auto out = std::make_shared(); QElapsedTimer clock; clock.start(); raceRegistrationAgainstFail(w.client, [obj, out] { obj->callMethodAsyncWithError(kToken, QStringLiteral("ping"), QVariantList{}, kTimeoutMs, [out](QVariant, const logos::CallError& e) { { std::lock_guard g(out->mu); out->err = e; } out->calls.fetch_add(1); }); }); // Generous: pre-fix this needs the whole deadline plus the hop through // the Qt loop, and the test has to OBSERVE that rather than give up on it. pumpUntil(out->calls, 1, kTimeoutMs + 4000); const qint64 elapsed = clock.elapsed(); pump(60); // a second delivery would land here logos::CallError seen; { std::lock_guard g(out->mu); seen = out->err; } if (out->calls.load() == 0) ++missing; else if (out->calls.load() > 1) ++doubled; if (seen.code == "timeout") ++timedOut; // "connection stopped" is the reclaim's own wording; fail()'s sweep // reports its reason instead. Either is a prompt, honest answer — this // only distinguishes which half of the race ran. if (seen.message.find("connection stopped") != std::string::npos) ++reached; if (out->calls.load() == 1) latencies.push_back(elapsed); obj->release(); w.provider->stop(); pump(20); } std::sort(latencies.begin(), latencies.end()); const qint64 worst = latencies.empty() ? -1 : latencies.back(); std::cout << " " << kRounds << " rounds, " << kTimeoutMs << "ms deadline -> answered-by-reclaim=" << reached << " reported-as-TIMEOUT=" << timedOut << " never-delivered=" << missing << " doubled=" << doubled << " worst latency=" << worst << "ms" << std::endl; EXPECT_EQ(missing, 0); EXPECT_EQ(doubled, 0); EXPECT_EQ(timedOut, 0) << timedOut << " of " << kRounds << " calls waited out their entire " << kTimeoutMs << "ms deadline and were then reported as a TIMEOUT. The " "connection was already torn down when the call was made; the honest " "code is transport_error, and it was available immediately."; ASSERT_FALSE(latencies.empty()); EXPECT_LT(worst, kTimeoutMs / 2) << "a call took " << worst << "ms to be told the transport was gone"; EXPECT_GT(reached, 0) << "the interleaving this test exists for was never reached in " << kRounds << " rounds — this run proved nothing"; } // ── 3. the same gap on the methods map, where there is no caller timeout ───── // // sendMethods() has the identical shape, and getMethods() above it waits on a // hard-coded five-second future. A caller cannot shorten that, so the cost of // losing this registration is a fixed five-second stall per introspection — // which is what module discovery does on a connection that has just dropped. // // PRE-FIX (cf1b9b0), 25 rounds: 24 reached the interleaving and each blocked its // caller for the full five seconds (worst 5049ms) before giving up with no // answer, leaving 24 promises parked on dead connections. POST-FIX: worst wait // 30-44ms, 0 promises left. TEST_F(PlainSendAfterFailTest, GetMethodsDoesNotWaitOutItsFiveSecondFutureWhenTheConnectionFails) { IoWorker io; EagerProvider provider; constexpr int kRounds = 25; int reached = 0; int leaked = 0; std::vector latencies; for (int r = 0; r < kRounds; ++r) { Wire w = makeWire(io.ioc(), &provider); ASSERT_NE(w.client, nullptr); auto client = w.client; auto elapsed = std::make_shared>(-1); auto text = std::make_shared(); raceRegistrationAgainstFail(client, [client, elapsed, text] { QElapsedTimer t; t.start(); MethodsMessage msg; msg.id = client->nextId(); msg.object = "probe_module"; auto fut = client->sendMethods(std::move(msg)); // The same five seconds the real getMethods() waits. if (fut.wait_for(std::chrono::seconds(5)) == std::future_status::ready) *text = fut.get().err; elapsed->store(t.elapsed()); }); latencies.push_back(elapsed->load()); if (*text == "connection stopped") ++reached; leaked += static_cast(pendingMethods(client)); w.provider->stop(); } std::sort(latencies.begin(), latencies.end()); const qint64 worst = latencies.back(); std::cout << " " << kRounds << " getMethods rounds -> answered-by-reclaim=" << reached << " worst wait=" << worst << "ms, promises left on a dead connection=" << leaked << std::endl; EXPECT_LT(worst, 1000) << "a getMethods() waited " << worst << "ms — its whole hard-coded " "future timeout — because its promise was registered on a connection " "fail() had already swept"; EXPECT_EQ(leaked, 0) << "a promise is still parked in the methods map of a stopped connection"; EXPECT_GT(reached, 0) << "the interleaving was never reached; nothing proved"; } // ── 4. exactly-once, at volume, on the path the fix adds ──────────────────── // // THE THREE TESTS ABOVE ARE NOT DETECTORS OF EXACTLY-ONCE. Each resolves one // call once, so each stays green whatever guards the handler — the same trap // tests/protocol/CMakeLists.txt records for the fold's per-path tests. The fix // adds a THIRD contender for a registered handler (the post-registration // reclaim, alongside dispatchIncoming and fail()'s sweep), so the property that // needs re-proving is that the extract-and-erase under m_mu still lets exactly // one of them have it. // // So: 10,000 calls, registered from four threads, with stop() landing in the // middle of the burst. Every call counts its own invocations. Both failures are // counted separately, because they are different bugs — a handler invoked twice // means the reclaim and the sweep both won, and a handler never invoked is the // defect this file is about, measured at volume instead of one round at a time. // // VALIDATED AS A DOUBLE-DELIVERY DETECTOR, against the obvious spelling of the // fix rather than an imaginary one: a throwaway checkout in which sendCallAsync // COPIES its handler into the map and then delivers that copy without the // extract-and-erase (the "I already have the handler, why look it up" version). // It reports 4, 6, 7 and 8 doubles per 10,000 over four runs. Thrown away with // the checkout; nothing in this tree switches it on. // // AND WHY IT IS NEEDED AT ALL, given the suite already has a 10,000-call // release-race: IoFoldTest.ReleaseRacingRepliesInFlightDeliversEachCallOnce is // BLIND to this path — measured, 0 doubles against the same broken reclaim — // because it races teardown of the HANDLE against replies while the connection // stays up, so sendCallAsync's stopped branch is never taken. The contended // object has to be the connection. // // PRE-FIX (cf1b9b0): 14 of the 10,000 calls dropped, and 14 registrations left // on dead connections — a real but weak signal, since the unaided window is only // a few instructions wide (tests 1-3 are the wide detectors of the drop). // POST-FIX: 0 dropped, 0 doubled, over 8 runs. TEST_F(PlainSendAfterFailTest, AStopRacingABurstOfRegistrationsAnswersEveryCallExactlyOnce) { IoWorker io; EagerProvider provider; constexpr int kRounds = 10; constexpr int kWarm = 40; constexpr int kThreads = 4; constexpr int kPer = 240; constexpr int kPerRound = kWarm + kThreads * kPer; // 1,000 constexpr int kTotal = kRounds * kPerRound; // 10,000 int doubled = 0; int dropped = 0; int answered = 0; int byReply = 0; int bySweep = 0; int byClosed = 0; int leaked = 0; for (int r = 0; r < kRounds; ++r) { Wire w = makeWire(io.ioc(), &provider); ASSERT_NE(w.client, nullptr); auto client = w.client; provider.hold(false); // Shared-owned for the same reason as Outcome above: a handler that // loses the race still runs, on a thread this loop does not join. auto counts = std::make_shared>>(kPerRound); auto reply = std::make_shared>(0); auto closed = std::make_shared>(0); auto swept = std::make_shared>(0); auto issued = std::make_shared>(0); auto send = [client, counts, reply, closed, swept](int slot) { CallMessage msg; msg.id = client->nextId(); msg.object = "probe"; msg.method = "ping"; client->sendCallAsync(std::move(msg), [counts, reply, closed, swept, slot](ResultMessage res) { if (res.ok) reply->fetch_add(1); else if (res.errCode == "TRANSPORT_CLOSED") closed->fetch_add(1); else swept->fetch_add(1); (*counts)[slot].fetch_add(1); }); }; // ── EACH RESOLVER IS MADE LIVE BY CONSTRUCTION, NOT BY TIMING ──────── // // The three "this resolver ran" assertions at the bottom are what stops // a green run from being vacuous, so none of them may rest on a sleep. // An earlier cut set the teardown off after a fixed delay and then after // the first reply, and both are wrong in opposite directions on a slow // box: the first landed the stop ahead of the whole burst, and the // second behind all of it (measured on a 3-core macOS CI runner — 0 // calls took the stopped path, so the run proved nothing about it). // // Phase 1 answers normally and is WAITED for, which is what makes a // reply-resolved call certain. for (int i = 0; i < kWarm; ++i) send(i); { QElapsedTimer t; t.start(); while (reply->load() == 0 && t.elapsed() < 15000) std::this_thread::yield(); } ASSERT_GT(reply->load(), 0) << "round " << r << ": the provider never answered anything"; // Phase 2 answers NOTHING — the provider holds every reply — so every // call registered between here and the stop is still pending when // fail() sweeps, which is what makes a sweep-resolved call certain. provider.hold(true); std::vector callers; callers.reserve(kThreads); for (int t = 0; t < kThreads; ++t) { callers.emplace_back([send, issued, t] { for (int i = 0; i < kPer; ++i) { send(kWarm + t * kPer + i); issued->fetch_add(1); } }); } // And the stop is triggered by a COUNT of registrations rather than a // clock, so calls are still being issued when it lands however slow the // box is — which is what makes a reclaim-resolved call certain. The // trigger sweeps across rounds and stays far below the 960 the burst // will issue. const int trigger = 80 + r * 60; { QElapsedTimer t; t.start(); while (issued->load() < trigger && t.elapsed() < 15000) std::this_thread::yield(); } client->stop("peer vanished mid-burst"); for (auto& th : callers) th.join(); std::this_thread::sleep_for(std::chrono::milliseconds(30)); for (const auto& c : *counts) { const int n = c.load(); if (n == 0) ++dropped; else ++answered; if (n > 1) doubled += n - 1; } byReply += reply->load(); byClosed += closed->load(); bySweep += swept->load(); leaked += static_cast(pendingCalls(client)); w.provider->stop(); provider.dropHeld(); } std::cout << " " << kTotal << " calls racing stop() -> answered=" << answered << " (by a reply=" << byReply << ", by fail()'s sweep=" << bySweep << ", stopped-connection=" << byClosed << ") DROPPED=" << dropped << " DOUBLED=" << doubled << " left registered on a dead connection=" << leaked << std::endl; EXPECT_EQ(dropped, 0) << dropped << " of " << kTotal << " calls were never answered at all"; EXPECT_EQ(doubled, 0) << doubled << " calls were answered more than once: the extract-and-erase " "no longer makes the reclaim and fail()'s sweep mutually exclusive"; EXPECT_EQ(leaked, 0); // All three resolvers have to have been live, or the burst did not straddle // the teardown and this test raced nothing. Each is arranged for above // rather than hoped for; a failure here is a broken harness, not a broken // transport, and says which of the three did not run. EXPECT_GT(byReply, 0) << "no call was answered by a reply"; EXPECT_GT(bySweep, 0) << "no call was answered by fail()'s sweep"; EXPECT_GT(byClosed, 0) << "no call was answered as a stopped connection"; }