fix: enforce flat-phase contract via assert (no depth counter)#12
Merged
Conversation
Replace the PHASE_DEPTH counter with an unconditional assert in begin_phase() that panics when a phase is already active. The counter masked correctness bugs: a panic between paired calls orphaned the depth and silently shifted subsequent phases into nested mode, where the slab kept growing instead of recycling. Mirrors the pattern Emile cherry-picked into leanMultisig as 'zk-alloc: assert begin_phase has flat lifetime' (684a526c). Bringing it back to the standalone repo so downstream consumers see the same contract. - src/lib.rs: drop PHASE_DEPTH, swap-based assert in begin_phase, unconditional store in end_phase. Doc-comment now states the contract explicitly. - tests/test_nested_phase.rs: rewritten as a #[should_panic] contract test pinning the assert message. - tests/test_panic_phase.rs: pinned to 'next begin_phase trips the assert loudly' after an orphaned phase, instead of the old 'depth-counter preserves data' assertion. - tests/test_phase_guard.rs: nested_phase_guards_compose → nested_phase_guards_panic (#[should_panic]). Lock acquires ignore poisoning so the should_panic test does not cascade-fail the binary. - tests/correctness.rs, tests/test_crossbeam_epoch.rs: add a per-binary PHASE_LOCK around phase-touching tests so they don't race on ARENA_ACTIVE under parallel test execution. Add the missing end_phase() in phase_boundary_does_not_crash. - tests/test_realloc_overlap.rs: reflow a doc-comment ASCII diagram the new clippy 1.94 doc_overindented_list_items lint rejected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace the
PHASE_DEPTHcounter with an unconditional assert inbegin_phase()that panics when a phase is already active. The counter masked correctness bugs: a panic between pairedbegin_phase()/end_phase()orphaned the depth, and any subsequentbegin_phase()then silently nested under the orphan instead of recycling the slab — turning a bug into a memory leak the user couldn't see.Mirrors the pattern on commit
684a526c("zk-alloc: assert begin_phase has flat lifetime"), which currently lives onleanEthereum/leanMultisig#215("zkalloc fix: safe arena routing", OPEN since 2026-05-06, not merged) and on aBarnadrot/leanMultisigexperiment branch. Neither downstream main has the pattern yet. This PR brings it to standaloneBarnadrot/zk-allocindependently; a separate sync to leanMultisig vendoredcrates/backend/zk-alloc/remains a follow-up.Behavior change (caller-visible)
begin_phase()now panics if called while another phase is active. The supported pairing model is exactly onebegin_phase()perend_phase()(or usePhaseGuard/phase()for panic-safe pairing). Nested phases are no longer tolerated.If you have code that relied on nested begin/end composing, it will now panic at runtime — that path was already silently wrong (the inner begin's GENERATION bump recycled the outer's slab on the next allocation); the assert just makes the failure loud.
Why this is the right shape
PhaseGuard, that invariant could not be statically enforced.Test changes
tests/test_nested_phase.rs— rewritten as a#[should_panic]contract test pinning the assert message.tests/test_panic_phase.rs— pinned to "nextbegin_phasetrips the assert loudly" after an orphaned phase, instead of the old "depth counter preserves data" assertion.tests/test_phase_guard.rs::nested_phase_guards_compose→nested_phase_guards_panic(#[should_panic]). Lock acquires useunwrap_or_else(|e| e.into_inner())so the should_panic test doesn't poison the lock and cascade-fail the binary.tests/correctness.rs,tests/test_crossbeam_epoch.rs— added a per-binaryPHASE_LOCKaround phase-touching tests so they don't race onARENA_ACTIVEunder parallel test execution. Added the missingend_phase()inphase_boundary_does_not_crash.tests/test_realloc_overlap.rs— reflowed an ASCII-diagram doc comment that the new clippy 1.94doc_overindented_list_itemslint rejected (unrelated to the phase-contract change but blocked CI).Verified locally
cargo fmt --checkcleancargo clippy --workspace --all-targets -- -D warningscleancargo test --workspace— all 21 tests pass across 8 test binariesTest plan
leanMultisigdoes not yet run this shape — PR #215 (Emile, OPEN) has it on an unmerged branch. A separate sync PR to leanMultisig vendoredcrates/backend/zk-alloc/is queued as a follow-up after this lands.PhaseGuard/phase()before merging.🤖 Generated with Claude Code