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 <noreply@anthropic.com>
This commit is contained in:
parent
37967e8b9b
commit
4ffa4c9628
2 changed files with 129 additions and 6 deletions
|
|
@ -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<int> calls{0};
|
||||
std::atomic<int> refused{0};
|
||||
std::thread other([runtime, &calls, &refused] {
|
||||
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(2 * kBusyPollMs);
|
||||
std::array<char, 256> message{};
|
||||
while (std::chrono::steady_clock::now() < until) {
|
||||
if (cultmesh_quic_last_error(runtime, message.data(), static_cast<int32_t>(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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue