Self's ruling of 2026-09-22 called it after seven Soul passes: a wall-clock probe against cultmesh_quic_next_event's timeout can never tell the host's argument from a clamp, floor, round, scale or later-poll fault that happens to be the identity at the probe's own value, because there is always another function that matches. Move the observation to where the rule is decided. Add cultmesh_quic_debug_last_wait_ms / _reset_last_wait_ms, built exactly like the existing hold seam: gated behind CULTMESH_QUIC_DEBUG_ASSERTS, absent from a shipped build. CULTMESH_QUIC_DEBUG_RECORD_WAIT wraps the literal timeout_ms handed to the condition wait as a pass-through, so what is recorded and what is actually waited on are the same evaluation and cannot diverge except by changing what the macro wraps. Two scenarios: - waitseam asserts the recorded wait equals the host's own argument, exactly, over 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300 and INT32_MAX (the header states no narrower maximum), and over 40 polls on one runtime past the point every prior scenario stopped asking. timeout_ms <= 0 never reaches the wait at all, so its probe checks the honest converse — the seam stays untouched — which is what catches a seam that records the raw argument on every call instead of only the value it actually hands its wait. - pollhammer runs one thread hammering every gate-touching entry point that cannot itself queue an event, in a yield loop, against another thread's idle poll, once on a clean runtime and once after a refused listener_open has left runtime->error non-empty. The idle poll must still stay for its timeout. polltimeout, pollbusy and holdtimeout stay as generous-margin proof that the recorded wait is really waited, not as the precision instrument they used to have to be: the fastest-of-ten mechanism, the 15 ms probe's tight tolerance, and the stated-limits table they rested on are gone, because waitseam now kills every clamp, floor, round, scale, offset and later-poll mutant by equality regardless of timing. The Windows priority raise these three scenarios carried is gone with them: a flat 800 ms late tolerance already covers the worst overshoot the old table recorded (127 ms at normal priority under load), so raising the process above the load bought nothing the new margin does not already have. holdtimeout's long probe moves to 1600 ms, above the stated 1500 ms ceiling on the hold guard, with the hold kept armed to 3000 ms so a guard that parks a naturally-timed-out call anyway overshoots by well more than the margin. Verified locally on win32-x64: waitseam and pollhammer pass against the unmutated bridge; a seam-loosening mutant (recording the raw argument outside the wait's own branch) fails waitseam's 0 ms probe; an inline +25 ms offset on the wait's argument fails waitseam by exact mismatch; the S5 hold-guard mutant (timeout_ms > 1500) fails holdtimeout by 1.4 s overshoot; K1 (++wakes > 64), K2 (!error.empty()) and a no-predicate revert all fail pollhammer's idle poll within tens of milliseconds. All were hand-reverted and diffed clean against the working tree before this commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1037 lines
51 KiB
C++
1037 lines
51 KiB
C++
// Scenario runner for the bridge's runtime lifetime rules.
|
|
//
|
|
// This is the one part of the bridge a host cannot check by reading: whether
|
|
// `cultmesh_quic_runtime_close` is safe against host threads that are inside the
|
|
// library when it starts. Everything here drives that path and nothing else. It
|
|
// opens a runtime, parks pollers in `cultmesh_quic_next_event`, closes the
|
|
// runtime underneath them, and asserts what the header promises: every parked
|
|
// poller returns, and none of them touches the runtime after it is gone.
|
|
//
|
|
// It consumes the bridge through include/cultmesh_quic_native.h — the same
|
|
// contract a host has — and nothing else. No MsQuic header, no internals.
|
|
//
|
|
// The failures it exists to catch are not visible in the return values, because
|
|
// a use-after-free is not an error code. Two things make them visible instead:
|
|
// a sanitizer build (ThreadSanitizer for the notify/destroy race, AddressSanitizer
|
|
// for the freed-runtime dereference), and `CULTMESH_QUIC_DEBUG_ASSERTS`, which
|
|
// makes the bridge state the quiesce invariant itself rather than leaving it to
|
|
// be caught by an unlucky thread schedule.
|
|
//
|
|
// 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]
|
|
// cultmesh_quic_native_tests waitseam [iterations]
|
|
// cultmesh_quic_native_tests pollhammer [iterations]
|
|
//
|
|
// `closerace` hunts the race: many pollers, many iterations, and whatever the
|
|
// schedule gives. `holdclose` takes the guessing out of it — the bridge's
|
|
// development seam (section 7 of the header) holds the pollers inside the
|
|
// library until this scenario releases them, and reports how many host calls the
|
|
// bridge actually counted, so the scenario asserts the quiesce instead of
|
|
// assuming its own fixture worked. It needs CULTMESH_QUIC_DEBUG_ASSERTS.
|
|
//
|
|
// 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, 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
|
|
// not the fixture's.
|
|
//
|
|
// Self's ruling of 2026-09-22, after seven Soul passes against `polltimeout`,
|
|
// `pollbusy` and `holdtimeout` alone: a wall-clock probe cannot tell the host's
|
|
// timeout from any function of it that is the identity at the probe's own
|
|
// value, because there is always another function that matches. `waitseam`
|
|
// puts the observation where the rule is decided instead of where its effect
|
|
// eventually shows: it reads `cultmesh_quic_debug_last_wait_ms`, the bridge's
|
|
// own account of what it handed its condition wait, and asserts equality
|
|
// against the host's argument, deterministically, with no timing tolerance.
|
|
// `polltimeout`, `pollbusy` and `holdtimeout` stay only as generous-margin
|
|
// proof that the recorded wait is really waited and not merely recorded.
|
|
// `pollhammer` covers the wait's predicate the same way `pollbusy` used to try
|
|
// to: one thread hammers every gate-touching entry point that cannot itself
|
|
// queue an event, in a yield loop, while another holds a single idle poll, and
|
|
// the poll must still stay for its timeout.
|
|
//
|
|
// Exit code 0 means every assertion held. Anything else, including a sanitizer
|
|
// abort or the bridge's own assertion, is a failure. Under ThreadSanitizer run
|
|
// it with `TSAN_OPTIONS=halt_on_error=1`, or a reported race leaves the exit
|
|
// code to the last thing that set it.
|
|
|
|
#include <cultmesh_quic_native.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <climits>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
// Long enough that a poller is still parked when the close begins. The close is
|
|
// what ends the wait; this timeout only bounds the scenario if the close never
|
|
// arrives, which is itself the failure.
|
|
constexpr int32_t kPollTimeoutMs = 5000;
|
|
|
|
// A poller asks for a payload buffer, so the close races a `next_event` that has
|
|
// somewhere to copy into rather than one that can only ever return 0.
|
|
constexpr int32_t kPayloadCapacity = 4096;
|
|
|
|
int Fail(const std::string& message) {
|
|
std::fprintf(stderr, "FAILED: %s\n", message.c_str());
|
|
return 1;
|
|
}
|
|
|
|
// One iteration: `pollers` threads parked in `next_event`, then a close under
|
|
// them. Returns an empty string when every promise held.
|
|
//
|
|
// Each poller makes exactly one blocking call and does not call again. Section 5
|
|
// of the header puts the ordering on the host — no call may *begin* during a
|
|
// close — so a scenario that kept re-entering would be testing a shape the
|
|
// bridge declines to support, and its report would be the harness's own fault.
|
|
// A call already inside the library is the case the quiesce exists for, and that
|
|
// is the one run here.
|
|
std::string CloseRaceOnce(int pollers) {
|
|
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);
|
|
|
|
// Counts threads about to enter the blocking call. The close waits for all of
|
|
// them, so the race is run with the pollers actually inside rather than with
|
|
// threads that have not started yet.
|
|
std::atomic<int> entering{0};
|
|
std::atomic<int> returned{0};
|
|
std::atomic<int> undefined_return{0};
|
|
std::atomic<int> timed_out{0};
|
|
// Set just before the close, and read by each poller as it returns. A poller
|
|
// that is already out by then was never in the race, and a scenario full of
|
|
// those passes without testing anything: this is what catches a bridge whose
|
|
// blocking crossing has stopped blocking.
|
|
std::atomic<bool> closing_started{false};
|
|
std::atomic<int> left_before_the_close{0};
|
|
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(static_cast<size_t>(pollers));
|
|
for (int index = 0; index < pollers; ++index) {
|
|
threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out,
|
|
&closing_started, &left_before_the_close] {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
|
|
entering.fetch_add(1);
|
|
const auto started = std::chrono::steady_clock::now();
|
|
// Parked here when the close arrives. `closing` wakes the wait; the
|
|
// call scope this thread holds is what the close must count out
|
|
// before it frees anything.
|
|
const int32_t result = cultmesh_quic_next_event(
|
|
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
|
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - started).count();
|
|
|
|
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
|
|
// The close is what ends this wait. Sitting out the whole timeout
|
|
// instead means the wake never reached it.
|
|
if (elapsed >= kPollTimeoutMs / 2) timed_out.fetch_add(1);
|
|
if (!closing_started.load()) left_before_the_close.fetch_add(1);
|
|
returned.fetch_add(1);
|
|
});
|
|
}
|
|
|
|
while (entering.load() < pollers) std::this_thread::yield();
|
|
// Settle so the pollers are inside the blocking wait rather than on their way
|
|
// to it. A poller that has not entered yet is a call beginning during the
|
|
// close, which is the host's to avoid, not the bridge's to survive.
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
|
|
|
closing_started.store(true);
|
|
cultmesh_quic_runtime_close(runtime);
|
|
for (auto& thread : threads) thread.join();
|
|
|
|
if (left_before_the_close.load() != 0)
|
|
return std::to_string(left_before_the_close.load()) + " of " + std::to_string(pollers) +
|
|
" pollers returned before the close began, so they were never in the race";
|
|
if (undefined_return.load() != 0)
|
|
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
|
|
if (timed_out.load() != 0)
|
|
return std::to_string(timed_out.load()) + " of " + std::to_string(pollers) +
|
|
" pollers sat out the timeout instead of being woken by the close";
|
|
if (returned.load() != pollers)
|
|
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
|
|
return {};
|
|
}
|
|
|
|
// The three timeouts `polltimeout` asks for: a spread, not a hunt for any
|
|
// particular derivation. `waitseam` is what proves the bridge hands its
|
|
// condition wait the host's own argument, exactly, at every value that matters
|
|
// — these three exist only to prove that value is then really waited on, so
|
|
// the spread just needs to be a short one, an ordinary one and a long one.
|
|
constexpr int32_t kShortestPollMs = 15;
|
|
constexpr int32_t kShortPollMs = 200;
|
|
constexpr int32_t kLongPollMs = 7300;
|
|
|
|
// How much sooner than the timeout a poll may return. A wait may be late; it may
|
|
// not be early, because returning early is exactly what waiting on a shorter
|
|
// duration looks like. This is scheduler granularity, not slack.
|
|
constexpr int kEarlyToleranceMs = 20;
|
|
|
|
// How much later than the timeout a poll may return. Generous on purpose: these
|
|
// scenarios are no longer where a clamp, a floor, a round or a scale is caught
|
|
// — `waitseam` catches those by equality, with no clock involved — so this only
|
|
// has to catch a wait that is wrong by a lot: a constant of the bridge's own, a
|
|
// wait that never happens, a hold that parks a call its own timeout already
|
|
// ended. Each of those misses by seconds, not milliseconds, so one flat, wide
|
|
// margin serves every probe this file times, on a machine under real load.
|
|
constexpr int kGenerousLateToleranceMs = 800;
|
|
|
|
struct TimedPoll {
|
|
int32_t result;
|
|
long long elapsed_ms;
|
|
};
|
|
|
|
TimedPoll PollFor(void* runtime, int32_t timeout_ms) {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const int32_t result = cultmesh_quic_next_event(
|
|
runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required);
|
|
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - started).count();
|
|
return {result, elapsed};
|
|
}
|
|
|
|
// The host's timeout, and the only thing it is: how long a poll with nothing to
|
|
// deliver stays inside the library. Nothing else here checks it — `closerace`
|
|
// and `holdclose` both end their waits with a close, so in both of them the
|
|
// bridge could wait on any duration at all and every assertion would still hold.
|
|
// A runtime with no listener and no connection has nothing to deliver, so the
|
|
// timeout is what ends this poll, and the elapsed time is what it waited on.
|
|
// The bands every timed poll is held to, and the worst overshoot seen, which is
|
|
// printed so a tolerance can be checked against a machine under load rather than
|
|
// trusted.
|
|
struct Overshoot {
|
|
long long worst_ms = LLONG_MIN;
|
|
void Record(const TimedPoll& poll, int32_t timeout_ms) {
|
|
worst_ms = (std::max)(worst_ms, poll.elapsed_ms - timeout_ms);
|
|
}
|
|
};
|
|
|
|
std::string CheckTimedPoll(const TimedPoll& poll, int32_t timeout_ms, int late_tolerance_ms) {
|
|
const std::string asked = "a poll asking for " + std::to_string(timeout_ms) + " ms ";
|
|
if (poll.result != 0)
|
|
return asked + "returned " + std::to_string(poll.result) + ", not the 0 an idle runtime owes it";
|
|
if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs)
|
|
return asked + "returned after " + std::to_string(poll.elapsed_ms) +
|
|
" ms: the wait ended on some shorter duration than the one it was given";
|
|
if (poll.elapsed_ms > static_cast<long long>(timeout_ms) + late_tolerance_ms)
|
|
return asked + "returned after " + std::to_string(poll.elapsed_ms) +
|
|
" ms: the wait ended on some longer duration than the one it was given";
|
|
return {};
|
|
}
|
|
|
|
constexpr std::array<int32_t, 3> kTimedProbesMs{kShortestPollMs, kShortPollMs, kLongPollMs};
|
|
|
|
std::string PollTimeoutOnce(std::array<Overshoot, 3>& 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::string failure;
|
|
for (size_t probe = 0; probe < kTimedProbesMs.size() && failure.empty(); ++probe) {
|
|
const TimedPoll poll = PollFor(runtime, kTimedProbesMs[probe]);
|
|
overshoot[probe].Record(poll, kTimedProbesMs[probe]);
|
|
failure = CheckTimedPoll(poll, kTimedProbesMs[probe], kGenerousLateToleranceMs);
|
|
}
|
|
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return failure;
|
|
}
|
|
|
|
int PollTimeout(int iterations) {
|
|
std::array<Overshoot, 3> overshoot{};
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = PollTimeoutOnce(overshoot);
|
|
if (!failure.empty())
|
|
return Fail("polltimeout iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("polltimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d, %lld ms at %d)\n",
|
|
iterations, overshoot[0].worst_ms, kTimedProbesMs[0], overshoot[1].worst_ms, kTimedProbesMs[1],
|
|
overshoot[2].worst_ms, kTimedProbesMs[2]);
|
|
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, kGenerousLateToleranceMs);
|
|
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) {
|
|
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.
|
|
constexpr uint16_t kClosedPort = 9;
|
|
|
|
// How long the failure is given to arrive. The loopback refusal is immediate;
|
|
// if the refusal is swallowed instead, MsQuic's own handshake timeout ends the
|
|
// connection at 10 seconds and event 4 still arrives, so this only bounds a
|
|
// bridge that reports nothing at all.
|
|
constexpr int kEventDeadlineMs = 20000;
|
|
|
|
// The bridge writes its own reason sentences and every one of them starts here.
|
|
// Checking the bytes, and not only the length, is what separates a payload
|
|
// copied out of the queued event from one copied out of an event that was popped
|
|
// first: the second reads freed memory, which does not spell this.
|
|
constexpr char kReasonPrefix[] = "CultMesh QUIC connection";
|
|
|
|
// The two-phase poll, which is the shape every host uses to size its buffer:
|
|
// ask with nothing, be told what it needs, ask again. What makes it work is that
|
|
// the refusal consumes nothing — the event is still queued, payload and all,
|
|
// after a return of 2 — and that the copy happens before the event is popped.
|
|
// Both are section 6 of the header, and nothing committed reached either.
|
|
std::string PayloadFitOnce() {
|
|
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::string failure;
|
|
uint64_t connection_id = 0;
|
|
const int32_t connecting =
|
|
cultmesh_quic_connection_open(runtime, "127.0.0.1", kClosedPort, &connection_id);
|
|
if (connecting != 0) {
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return "cultmesh_quic_connection_open returned " + std::to_string(connecting);
|
|
}
|
|
|
|
// An out parameter the bridge must not write on a refusal, filled with
|
|
// something the bridge never writes so that a write is visible.
|
|
cultmesh_quic_event poisoned{};
|
|
std::memset(&poisoned, 0xab, sizeof(poisoned));
|
|
cultmesh_quic_event event = poisoned;
|
|
int32_t required = 0;
|
|
int32_t result = 0;
|
|
|
|
// Ask with no room at all until the event carrying the failure reason
|
|
// arrives. An event with no payload fits in nothing, so it is delivered and
|
|
// polled past rather than mistaken for this one.
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kEventDeadlineMs);
|
|
for (;;) {
|
|
event = poisoned;
|
|
required = 0;
|
|
result = cultmesh_quic_next_event(runtime, 250, &event, nullptr, 0, &required);
|
|
if (result == 2 && required > 0) break;
|
|
if (result < 0) {
|
|
failure = "cultmesh_quic_next_event returned " + std::to_string(result);
|
|
break;
|
|
}
|
|
if (std::chrono::steady_clock::now() >= deadline) {
|
|
failure = "no event carrying a payload arrived within " +
|
|
std::to_string(kEventDeadlineMs) + " ms of a connection to a closed port";
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (failure.empty() && std::memcmp(&event, &poisoned, sizeof(event)) != 0)
|
|
failure = "the refusal wrote to *out_event, which the header says it leaves alone";
|
|
|
|
// Again, with the same empty buffer: a refusal that consumed the event
|
|
// leaves nothing to find the second time.
|
|
if (failure.empty()) {
|
|
cultmesh_quic_event again = poisoned;
|
|
int32_t required_again = 0;
|
|
const int32_t second = cultmesh_quic_next_event(runtime, 0, &again, nullptr, 0, &required_again);
|
|
if (second != 2 || required_again != required)
|
|
failure = "the second ask returned " + std::to_string(second) + " needing " +
|
|
std::to_string(required_again) + ", not the 2 needing " + std::to_string(required) +
|
|
" the first one did: the refusal consumed the event";
|
|
}
|
|
|
|
// And with room, which is where the copy happens. The tail beyond the
|
|
// payload is written too, so a copy that ran past what it was told to write
|
|
// is visible.
|
|
if (failure.empty()) {
|
|
constexpr uint8_t kTail = 0x5a;
|
|
std::vector<uint8_t> payload(static_cast<size_t>(required) + 16, kTail);
|
|
cultmesh_quic_event delivered = poisoned;
|
|
int32_t required_delivered = 0;
|
|
const int32_t third = cultmesh_quic_next_event(
|
|
runtime, 0, &delivered, payload.data(), static_cast<int32_t>(payload.size()),
|
|
&required_delivered);
|
|
const std::string reason(reinterpret_cast<const char*>(payload.data()),
|
|
static_cast<size_t>(required));
|
|
if (third != 1 || required_delivered != required)
|
|
failure = "the ask with room returned " + std::to_string(third) + " needing " +
|
|
std::to_string(required_delivered) + ", not the 1 needing " +
|
|
std::to_string(required) + " the refusal promised";
|
|
else if (delivered.type != CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN ||
|
|
delivered.connection_id != connection_id ||
|
|
delivered.payload_length != required)
|
|
failure = "the delivered event was type " + std::to_string(delivered.type) +
|
|
" on connection " + std::to_string(delivered.connection_id) + " carrying " +
|
|
std::to_string(delivered.payload_length) + " byte(s), not the failed connection's own";
|
|
else if (reason.rfind(kReasonPrefix, 0) != 0)
|
|
failure = "the payload delivered was '" + reason + "', not a reason this bridge writes";
|
|
else if (payload[static_cast<size_t>(required)] != kTail)
|
|
failure = "the copy wrote past the " + std::to_string(required) + " bytes it reported";
|
|
}
|
|
|
|
// Consumed now, and only now.
|
|
if (failure.empty()) {
|
|
cultmesh_quic_event emptied = poisoned;
|
|
int32_t required_emptied = 0;
|
|
const int32_t fourth =
|
|
cultmesh_quic_next_event(runtime, 0, &emptied, nullptr, 0, &required_emptied);
|
|
if (fourth != 0)
|
|
failure = "the event was still queued after being delivered: a further ask returned " +
|
|
std::to_string(fourth);
|
|
}
|
|
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return failure;
|
|
}
|
|
|
|
int PayloadFit(int iterations) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = PayloadFitOnce();
|
|
if (!failure.empty())
|
|
return Fail("payloadfit iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("payloadfit %dx: ok\n", iterations);
|
|
return 0;
|
|
}
|
|
|
|
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
|
|
|
|
// How long a poll that does not block is given to prove it by returning, after
|
|
// the calls have been counted and before the close begins. A bridge whose
|
|
// blocking wait no longer blocks has emptied itself of host calls by the time
|
|
// the close starts, and `calls_at_close` then says 0.
|
|
constexpr int kSettleMs = 100;
|
|
|
|
// How long the close is required to stay inside `cultmesh_quic_runtime_close`
|
|
// while the pollers are held. The held calls are what it is waiting for, and
|
|
// nothing releases them until this elapses, so a close that returns first gave
|
|
// up on a call that was still inside. This bounds what the scenario can see: a
|
|
// close that waits for longer than this and then gives up is not distinguished
|
|
// from one that waits properly, which is the honest limit of the check.
|
|
constexpr int kHeldWindowMs = 500;
|
|
|
|
// How long the pollers are given to be counted inside the library before the
|
|
// close begins. Reaching `pollers` is the normal path; the deadline only stops
|
|
// a bridge that never counts them from hanging the scenario.
|
|
constexpr int kCountedDeadlineMs = 2000;
|
|
|
|
// One iteration of the quiesce itself, with the guesswork taken out: the pollers
|
|
// are held inside the library by the bridge's own development seam until this
|
|
// scenario lets them go, and the bridge says how many calls it counted rather
|
|
// than the scenario assuming the schedule put them there.
|
|
//
|
|
// Three promises, and a mutation that breaks any one of them is seen here:
|
|
// - the calls are counted (`peak`), so a blocking crossing outside a call scope
|
|
// is not merely uncounted-but-lucky, it reads as zero;
|
|
// - they were still counted when the close began its wait (`calls_at_close`),
|
|
// so a poll that stopped blocking cannot leave the scenario testing an empty
|
|
// library;
|
|
// - the close did not return while they were held, so a wait that gives up
|
|
// after any interval shorter than the hold is not a wait.
|
|
std::string HoldCloseOnce(int pollers) {
|
|
cultmesh_quic_debug_hold_calls(1);
|
|
void* runtime = nullptr;
|
|
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
|
if (opened != 0 || runtime == nullptr) {
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
|
}
|
|
|
|
std::atomic<int> returned{0};
|
|
std::atomic<int> undefined_return{0};
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(static_cast<size_t>(pollers));
|
|
for (int index = 0; index < pollers; ++index) {
|
|
threads.emplace_back([runtime, &returned, &undefined_return] {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
const int32_t result = cultmesh_quic_next_event(
|
|
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
|
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
|
|
returned.fetch_add(1);
|
|
});
|
|
}
|
|
|
|
const auto counted_deadline =
|
|
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
|
while (cultmesh_quic_debug_peak_calls() < pollers &&
|
|
std::chrono::steady_clock::now() < counted_deadline)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
|
|
|
std::atomic<bool> closed{false};
|
|
std::thread closer([runtime, &closed] {
|
|
cultmesh_quic_runtime_close(runtime);
|
|
closed.store(true);
|
|
});
|
|
const auto held_until =
|
|
std::chrono::steady_clock::now() + std::chrono::milliseconds(kHeldWindowMs);
|
|
while (!closed.load() && std::chrono::steady_clock::now() < held_until)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
|
|
const bool closed_while_held = closed.load();
|
|
const int32_t peak = cultmesh_quic_debug_peak_calls();
|
|
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
|
|
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
closer.join();
|
|
for (auto& thread : threads) thread.join();
|
|
|
|
if (peak != pollers)
|
|
return "the library's peak count of host calls inside it was " + std::to_string(peak) + ", not " +
|
|
std::to_string(pollers) + ": either the blocking crossing is not inside a call scope, or it " +
|
|
"did not block long enough for the pollers to be inside it together";
|
|
if (at_close != pollers)
|
|
return "the close began its wait with " + std::to_string(at_close) +
|
|
" host call(s) counted inside, not " + std::to_string(pollers) +
|
|
"; the pollers were not inside the library when the close started";
|
|
if (closed_while_held)
|
|
return "the close returned while " + std::to_string(pollers) +
|
|
" host call(s) were still held inside the library";
|
|
if (undefined_return.load() != 0)
|
|
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
|
|
if (returned.load() != pollers)
|
|
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
|
|
return {};
|
|
}
|
|
|
|
// One iteration of the other half of section 4: a call made while a close is
|
|
// running is refused, and is not counted behind the close's wait.
|
|
//
|
|
// The prohibition is the host's — no call may begin after the close starts — and
|
|
// the bridge does not make it optional. What it does do is refuse the call that
|
|
// races the start, and that refusal is load-bearing rather than courteous: a
|
|
// call admitted after the closer has begun increments the in-flight count behind
|
|
// a wait that has already read it, and the wait can then be left waiting on a
|
|
// count that reaches zero only when the late caller happens to leave.
|
|
//
|
|
// The seam is what makes the window wide enough to aim at: a held poller keeps
|
|
// the close inside its wait for as long as this scenario wants it there.
|
|
std::string LateCallOnce() {
|
|
cultmesh_quic_debug_hold_calls(1);
|
|
void* runtime = nullptr;
|
|
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
|
if (opened != 0 || runtime == nullptr) {
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
|
}
|
|
|
|
std::thread poller([runtime] {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
cultmesh_quic_next_event(
|
|
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
|
});
|
|
|
|
const auto counted_deadline =
|
|
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
|
while (cultmesh_quic_debug_peak_calls() < 1 &&
|
|
std::chrono::steady_clock::now() < counted_deadline)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
|
|
|
std::thread closer([runtime] { cultmesh_quic_runtime_close(runtime); });
|
|
// The close has begun its wait once it has read the count, and the held
|
|
// poller is what keeps it there.
|
|
const auto started_deadline =
|
|
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
|
while (cultmesh_quic_debug_calls_at_close() < 0 &&
|
|
std::chrono::steady_clock::now() < started_deadline)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
|
|
|
|
// Two calls, from a thread of their own so that a bridge which admits them
|
|
// cannot park this one: one that does not block, and then the blocking
|
|
// crossing. That order, because a bridge that admits the blocking one parks
|
|
// it inside the library until the hold is released, and a call made after
|
|
// that would be racing the teardown rather than the wait.
|
|
std::atomic<int32_t> late_poll{0};
|
|
std::atomic<int32_t> late_error{0};
|
|
std::thread late([runtime, &late_poll, &late_error] {
|
|
std::array<char, 256> message{};
|
|
late_error.store(cultmesh_quic_last_error(runtime, message.data(),
|
|
static_cast<int32_t>(message.size())));
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
late_poll.store(cultmesh_quic_next_event(
|
|
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required));
|
|
});
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
late.join();
|
|
closer.join();
|
|
poller.join();
|
|
|
|
if (at_close < 1)
|
|
return "the close did not begin its wait with the poller counted inside, so nothing held it "
|
|
"open for a call to race";
|
|
if (late_poll.load() != -1)
|
|
return "cultmesh_quic_next_event returned " + std::to_string(late_poll.load()) +
|
|
" to a call made during the close, not the -1 that refuses it";
|
|
if (late_error.load() != -1)
|
|
return "cultmesh_quic_last_error returned " + std::to_string(late_error.load()) +
|
|
" to a call made during the close, not the -1 that refuses it";
|
|
return {};
|
|
}
|
|
|
|
// How long `holdtimeout`'s two polls ask for, and how long the hold stays armed
|
|
// under them. The gap between each poll and the hold is the whole measurement:
|
|
// a poll the hold parks leaves when the hold is released, so its elapsed time
|
|
// lands at the last number instead of its own.
|
|
//
|
|
// Two polls, a short one and a long one, because a guard that tests the timeout
|
|
// instead of the wake — park it unless it was long — is identity on any single
|
|
// value on one side of its threshold. The long one sits above 1500, the stated
|
|
// ceiling on how long the hold guard may treat a long wait as surely worth
|
|
// parking; the hold stays armed well past it so a guard that parks anyway is
|
|
// held for the rest of the arming instead of leaving on its own timeout, which
|
|
// is the overshoot this scenario's late tolerance is wide enough to see.
|
|
constexpr int32_t kHeldTimeoutPollMs = 150;
|
|
constexpr int32_t kHeldLongTimeoutPollMs = 1600;
|
|
constexpr int kHoldArmedMs = 3000;
|
|
|
|
// The seam's own rule, and the only scenario that can see it: the hold parks a
|
|
// call the wait woke, and not one whose own timeout expired.
|
|
//
|
|
// Everything else that arms the hold parks a woken call, so the guard governs
|
|
// nothing either of them observes — the timeout scenario never arms the hold,
|
|
// and the hold scenarios never let a timeout expire, so nothing was ever in
|
|
// both states at once and the guard could be deleted with every scenario still
|
|
// green. This puts calls in both states: the hold is armed, and the runtime is
|
|
// idle, so each wait ends on its timeout and the call is on its way out.
|
|
//
|
|
// It matters because a scenario that parked such a call would then report the
|
|
// bridge keeping a host call inside the library when what kept it was the
|
|
// fixture — the quiesce numbers `holdclose` asserts would be the seam's, not
|
|
// the bridge's.
|
|
std::string HoldTimeoutOnce(std::array<Overshoot, 2>& overshoot) {
|
|
cultmesh_quic_debug_hold_calls(1);
|
|
void* runtime = nullptr;
|
|
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
|
if (opened != 0 || runtime == nullptr) {
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
|
}
|
|
|
|
// Each on a thread of its own, because a parked call does not come back until
|
|
// the release below and this thread is what releases it.
|
|
constexpr std::array<int32_t, 2> timeouts{kHeldTimeoutPollMs, kHeldLongTimeoutPollMs};
|
|
std::array<TimedPoll, 2> polls{};
|
|
std::array<std::thread, 2> pollers;
|
|
for (size_t index = 0; index < timeouts.size(); ++index)
|
|
pollers[index] = std::thread([runtime, &polls, &timeouts, index] {
|
|
polls[index] = PollFor(runtime, timeouts[index]);
|
|
});
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(kHoldArmedMs));
|
|
cultmesh_quic_debug_hold_calls(0);
|
|
for (auto& poller : pollers) poller.join();
|
|
cultmesh_quic_runtime_close(runtime);
|
|
|
|
for (size_t index = 0; index < timeouts.size(); ++index) {
|
|
const TimedPoll& poll = polls[index];
|
|
const int32_t timeout_ms = timeouts[index];
|
|
overshoot[index].Record(poll, timeout_ms);
|
|
if (poll.result != 0)
|
|
return "a poll ended by its own timeout returned " + std::to_string(poll.result) +
|
|
", not the 0 an idle runtime owes it";
|
|
if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs)
|
|
return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " +
|
|
std::to_string(poll.elapsed_ms) + " ms, so the fixture never let its timeout run out";
|
|
if (poll.elapsed_ms > timeout_ms + kGenerousLateToleranceMs)
|
|
return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " +
|
|
std::to_string(poll.elapsed_ms) + " ms: the hold parked a call its own timeout had "
|
|
"already ended, and it left when the hold did";
|
|
}
|
|
return {};
|
|
}
|
|
|
|
int HoldTimeout(int iterations) {
|
|
std::array<Overshoot, 2> overshoot{};
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = HoldTimeoutOnce(overshoot);
|
|
if (!failure.empty())
|
|
return Fail("holdtimeout iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("holdtimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d)\n", iterations,
|
|
overshoot[0].worst_ms, kHeldTimeoutPollMs, overshoot[1].worst_ms, kHeldLongTimeoutPollMs);
|
|
return 0;
|
|
}
|
|
|
|
int LateCall(int iterations) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = LateCallOnce();
|
|
if (!failure.empty())
|
|
return Fail("latecall iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("latecall %dx: ok\n", iterations);
|
|
return 0;
|
|
}
|
|
|
|
int HoldClose(int iterations, int pollers) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = HoldCloseOnce(pollers);
|
|
if (!failure.empty())
|
|
return Fail("holdclose iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("holdclose %dx%d: ok\n", iterations, pollers);
|
|
return 0;
|
|
}
|
|
|
|
// The values `waitseam` asks for: the smallest, the seam's own sentinel-versus-
|
|
// zero boundary, one on either side of a 16 ms quantum and of a 40 ms one, the
|
|
// ordinary `pollbusy` timeout and its neighbours, `polltimeout`'s long probe,
|
|
// and INT32_MAX. Nothing in section 6 of the header states a maximum on
|
|
// `timeout_ms` narrower than what `int32_t` itself holds, so INT32_MAX is the
|
|
// ceiling this checks and nothing wider is claimed.
|
|
constexpr std::array<int32_t, 11> kWaitSeamProbesMs{
|
|
0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300, INT32_MAX};
|
|
|
|
// How long a probe's own poller thread is given to reach the wait before this
|
|
// gives up on it and fails with the probe named, rather than hanging.
|
|
constexpr int kWaitSeamDeadlineMs = 2000;
|
|
|
|
// One probe: ask a fresh, otherwise-idle runtime for `timeout_ms` and read
|
|
// `cultmesh_quic_debug_last_wait_ms` — the bridge's own account of what it
|
|
// handed its condition wait — instead of measuring how long the call took.
|
|
// Every clamp, floor, round, scale, offset and later-poll mapping Soul's seven
|
|
// passes found is the identity at some wall-clock probe; none of them is the
|
|
// identity here unless it is also the identity at every value below, because
|
|
// this reads the argument itself rather than inferring it from elapsed time.
|
|
//
|
|
// `timeout_ms <= 0` never reaches the wait at all — `cultmesh_quic_next_event`
|
|
// returns at once when the queue is empty — so there is nothing for the seam to
|
|
// record, and the honest check is the converse: it must still say nothing,
|
|
// which is exactly what a seam that recorded the raw argument on every call,
|
|
// instead of only the value it actually handed its wait, would get wrong.
|
|
std::string WaitSeamProbeOnce(int32_t timeout_ms) {
|
|
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);
|
|
cultmesh_quic_debug_reset_last_wait_ms();
|
|
|
|
std::string failure;
|
|
if (timeout_ms <= 0) {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
const int32_t result = cultmesh_quic_next_event(
|
|
runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required);
|
|
const int32_t recorded = cultmesh_quic_debug_last_wait_ms();
|
|
if (result != 0)
|
|
failure = "a poll asking for " + std::to_string(timeout_ms) + " ms returned " +
|
|
std::to_string(result) + ", not the 0 an idle runtime owes it";
|
|
else if (recorded != -1)
|
|
failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " +
|
|
std::to_string(recorded) + " ms on the wait seam, though the bridge's own guard "
|
|
"never enters a wait for a timeout that is not greater than zero";
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return failure;
|
|
}
|
|
|
|
// A real wait, on a thread of its own: a probe at INT32_MAX has to be
|
|
// released by the close, not waited out.
|
|
std::thread poller([runtime, timeout_ms] {
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
cultmesh_quic_next_event(runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required);
|
|
});
|
|
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kWaitSeamDeadlineMs);
|
|
int32_t recorded = cultmesh_quic_debug_last_wait_ms();
|
|
while (recorded == -1 && std::chrono::steady_clock::now() < deadline) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
recorded = cultmesh_quic_debug_last_wait_ms();
|
|
}
|
|
|
|
cultmesh_quic_runtime_close(runtime);
|
|
poller.join();
|
|
|
|
if (recorded != timeout_ms)
|
|
failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " +
|
|
std::to_string(recorded) + " ms on the wait seam: the bridge handed its condition "
|
|
"wait something other than the host's own argument";
|
|
return failure;
|
|
}
|
|
|
|
// Past the twelfth poll on one runtime: no scenario before this one ever asked
|
|
// a runtime for a thirteenth, so a wait that only changes shape after twelve
|
|
// had nowhere to be seen. Forty short waits on a runtime that stays open for
|
|
// all of them, each checked by equality.
|
|
constexpr int32_t kWaitSeamRepeatedMs = 5;
|
|
constexpr int kWaitSeamRepeatedPolls = 40;
|
|
|
|
std::string WaitSeamRepeatedOnce() {
|
|
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::string failure;
|
|
for (int poll = 0; poll < kWaitSeamRepeatedPolls && failure.empty(); ++poll) {
|
|
cultmesh_quic_debug_reset_last_wait_ms();
|
|
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
|
cultmesh_quic_event event{};
|
|
int32_t required = 0;
|
|
cultmesh_quic_next_event(
|
|
runtime, kWaitSeamRepeatedMs, &event, payload.data(), kPayloadCapacity, &required);
|
|
const int32_t recorded = cultmesh_quic_debug_last_wait_ms();
|
|
if (recorded != kWaitSeamRepeatedMs)
|
|
failure = "poll " + std::to_string(poll + 1) + " of " +
|
|
std::to_string(kWaitSeamRepeatedPolls) + " on one runtime recorded " +
|
|
std::to_string(recorded) + " ms, not " + std::to_string(kWaitSeamRepeatedMs);
|
|
}
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return failure;
|
|
}
|
|
|
|
int WaitSeam(int iterations) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
for (int32_t probe : kWaitSeamProbesMs) {
|
|
const std::string failure = WaitSeamProbeOnce(probe);
|
|
if (!failure.empty())
|
|
return Fail("waitseam iteration " + std::to_string(iteration) + " probe " +
|
|
std::to_string(probe) + ": " + failure);
|
|
}
|
|
const std::string repeated = WaitSeamRepeatedOnce();
|
|
if (!repeated.empty())
|
|
return Fail("waitseam iteration " + std::to_string(iteration) + ": " + repeated);
|
|
}
|
|
std::printf("waitseam %dx: ok (%zu probes, %d repeated polls on one runtime)\n",
|
|
iterations, kWaitSeamProbesMs.size(), kWaitSeamRepeatedPolls);
|
|
return 0;
|
|
}
|
|
|
|
#endif // CULTMESH_QUIC_DEBUG_ASSERTS
|
|
|
|
// How long `pollhammer`'s idle poll asks for. One host thread hammers every
|
|
// gate-touching entry point that cannot itself queue an event, in a yield
|
|
// loop, while another holds this one poll idle; each hammered call's
|
|
// `CallScope` destructor notifies every waiter on the runtime's condition
|
|
// variable (cultmesh_quic_native.cpp:286), so the idle poll is woken many times
|
|
// over for every timeout it is given here. That is exactly the shape a wake
|
|
// count or an error-state predicate exploits, and exactly what a revert that
|
|
// returns on any wake at all fails immediately.
|
|
//
|
|
// `cultmesh_quic_connection_open`, `cultmesh_quic_listener_open` and
|
|
// `cultmesh_quic_stream_open` touch the same gate but can each queue a real
|
|
// event on this runtime, and a genuine event is allowed to end the idle poll
|
|
// early — hammering with those would make an early return ambiguous between
|
|
// the fault this scenario hunts and a real delivery, so they are left out. The
|
|
// refused `listener_open` below is a setup step run once, not part of the
|
|
// loop, and it leaves no listener behind.
|
|
constexpr int32_t kHammerPollMs = 1000;
|
|
|
|
std::string PollHammerOnce(bool with_recorded_error) {
|
|
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);
|
|
|
|
if (with_recorded_error) {
|
|
// Not a certificate at all: PKCS12 parsing fails before any network
|
|
// I/O, so this is refused synchronously and leaves no listener behind,
|
|
// and `runtime->error` is non-empty for the rest of this runtime's life.
|
|
const uint8_t junk[] = {0x00, 0x01, 0x02, 0x03};
|
|
uint64_t listener_id = 0;
|
|
uint16_t bound_port = 0;
|
|
const int32_t result = cultmesh_quic_listener_open(
|
|
runtime, nullptr, 0, junk, static_cast<int32_t>(sizeof(junk)), "", &listener_id, &bound_port);
|
|
if (result >= 0) {
|
|
cultmesh_quic_runtime_close(runtime);
|
|
return "cultmesh_quic_listener_open accepted a junk PKCS12 credential";
|
|
}
|
|
}
|
|
|
|
std::atomic<bool> hammering{true};
|
|
std::thread hammer([runtime, &hammering] {
|
|
constexpr uint64_t kBogusId = 0xffffffffffffffffull;
|
|
const uint8_t frame[] = {0x00};
|
|
std::array<char, 256> message{};
|
|
while (hammering.load(std::memory_order_relaxed)) {
|
|
cultmesh_quic_last_error(runtime, message.data(), static_cast<int32_t>(message.size()));
|
|
cultmesh_quic_last_status(runtime);
|
|
cultmesh_quic_connection_shutdown(runtime, kBogusId, 0);
|
|
cultmesh_quic_stream_shutdown(runtime, kBogusId, 0);
|
|
cultmesh_quic_stream_send_frame(runtime, kBogusId, frame, 1, 0);
|
|
cultmesh_quic_connection_certificate_complete(runtime, kBogusId, 0);
|
|
cultmesh_quic_listener_close(runtime, kBogusId);
|
|
std::this_thread::yield();
|
|
}
|
|
});
|
|
|
|
const TimedPoll poll = PollFor(runtime, kHammerPollMs);
|
|
hammering.store(false, std::memory_order_relaxed);
|
|
hammer.join();
|
|
cultmesh_quic_runtime_close(runtime);
|
|
|
|
if (poll.result != 0)
|
|
return "the idle poll returned " + std::to_string(poll.result) +
|
|
", not the 0 an idle runtime owes it";
|
|
if (poll.elapsed_ms < kHammerPollMs - kEarlyToleranceMs)
|
|
return "the idle poll returned after " + std::to_string(poll.elapsed_ms) +
|
|
" ms while another thread hammered the gate, ending its wait early";
|
|
if (poll.elapsed_ms > static_cast<long long>(kHammerPollMs) + kGenerousLateToleranceMs)
|
|
return "the idle poll returned after " + std::to_string(poll.elapsed_ms) +
|
|
" ms: well past its own timeout";
|
|
return {};
|
|
}
|
|
|
|
int PollHammer(int iterations) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
std::string failure = PollHammerOnce(false);
|
|
if (!failure.empty())
|
|
return Fail("pollhammer iteration " + std::to_string(iteration) + " (clean runtime): " + failure);
|
|
failure = PollHammerOnce(true);
|
|
if (!failure.empty())
|
|
return Fail("pollhammer iteration " + std::to_string(iteration) +
|
|
" (after a refused call recorded an error): " + failure);
|
|
}
|
|
std::printf("pollhammer %dx: ok\n", iterations);
|
|
return 0;
|
|
}
|
|
|
|
int CloseRace(int iterations, int pollers) {
|
|
for (int iteration = 0; iteration < iterations; ++iteration) {
|
|
const std::string failure = CloseRaceOnce(pollers);
|
|
if (!failure.empty())
|
|
return Fail("closerace iteration " + std::to_string(iteration) + ": " + failure);
|
|
}
|
|
std::printf("closerace %dx%d: ok\n", iterations, pollers);
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
if (argc < 2) {
|
|
std::fprintf(stderr,
|
|
"usage: %s closerace|holdclose|holdtimeout|polltimeout|pollbusy|pollhammer|payloadfit|"
|
|
"latecall|waitseam [iterations] [pollers]\n",
|
|
argv[0]);
|
|
return 2;
|
|
}
|
|
const std::string scenario = argv[1];
|
|
const int iterations = argc > 2 ? std::atoi(argv[2]) : 20;
|
|
const int pollers = argc > 3 ? std::atoi(argv[3]) : 256;
|
|
if (iterations <= 0 || pollers <= 0) {
|
|
std::fprintf(stderr, "%s needs a positive iteration and poller count\n", scenario.c_str());
|
|
return 2;
|
|
}
|
|
if (scenario == "closerace") return CloseRace(iterations, pollers);
|
|
if (scenario == "polltimeout") return PollTimeout(iterations);
|
|
if (scenario == "pollbusy") return PollBusy(iterations);
|
|
if (scenario == "pollhammer") return PollHammer(iterations);
|
|
if (scenario == "payloadfit") return PayloadFit(iterations);
|
|
if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout" ||
|
|
scenario == "waitseam") {
|
|
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
|
|
if (scenario == "latecall") return LateCall(iterations);
|
|
if (scenario == "holdtimeout") return HoldTimeout(iterations);
|
|
if (scenario == "waitseam") return WaitSeam(iterations);
|
|
return HoldClose(iterations, pollers);
|
|
#else
|
|
std::fprintf(stderr,
|
|
"%s needs the development seam: configure with "
|
|
"-DCULTMESH_QUIC_DEBUG_ASSERTS=ON\n", scenario.c_str());
|
|
return 2;
|
|
#endif
|
|
}
|
|
std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str());
|
|
return 2;
|
|
}
|