From 5646ff73518e4edc502b6a1f80596906a01f439b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 12:27:56 +0200 Subject: [PATCH] Land the QUIC Cut 3 eighth fix batch: release config, whole-duration seam, pollhammer live objects, zero-timeout bound, closerace starvation fix Self's ruling of 2026-09-22 named six findings against Soul's eighth pass. This lands the code side of all six: - The wait-record seam (CULTMESH_QUIC_DEBUG_RECORD_WAIT) now wraps the whole duration handed to `wait_for`, not the raw `timeout_ms` before it is converted to milliseconds, so arithmetic applied after that conversion can no longer live outside what the seam records. Its "unset" sentinel moves to a separate `debug_wait_recorded` flag so a recorded -1 could never be confused with nothing recorded. - `pollhammer` also runs against a runtime holding a live connection to an unroutable address (RFC 5737 TEST-NET-1), a best-effort stream on it, and a real (never-connected) listener, closing the gap where the wait's predicate was only ever exercised on an empty runtime. - A new `zerotimeout` scenario pins that `timeout_ms <= 0` returns within 50 ms, probing 0, -1 and INT32_MIN. - `closerace`'s settle no longer sleeps a guessed duration: where the development seam exists it waits for `debug_peak_calls` to reach the poller count (reset before any poller thread exists, so nothing can zero a count a poller already raised) before a short fixed margin closes the last gap between being counted and being inside the condition wait. - `kGenerousLateToleranceMs` narrows from 800 ms to 400 ms so the wall-clock scenarios can catch arithmetic applied outside the seam's own argument, which `waitseam`'s equality check structurally cannot see. scripts/mutate-cultmesh.mjs gains a plain `release` configuration on both platforms (CULTMESH_QUIC_DEBUG_ASSERTS=OFF, CMAKE_BUILD_TYPE=Release) running `polltimeout`, `pollbusy`, `pollhammer` and `zerotimeout`, and seven new mutation entries: R1 (the release macro divides the duration by 10), W1-W3 (600 ms added, a 500 ms floor, and a 1000/909 scale, each applied outside the seam's own argument), P1 (the idle predicate also ends on a live connection), and Z1-Z2 (a 500 ms sleep and a 300 ms spin on a non-positive timeout). Two stale comments are corrected: the scenario list above `assertScenarios` now names `pollhammer` among the clock-sensitive entries, and a historical "1300" probe value that no longer matches the test file is removed. The header's section 7 intro now says the seam serves both the quiesce and the wait recording, not quiesce alone. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh_quic_native.cpp | 57 ++-- .../include/cultmesh_quic_native.h | 21 +- .../tests/cultmesh_quic_native_tests.cpp | 253 +++++++++++++++++- scripts/mutate-cultmesh.mjs | 230 ++++++++++++---- 4 files changed, 463 insertions(+), 98 deletions(-) diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index 4c2ae972..5964de43 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ #include #include #include +#include #include #include @@ -88,24 +90,27 @@ // decrements the count and notifies. That is the ordinary exit every export // makes, and it is the bridge's wait — not the hold — that keeps the runtime // alive underneath it. -// - RECORD_WAIT wraps the exact `timeout_ms` handed to the condition wait in -// `cultmesh_quic_next_event`, in place, as a pass-through: it stores the -// value and returns it unchanged, so a mutation to what is actually waited -// on changes what this reports rather than the two ever being able to -// diverge. A scenario reads it and asserts equality against the timeout it -// asked for, deterministically and with no timing tolerance — the method -// Self's ruling of 2026-09-22 asks for in place of inferring the same fact -// from how long a call took to return. +// - RECORD_WAIT wraps the whole duration handed to the condition wait in +// `cultmesh_quic_next_event`, in place, as a pass-through: it stores that +// duration's count and returns the duration unchanged, so a mutation to +// what is actually waited on changes what this reports rather than the two +// ever being able to diverge. It wraps `timeout_ms` after conversion to +// `std::chrono::milliseconds`, not before — Self's ruling of 2026-09-22, +// after arithmetic applied to the converted duration survived a seam that +// only recorded the raw integer underneath it. A scenario reads it and +// asserts equality against the timeout it asked for, deterministically and +// with no timing tolerance, in place of inferring the same fact from how +// long a call took to return. #if defined(CULTMESH_QUIC_DEBUG_ASSERTS) #define CULTMESH_QUIC_DEBUG_PEAK(count) DebugRaisePeak(count) #define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) DebugRecordCallsAtClose(count) #define CULTMESH_QUIC_DEBUG_HELD(lock) DebugHoldCall(lock) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms) DebugRecordWait(timeout_ms) +#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) DebugRecordWait(duration) #else #define CULTMESH_QUIC_DEBUG_PEAK(count) ((void)0) #define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) ((void)0) #define CULTMESH_QUIC_DEBUG_HELD(lock) (false) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms) (timeout_ms) +#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) (duration) #endif namespace { @@ -249,6 +254,13 @@ std::mutex debug_hold_gate; std::condition_variable debug_hold_signal; bool debug_hold_armed = false; std::atomic debug_last_wait_ms{-1}; +// Separate from the value above on purpose: once the seam records the whole +// duration (not just a millisecond count already known to be positive), a +// mutation could in principle make a wait get recorded at exactly -1, and an +// int32_t alone cannot tell that apart from "nothing recorded yet". This flag +// is the "recorded" bit; the getter below is the only thing that still hands +// out -1 for the unset case. +std::atomic debug_wait_recorded{false}; void DebugRaisePeak(int count) { int peak = debug_peak_calls.load(std::memory_order_relaxed); @@ -270,14 +282,18 @@ bool DebugHoldCall(std::unique_lock& lock) { return true; } -// A pass-through: it stores `timeout_ms` and returns it unmodified, so wrapping -// it around the exact expression handed to the condition wait makes the two -// impossible to desync by construction. Whatever that expression evaluates to -// — the plain argument today, or a derivation a future change or a mutation -// introduces — is what gets recorded, because it is the same evaluation. -int32_t DebugRecordWait(int32_t timeout_ms) { - debug_last_wait_ms.store(timeout_ms); - return timeout_ms; +// A pass-through: it stores the duration's count and returns the duration +// unmodified, so wrapping it around the whole expression handed to the +// condition wait makes the two impossible to desync by construction. Whatever +// that expression evaluates to — the plain converted argument today, or a +// derivation a future change or a mutation introduces inside this call — is +// what gets recorded, because it is the same evaluation. Anything applied +// outside this call, after it returns, is what the wall-clock scenarios exist +// to catch instead; see their generous-tolerance comment. +std::chrono::milliseconds DebugRecordWait(std::chrono::milliseconds duration) { + debug_last_wait_ms.store(static_cast(duration.count())); + debug_wait_recorded.store(true); + return duration; } #endif @@ -1199,7 +1215,7 @@ CULTMESH_API int32_t cultmesh_quic_next_event( // sooner, and `woken` is which of the two happened. RECORD_WAIT wraps // this exact argument; see the development seam note near its macros. const bool woken = runtime->signal.wait_for( - lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)), + lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)), [runtime] { return !runtime->events.empty() || runtime->closing; }); // Development builds only; a no-op unless a scenario has armed the hold. // A held call returns the same 0 an empty queue would have, and makes no @@ -1487,10 +1503,11 @@ CULTMESH_API int32_t cultmesh_quic_debug_calls_at_close(void) { } CULTMESH_API int32_t cultmesh_quic_debug_last_wait_ms(void) { - return debug_last_wait_ms.load(); + return debug_wait_recorded.load() ? debug_last_wait_ms.load() : -1; } CULTMESH_API void cultmesh_quic_debug_reset_last_wait_ms(void) { + debug_wait_recorded.store(false); debug_last_wait_ms.store(-1); } #endif diff --git a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h b/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h index 100b1efc..758ecb8a 100644 --- a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h +++ b/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h @@ -316,9 +316,10 @@ CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime); * * Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which * a shipped build never is. It exists for the runtime-lifetime scenarios: the - * quiesce is a promise about host calls that are inside the library, and a - * scenario that can only sleep and hope cannot tell a bridge that counts them - * from one that does not. + * quiesce is a promise about host calls that are inside the library, and the + * wait scenarios need the same kind of promise about the exact duration the + * bridge hands its own condition wait. A scenario that can only sleep and hope + * cannot tell a bridge that counts and waits correctly from one that does not. * * It is process-wide, not per-runtime, because the numbers outlive the runtime * they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count @@ -341,12 +342,14 @@ CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void); * since the hold was armed. This is the number the wait exists for. */ CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void); -/* The `timeout_ms` most recently handed to the condition wait inside - * `cultmesh_quic_next_event`, recorded at the point the wait is entered rather - * than observed from outside. A poll whose queue is not empty, or whose - * `timeout_ms` is not greater than zero, never enters that wait and leaves this - * unchanged. -1 until the first such wait, or after - * `cultmesh_quic_debug_reset_last_wait_ms`. +/* The whole duration, in milliseconds, most recently handed to the condition + * wait inside `cultmesh_quic_next_event`, recorded at the point the wait is + * entered rather than observed from outside. A poll whose queue is not empty, + * or whose `timeout_ms` is not greater than zero, never enters that wait and + * leaves this unchanged. -1 until the first such wait, or after + * `cultmesh_quic_debug_reset_last_wait_ms` — tracked by a separate internal + * flag, not by -1 being stored here, so a wait that ever recorded exactly -1 + * itself could not be confused with nothing having been recorded. * * This exists because 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 — a floor, 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 6dae9a8a..b11610aa 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 @@ -25,6 +25,8 @@ // cultmesh_quic_native_tests holdtimeout [iterations] // cultmesh_quic_native_tests waitseam [iterations] // cultmesh_quic_native_tests pollhammer [iterations] +// cultmesh_quic_native_tests zerotimeout [iterations] +// cultmesh_quic_native_tests payloadfit [iterations] // // `closerace` hunts the race: many pollers, many iterations, and whatever the // schedule gives. `holdclose` takes the guessing out of it — the bridge's @@ -124,6 +126,17 @@ std::string CloseRaceOnce(int pollers) { std::atomic closing_started{false}; std::atomic left_before_the_close{0}; +#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) + // Reset before any poller thread exists, so nothing here can race a + // `CallScope` that has already been counted: `cultmesh_quic_next_event` + // raises `debug_peak_calls` the moment its `CallScope` is constructed, at + // the very top of the call, and a reset performed even microseconds after + // that construction would zero a count no further call is coming to raise + // again, stranding the settle loop below at "not yet" until its deadline. + cultmesh_quic_debug_hold_calls(1); + cultmesh_quic_debug_hold_calls(0); +#endif + std::vector threads; threads.reserve(static_cast(pollers)); for (int index = 0; index < pollers; ++index) { @@ -153,10 +166,36 @@ std::string CloseRaceOnce(int pollers) { } 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. +#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) + // A fixed settle races CPU starvation: under enough contending threads a + // poller that has only incremented `entering` can sit preempted for far + // longer than any sleep this scenario could afford, so the close would + // begin before it ever reached the blocking wait — not the race this + // scenario exists to run, just a fixture that gave up too soon. Where the + // development seam is compiled in, this reads the bridge's own count + // instead of guessing at a duration: the settle ends only once every + // poller is actually counted inside, however long the scheduler took. + const auto counted_deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (cultmesh_quic_debug_peak_calls() < pollers && + std::chrono::steady_clock::now() < counted_deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + // `active_calls` — what `peak_calls` reflects — is raised the moment a + // poller's `CallScope` is constructed, at the top of + // `cultmesh_quic_next_event`, not when it reaches the condition wait a few + // statements later. Counted-inside is the expensive part to wait for under + // starvation (getting the scheduler to run a thread at all); a short fixed + // settle covers the cheap remainder (a lock acquisition and an empty-queue + // check) once every poller is already scheduled and running. std::this_thread::sleep_for(std::chrono::milliseconds(50)); +#else + // No seam in this configuration (the sanitizer build deliberately ships + // without one), so the settle falls back to a longer fixed window rather + // than the 50 ms that starved under 16 contending burners. It is still a + // window, not a guarantee — the honest limit of a config with no way to + // ask the bridge what it has actually counted. + std::this_thread::sleep_for(std::chrono::milliseconds(500)); +#endif closing_started.store(true); cultmesh_quic_runtime_close(runtime); @@ -194,9 +233,15 @@ constexpr int kEarlyToleranceMs = 20; // — `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; +// ended, or the W1-W3 family Self's ruling of 2026-09-22 added, which apply +// their arithmetic to the duration `wait_for` actually receives *after* the +// seam has already recorded it and so cannot be caught by `waitseam`'s +// equality check at all. 400 ms sits above the worst overshoot a 16-burner +// stress pass measured on a loaded win32 host (180 ms; 27 ms on linux) and +// below every one of W1-W3's smallest offsets (485 ms), so one flat margin +// still serves every probe this file times without going blind to the family +// it was widened for. +constexpr int kGenerousLateToleranceMs = 400; struct TimedPoll { int32_t result; @@ -917,7 +962,109 @@ int WaitSeam(int iterations) { // loop, and it leaves no listener behind. constexpr int32_t kHammerPollMs = 1000; -std::string PollHammerOnce(bool with_recorded_error) { +// A self-signed ECDSA P-256 PKCS12, generated once with OpenSSL and embedded so +// this scenario needs no crypto library of its own and no network fetch: +// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ +// -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=cultmesh-quic-native-tests" +// openssl pkcs12 -export -out test.p12 -inkey key.pem -in cert.pem -passout pass: +// Used only to give pollhammer's live-objects run a listener that can actually +// load a credential; nothing ever connects to it, so it never accepts and never +// queues an event this scenario would have to tell apart from a bug. +constexpr std::array kSelfSignedPkcs12{{ + 0x30, 0x82, 0x04, 0x3c, 0x02, 0x01, 0x03, 0x30, 0x82, 0x03, 0xf2, 0x06, + 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, + 0x03, 0xe3, 0x04, 0x82, 0x03, 0xdf, 0x30, 0x82, 0x03, 0xdb, 0x30, 0x82, + 0x02, 0x8a, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, + 0x06, 0xa0, 0x82, 0x02, 0x7b, 0x30, 0x82, 0x02, 0x77, 0x02, 0x01, 0x00, + 0x30, 0x82, 0x02, 0x70, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, + 0x01, 0x07, 0x01, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, 0x09, 0x2a, 0x86, + 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, 0x04, 0x10, 0x48, + 0xdf, 0x04, 0x5a, 0x31, 0x34, 0x6d, 0xe0, 0x7c, 0xde, 0x3c, 0xa2, 0xde, + 0xf2, 0x8d, 0x20, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, 0x06, 0x08, 0x2a, + 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, 0x30, 0x1d, 0x06, + 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a, 0x04, 0x10, + 0x44, 0x04, 0xd9, 0x9b, 0x99, 0x3a, 0xcb, 0x89, 0xc0, 0xcb, 0x53, 0xe3, + 0xe3, 0x5b, 0xb4, 0x48, 0x80, 0x82, 0x02, 0x00, 0x92, 0xcc, 0x04, 0xcf, + 0x7d, 0x58, 0xec, 0x68, 0x1a, 0x1b, 0xd5, 0x3b, 0x17, 0x57, 0x22, 0xd5, + 0x45, 0x00, 0xff, 0x05, 0x0f, 0x0d, 0x18, 0x8b, 0x72, 0x5b, 0xcc, 0xd9, + 0x87, 0xf4, 0x99, 0x44, 0x7c, 0xd6, 0xc3, 0x02, 0x2a, 0xaf, 0xd9, 0x10, + 0x14, 0xd3, 0x9f, 0x1a, 0x2c, 0x7b, 0x4f, 0xe6, 0x0b, 0x1d, 0x7f, 0xb9, + 0xf4, 0x01, 0x5c, 0x4b, 0xd5, 0xa1, 0x57, 0xb0, 0x3d, 0xe0, 0xbc, 0x70, + 0x72, 0xe0, 0xe9, 0xb8, 0x9a, 0xde, 0x68, 0xfe, 0x7c, 0xc7, 0xca, 0xcc, + 0x61, 0x32, 0x88, 0x72, 0xdd, 0x97, 0x28, 0x7b, 0x5b, 0x0d, 0x30, 0xf5, + 0xa2, 0xd1, 0xe8, 0x89, 0xd4, 0x1d, 0x88, 0x28, 0xd2, 0xc3, 0x02, 0x93, + 0xa1, 0x27, 0xfb, 0x4a, 0xe7, 0xdc, 0xac, 0xf3, 0x0d, 0xa9, 0xd3, 0x14, + 0x37, 0x71, 0xb7, 0xe5, 0xaf, 0xdb, 0xb6, 0xda, 0x42, 0xb7, 0x24, 0x7a, + 0x94, 0xad, 0x73, 0x79, 0xde, 0x21, 0xb9, 0x13, 0x63, 0x98, 0x33, 0x62, + 0x4a, 0x36, 0xb8, 0x6d, 0x14, 0x8b, 0x84, 0xf0, 0x8e, 0x55, 0xcc, 0x95, + 0x14, 0x54, 0xcc, 0x68, 0x4b, 0x09, 0x24, 0xb4, 0x79, 0x49, 0x05, 0xc7, + 0x0e, 0xfe, 0xed, 0x23, 0x8c, 0x87, 0xc6, 0xc3, 0x76, 0x25, 0xd9, 0xd6, + 0xae, 0x5a, 0xf4, 0x86, 0x74, 0xb8, 0x44, 0x22, 0xe8, 0xfc, 0xa5, 0xc2, + 0x25, 0xbd, 0x84, 0xfd, 0x92, 0x96, 0xe2, 0x66, 0xa9, 0xb0, 0x1f, 0xd3, + 0x8d, 0xf7, 0x1c, 0xce, 0xed, 0x09, 0x11, 0x61, 0xec, 0x2d, 0xa4, 0x9d, + 0xb9, 0xdb, 0xd4, 0x2e, 0xa4, 0xff, 0xe6, 0x09, 0x8e, 0x9a, 0x0b, 0xdf, + 0x30, 0xf4, 0xea, 0xe6, 0x09, 0x2d, 0x16, 0xec, 0x5d, 0xb6, 0x39, 0xda, + 0xd4, 0x43, 0x41, 0x95, 0xc8, 0x32, 0xd2, 0xac, 0x17, 0x58, 0x05, 0x7e, + 0x10, 0x24, 0xae, 0xd6, 0x58, 0x80, 0x0a, 0x29, 0xcb, 0x4b, 0x78, 0x62, + 0xbe, 0xf6, 0x6c, 0x7d, 0x44, 0x07, 0x7b, 0xfb, 0xd9, 0xb6, 0x74, 0x75, + 0x9f, 0xe6, 0xb4, 0xc3, 0x54, 0x53, 0x46, 0xdb, 0xf2, 0xa1, 0x47, 0xb8, + 0xf1, 0xff, 0x01, 0xba, 0xde, 0xf9, 0xca, 0xd6, 0xc0, 0x38, 0x40, 0xd0, + 0x86, 0x72, 0xc8, 0xcf, 0xdd, 0xd6, 0xf6, 0x36, 0xe0, 0xf3, 0x2e, 0x95, + 0x23, 0xdb, 0x99, 0x72, 0x1c, 0xa6, 0xa0, 0xf7, 0x17, 0x93, 0x1e, 0x20, + 0x6c, 0x75, 0x25, 0x0e, 0x1c, 0xab, 0xa4, 0xce, 0x35, 0xd4, 0xf4, 0xc6, + 0x63, 0x89, 0x0d, 0xa9, 0x91, 0x0b, 0x7e, 0xce, 0x7d, 0x4d, 0x5e, 0xbf, + 0xfd, 0xa9, 0xbd, 0xb6, 0x1e, 0x2b, 0x08, 0x5d, 0x7a, 0xdd, 0x61, 0xdf, + 0x24, 0xe0, 0xff, 0x4b, 0xeb, 0xa2, 0xeb, 0xb6, 0x2f, 0x2c, 0xbf, 0x9b, + 0x5f, 0x6a, 0x1a, 0x33, 0xae, 0x02, 0x6d, 0xbf, 0x8e, 0x6f, 0xeb, 0xda, + 0x17, 0x88, 0x79, 0xf8, 0xe0, 0x1c, 0x1d, 0x64, 0x7c, 0xc3, 0xc2, 0xc1, + 0xce, 0x12, 0x67, 0x74, 0xbc, 0xef, 0x9f, 0x2f, 0x35, 0xef, 0x40, 0x24, + 0xc1, 0x5f, 0x82, 0xc9, 0x00, 0x2c, 0xbf, 0x2b, 0xbf, 0x64, 0x39, 0x63, + 0x0c, 0xcd, 0x6f, 0xe5, 0x18, 0xfa, 0xeb, 0x02, 0x61, 0xc3, 0x22, 0xb0, + 0xfd, 0xfb, 0x9d, 0x32, 0xbb, 0x2f, 0xd8, 0x9d, 0x63, 0x8d, 0x9e, 0xb1, + 0x5a, 0x7d, 0xc3, 0xe5, 0xcc, 0xae, 0xe4, 0x71, 0x0d, 0x7b, 0xa9, 0x80, + 0xbd, 0xef, 0x86, 0x12, 0x13, 0x42, 0xf8, 0x14, 0xcb, 0xa9, 0xfd, 0x86, + 0xea, 0xd5, 0x81, 0x54, 0x87, 0xbc, 0x66, 0x24, 0xfd, 0x12, 0xf5, 0x6b, + 0xf4, 0x45, 0x7e, 0x41, 0x8e, 0x00, 0xf3, 0x63, 0xc7, 0x24, 0xc5, 0xa1, + 0xfd, 0x70, 0xbc, 0xfa, 0x26, 0xa0, 0x07, 0xc2, 0x97, 0xb1, 0xe0, 0x42, + 0x5a, 0x2b, 0x65, 0xec, 0xda, 0x45, 0x79, 0xe5, 0x18, 0xf2, 0xce, 0x8e, + 0xc3, 0xe3, 0x74, 0x6d, 0x30, 0x82, 0x01, 0x49, 0x06, 0x09, 0x2a, 0x86, + 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, 0x01, 0x3a, 0x04, + 0x82, 0x01, 0x36, 0x30, 0x82, 0x01, 0x32, 0x30, 0x82, 0x01, 0x2e, 0x06, + 0x0b, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02, + 0xa0, 0x81, 0xf7, 0x30, 0x81, 0xf4, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, + 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, + 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, + 0x04, 0x10, 0xd8, 0x91, 0xb9, 0x92, 0x3a, 0x1e, 0x6b, 0xa2, 0x57, 0x44, + 0x16, 0x3a, 0xea, 0x36, 0x8d, 0x60, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, + 0x06, 0x08, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, + 0x30, 0x1d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, + 0x2a, 0x04, 0x10, 0x35, 0x48, 0x03, 0x23, 0x6a, 0x3d, 0x5f, 0x96, 0xb7, + 0xfb, 0xb1, 0xcb, 0xdc, 0x0c, 0x1f, 0x79, 0x04, 0x81, 0x90, 0x4d, 0x36, + 0x5b, 0x1e, 0xc1, 0xc1, 0xa3, 0xfa, 0x85, 0xe6, 0x58, 0x89, 0x5d, 0xbe, + 0x17, 0xb4, 0x90, 0x96, 0x2d, 0x3d, 0x32, 0xe2, 0x73, 0x6c, 0x2c, 0xfd, + 0x7b, 0xe4, 0x9c, 0x82, 0x3c, 0xe4, 0x7a, 0x58, 0x45, 0xdd, 0xcd, 0x00, + 0xa2, 0x52, 0xf9, 0x98, 0xd4, 0x0f, 0x64, 0x0b, 0x58, 0x27, 0xfe, 0xfc, + 0xfd, 0x60, 0x3f, 0x7e, 0x6f, 0x73, 0x73, 0xda, 0x16, 0x34, 0x83, 0x38, + 0x89, 0x3d, 0xbe, 0xde, 0x0a, 0x47, 0x7b, 0xcd, 0xc1, 0x88, 0x72, 0x38, + 0x95, 0xb5, 0x54, 0xfa, 0x2a, 0x84, 0x2b, 0xcd, 0x98, 0xe3, 0x20, 0xd2, + 0xac, 0xd8, 0xf5, 0xa8, 0xd2, 0xef, 0x1f, 0x99, 0x88, 0x16, 0x6f, 0xf2, + 0x08, 0xe9, 0x28, 0xc8, 0x7a, 0x02, 0x57, 0x5c, 0x1c, 0x94, 0x0f, 0x57, + 0xfc, 0x0e, 0x85, 0x63, 0xc5, 0x44, 0x45, 0x4f, 0xc6, 0x35, 0x52, 0x3d, + 0xd4, 0xe2, 0x22, 0x6f, 0x72, 0xde, 0x55, 0xad, 0x20, 0x84, 0x91, 0x14, + 0x7e, 0x5f, 0x98, 0x2a, 0x5e, 0x2b, 0xf4, 0xc5, 0x10, 0x1b, 0x31, 0x25, + 0x30, 0x23, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, + 0x15, 0x31, 0x16, 0x04, 0x14, 0xbd, 0x8a, 0x08, 0x3c, 0x66, 0xf7, 0x9d, + 0xa0, 0x25, 0x8b, 0x33, 0xac, 0xc2, 0x5e, 0xdc, 0x76, 0xd4, 0x33, 0x1a, + 0xd6, 0x30, 0x41, 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, + 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20, 0x4e, 0x6f, + 0x75, 0x90, 0x57, 0xb4, 0x39, 0x1e, 0xc3, 0x97, 0x50, 0x5e, 0x4d, 0xf2, + 0xa8, 0xf9, 0x0e, 0xcd, 0x7f, 0x57, 0x31, 0xae, 0xf2, 0x87, 0x0a, 0x55, + 0xb0, 0xbc, 0xd6, 0x11, 0x83, 0xc3, 0x04, 0x08, 0x52, 0x79, 0xb1, 0x83, + 0xe6, 0xe4, 0x6e, 0x4d, 0x02, 0x02, 0x08, 0x00, +}}; + +std::string PollHammerOnce(bool with_recorded_error, bool with_live_objects) { void* runtime = nullptr; const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); if (opened != 0 || runtime == nullptr) @@ -938,6 +1085,36 @@ std::string PollHammerOnce(bool with_recorded_error) { } } + // Self's ruling of 2026-09-22, F3: the predicate covered by the hammer set + // above is only ever exercised on a runtime with nothing live in it. An + // unroutable address (RFC 5737 TEST-NET-1) gets a connection into + // `runtime->connections` at once — `cultmesh_quic_connection_open` inserts + // it before the handshake starts — and that handshake then never completes + // inside this scenario's poll window, so it queues no event of its own. A + // best-effort stream rides the same connection. The listener is real (a + // credential MsQuic actually loads) but nothing ever connects to it, so it + // never accepts and never queues an event either: every object here is + // live in the runtime's maps without ever waking the idle poll on its own. + uint64_t live_connection_id = 0; + uint64_t live_stream_id = 0; + uint64_t live_listener_id = 0; + if (with_live_objects) { + const int32_t connecting = + cultmesh_quic_connection_open(runtime, "192.0.2.1", 9, &live_connection_id); + if (connecting != 0) { + cultmesh_quic_runtime_close(runtime); + return "cultmesh_quic_connection_open (live-objects setup) returned " + + std::to_string(connecting); + } + // Best-effort: whether this succeeds is not the point of the + // scenario, and P1's predicate names `connections`, not `streams`. + cultmesh_quic_stream_open( + runtime, live_connection_id, CULTMESH_QUIC_STREAM_RELIABLE, &live_stream_id); + uint16_t bound_port = 0; + cultmesh_quic_listener_open(runtime, "127.0.0.1", 0, kSelfSignedPkcs12.data(), + static_cast(kSelfSignedPkcs12.size()), "", &live_listener_id, &bound_port); + } + std::atomic hammering{true}; std::thread hammer([runtime, &hammering] { constexpr uint64_t kBogusId = 0xffffffffffffffffull; @@ -958,6 +1135,9 @@ std::string PollHammerOnce(bool with_recorded_error) { const TimedPoll poll = PollFor(runtime, kHammerPollMs); hammering.store(false, std::memory_order_relaxed); hammer.join(); + if (live_listener_id != 0) cultmesh_quic_listener_close(runtime, live_listener_id); + if (live_connection_id != 0) cultmesh_quic_connection_shutdown(runtime, live_connection_id, 0); + (void)live_stream_id; cultmesh_quic_runtime_close(runtime); if (poll.result != 0) @@ -974,18 +1154,68 @@ std::string PollHammerOnce(bool with_recorded_error) { int PollHammer(int iterations) { for (int iteration = 0; iteration < iterations; ++iteration) { - std::string failure = PollHammerOnce(false); + std::string failure = PollHammerOnce(false, false); if (!failure.empty()) return Fail("pollhammer iteration " + std::to_string(iteration) + " (clean runtime): " + failure); - failure = PollHammerOnce(true); + failure = PollHammerOnce(true, false); if (!failure.empty()) return Fail("pollhammer iteration " + std::to_string(iteration) + " (after a refused call recorded an error): " + failure); + failure = PollHammerOnce(false, true); + if (!failure.empty()) + return Fail("pollhammer iteration " + std::to_string(iteration) + + " (with a live connection, stream and listener): " + failure); } std::printf("pollhammer %dx: ok\n", iterations); return 0; } +// Self's ruling of 2026-09-22, F4: any `timeout_ms <= 0` has to return within a +// small, generously-margined bound, and a negative timeout has to be probed +// too — the bridge's own guard (`timeout_ms > 0`) already treats zero and +// negative alike, so 0, -1 and INT32_MIN all take the same fast path and none +// of them may collide with the seam's own "nothing recorded" sentinel. +constexpr std::array kZeroTimeoutProbesMs{0, -1, INT32_MIN}; + +// Generous next to `kEarlyToleranceMs`/`kGenerousLateToleranceMs` because a +// non-positive timeout never enters the condition wait at all, so this is +// bounding a runtime lock acquisition and an empty-queue check, not a +// scheduler wakeup — the margin only has to be well clear of a mistake that +// makes this path sleep or spin, not of ordinary jitter. +constexpr int kZeroTimeoutBoundMs = 50; + +std::string ZeroTimeoutOnce(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); + + const TimedPoll poll = PollFor(runtime, timeout_ms); + cultmesh_quic_runtime_close(runtime); + + if (poll.result != 0) + return "a poll asking for " + std::to_string(timeout_ms) + " ms returned " + + std::to_string(poll.result) + ", not the 0 an idle runtime owes it"; + if (poll.elapsed_ms > kZeroTimeoutBoundMs) + return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + + std::to_string(poll.elapsed_ms) + " ms: a non-positive timeout must return within " + + std::to_string(kZeroTimeoutBoundMs) + " ms, not stall or spin"; + return {}; +} + +int ZeroTimeout(int iterations) { + for (int iteration = 0; iteration < iterations; ++iteration) { + for (int32_t probe : kZeroTimeoutProbesMs) { + const std::string failure = ZeroTimeoutOnce(probe); + if (!failure.empty()) + return Fail("zerotimeout iteration " + std::to_string(iteration) + " probe " + + std::to_string(probe) + ": " + failure); + } + } + std::printf("zerotimeout %dx: ok (%zu probes)\n", iterations, kZeroTimeoutProbesMs.size()); + return 0; +} + int CloseRace(int iterations, int pollers) { for (int iteration = 0; iteration < iterations; ++iteration) { const std::string failure = CloseRaceOnce(pollers); @@ -1001,8 +1231,8 @@ int CloseRace(int iterations, int pollers) { 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", + "usage: %s closerace|holdclose|holdtimeout|polltimeout|pollbusy|pollhammer|zerotimeout|" + "payloadfit|latecall|waitseam [iterations] [pollers]\n", argv[0]); return 2; } @@ -1017,6 +1247,7 @@ int main(int argc, char** argv) { if (scenario == "polltimeout") return PollTimeout(iterations); if (scenario == "pollbusy") return PollBusy(iterations); if (scenario == "pollhammer") return PollHammer(iterations); + if (scenario == "zerotimeout") return ZeroTimeout(iterations); if (scenario == "payloadfit") return PayloadFit(iterations); if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout" || scenario == "waitseam") { diff --git a/scripts/mutate-cultmesh.mjs b/scripts/mutate-cultmesh.mjs index ab117986..e16fcd14 100644 --- a/scripts/mutate-cultmesh.mjs +++ b/scripts/mutate-cultmesh.mjs @@ -468,8 +468,8 @@ const mutations = [ // 50 ms settle, so the poller left before the close began and the scenario // complained about its own fixture rather than the bridge. rule: "a positive timeout blocks until an event or the close (loosening: it waits a token 1 ms)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(1)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(1)),", }, // And the rule underneath that one: the duration waited on is the host's, not // one of the bridge's own. Self's ruling of 2026-09-22 moved this whole family @@ -485,8 +485,8 @@ const mutations = [ target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (revert: a shorter constant of the bridge's own)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(100)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(100)),", }, { target: "native", @@ -494,8 +494,8 @@ const mutations = [ // The lengthening, which is the half that looks harmless: every poll still // blocks, every poll still returns, and nothing anywhere reports an error. rule: "the wait is on the host's timeout (loosening: a longer constant of the bridge's own)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(2000)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(2000)),", }, { target: "native", @@ -504,30 +504,30 @@ const mutations = [ // and the most ordinary spelling this line will ever be given: cap the wait // so a shutdown gets noticed. rule: "the wait is on the host's timeout (loosening: the bridge caps it at a second)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((std::min)(timeout_ms, 1000))),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((std::min)(timeout_ms, 1000))),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], // The same clamp at a round ceiling nobody would call short. rule: "the wait is on the host's timeout (loosening: the bridge caps it at a round five seconds)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((std::min)(timeout_ms, 5000))),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((std::min)(timeout_ms, 5000))),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: a floor, so a polling host cannot spin)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((std::max)(timeout_ms, 100))),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((std::max)(timeout_ms, 100))),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: a little grace added to it)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms + 100)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms + 100)),", }, // Soul's seventh pass named eight more derivations that were each the // identity at every wall-clock probe the previous six passes could afford: @@ -541,22 +541,22 @@ const mutations = [ target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: a floor at 50, invisible to a late-only check)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms < 50 ? 0 : timeout_ms)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms < 50 ? 0 : timeout_ms)),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: rounded down to a 16 ms quantum)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms / 16 * 16)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms / 16 * 16)),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: rounded up to a 40 ms quantum)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((timeout_ms + 39) / 40 * 40)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((timeout_ms + 39) / 40 * 40)),", }, { target: "native", @@ -566,41 +566,41 @@ const mutations = [ // a fault that only shows past the twelfth had nowhere to be seen at all. rule: "the wait is on the host's timeout (loosening: it doubles once a runtime has polled more than 12 times)", old: " const bool woken = runtime->signal.wait_for(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),\n" + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", new: " static std::atomic debug_poll_count{0};\n" + " const int32_t doubled_timeout_ms = (++debug_poll_count > 12) ? timeout_ms * 2 : timeout_ms;\n" + " const bool woken = runtime->signal.wait_for(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(doubled_timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(doubled_timeout_ms)),\n" + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: the bridge caps it at 7275 ms)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((std::min)(timeout_ms, 7275))),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((std::min)(timeout_ms, 7275))),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: 25 ms of grace added to it)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms + 25)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms + 25)),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: a floor of 40, so a polling host cannot spin)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT((std::max)(timeout_ms, 40))),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds((std::max)(timeout_ms, 40))),", }, { target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait is on the host's timeout (loosening: scaled up by 6%)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", - new: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms * 106 / 100)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms * 106 / 100)),", }, // The seam itself: development-only, folded away to a pass-through in a // release build (see the macro's `#else` in cultmesh_quic_native.cpp), and @@ -612,7 +612,7 @@ const mutations = [ target: "native", honestOn: ["linux-x64", "win32-x64"], rule: "the wait seam records what the bridge actually waits on (revert: it never records)", - old: "std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),", + old: "CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", new: "std::chrono::milliseconds(timeout_ms),", }, { @@ -632,10 +632,10 @@ const mutations = [ " // sooner, and `woken` is which of the two happened. RECORD_WAIT wraps\n" + " // this exact argument; see the development seam note near its macros.\n" + " const bool woken = runtime->signal.wait_for(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),\n" + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", new: " std::unique_lock lock(runtime->gate);\n" + - " CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms);\n" + + " CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms));\n" + " if (runtime->events.empty() && timeout_ms > 0) {\n" + " // The host's timeout bounds the wait; an event or the close ends it\n" + " // sooner, and `woken` is which of the two happened. RECORD_WAIT wraps\n" + @@ -659,10 +659,10 @@ const mutations = [ // milliseconds of its 1000 ms ask. 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(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),\n" + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", new: " const bool woken = runtime->signal.wait_for(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms))) ==\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms))) ==\n" + " std::cv_status::no_timeout;\n", }, { @@ -673,10 +673,10 @@ const mutations = [ // 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(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),\n" + " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", new: " runtime->signal.wait_for(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)));\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)));\n" + " const bool woken = !runtime->events.empty() || runtime->closing;\n", }, { @@ -689,11 +689,11 @@ const mutations = [ // 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(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)),\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(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(\n" + - " lock, std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms))) ==\n" + + " lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms))) ==\n" + " std::cv_status::no_timeout) {\n" + " }\n" + " const bool woken = !runtime->events.empty() || runtime->closing;\n", @@ -759,11 +759,10 @@ const mutations = [ { target: "native", honestOn: ["linux-x64", "win32-x64"], - // S5: the same guard at 1500, which `holdtimeout` used to carry at a long - // probe of 1300 and could not see. The long probe is 1600 now, above the - // stated ceiling, with the hold kept armed past it — a call this guard - // parks anyway sits out the rest of the arming instead of leaving on its - // own timeout, well past the scenario's generous late tolerance. + // S5: the same guard at 1500. `holdtimeout`'s long probe sits above it, at + // 1600, with the hold kept armed past it — a call this guard parks anyway + // sits out the rest of the arming instead of leaving on its own timeout, + // well past the scenario's generous late tolerance. rule: "the hold parks only a call the wait woke (loosening: it also parks any long wait, over 1500)", old: " if (woken && CULTMESH_QUIC_DEBUG_HELD(lock)) return 0;\n", new: " if ((woken || timeout_ms > 1500) && CULTMESH_QUIC_DEBUG_HELD(lock)) return 0;\n", @@ -939,6 +938,93 @@ const mutations = [ new: " runtime->signal.wait_for(lock, std::chrono::milliseconds(50),\n" + " [runtime] { return runtime->active_calls == 0; });", }, + // Self's ruling of 2026-09-22, the eighth fix batch. Six findings, six + // entries below (R1, W1-W3, P1, Z1-Z2); F5 (closerace under starvation) and + // F6 (stale prose) needed no new mutant. + // + // F1: nothing ran the release macro's `#else` branch. `polltimeout`, + // `pollbusy`, `pollhammer` and `zerotimeout` now run against a plain + // release configuration (asserts OFF, CMAKE_BUILD_TYPE=Release) on both + // targets, and R1 changes that branch from a pass-through to a tenth of the + // real timeout — invisible to every configuration that never built it. + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "R1: the release macro is a pass-through (loosening: it divides the duration by 10)", + old: "#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) (duration)", + new: "#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) ((duration)/10)", + }, + // F2: the seam now wraps the whole duration handed to `wait_for`, so + // anything that reaches the wait through the seam's own argument is caught + // by `waitseam`'s equality check. These three apply their arithmetic + // outside that argument instead — to the return value of RECORD_WAIT, + // after it has already recorded and returned the honest duration — so + // `waitseam` cannot see them by construction; only the wall-clock + // scenarios can, which is why `kGenerousLateToleranceMs` was narrowed + // alongside this entry. + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "W1: 600 ms added to the wait outside the seam's own argument", + old: "lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)) + " + + "std::chrono::milliseconds(600),", + }, + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "W2: the wait floored at 500 ms outside the seam's own argument", + old: "lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "lock, (std::max)(CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)), " + + "std::chrono::milliseconds(500)),", + }, + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "W3: the wait scaled by a 1000/909 ratio outside the seam's own argument", + old: "lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)),", + new: "lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)) * 1000 / 909,", + }, + // F3: `pollhammer` covered the predicate only on a runtime with nothing + // live in it. P1 adds the survivor: a busy host with a connection is + // treated the same as a busy host with events queued, so it never sleeps. + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "P1: an idle poll also ends when the runtime holds a live connection", + old: " [runtime] { return !runtime->events.empty() || runtime->closing; });\n", + new: " [runtime] {\n" + + " return !runtime->events.empty() || runtime->closing ||\n" + + " !runtime->connections.empty();\n" + + " });\n", + }, + // F4: a non-positive timeout was never timed at all — the bridge's own + // guard skips the wait outright, so nothing pinned how fast that path has + // to return. Both entries insert a stall on exactly that path, ahead of + // the guard that already exists. + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "Z1: a non-positive timeout sleeps 500 ms before returning", + old: " if (runtime->events.empty() && timeout_ms > 0) {\n", + new: " if (runtime->events.empty() && timeout_ms <= 0) {\n" + + " std::this_thread::sleep_for(std::chrono::milliseconds(500));\n" + + " }\n" + + " if (runtime->events.empty() && timeout_ms > 0) {\n", + }, + { + target: "native", + honestOn: ["linux-x64", "win32-x64"], + rule: "Z2: a non-positive timeout spins for 300 ms before returning", + old: " if (runtime->events.empty() && timeout_ms > 0) {\n", + new: " if (runtime->events.empty() && timeout_ms <= 0) {\n" + + " const auto debug_z2_until =\n" + + " std::chrono::steady_clock::now() + std::chrono::milliseconds(300);\n" + + " while (std::chrono::steady_clock::now() < debug_z2_until) {\n" + + " }\n" + + " }\n" + + " if (runtime->events.empty() && timeout_ms > 0) {\n", + }, ]; function digest(bytes) { @@ -965,23 +1051,28 @@ function testsPass(workspace) { // the scenario runner beside it and run the scenarios. There is no npm here and // no `dist/`; the artifacts live under artifacts/, which is git-ignored. // -// Two configurations, because the two rules are visible to different things. The -// assertion build states the quiesce invariant outright, which is what makes the -// missing wait fail on the first close instead of on an unlucky one. The -// ThreadSanitizer build is the only thing that sees a notify land on a destroyed -// condition variable, and it exists on linux-x64 alone. +// Two or three configurations, because the rules are visible to different +// things. The assertion build states the quiesce invariant outright, which is +// what makes the missing wait fail on the first close instead of on an unlucky +// one. The ThreadSanitizer build is the only thing that sees a notify land on +// a destroyed condition variable, and it exists on linux-x64 alone. The +// release build is the thing that actually ships: asserts off, no development +// seam, no sanitizer — added by Self's ruling of 2026-09-22 because it is the +// one configuration where R1's release-only macro branch is reachable at all. // // `holdclose`, `latecall` and `holdtimeout` run only where the development seam // exists, which is the assertion build: they drive the quiesce and the refusal // through that seam rather than through a sleep, and they are what kill the // rules about counting a host call at all. The ThreadSanitizer build // 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. +// it is there for is the race in a library built like the one that ships, and +// the release build keeps that same shape for the rules that ship-shape alone +// can hide. // -// `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. +// `polltimeout`, `pollbusy`, `pollhammer` 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"], @@ -990,15 +1081,34 @@ const assertScenarios = [ ["polltimeout", "3"], ["pollbusy", "3"], ["pollhammer", "3"], + ["zerotimeout", "3"], ["payloadfit", "3"], ["closerace", "20", "256"], ]; +// Self's ruling of 2026-09-22, F1: the assertion build is the only +// configuration that ever ran the timed scenarios, so R1's release-only +// `#else` branch (cultmesh_quic_native.cpp's CULTMESH_QUIC_DEBUG_RECORD_WAIT +// macro) was unobservable by construction — nothing built without +// CULTMESH_QUIC_DEBUG_ASSERTS ever ran `polltimeout`, `pollbusy` or +// `pollhammer` against it. This configuration is the thing that ships: a +// plain Release build, asserts off, no sanitizer, running the timed +// scenarios plus the new zero-timeout check against it. +const releaseScenarios = [ + ["polltimeout", "3"], + ["pollbusy", "3"], + ["pollhammer", "3"], + ["zerotimeout", "3"], +]; const nativeConfigurations = platform === "linux-x64" ? [ { name: "asserts", asserts: "ON", sanitizer: null, scenarios: assertScenarios }, { name: "tsan", asserts: "OFF", sanitizer: "thread", scenarios: [["closerace", "20", "256"]] }, + { name: "release", asserts: "OFF", sanitizer: null, buildType: "Release", scenarios: releaseScenarios }, ] - : [{ name: "asserts", asserts: "ON", sanitizer: null, scenarios: assertScenarios }]; + : [ + { name: "asserts", asserts: "ON", sanitizer: null, scenarios: assertScenarios }, + { name: "release", asserts: "OFF", sanitizer: null, buildType: "Release", scenarios: releaseScenarios }, + ]; function msquicArguments() { if (platform === "win32-x64") { @@ -1087,10 +1197,14 @@ function nativeScenariosPass() { const flags = configuration.sanitizer ? [`-fsanitize=${configuration.sanitizer}`, "-fno-omit-frame-pointer", "-g"].join(" ") : ""; + // The release configuration builds Release rather than RelWithDebInfo — it + // is the thing that actually ships, and R1 targets the macro branch that + // only a build without CULTMESH_QUIC_DEBUG_ASSERTS ever compiles. + const buildType = configuration.buildType ?? "RelWithDebInfo"; // Multi-config generators (MSVC) put the binaries in a per-config directory; // single-config ones (Ninja, Makefiles) do not. const binaries = platform === "win32-x64" - ? join(build, "bin", "RelWithDebInfo") + ? join(build, "bin", buildType) : join(build, "bin"); try { execFileSync("cmake", [ @@ -1099,7 +1213,7 @@ function nativeScenariosPass() { // default is Makefiles, and the container the bridge is built in carries // ninja rather than make, so the default configured nothing at all. ...(platform === "linux-x64" ? ["-G", "Ninja"] : []), - "-DCMAKE_BUILD_TYPE=RelWithDebInfo", + `-DCMAKE_BUILD_TYPE=${buildType}`, `-DCMAKE_CXX_FLAGS=${flags}`, `-DCMAKE_EXE_LINKER_FLAGS=${flags}`, `-DCMAKE_SHARED_LINKER_FLAGS=${flags}`, @@ -1107,7 +1221,7 @@ function nativeScenariosPass() { `-DCULTMESH_QUIC_DEBUG_ASSERTS=${configuration.asserts}`, ...msquic.configure, ], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); - execFileSync("cmake", ["--build", build, "--config", "RelWithDebInfo"], + execFileSync("cmake", ["--build", build, "--config", buildType], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { // A mutation that does not compile is killed by the build, which is a