From 5cebd2ca05f9be7ccfd52f5c1850781a60095c7b Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Sun, 1 Nov 2026 22:31:04 +0100 Subject: [PATCH 1/2] test(core): pin latency_ms against a modulo scale The weekly mutants run (41192647029) left `latency_us / 1_000` with `%` as its one survivor: the wiremock delay tests bound a 200 ms wait to [200, 5200] ms, and 200_xxx us modulo 1000 lands inside that window often enough to pass. A unit test on the private `record` pins the scale deterministically: two sub-millisecond waits sum to one whole millisecond, where `%` reports 300 and `*` 1_300_000, and the last status wins with a response-less request reading `None`. Hand-applied, both scale mutants fail the test and the unmutated code passes it. --- crates/bugwarden-core/src/client.rs | 20 ++++++++++++++++++++ docs/DESIGN.md | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/bugwarden-core/src/client.rs b/crates/bugwarden-core/src/client.rs index b2038f8..86bb7c2 100644 --- a/crates/bugwarden-core/src/client.rs +++ b/crates/bugwarden-core/src/client.rs @@ -1047,4 +1047,24 @@ mod tests { "Debug keeps the status and the code: {own}" ); } + + /// Microseconds are summed across requests and reported as whole + /// milliseconds, so two sub-millisecond waits make one together; a `%` + /// or `*` in the scale reports 300 or 1_300_000 for the same pair. + #[test] + fn upstream_stats_sum_microseconds_into_whole_milliseconds() { + let stats = UpstreamStats::default(); + stats.record(Duration::from_micros(600), Some(200)); + stats.record(Duration::from_micros(700), None); + assert_eq!(stats.requests(), 2); + assert_eq!(stats.latency_ms(), 1); + assert_eq!( + stats.status(), + None, + "the last request produced no response" + ); + stats.record(Duration::from_micros(2_345_678), Some(404)); + assert_eq!(stats.latency_ms(), 2_346); + assert_eq!(stats.status(), Some(404)); + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f692957..fe9dcb5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -3957,8 +3957,11 @@ wired, `server.rs` and `main.rs` are the reference. Display is the unchanged text; the code is read only inside an `"error": true` envelope (a proxy's non-2xx JSON, a string code and a float code give none); a gateway's HTML under 502 and a 200 non-JSON body are - typed with no code; and neither the type's Debug nor anyhow's alternate - Debug carries the message. + typed with no code; neither the type's Debug nor anyhow's alternate + Debug carries the message; and `UpstreamStats` sums microseconds across + requests into whole milliseconds (two sub-millisecond waits make one, so + neither a `%` nor a `*` scale passes), the last status winning and a + response-less request reading `None`. - Unit tests (#[cfg(test)] in crates/bugwarden/src/server.rs): assemble_bug_info re-classification — a body embargoed after the verdict is refused, a body that now earns only summary is downgraded, a body granting neither read nor From 5ea1fc37b866aa8b6745b174e5b644c1ba7ff9fb Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Sun, 1 Nov 2026 22:31:04 +0100 Subject: [PATCH 2/2] ci(mutants): exclude the hand-reproduced cursor hangs Run 41192647029 reported 21 TIMEOUTs, every one a loop cursor whose mutated advance can never pass the current byte: `*= 1` leaves the index in place so the body re-runs in the same state forever, and `-=` steps back onto a byte the preceding branch advances over again so the pair cycles (on other inputs it underflows, which those tests already catch). Each was reproduced on its own under a 10 s per-test timeout, against groups that finish unmutated in under 5 s: qs_mask (2044 -=; 2052, 2078, 2082 both; 2063 *=), qs_split_ranges 2099 *=, qs_find_operator 2116 and 2132 both, qs_has_question 2144 *=, glob_match 164/172/180 *=, embedded_local_ids 942 both, and the stdio delimiter advance 196 both, which DESIGN.md already records as a hand-verified hang. Each mutant hangs at least one test; some `-=` ones also fail others through an underflow, but cargo-mutants runs plain `cargo test`, so the binary holding the hang never exits and the verdict is TIMEOUT either way. No timeout masks a survivor and no added test could turn one into a kill; they are excluded line-anchored like the otel hangs, so the Saturday run stops paying the floor for verdicts already in hand. Verified by --list diff: exactly these 21 disappear and every killed sibling (the -= twins at 2063, 2099, 2144 and the glob lines, the *= at 2044) stays. The mutants.yml note that kept the stdio hangs in scope is updated to match. --- .cargo/mutants.toml | 33 +++++++++++++++++++++++++++++++++ .github/workflows/mutants.yml | 5 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c2316a8..2fbac63 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -130,12 +130,45 @@ exclude_re = [ # not pay the timeout floor weekly for a verdict already in hand. "otel\\.rs:931:21:.*\\+= with \\*=", "otel\\.rs:634:22:.*== with !=", + # The quicksearch byte scanners' cursor hangs, hand-reproduced test by + # test under a 10s timeout: `i *= 1` leaves the index where it is, so + # the loop re-runs the same byte in the same state forever; `i -= 1` + # (`-= 2` past an escape) steps back onto a byte the preceding branch + # advances over again, so the pair cycles (on other inputs it + # underflows, and those tests fail). Each one hangs at least one test + # in the same binary, which then never exits, so no added test can + # turn it into a kill. + "server\\.rs:2044:15:.*\\+= with -=", + "server\\.rs:2052:15:.*\\+= with (-=|\\*=)", + "server\\.rs:2063:15:.*\\+= with \\*=", + "server\\.rs:2078:15:.*\\+= with (-=|\\*=)", + "server\\.rs:2082:11:.*\\+= with (-=|\\*=)", + "server\\.rs:2099:11:.*\\+= with \\*=", + "server\\.rs:2116:15:.*\\+= with (-=|\\*=)", + "server\\.rs:2132:11:.*\\+= with (-=|\\*=)", + "server\\.rs:2144:11:.*\\+= with \\*=", + # The foreign-host skip `at = pos + 1`: `pos - 1` and `pos * 1` both + # re-find the occurrence just rejected, so the scan never passes it + # (embedded_scan_needs_a_domain_boundary_on_the_left hangs). + "guard\\.rs:942:26:.*\\+ with (-|\\*)", + # The stdio delimiter advance `newline + 1` (DESIGN.md): `- 1` and + # `* 1` leave the newline inside `rest`, so the frame loop finds it + # again forever (the multi-frame drain hangs; `- 1` on a bare newline + # underflows instead, in the same binary). + "stdio\\.rs:196:34:.*\\+ with (-|\\*)", # The glob backtrack advance is unobservable: `p = star_pos` re-enters # the `'*'` arm and lands on the identical (p, resume, s) state one # iteration later (or skips the same `'*'` in the trailing-star loop), # proven by 265k exhaustive + 200k random differential cases with zero # divergences. "policy\\.rs:171:26:.*\\+ with \\*", + # Its three advances frozen by `*= 1` — the `'*'` skip (164), the + # backtrack resume (172) and the trailing-star drain (180) — never + # move p or resume, so every glob test with a `*` hangs; reproduced + # by hand like the otel hangs above. + "policy\\.rs:164:15:.*\\+= with \\*=", + "policy\\.rs:172:20:.*\\+= with \\*=", + "policy\\.rs:180:11:.*\\+= with \\*=", # Typo-shaped ports are refused by exact refusal text the anchor tests # pin; `>=` for `>` is identical (at equal lengths the byte lookup is # None), so that one is documented in DESIGN.md, not tested. diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 0a784c0..e9738bd 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -87,8 +87,9 @@ jobs: # the tier/variant/tag exclusions, policy.rs the glob-advance one, # client.rs none; the eight small files carry two # documented-not-tested exclusions (config port >=, custom comma - # collapse) while the stdio delimiter hangs stay in scope and pay - # the floor weekly. + # collapse). Hand-reproduced cursor hangs (server, policy, guard, + # stdio) are excluded like otel's, so a verdict already in hand does + # not pay the floor weekly. MUTANTS_SCOPE: crates/bugwarden-core/src/guard.rs crates/bugwarden/src/main.rs crates/bugwarden/src/server.rs crates/bugwarden/src/audit.rs crates/bugwarden/src/otel.rs crates/bugwarden-core/src/client.rs crates/bugwarden-core/src/policy.rs crates/bugwarden-core/src/custom_fields.rs crates/bugwarden/src/config.rs crates/bugwarden/src/http_auth.rs crates/bugwarden/src/stdio.rs crates/bugwarden/src/tracing_fields.rs crates/bugwarden/src/http_session.rs crates/bugwarden/src/panic_hook.rs crates/bugwarden-core/src/quoted.rs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1