From 4ffa4c962823b3a8020d1ff53bd4be11ea096e02 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 02:01:27 +0200 Subject: [PATCH] Pin the wait's predicate with a host that has a second thread Soul's sixth pass (N1): every CallScope destructor wakes every waiter, so without the predicate a poll leaves whenever another host thread makes any call. Every scenario had a single-threaded host, and dropping the predicate survived the whole matrix on both targets. `pollbusy` is a public-API scenario and needs no development seam. A second host thread calls `cultmesh_quic_last_error` every 20 ms for twice the poll's timeout while an idle `poll(1000)` waits. It checks the timing first. It then checks the other thread's call count, so a poll that passed without ever being woken reads as the fixture failing. Three harness entries: - revert: no predicate, `woken` taken from `cv_status`; - loosening: the wait reads what woke it and does not wait again; - loosening: a hand-written loop that restarts the whole timeout on every wake. Its lateness is a function of the input. Each was compiled in and run through every scenario on both targets: - revert and read-what-woke: `pollbusy` returns at 0 ms; - re-arm: `pollbusy` returns at about 3000 ms. The earlier scenarios all stay green under all three. The harness kills all three on both targets. Co-Authored-By: Claude Opus 5 --- .../tests/cultmesh_quic_native_tests.cpp | 84 ++++++++++++++++++- scripts/mutate-cultmesh.mjs | 51 ++++++++++- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp index bf53ad42..5d085d67 100644 --- a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp +++ b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp @@ -20,6 +20,7 @@ // cultmesh_quic_native_tests closerace [iterations] [pollers] // cultmesh_quic_native_tests holdclose [iterations] [pollers] // cultmesh_quic_native_tests polltimeout [iterations] +// cultmesh_quic_native_tests pollbusy [iterations] // cultmesh_quic_native_tests latecall [iterations] // cultmesh_quic_native_tests holdtimeout [iterations] // @@ -33,8 +34,10 @@ // In both of those the wait is ended by the close, so the host's timeout governs // nothing either of them can see, and a bridge waiting on a constant of its own // passes both. `polltimeout` is the one that lets the timeout govern the return -// and measures it. `latecall` covers the other half of section 4: a call that -// races the start of a close is refused rather than counted behind its wait. +// and measures it, and `pollbusy` measures it again with the host's other thread +// calling in throughout, which wakes the wait. `latecall` covers the other half +// of section 4: a call that races the start of a close is refused rather than +// counted behind its wait. // `holdtimeout` covers the seam's own rule, which nothing else is in a position // to see: the hold parks a call the wait woke and not one whose own timeout // expired, so the quiesce the hold scenarios assert is the bridge's doing and @@ -336,6 +339,80 @@ int PollTimeout(int iterations) { return 0; } +// How long `pollbusy`'s poll asks for, and how often the host's other thread +// calls into the library while it waits. +constexpr int32_t kBusyPollMs = 1000; +constexpr int kBusyCallEveryMs = 20; + +// The same rule as `polltimeout` — a poll with nothing to deliver stays for its +// timeout — for a host with more than one thread, which is every real host: one +// thread polls, another sends, shuts streams down, reads the last error. +// +// Every host call leaves through a scope that wakes every waiter on the +// runtime's condition variable, because that is how the close learns a call has +// left. So a poll is woken each time any other thread makes any call, and what +// keeps it inside is the wait's predicate: nothing queued and no close means +// wait again, for the rest of the time the host asked for. With one host thread +// nothing else ever calls during the wait, and the predicate could be deleted +// with every other scenario still green. +// +// The other thread keeps calling for twice the poll's timeout and then stops, +// so a wait that restarts its whole timeout on every wake is seen as late +// rather than hanging the scenario. +std::string PollBusyOnce(Overshoot& overshoot) { + void* runtime = nullptr; + const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); + if (opened != 0 || runtime == nullptr) + return "cultmesh_quic_runtime_open returned " + std::to_string(opened); + + std::atomic calls{0}; + std::atomic refused{0}; + std::thread other([runtime, &calls, &refused] { + const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(2 * kBusyPollMs); + std::array message{}; + while (std::chrono::steady_clock::now() < until) { + if (cultmesh_quic_last_error(runtime, message.data(), static_cast(message.size())) < 0) + refused.fetch_add(1); + calls.fetch_add(1); + std::this_thread::sleep_for(std::chrono::milliseconds(kBusyCallEveryMs)); + } + }); + + const TimedPoll poll = PollFor(runtime, kBusyPollMs); + const int calls_during = calls.load(); + other.join(); + cultmesh_quic_runtime_close(runtime); + + overshoot.Record(poll, kBusyPollMs); + if (refused.load() != 0) + return std::to_string(refused.load()) + " call(s) from the host's other thread were refused " + "by a runtime that was not closing"; + // The timing first: a poll that left on the first wake returns before the + // other thread has had time to make many calls, and that is the bridge + // failing, not the fixture. + const std::string failure = CheckTimedPoll(poll, kBusyPollMs, LateToleranceMs(kBusyPollMs)); + if (!failure.empty()) return failure + ", while the host's other thread was calling in"; + // A poll that passed with fewer calls than this was never woken, and passed + // because the fixture did nothing rather than because the bridge held. + if (calls_during < 3) + return "the host's other thread made " + std::to_string(calls_during) + + " call(s) during the poll, so nothing woke it"; + return {}; +} + +int PollBusy(int iterations) { + MeasureAboveTheLoad(); + Overshoot overshoot{}; + for (int iteration = 0; iteration < iterations; ++iteration) { + const std::string failure = PollBusyOnce(overshoot); + if (!failure.empty()) + return Fail("pollbusy iteration " + std::to_string(iteration) + ": " + failure); + } + std::printf("pollbusy %dx: ok (worst overshoot %lld ms at %d)\n", iterations, overshoot.worst_ms, + kBusyPollMs); + return 0; +} + // Nothing listens on the discard port, so a connection to it fails and the // bridge says why. That reason is the payload this scenario needs, and reaching // it takes no listener, no credential and no established connection. @@ -755,7 +832,7 @@ int CloseRace(int iterations, int pollers) { int main(int argc, char** argv) { if (argc < 2) { std::fprintf(stderr, - "usage: %s closerace|holdclose|holdtimeout|polltimeout|payloadfit|latecall " + "usage: %s closerace|holdclose|holdtimeout|polltimeout|pollbusy|payloadfit|latecall " "[iterations] [pollers]\n", argv[0]); return 2; @@ -769,6 +846,7 @@ int main(int argc, char** argv) { } if (scenario == "closerace") return CloseRace(iterations, pollers); if (scenario == "polltimeout") return PollTimeout(iterations); + if (scenario == "pollbusy") return PollBusy(iterations); if (scenario == "payloadfit") return PayloadFit(iterations); if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout") { #if defined(CULTMESH_QUIC_DEBUG_ASSERTS) diff --git a/scripts/mutate-cultmesh.mjs b/scripts/mutate-cultmesh.mjs index b362a12a..f390db35 100644 --- a/scripts/mutate-cultmesh.mjs +++ b/scripts/mutate-cultmesh.mjs @@ -547,6 +547,50 @@ const mutations = [ old: "std::chrono::milliseconds(timeout_ms),", new: "std::chrono::milliseconds(timeout_ms + 100),", }, + // The wait's predicate: a poll with nothing to deliver stays for its timeout + // however often the host's other threads call in. Every call leaves through a + // CallScope that wakes every waiter, so it is the predicate, and nothing else, + // that sends a woken poll back to waiting. Every scenario before `pollbusy` had + // a single-threaded host, nothing called during a wait, and dropping the + // predicate survived the whole matrix on both targets. + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "an idle poll stays for its timeout while other host threads call (revert: the wait has no predicate)", + old: " const bool woken = runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms),\n" + + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", + new: " const bool woken = runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms)) ==\n" + + " std::cv_status::no_timeout;\n", + }, + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + // The careful-looking spelling: it works out for itself what woke it, so the + // hold is never asked about a wake that found nothing, and it still leaves on + // any wake at all. Only a second host thread can see it. + rule: "an idle poll stays for its timeout while other host threads call (loosening: it reads what woke it and does not wait again)", + old: " const bool woken = runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms),\n" + + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", + new: " runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms));\n" + + " const bool woken = !runtime->events.empty() || runtime->closing;\n", + }, + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + // The hand-written loop, which is the ordinary way to spell a predicate + // wait and gets the other half wrong: it does wait again, for the whole + // timeout each time. A host whose other thread calls more often than its poll + // timeout never sees the poll come back while that thread is busy. A function + // of the input, so no single probe value makes it the identity. + rule: "an idle poll stays for its timeout while other host threads call (loosening: each wake restarts the whole timeout)", + old: " const bool woken = runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms),\n" + + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", + new: " while (runtime->events.empty() && !runtime->closing &&\n" + + " runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms)) ==\n" + + " std::cv_status::no_timeout) {\n" + + " }\n" + + " const bool woken = !runtime->events.empty() || runtime->closing;\n", + }, // The seam's own rule, and the only non-comment source change of the last pass // that nothing pinned. It is development-only and folds away in release, so // nothing shipped was at risk; it is still the shape this campaign keeps @@ -781,15 +825,16 @@ function testsPass(workspace) { // deliberately keeps the shipped shape — no assertions, no seam — because what // it is there for is the race in a library built like the one that ships. // -// `polltimeout` and `holdtimeout` both measure a wait against the timeout it -// asked for, so they are run only where nothing distorts the clock: a -// sanitizer's slowdown would make their bands meaningless, which is the honest +// `polltimeout`, `pollbusy` and `holdtimeout` measure a wait against the +// timeout it asked for, so they are run only where nothing distorts the clock: +// a sanitizer's slowdown would make their bands meaningless, which is the honest // limit of those entries. const assertScenarios = [ ["holdclose", "3", "64"], ["latecall", "5"], ["holdtimeout", "3"], ["polltimeout", "3"], + ["pollbusy", "3"], ["payloadfit", "3"], ["closerace", "20", "256"], ];