Conversation
`cargo test --release --lib` has been red on clean main. It is not a race, as it had been read for months, but a PERMANENT leak through PRODUCTION code. `command::config::config_set` publishes to five process-global atomics on the CONFIG SET path -- MAXMEMORY_GLOBAL, MAXMEMORY_HINT, MAXMEMORY_PER_SHARD_HINT and MAXMEMORY_POLICY_GLOBAL in storage::eviction, plus DB_MAXMEMORY_ANY_SET in storage::db_quota. The unit tests that drove it never put them back. Under `cargo test --lib` -- ONE process for the whole suite, unlike nextest's process-per-test -- whichever test later READ that state failed. So the VICTIM was order-dependent while the CAUSE was not, which is exactly why isolating the red test showed green and proved nothing. Five writers, each individually sufficient (proven by running each one alone with the victim under --test-threads=1): config_set_maxmemory_accepts_redis_memory_units MAXMEMORY_GLOBAL=1048576 config_set_db_maxmemory_accepts_memory_units DB_MAXMEMORY_ANY_SET=true test_config_set_maxmemory MAXMEMORY_GLOBAL=1048576 test_config_set_multiple_params MAXMEMORY_GLOBAL=2048 test_config_set_db_maxmemory DB_MAXMEMORY_ANY_SET=true Attribution control: `-- --skip command::config` passes 5541/0. `config_set_maxmemory_rejects_what_redis_rejects` is the negative control -- it takes the reject path and leaks nothing. `test_config_set_db_maxmemory` reads green under a PREFIX filter only because a sibling republishes `false` after it; --exact exposes it. The fix is a #[cfg(test)] `PublishedLimits` RAII guard beside the existing `ForceWriteGate` in storage::eviction. It snapshots all five atomics on construction and restores them on Drop. Drop-on-unwind is the whole point: eviction.rs's two atomic tests, db_quota's publish_and_read_any_set_flag and conn/tests.rs's inline-spill-gate test all restored MANUALLY at the end of their bodies, so a panic mid-way skipped the restore entirely and left the poison behind for a run that had already failed -- worse than never restoring. db_quota gains a #[cfg(test)] restore_db_maxmemory_any_set rather than widening the static, because the public publisher derives the flag from a whole RuntimeConfig and cannot express "put back what was there". To make it durable rather than a one-time sweep, command::config::tests calls a module-local `config_set_scoped`, and a module-local shadow of the glob-imported `config_set` means nothing in that module can reach the unguarded publisher without spelling out `super::config_set`. Forgetting is a compile-time impossibility, not a convention. The CI waiver that had been hiding all of this is RETIRED. scripts/libtest-singleproc-gate.sh defaulted LIBTEST_KNOWN_FAILURES=1 and named the victim in LIBTEST_KNOWN_FAILURE; both are now empty, so the gate waives nothing and any failure at all fails it. The knobs themselves stay as the mechanism for a future known failure, and the self-test proves the mechanism still works: it gains "the retired waiver no longer waives moon#856's victim" plus three cases that re-arm a waiver through the environment and check it still grants the named failure and still refuses a different or a second one. 14/14 green. Two CI files change, and ONLY in comments -- no stage, no command, no matrix entry moves. .github/workflows/ci.yml and scripts/ci-local.sh each describe the single-process stage as "waives one known failure (moon#856)", which stops being true with this commit. Retiring a waiver in the script while three files keep advertising it is the divergence moon#913 is about, so the prose is retired with it. `git diff` on both files is comments only. Finally, the victim's 100-attempt retry loop is deleted. That loop -- sampling the globals around each gate call and `continue`-ing when a sibling had a limit published -- is what turned a deterministic leak into something that read as a flake. It now ESTABLISHES the unset state under the guard, asserts that precondition, and fails immediately naming the maxmemory and db-quota values it actually observed. Measured on macOS aarch64 at a8eb2ef, default features: before 5558 passed; 1 failed after 5559 passed; 0 failed The guard was attacked before being trusted: removing it from one test turns the gate red again. Closes #856 author: Tin Dang
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independently verified the fix. One open item on the merge bar, attribution in progress. The fix works — red→green confirmed at the merge-baseI ran the same single-process gate on
So the leak reproduces at the merge-base and is gone here. Test count went 5546 → 5559: 13 tests added, none removed. Anti-regression checks, because a green suite can also mean the victim was weakened:
The shadow is better than what was asked forThe brief asked for a scoped helper so no test would forget the guard. This PR makes it so no test can: the module-local One open itemA full gate run on this branch reported: Probably load-induced, not this PR — but not yet proven. Evidence each way: For environmental: my direct Against dismissing it: this PR touches The gate's own guidance is right — re-running the test alone proves nothing, since the mechanism is order-dependent process-global state. I will re-run the full gate on a quiet host and report. If it is environmental, this PR is clean as-is. Note for anyone reproducing the attributionRunning the gate on the tokio leg by hand needs Still DRAFT, nothing merged. |
Attribution of the
|
base a8eb2efc |
head e671268c (this PR) |
|
|---|---|---|
| total | 5558 passed, 1 failed | 5559 passed, 0 failed |
scripting::bridge::tests::gate_is_skipped_with_spill_sender_when_no_limit_is_configured |
FAILED | ok |
admin::footprint::footprint_tests::footprint_is_phys_footprint_not_resident_size |
ok | ok |
Two conclusions.
1. The fix is proven red→green on the bug it targets, in my own A/B rather than on
the agent's word. The merge-base reproduces exactly the failure this PR exists to fix,
and the head commit clears it. The totals also land precisely where the gate script's
own header predicts — 5559 / 0 default features (was 5352 / 1 at 7a87f69f).
2. The footprint failure is not attributable to this PR. It passed on both legs here.
Why it can fail on an unrelated run
Reading src/admin/footprint.rs:490, the test asserts two process-wide absolute
deltas measured across a window:
rss_growth > 32 MiB— the 64 MiB clean file-backed mapping must fault infp_growth < 16 MiB—phys_footprintmust not absorb it
TEST_LOCK serialises only the eight tests inside footprint.rs, not the other ~5,550
tests in the binary, and cargo test --lib runs them on parallel threads. So:
- any concurrent test thread allocating >16 MiB of anonymous memory during the window
breaks the second assertion; and - under memory pressure the kernel reclaims clean file-backed pages — the cheapest
thing it can evict — as fast as the probe loop faults them in, breaking the first.
Neither path involves PublishedLimits or maxmemory, which is the entire surface this
PR touches. That is consistent with it being order- and load-dependent, which is what
the gate's own message says about single-process failures.
Honest caveat on what I ran: my A/B used --release and --test-threads=8, where
the gate uses a debug build and the default thread count. So I did not reproduce the
footprint failure and cannot claim to have diagnosed it from a live repro — I showed it
does not reproduce on either leg under my configuration, and that this PR's target bug
is fixed. The mechanism above is read from the source, not measured.
If the footprint test is worth hardening — the two assertions are genuinely racy against
a parallel --lib run — that is its own issue, not a blocker here.
Closes #856. (#974 is a duplicate; the reporter is closing it.)
What was actually wrong
It is not a race. It is a permanent leak through production code.
command::config::config_setpublishes to five process-global atomics on theCONFIG SETpath —
MAXMEMORY_GLOBAL,MAXMEMORY_HINT,MAXMEMORY_PER_SHARD_HINT,MAXMEMORY_POLICY_GLOBAL(src/storage/eviction.rs) andDB_MAXMEMORY_ANY_SET(
src/storage/db_quota.rs). The unit tests that drove it never put them back. Undercargo test --lib— one process for the whole suite, unlike nextest's process-per-test —whichever test later READ that state failed.
That is why the VICTIM looked order-dependent while the CAUSE was not, and why isolating the
red test showed green and proved nothing.
Five writers, each individually sufficient (each proven by running it alone with the victim
under
--test-threads=1):config_set_maxmemory_accepts_redis_memory_unitsMAXMEMORY_GLOBAL = 1048576config_set_db_maxmemory_accepts_memory_unitsDB_MAXMEMORY_ANY_SET = truetest_config_set_maxmemoryMAXMEMORY_GLOBAL = 1048576test_config_set_multiple_paramsMAXMEMORY_GLOBAL = 2048test_config_set_db_maxmemoryDB_MAXMEMORY_ANY_SET = trueAttribution control:
-- --skip command::config→ 5541 passed / 0 failed.Negative control:
config_set_maxmemory_rejects_what_redis_rejectstakes the reject path andleaks nothing.
test_config_set_db_maxmemoryreads green under a prefix filter only becausea sibling republishes
falseafter it;--exactexposes it.The fix
PublishedLimits— a#[cfg(test)]RAII guard insrc/storage/eviction.rs, beside theexisting
ForceWriteGate. Snapshots all five atomics oncapture(), restores them onDrop.db_quotagains a#[cfg(test)] restore_db_maxmemory_any_set(bool)rather than widening thestatic, because the public publisher derives the flag from a whole
RuntimeConfigand cannotexpress "put back what was there". Nesting is LIFO-correct.
Drop-on-unwind is the point. Four tests restored MANUALLY at the end of their bodies(
eviction.rs×2,db_quota.rs,server/conn/tests.rs), so a panic mid-body skipped therestore entirely and left the poison behind for a run that had already failed — worse than
never restoring.
db_quota.rs's own comment conceded this. All four are converted.Un-forgettable, not a convention.
command::config::testscalls a module-localconfig_set_scoped, and a module-local shadow of the glob-importedconfig_setmeansnothing in that module can reach the unguarded publisher without spelling out
super::config_set. A new test cannot forget — that would be a compile-visible act, not anomission. (The attack below uses
super::precisely because it is the only spelling thatworks.)
Waiver RETIRED.
scripts/libtest-singleproc-gate.shdefaultedLIBTEST_KNOWN_FAILURES=1and named this exact test in
LIBTEST_KNOWN_FAILURE. Both are now empty: the gate waivesnothing and any failure at all fails it.
Spin deleted. The victim's 100-attempt retry loop — sampling the globals around each gate
call and
continue-ing when a sibling had a limit published — is what turned a deterministicleak into something that read as a flake for months. It now ESTABLISHES the unset state under
the guard, asserts that precondition, and fails immediately naming the values it observed.
Adoption: 16 tests
12 in
src/command/config.rs(the brief listed 10;test_config_set_invalid_policyandtest_config_set_unknown_paramalso callconfig_setand are routed too, since the point isthat the module has no unguarded path), 2 in
src/storage/eviction.rs, 1 insrc/storage/db_quota.rs, 1 insrc/server/conn/tests.rs. Theconn/tests.rsone appliedcleanly and its manual restore is removed; the guard covers the panic-inside-
runcase themanual line could not.
Verification
All on macOS aarch64, default features,
CARGO_TARGET_DIR=$HOME/n1-target.1. BEFORE — reproduced on clean main (
a8eb2efc)2. AFTER —
cargo test --release --lib3. The one the prefix filter hides —
--exact4.
scripts/libtest-singleproc-gate.sh— green with ZERO waivers5. The gate's own self-test — 14/14
the retired waiver no longer waives moon#856's victimis the retirement check: if that lineever reads
rc=0, the waiver has crept back in. The threere-armed waivercases keep thewaiver mechanism proven — the knobs stay (empty) so a future known failure can be tolerated
deliberately, and count-only waiving stays refused.
6. ATTACK THE GUARD — mandatory, and here is the red
A guard that cannot be shown to fail is not evidence. Two runs, same binary shape, one variable.
A wrinkle worth stating plainly: after this fix the victim ESTABLISHES its own precondition, so
removing the guard from a config test would no longer redden it. That would make the guard
look decorative when it is not. So the attack uses a temporary probe with the victim's
pre-fix shape — it READS the published atomics without establishing them — which isolates
the guard as the only variable:
CONTROL — probe added, guards intact → GREEN. Nothing in the entire lib binary leaves a
limit published:
ATTACK — guard removed from ONE test → RED. The single change, in
test_config_set_maxmemory:maxmemory=1048576is exactly the valuetest_config_set_maxmemorysets. One guard removed,one leak, the gate red — and the retired waiver does not wave it through. Both mutations were
reverted before the commit;
git diff --stat HEADis empty.A permanent version of this probe was considered and rejected: it only catches leakers that are
scheduled before it, so it would be an order-dependent canary — the
config_setshadow is thestructural guarantee instead.
7. Both runtimes + clippy + fmt
(
cargo fmt --checkcaught one line inbridge.rsafter the first commit; fixed and amended,then clippy and both
checklegs re-run on the amended tree.)Scope note: two CI files changed, comments only
.github/workflows/ci.ymlandscripts/ci-local.share outside the original brief. Both editsare comment-only — no stage, no
run:line, norun_step, no matrix entry moves; therun:lines are byte-identical on both sides of each hunk. Each file asserted in the presenttense that the single-process stage "waives one known failure (moon#856)", which this PR makes
false. Retiring a waiver in the script while three files keep advertising it is exactly the
divergence #913 is about, so the prose is retired with it.
Not done (deliberate)
std::sync→parking_lotconversion, noconfig_setrefactor.scripts/ci-local.shNOT run — the merge bar is the reviewer's to run.gate_is_skipped_with_spill_sender_when_no_limit_is_configuredfails on main under parallel execution #856:config_set'snotify-keyspace-eventsarm callscrate::notify::publish_flags, another process-globalpublisher with no test-side restore. No test in
command::config::testscurrently exercisesit, so nothing is red today — worth its own issue rather than widening this PR.