You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Integration Tests failed on PR #856 with a single failure out of 1593:
1) test stats/0 tracks active bots after dispatch (Hypatia.Safety.RateLimiterTest)
test/safety_test.exs:122
Assertion with >= failed
code: assert stats.active_bots >= 2
left: 0
right: 2
stacktrace:
test/safety_test.exs:130: (test)
1593 tests, 1 failure, 242 excluded
It is a flake, and that is proven rather than assumed
PR #856's head d84e18a and the merged main commit ecc56fa2 have the byte-identical tree 8cdf99ae049fc5d5d12ea8e07def4d0da24def18 (the squash
changed only the commit object). Both runs logged Running ExUnit with seed: 0, max_cases: 8 — seed 0 disables shuffling, so
execution order was identical too.
Same tree, same seed, opposite verdicts: red on the PR run, green on main.
The nondeterminism is therefore timing/state-based, not ordering-based, and it
is independent of the change under test (a comment in an unrelated module).
⚠ This rules out the obvious first hypothesis. Do not "fix" it by reordering
tests or pinning a seed — the seed is already pinned and it still flakes.
Mechanism
Hypatia.Safety.RateLimiter is started by the application supervisor
(lib/application.ex:60). Three consequences:
setup's recovery branch is dead code in the normal case. test/safety_test.exs:10-14 and test/concurrency_test.exs:165 both do:
whereis almost always returns the application's pid, so start_supervised!
never runs and the test controls neither the process's lifetime nor its state.
State is global and never reset across all 1593 tests.bot_windows is a
plain map; handle_cast({:record, _}) only ever Map.puts into it and
nothing prunes whole entries (handle_call({:check, _}) filters timestamps within a bot's window, never removes the key). active_bots is map_size(state.bot_windows) (lib/safety/rate_limiter.ex:124), so it grows
monotonically for the life of the VM.
0 is only reachable via a restart.record_dispatch/1 is a cast and stats/0 is a call; issued from the same test process to the same
GenServer they are strictly ordered in the mailbox, so two casts followed by a
call can never observe an empty map — unless the GenServer died and was
restarted by its supervisor with a fresh %__MODULE__{} between them.
So the failure is evidence that something elsewhere in the suite crashes the
shared RateLimiter at a nondeterministic moment, silently resetting global
state underneath an unrelated test. The assertion is the detector, not the bug.
Why it matters beyond one red square
Any test asserting on RateLimiter state is asserting on a process that 1592
other tests share and that a crash can reset without warning. Today that costs
an occasional red PR; it also means a genuine rate-limiter regression could be
masked by a restart that clears the evidence.
Acceptance criteria
Identify what crashes the shared RateLimiter (prime suspect: test/concurrency_test.exs, which stresses it and carries the same
whereis/start_supervised! pattern). Name it explicitly; do not guess.
Give RateLimiterTest a process whose lifetime and state it owns — e.g.
start a named-per-test instance rather than reaching for the global one,
so setup genuinely establishes the precondition it appears to establish.
Remove the whereis/start_supervised! branch, which today asserts nothing.
Add a reset path (or per-test instance) so bot_windows cannot accumulate
across the suite.
Kill a mutant: make the shared GenServer restart deliberately mid-test
(e.g. Process.exit(GenServer.whereis(RateLimiter), :kill)) and show the old test goes red while the fixed test stays green. A fix that merely
stops reproducing is not evidence — the flake did not reproduce on main
either.
Do not paper over it with :timer.sleep, a retry, or @tag :flaky.
lib/application.ex:60 — Hypatia.Safety.RateLimiter in the supervision tree.
Surfaced while landing #855; filed as an issue rather than folded into that PR,
per the standing ruling that a new finding is an issue and not a merge blocker.
It is unreachable from #856's change (a comment in scanner_suppression.ex).
Symptom
Integration Testsfailed on PR #856 with a single failure out of 1593:It is a flake, and that is proven rather than assumed
PR #856's head
d84e18aand the mergedmaincommitecc56fa2have thebyte-identical tree
8cdf99ae049fc5d5d12ea8e07def4d0da24def18(the squashchanged only the commit object). Both runs logged
Running ExUnit with seed: 0, max_cases: 8— seed 0 disables shuffling, soexecution order was identical too.
Same tree, same seed, opposite verdicts: red on the PR run, green on
main.The nondeterminism is therefore timing/state-based, not ordering-based, and it
is independent of the change under test (a comment in an unrelated module).
⚠ This rules out the obvious first hypothesis. Do not "fix" it by reordering
tests or pinning a seed — the seed is already pinned and it still flakes.
Mechanism
Hypatia.Safety.RateLimiteris started by the application supervisor(
lib/application.ex:60). Three consequences:setup's recovery branch is dead code in the normal case.test/safety_test.exs:10-14andtest/concurrency_test.exs:165both do:whereisalmost always returns the application's pid, sostart_supervised!never runs and the test controls neither the process's lifetime nor its state.
State is global and never reset across all 1593 tests.
bot_windowsis aplain map;
handle_cast({:record, _})only everMap.puts into it andnothing prunes whole entries (
handle_call({:check, _})filters timestampswithin a bot's window, never removes the key).
active_botsismap_size(state.bot_windows)(lib/safety/rate_limiter.ex:124), so it growsmonotonically for the life of the VM.
0is only reachable via a restart.record_dispatch/1is acastandstats/0is acall; issued from the same test process to the sameGenServer they are strictly ordered in the mailbox, so two casts followed by a
call can never observe an empty map — unless the GenServer died and was
restarted by its supervisor with a fresh
%__MODULE__{}between them.So the failure is evidence that something elsewhere in the suite crashes the
shared
RateLimiterat a nondeterministic moment, silently resetting globalstate underneath an unrelated test. The assertion is the detector, not the bug.
Why it matters beyond one red square
Any test asserting on
RateLimiterstate is asserting on a process that 1592other tests share and that a crash can reset without warning. Today that costs
an occasional red PR; it also means a genuine rate-limiter regression could be
masked by a restart that clears the evidence.
Acceptance criteria
RateLimiter(prime suspect:test/concurrency_test.exs, which stresses it and carries the samewhereis/
start_supervised!pattern). Name it explicitly; do not guess.RateLimiterTesta process whose lifetime and state it owns — e.g.start a named-per-test instance rather than reaching for the global one,
so
setupgenuinely establishes the precondition it appears to establish.Remove the
whereis/start_supervised!branch, which today asserts nothing.bot_windowscannot accumulateacross the suite.
(e.g.
Process.exit(GenServer.whereis(RateLimiter), :kill)) and show theold test goes red while the fixed test stays green. A fix that merely
stops reproducing is not evidence — the flake did not reproduce on
maineither.
:timer.sleep, a retry, or@tag :flaky.Evidence
Integration Tests, 21:54:39Z–21:54:52Z, tree8cdf99ae.main@ecc56fa2,Integration Testssuccess, same tree.lib/safety/rate_limiter.ex:124—active_bots: map_size(state.bot_windows).lib/application.ex:60—Hypatia.Safety.RateLimiterin the supervision tree.Surfaced while landing #855; filed as an issue rather than folded into that PR,
per the standing ruling that a new finding is an issue and not a merge blocker.
It is unreachable from #856's change (a comment in
scanner_suppression.ex).