From 1bfee5e7f2f98de0176b032bfb53f0a84c5a77e3 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 10:23:05 +0200 Subject: [PATCH 01/21] feat: size-routing fix for phase-crossing allocations Route allocations smaller than MIN_ARENA_BYTES (default 4096) to System allocator even during active phases. This prevents library-internal allocations (tracing-subscriber Registry, hashbrown HashMap, crossbeam Injector blocks) from landing in arena memory that gets recycled on begin_phase(). Root cause: libraries like tracing-subscriber intentionally pool heap capacity across logical lifetimes (ExtensionsInner::clear() retains HashMap backing). When this pooled memory is arena-allocated, the next phase recycles it while the library still holds a reference. Key changes: - MIN_ARENA_BYTES=4096 default (configurable via ZK_ALLOC_MIN_BYTES env) - SLAB_SIZE now configurable via ZK_ALLOC_SLAB_GB env var - POISON_RESET diagnostic mode (ZK_ALLOC_POISON_RESET=1) for debugging - MADV_DONTNEED support in syscall module - 6 test files covering reproduction, stress, boundary cases - findings.tsv: full 15-finding audit trail from exp5 Verified: 100 iterations on Plonky3 prove, 30 proofs on leanMultisig, no measurable performance regression. Replaces flush_rayon as primary soundness fix. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 3 + findings.tsv | 20 ++++ src/lib.rs | 86 +++++++++++++++- src/syscall.rs | 4 +- tests/test_many_spans_stress.rs | 66 ++++++++++++ tests/test_rayon.rs | 42 ++++++++ tests/test_rayon_audit.rs | 163 ++++++++++++++++++++++++++++++ tests/test_scope_nesting.rs | 61 +++++++++++ tests/test_size_distribution.rs | 71 +++++++++++++ tests/test_size_routing_stress.rs | 47 +++++++++ 10 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 findings.tsv create mode 100644 tests/test_many_spans_stress.rs create mode 100644 tests/test_rayon.rs create mode 100644 tests/test_rayon_audit.rs create mode 100644 tests/test_scope_nesting.rs create mode 100644 tests/test_size_distribution.rs create mode 100644 tests/test_size_routing_stress.rs diff --git a/Cargo.toml b/Cargo.toml index 9452e91..8a6d121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ libc = "0.2" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +rayon = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } [[bench]] name = "alloc_throughput" diff --git a/findings.tsv b/findings.tsv new file mode 100644 index 0000000..968ec21 --- /dev/null +++ b/findings.tsv @@ -0,0 +1,20 @@ +id category description reproducible severity fix_status +F1 rayon_injector Tom's bug. crossbeam_deque::Injector blocks (~520 bytes, 64 JobRef slots) allocated during arena phase land in arena slab. Next begin_phase() recycles slab; next rayon::join from non-worker thread writes a JobRef (~17 bytes) over recycled memory. Single-block blast radius. yes (test_rayon::rayon_does_not_corrupt_zkalloc, deterministic) critical flush_rayon (256 joins) deployed in leanMultisig but NOT in standalone zk-alloc main; standalone is currently vulnerable +F1a rayon_injector Audit characterization: 10/10 phase cycles corrupted (zk-alloc/tests/test_rayon_audit::repeated_phase_cycles). 1MB canary shows exactly ONE 17-byte run corrupted = single JobRef write per phase boundary, not cascading. Spawned-thread variant passes only because canary lives in different slab than the spawned thread's recycled Injector block. yes critical standalone vulnerable +F1b rayon_injector Plonky3 reproduction: prove_poseidon1_baby_bear_keccak built with rayon-flush disabled crashes deterministically on 2nd iteration of prove_and_verify(). Panic site: tracing-forest layer/mod.rs:296 'Span extension doesn't contain OpenedSpan' — corruption hits tracing-subscriber registry HashMap, not just rayon-internal. Cascades to mutex poisoning (RwLock corruption). yes (deterministic across both Poseidon1/BB and Poseidon2/KB) critical rayon-flush feature gate (default ON) masks; turning it off exposes +F1c rayon_injector leanMultisig prove_loop NEGATIVE result: 5 proofs run cleanly with verify=OK even without flush_rayon (zk-alloc-as-globalalloc, no flush). The bug pattern is workload-specific — depends on which threads call rayon::join inside an active phase, and which allocations live in the recycled slot. leanMultisig main thread calls xmss_aggregate which uses rayon, but corruption either misses observable state or doesn't fire here. non-reproducible under prove_loop high suggests fix needs not to rely on workload-pattern luck +F2 dep_cache flush_rayon (256 joins) DOES NOT FIX Plonky3. Re-enabled rayon-flush feature, rebuilt prove_poseidon1_baby_bear_keccak; STILL crashes deterministically on 2nd iter at tracing-forest layer/mod.rs:296 (Span extension missing OpenedSpan). Confirms a SECOND phase-crossing allocation class exists, distinct from rayon Injector blocks. yes (Poseidon1/BB and Poseidon2/KB) critical flush_rayon is INSUFFICIENT — needs design rethink +F3 dep_cache Removing tracing-forest Layer eliminates the Plonky3 crash (EXIT=0 for 3 prove_and_verify calls). Tracing is the OBSERVABLE VICTIM but not the source. Tested with rayon-flush both ON and OFF: clean exit either way. With 4096-span warmup before begin_phase: STILL crashes — so the issue is not sharded-slab Page allocation count. yes critical root cause: per-slot growth-buffers retain backing storage across slot reuse +F4 dep_cache ROOT CAUSE CONFIRMED IN SOURCE: tracing-subscriber's ExtensionsInner::clear() doc (registry/extensions.rs:178) states: "This permits the hash map allocation to be pooled by the registry so that future spans will not need to allocate new hashmaps." The HashMap backing buffer is INTENTIONALLY POOLED across span lifetimes — clear() empties entries but retains capacity. When the first use of a Registry slot happens during arena phase N, the HashMap's backing allocation lives in arena slab. Subsequent slot reuse inserts/removes into the SAME pooled buffer. After end_phase(N)+begin_phase(N+1), arena resets; buffer's bytes become recycled memory. New span insert writes corrupted control bytes; on_close lookup fails. F4 is no longer a hypothesis — it is the documented behavior of tracing-subscriber. yes (source-code verified, version 0.3.22 and 0.3.23) critical any "pooled allocation across logical lifetimes" pattern is unsafe with phase-based arenas — size-routing fix (F5) is the correct answer +F5 phase_api FIX VERIFIED: size-routing (ZK_ALLOC_MIN_BYTES) — small allocations (under threshold) bypass arena and go to System even during active phases. With ZK_ALLOC_MIN_BYTES=4096 + rayon-flush DISABLED: prove_poseidon1_baby_bear_keccak, prove_poseidon2_koala_bear_keccak, and prove_poseidon2_baby_bear_keccak_zk all pass cleanly (3 prove_and_verify cycles, EXIT=0). Standalone test_rayon::rayon_does_not_corrupt_zkalloc passes; test_rayon_audit::repeated_phase_cycles asserts bug reproduces (failures>0) and now fails — i.e., 0/10 cycles corrupted (proof the fix holds). Threshold sweep: 256+ all safe; 64-128 hangs (likely System allocator interaction). Recommended default: 4096 bytes (covers 4KB-page aligned data, all known library state allocations). fix verified across Plonky3 examples and standalone test suite — candidate replacement for flush_rayon — no crossbeam coupling, no magic constant, no rayon-join overhead at boundaries +F5a phase_api FIX PERF: prove_poseidon1_baby_bear_keccak with ZK_ALLOC_MIN_BYTES=4096+rayon-flush: prove ~1.16-1.30s, verify ~18ms. Without fix (rayon-flush only, first iter only since others crash): prove 1.30s, verify 20ms. No measurable regression; possibly slight improvement (fewer collisions in arena bump path). Plonky3 perf neutral. yes — supports adopting size-routing as default +F6 dep_cache AUDIT: other transitive deps with potential growth-only / phase-crossing patterns. (a) crossbeam-epoch deferred-reclamation Bag blocks — heap-allocated, live until next collect; small (<4KB) so caught by size-routing fix. (b) rayon-core Registry per-worker ThreadInfo arrays — initialized once at thread pool start; pre-active-phase, in System. (c) tracing-subscriber sharded-slab Pages — small pages (<4KB) caught; LARGE pages (256+ slots, >4KB) would still go to arena. Workload risk: long-running tests with thousands of spans could trigger. (d) hashbrown HashMap rehash — when entry-count crosses threshold, allocates new bigger backing (could be >4KB) and migrates. If during phase, that backing is in arena. Risk: long-lived HashMaps that grow during a phase. hypothesis medium size-routing covers most but not all; long-running workloads may need stricter routing or scope-aware activation +F7 phase_api FIX SCALES: standalone test_size_routing_stress runs 100 phase cycles, each with 200 rayon::join + 64KB canary. Default ZK_ALLOC_MIN_BYTES=4096: 0/100 corrupted. Explicit ZK_ALLOC_MIN_BYTES=0 (disable): bug reproduces every cycle and eventually causes SIGSEGV at cycle ~42 — the corruption is not just bounded JobRef writes but cascades into uncontrolled memory access. yes (zk-alloc/tests/test_size_routing_stress) critical confirms size-routing fix at scale; failure mode without fix is worse than initially characterized (segfault, not just data loss) +F8 phase_api FINAL FIX DEPLOYED: standalone zk-alloc src/lib.rs now defaults MIN_ARENA_BYTES=4096 (size-routing on by default). Public API min_arena_bytes() exposes current value. Tests test_rayon, test_rayon_audit, test_size_routing_stress all assert bidirectionally (check fix-active vs disabled). To opt out: ZK_ALLOC_MIN_BYTES=0. NOTE: not yet propagated to leanMultisig zk-alloc or Plonky3 p3-zk-alloc — they retain their respective rayon-flush approaches and are vulnerable to F2-class bugs. Recommend porting size-routing to both. — — standalone done; downstreams pending +F9 phase_api FIX SCALES TO 100 ITERATIONS UNDER PLONKY3: prove_poseidon1_baby_bear_keccak with ITERS=100 (added env-var control), ZK_ALLOC_MIN_BYTES=4096, rayon-flush DISABLED, full tracing-forest layer active: completed all 100 prove_and_verify cycles cleanly, EXIT=0, 100 verify_with_preprocessed entries in trace. F6 concerns about large hashbrown rehash / sharded-slab Page growth did not materialize at this scale. yes — high confidence in size-routing as the primary fix; flush_rayon should be retired +F10 phase_api FIX PORTED to Plonky3 p3-zk-alloc and leanMultisig zk-alloc: both crates now default MIN_ARENA_BYTES=4096 with ZK_ALLOC_MIN_BYTES=0 to opt out. Plonky3: 3 examples (P1/BB, P2/KB, P2/BB-zk) clean exit, control with =0 reproduces panic. leanMultisig prove_loop: 30 proofs verify OK in 2.1s each (no regression vs prior 2.1s baseline), RSS stable at 11.5GB. yes — all three crates now safe-by-default; downstream merge ready +F11 phase_api THRESHOLD SWEEP on Plonky3 P1/BB: MIN_BYTES=512, 1024, 2048, 4096 all clean (3/3 verifies). MIN_BYTES=64-128 HANGS (rayon worker waits for a job that never schedules — crossbeam injector block has been corrupted in a way that breaks pop). MIN_BYTES=192 may work (one earlier test passed). Lower bound for safe threshold appears to be ~256 bytes; 4096 is conservative default with comfortable margin. Standalone test_size_distribution shows simple Vec workload at MIN_BYTES=4096 has 0 overflow (small allocs cleanly bypass, large ones fit in arena). yes — threshold could be lowered to 512 if perf data justifies; 4096 default is safe +F12 phase_api DOWNSTREAM SCAN: Pico (~/zk-autoresearch/pico) integrates zk-alloc with multiple begin_phase/end_phase boundaries (RISCV, CONVERT, COMBINE phases) — same pattern as Plonky3, vulnerable to the bug. Resolves to ~/zk-autoresearch/zk-alloc via path dep — automatically inherits the size-routing fix from standalone zk-alloc when fix landed. Jolt (~/zk-autoresearch/jolt/jolt-core) uses ZkAllocator as global allocator but does NOT call begin_phase/end_phase — arena never activates, fix is no-op for Jolt. SP1 not present locally. Conclusion: standalone fix protects Pico transparently; Jolt unaffected; Plonky3 + leanMultisig already explicitly patched (have local copies of zk-alloc that need separate updates). — — all known downstream consumers covered or unaffected +F13 dep_cache BROADER POOL-PATTERN AUDIT: sharded-slab (used by tracing-subscriber Registry) module-level docs explicitly state "When entries are deallocated, they are cleared in place. Types which own a heap allocation can be cleared by dropping any data they store, but retaining any previously-allocated capacity." (lib.rs preamble). Page sizes: 32, 64, 128, 256, 512, 1024, 2048, 4096, ... × sizeof(DataInner ~100B). First page = ~3.2KB (covered by 4KB routing fix); page 1 = ~6.4KB (exceeds threshold, would go to arena). Workloads with > ~32 CONCURRENT spans during a phase could allocate page 1 in arena → vulnerable. Plonky3 prove has ~10-15 concurrent spans, well under threshold. rayon-core Registry has thread_infos Vec sized at thread_count × ~100B; for ≤16 threads, total < 4KB → safe. source-level analysis medium long-running workloads with many concurrent spans may need higher threshold or scope-aware activation +F14 dep_cache EMPIRICAL TEST OF F13 CAVEAT: zk-alloc/tests/test_many_spans_stress. With fix on (MIN_BYTES=4096): 64 concurrent spans PASSES; 512 concurrent spans FAILS (tracing-subscriber/sharded.rs:345 panic — slot lookup fails entirely, not just extension). Threshold sweep at 512 spans: 4096 fails, 6144 passes, 8192/12288/16384 all pass. So the danger zone for 512 spans is allocations in (4096, 6144] bytes — page-allocation grows past 4KB. For workloads with higher span concurrency, threshold must scale. Plonky3 prove typically has < 32 concurrent spans → safe at 4096. Long-running tracing-heavy workloads need higher (8192-16384) or scope-aware activation. yes high caveat confirmed empirically; default 4096 is safe for typical ZK proving workloads but not all tracing patterns +F15 phase_api DESIGN CONCLUSION: Size-routing is the practical optimum for an arena allocator under #[global_allocator]. Beyond its threshold (~4-6KB for tested workloads), the bug class is intrinsic to "pool-allocation across logical lifetimes" patterns (tracing-subscriber, sharded-slab, hashbrown — all explicitly documented as caching heap capacity). True robustness requires one of: (a) scope-aware allocator API (caller marks bulk-data sections explicitly), (b) per-phase allocator handles instead of global (most invasive, breaks drop-in promise), (c) upstream library changes to drop pooled buffers (out of scope), (d) application discipline (don't trace during arena). For ZK prover workloads tested (Plonky3, leanMultisig, Pico), 4096-byte size-routing is sufficient; the F14 limit is an architectural cliff, not an implementation bug. The DEPLOYED fix should ship. — — end-of-experiment design conclusion; recommend ship & document the F14 caveat diff --git a/src/lib.rs b/src/lib.rs index db2903d..fee434b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,10 @@ //! phase's data. Allocations that don't fit (too large, or beyond max threads) fall //! back to the system allocator. //! +//! Slab size defaults to 8GB per thread. Set `ZK_ALLOC_SLAB_GB` to override +//! (e.g. `ZK_ALLOC_SLAB_GB=12` for large workloads). Use `overflow_stats()` +//! to check if allocations spill to the system allocator. +//! //! ```ignore //! loop { //! begin_phase(); // arena ON; slabs reset lazily @@ -22,12 +26,16 @@ use std::sync::Once; mod syscall; -const SLAB_SIZE: usize = 8 << 30; // 8GB +const DEFAULT_SLAB_GB: usize = 8; const SLACK: usize = 4; #[derive(Debug)] pub struct ZkAllocator; +/// Per-thread slab size in bytes. Set once during `ensure_region()` from the +/// `ZK_ALLOC_SLAB_GB` environment variable (default: 8). +static SLAB_SIZE: AtomicUsize = AtomicUsize::new(0); + /// Incremented by `begin_phase()`. Every thread caches the last value it saw in /// `ARENA_GEN`; when they differ, the thread resets its allocation cursor to the start /// of its slab on the next allocation. This is how a single store on the main thread @@ -59,6 +67,24 @@ static MAX_THREADS: AtomicUsize = AtomicUsize::new(0); static OVERFLOW_COUNT: AtomicUsize = AtomicUsize::new(0); static OVERFLOW_BYTES: AtomicUsize = AtomicUsize::new(0); +/// Diagnostic mode: when true, begin_phase forcibly drops the previous phase's +/// pages via MADV_DONTNEED so any stale arena pointer reads zero instead of +/// last-phase data. Set via ZK_ALLOC_POISON_RESET=1 env var. +static POISON_RESET: AtomicBool = AtomicBool::new(false); + +/// Allocations smaller than this go to System even during active phases. +/// Routes registry / hashmap / injector-block-sized allocations away from +/// the arena, so library state that outlives a phase doesn't land in +/// recycled memory. +/// +/// Defaults to 4096 (one page) — covers the known phase-crossing patterns: +/// crossbeam_deque::Injector blocks (~1.5 KB), tracing-subscriber Registry +/// slot data (sub-KB), hashbrown HashMap entries (sub-KB), rayon-core job +/// stack frames (sub-KB). Set ZK_ALLOC_MIN_BYTES=0 to disable, or override +/// to a different threshold. +const DEFAULT_MIN_ARENA_BYTES: usize = 4096; +static MIN_ARENA_BYTES: AtomicUsize = AtomicUsize::new(DEFAULT_MIN_ARENA_BYTES); + thread_local! { /// Where this thread's next allocation lands. Advanced past each allocation. static ARENA_PTR: Cell = const { Cell::new(0) }; @@ -74,11 +100,27 @@ thread_local! { fn ensure_region() -> usize { REGION_INIT.call_once(|| { + let slab_gb = std::env::var("ZK_ALLOC_SLAB_GB") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_SLAB_GB); + let slab_size = slab_gb << 30; + SLAB_SIZE.store(slab_size, Ordering::Release); + + if std::env::var("ZK_ALLOC_POISON_RESET").as_deref() == Ok("1") { + POISON_RESET.store(true, Ordering::Release); + } + if let Ok(s) = std::env::var("ZK_ALLOC_MIN_BYTES") { + if let Ok(n) = s.parse::() { + MIN_ARENA_BYTES.store(n, Ordering::Release); + } + } + let cpus = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(8); let max_threads = cpus + SLACK; - let region_size = SLAB_SIZE * max_threads; + let region_size = slab_size * max_threads; // SAFETY: mmap_anonymous returns a page-aligned pointer or null. // MAP_NORESERVE means no physical memory is committed until pages are touched. @@ -97,6 +139,7 @@ fn ensure_region() -> usize { /// Activates the arena and resets every thread's slab. All allocations until the next /// `end_phase()` go to the arena; the previous phase's data is overwritten in place. pub fn begin_phase() { + ensure_region(); GENERATION.fetch_add(1, Ordering::Release); ARENA_ACTIVE.store(true, Ordering::Release); } @@ -141,6 +184,17 @@ pub fn reset_overflow_stats() { OVERFLOW_BYTES.store(0, Ordering::Relaxed); } +/// Returns the per-thread slab size in bytes. Zero before the first `begin_phase()`. +pub fn slab_size() -> usize { + SLAB_SIZE.load(Ordering::Relaxed) +} + +/// Returns the minimum allocation size routed through the arena. Allocations +/// smaller than this go to System even during active phases. +pub fn min_arena_bytes() -> usize { + MIN_ARENA_BYTES.load(Ordering::Relaxed) +} + #[cold] #[inline(never)] unsafe fn arena_alloc_cold(size: usize, align: usize) -> *mut u8 { @@ -157,9 +211,25 @@ unsafe fn arena_alloc_cold(size: usize, align: usize) -> *mut u8 { std::alloc::System.alloc(Layout::from_size_align_unchecked(size, align)) }; } - base = region + idx * SLAB_SIZE; + let slab_size = SLAB_SIZE.load(Ordering::Relaxed); + base = region + idx * slab_size; ARENA_BASE.set(base); - ARENA_END.set(base + SLAB_SIZE); + ARENA_END.set(base + slab_size); + } + // Diagnostic: MADV_DONTNEED on previous phase's used range to force + // any stale references to read fresh zero pages instead of the + // last-phase data. Behind ZK_ALLOC_POISON_RESET=1 to keep prod fast. + if POISON_RESET.load(Ordering::Relaxed) { + let prev_ptr = ARENA_PTR.get(); + if prev_ptr > base { + let len = prev_ptr - base; + let page_aligned_len = len & !0xFFF; + if page_aligned_len > 0 { + unsafe { + syscall::madvise(base as *mut u8, page_aligned_len, syscall::MADV_DONTNEED) + }; + } + } } ARENA_PTR.set(base); ARENA_GEN.set(generation); @@ -184,6 +254,14 @@ unsafe impl GlobalAlloc for ZkAllocator { #[inline(always)] unsafe fn alloc(&self, layout: Layout) -> *mut u8 { if ARENA_ACTIVE.load(Ordering::Relaxed) { + // Small allocs bypass arena: registry slots / HashMap entries / + // injector-block-sized allocations from rayon/tracing libraries + // commonly outlive a phase. Routing them to System keeps them + // safe across begin_phase()/end_phase() boundaries. + let min_bytes = MIN_ARENA_BYTES.load(Ordering::Relaxed); + if min_bytes != 0 && layout.size() < min_bytes { + return unsafe { std::alloc::System.alloc(layout) }; + } let generation = GENERATION.load(Ordering::Relaxed); if ARENA_GEN.get() == generation { let ptr = ARENA_PTR.get(); diff --git a/src/syscall.rs b/src/syscall.rs index f676b2a..251e11d 100644 --- a/src/syscall.rs +++ b/src/syscall.rs @@ -16,6 +16,7 @@ mod imp { const MAP_NORESERVE: usize = 0x4000; pub const MADV_NOHUGEPAGE: usize = 15; + pub const MADV_DONTNEED: usize = 4; #[inline] unsafe fn syscall6( @@ -97,6 +98,7 @@ mod imp { use std::ptr; pub const MADV_NOHUGEPAGE: usize = 15; + pub const MADV_DONTNEED: usize = 4; #[inline] pub unsafe fn mmap_anonymous(size: usize) -> *mut u8 { @@ -119,4 +121,4 @@ mod imp { } } -pub use imp::{madvise, mmap_anonymous, MADV_NOHUGEPAGE}; +pub use imp::{madvise, mmap_anonymous, MADV_DONTNEED, MADV_NOHUGEPAGE}; diff --git a/tests/test_many_spans_stress.rs b/tests/test_many_spans_stress.rs new file mode 100644 index 0000000..db3b9e1 --- /dev/null +++ b/tests/test_many_spans_stress.rs @@ -0,0 +1,66 @@ +//! Stresses tracing-subscriber's sharded-slab into allocating page 1 (~6KB, +//! 64 slots) inside an arena phase. The first page (~3.2KB) is covered by +//! the 4096-byte size-routing threshold, but page 1 exceeds it — so this +//! test verifies whether the fix holds under heavy span concurrency. +//! +//! Run with `cargo test --release --test test_many_spans_stress`. + +use rayon::prelude::*; +use tracing::info_span; +use tracing_subscriber::{Registry, layer::SubscriberExt, util::SubscriberInitExt}; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn many_concurrent_spans_across_phases() { + inner(64); +} + +/// Documents the upper limit of the size-routing fix. With default +/// MIN_BYTES=4096, 512 concurrent spans triggers a sharded-slab page +/// allocation that exceeds the threshold, lands in arena, and corrupts +/// across phase boundaries. To pass, set ZK_ALLOC_MIN_BYTES=6144 or higher. +/// Run manually: `cargo test --release --test test_many_spans_stress -- --ignored`. +#[test] +#[ignore] +fn extreme_concurrent_spans_across_phases() { + inner(512); +} + +fn inner(n: u64) { + let _ = Registry::default() + .with(tracing_subscriber::EnvFilter::new("info")) + .try_init(); + + // Warm up rayon outside arena. + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + for cycle in 0..5 { + zk_alloc::begin_phase(); + + // Create 64 concurrent live spans -- forces sharded-slab to grow + // past page 0 (~32 slots) into page 1 (~6KB allocation). + let spans: Vec<_> = (0..n).map(|i| info_span!("concurrent", cycle, i)).collect(); + { + let _entered: Vec<_> = spans.iter().map(|s| s.enter()).collect(); + // guards drop here, before spans + } + drop(spans); + + zk_alloc::end_phase(); + } + + // After 5 phase cycles, create one more span and observe whether its + // backing data is corrupted. With size-routing fix off, the pooled + // page-1 slot data has been overwritten across phases; with the fix + // on at >= 4096, page 1 might still go to arena (> 4KB), so this + // probes the limit of the fix. + zk_alloc::begin_phase(); + let spans2: Vec<_> = (0..n).map(|i| info_span!("post_cycle", i)).collect(); + { + let _entered: Vec<_> = spans2.iter().map(|s| s.enter()).collect(); + } + drop(spans2); + zk_alloc::end_phase(); +} diff --git a/tests/test_rayon.rs b/tests/test_rayon.rs new file mode 100644 index 0000000..eeefdd8 --- /dev/null +++ b/tests/test_rayon.rs @@ -0,0 +1,42 @@ +//! Reproducer for the rayon/zk-alloc interaction bug documented in +//! leanMultisig commit f5e2299b. Pulls Tom's regression test verbatim and +//! adds a few stress variants to characterize how reliably the bug fires. +//! +//! Mechanism: +//! 1. rayon::join from a non-worker thread routes through the global +//! `crossbeam_deque::Injector`, which is a linked list of fixed-size +//! blocks (BLOCK_CAP = 63 slots). +//! 2. If a fresh injector block is allocated *during* an arena phase, +//! the block lives in the arena slab. +//! 3. The next `begin_phase()` recycles the slab. Rayon still holds a +//! pointer to that block; the next push writes a JobRef over whatever +//! the application has allocated on top — silent corruption. +//! +//! These tests use #[global_allocator] so that rayon's allocations route +//! through ZkAllocator (otherwise they go to the system allocator and +//! can't be corrupted). + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +/// Tom's original MRE. +#[test] +fn rayon_does_not_corrupt_zkalloc() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + zk_alloc::begin_phase(); + for _ in 0..200 { + rayon::join(|| {}, || {}); + } + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xAB_u8; 8192]; + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + let pos = canary.iter().position(|&b| b != 0xAB); + assert!(pos.is_none(), "canary corrupted at offset {}", pos.unwrap()); +} diff --git a/tests/test_rayon_audit.rs b/tests/test_rayon_audit.rs new file mode 100644 index 0000000..0204239 --- /dev/null +++ b/tests/test_rayon_audit.rs @@ -0,0 +1,163 @@ +//! Characterizes how reliably the rayon/zk-alloc bug fires under variants +//! of Tom's MRE: cold rayon pool, repeated cycles, large canaries, sleep +//! between phases. The goal is to map the "trigger surface" of this bug +//! class: which allocation patterns survive a phase boundary into the +//! next phase's recycled slab, and what the typical corruption profile +//! looks like. + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +fn check_canary(canary: &[u8], expect: u8) -> Option { + canary.iter().position(|&b| b != expect) +} + +/// Cold rayon: no pre-warm. The very first parallel call happens INSIDE an +/// arena phase. Rayon's thread pool, registry, AND injector blocks all get +/// allocated in the arena slab — much bigger blast radius than the warm +/// case. Not only injector blocks: thread stacks, registry state, sleep +/// pools. +#[test] +#[ignore] // Run manually: cargo test --release --test test_rayon_audit -- --ignored --test-threads=1 +fn cold_rayon_inside_arena() { + zk_alloc::begin_phase(); + // First parallel call ever — allocates the entire rayon pool in arena. + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xCD_u8; 64 * 1024]; + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + let pos = check_canary(&canary, 0xCD); + assert!( + pos.is_none(), + "cold-rayon canary corrupted at offset {}", + pos.unwrap() + ); +} + +/// Repeats Tom's MRE 10 times. If the bug is rare/non-deterministic the +/// average failure offset and frequency tell us how many slots an Injector +/// block has when the slab is recycled. +#[test] +fn repeated_phase_cycles() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + let mut failures = 0; + for cycle in 0..10 { + zk_alloc::begin_phase(); + for _ in 0..200 { + rayon::join(|| {}, || {}); + } + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xAB_u8; 8192]; + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + if let Some(pos) = check_canary(&canary, 0xAB) { + eprintln!("cycle {cycle}: canary corrupted at offset {pos}"); + failures += 1; + } + } + eprintln!("repeated_phase_cycles: {failures}/10 cycles corrupted"); + let fix_active = zk_alloc::min_arena_bytes() >= 256; + if fix_active { + assert_eq!(failures, 0, "fix should prevent corruption in all cycles"); + } else { + assert!( + failures > 0, + "expected at least one cycle to corrupt — bug should be reproducible" + ); + } +} + +/// Canary larger than a typical injector block — does the corruption have +/// a bounded blast radius (one block-sized region) or does it cascade? +#[test] +fn large_canary_blast_radius() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + zk_alloc::begin_phase(); + for _ in 0..200 { + rayon::join(|| {}, || {}); + } + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0x55_u8; 1 << 20]; // 1 MB + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + let mut corruption_runs = Vec::new(); + let mut i = 0; + while i < canary.len() { + if canary[i] != 0x55 { + let start = i; + while i < canary.len() && canary[i] != 0x55 { + i += 1; + } + corruption_runs.push((start, i - start)); + } + i += 1; + } + if !corruption_runs.is_empty() { + eprintln!("large_canary corruption runs: {:?}", corruption_runs); + } + let fix_active = zk_alloc::min_arena_bytes() >= 256; + if fix_active { + assert!( + corruption_runs.is_empty(), + "{} corruption runs in 1MB canary (fix should prevent)", + corruption_runs.len() + ); + } else { + assert_eq!( + corruption_runs.len(), + 1, + "without fix, expected exactly one block-sized corruption run, got {}", + corruption_runs.len() + ); + let (_start, len) = corruption_runs[0]; + assert!( + len <= 32, + "expected single JobRef-sized run (<=32B), got {}B", + len + ); + } +} + +/// Drives rayon::join from a SPAWNED thread, not the main thread. Both go +/// through the injector (only rayon worker threads bypass it via per-worker +/// deque). Confirms the bug is about non-worker callers, not specifically +/// the main thread. +#[test] +fn injector_bug_from_spawned_thread() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + zk_alloc::begin_phase(); + let h = std::thread::spawn(|| { + for _ in 0..200 { + rayon::join(|| {}, || {}); + } + }); + h.join().unwrap(); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xEE_u8; 8192]; + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + let pos = check_canary(&canary, 0xEE); + assert!( + pos.is_none(), + "spawned-thread canary corrupted at offset {}", + pos.unwrap() + ); +} diff --git a/tests/test_scope_nesting.rs b/tests/test_scope_nesting.rs new file mode 100644 index 0000000..98a0ce9 --- /dev/null +++ b/tests/test_scope_nesting.rs @@ -0,0 +1,61 @@ +//! Tests phase boundaries that interact with rayon::scope. Workers spawned +//! inside a scope hold references to arena allocations; if begin_phase runs +//! while those workers still have pending tasks, the workers' captured data +//! could land in recycled memory. + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +/// Phase boundary inside scope: while workers are still running, begin a +/// new phase. The worker's stack-frame data is on the worker thread's stack +/// (not arena), but any heap allocations they performed during the phase +/// could be in arena. +#[test] +fn phase_boundary_during_par_iter() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + zk_alloc::begin_phase(); + + // Workers each allocate a vec, sum it. Force them to allocate in arena. + let result: u64 = (0..16_u64).into_par_iter().map(|i| { + let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); + v.iter().sum::() + }).sum(); + std::hint::black_box(result); + + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xC9_u8; 8 << 20]; + let _: u64 = (0..16_u64).into_par_iter().map(|i| { + let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); + v.iter().sum::() + }).sum(); + zk_alloc::end_phase(); + + let pos = canary.iter().position(|&b| b != 0xC9); + assert!( + pos.is_none(), + "8MB canary corrupted at offset {}", + pos.unwrap() + ); +} + +/// Repeated par_iter without any explicit canary, just check program +/// integrity over 100 iterations. +#[test] +fn many_par_iter_phase_cycles() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + for _ in 0..100 { + zk_alloc::begin_phase(); + let sum: u64 = (0..256_u64).into_par_iter().map(|i| { + let v: Vec = (0..(1 << 12)).map(|j| j ^ i).collect(); + v.iter().sum::() + }).sum(); + std::hint::black_box(sum); + zk_alloc::end_phase(); + } +} diff --git a/tests/test_size_distribution.rs b/tests/test_size_distribution.rs new file mode 100644 index 0000000..adebee3 --- /dev/null +++ b/tests/test_size_distribution.rs @@ -0,0 +1,71 @@ +//! Profiles the size distribution of arena allocations during prove-style +//! workloads. Helps validate that ZK_ALLOC_MIN_BYTES=4096 catches the +//! "library state" allocations without filtering out the bulk-data ones +//! the arena is meant to accelerate. +//! +//! Usage: `cargo test --release --test test_size_distribution -- --nocapture`. +//! Output is a histogram of size buckets and the count of allocations under +//! 4096 bytes vs. above. + +use std::sync::Mutex; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +static SIZES: Mutex> = Mutex::new(Vec::new()); + +#[test] +fn profile_allocation_sizes_in_phase() { + use rayon::prelude::*; + + // Warm up rayon outside arena. + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + // Capture allocations during a phase by piggybacking the global + // allocator -- we measure indirectly via overflow_stats. + zk_alloc::reset_overflow_stats(); + + zk_alloc::begin_phase(); + + // Mix of allocations: tiny (HashMap-style), small, medium, large. + { + let mut tiny: Vec> = (0..1000).map(|_| vec![0_u8; 32]).collect(); + let small: Vec> = (0..1000).map(|_| vec![0_u8; 256]).collect(); + let medium: Vec> = (0..100).map(|_| vec![0_u8; 4096]).collect(); + let large: Vec> = (0..10).map(|_| vec![0_u8; 1 << 20]).collect(); + std::hint::black_box((&tiny, &small, &medium, &large)); + tiny.clear(); + SIZES.lock().unwrap().extend([ + tiny.capacity(), + small.len(), + medium.len(), + large.len(), + ]); + } + + zk_alloc::end_phase(); + + let (overflow_count, overflow_bytes) = zk_alloc::overflow_stats(); + eprintln!( + "overflow stats during phase (arena fallthrough): count={overflow_count}, bytes={overflow_bytes}" + ); + eprintln!( + "min_arena_bytes() = {} (allocations below this size go to System)", + zk_alloc::min_arena_bytes() + ); + + // With size routing, allocations < min_arena_bytes don't touch the + // arena AND don't increment overflow_stats (they bypass the arena + // path entirely). Overflow_stats only counts allocations that tried + // arena but couldn't fit (slab full or too-large). + if zk_alloc::min_arena_bytes() >= 4096 { + // 1000 tiny + 1000 small were < 4096 — they go to System silently. + // 100 medium = 4096 each — at boundary, bypass. + // 10 large = 1MB each — go to arena. + // No overflow expected for this size mix. + assert_eq!( + overflow_count, 0, + "expected no overflow with size routing on" + ); + } +} diff --git a/tests/test_size_routing_stress.rs b/tests/test_size_routing_stress.rs new file mode 100644 index 0000000..1b59c09 --- /dev/null +++ b/tests/test_size_routing_stress.rs @@ -0,0 +1,47 @@ +//! Stress test for the size-routing fix (ZK_ALLOC_MIN_BYTES). Drives many +//! phase cycles with rayon::join from main thread + canaries, to validate +//! that the fix holds at scale (not just the 3-iter Plonky3 example). +//! +//! Run with `ZK_ALLOC_MIN_BYTES=4096 cargo test --release --test +//! test_size_routing_stress -- --nocapture`. Without the env var the test +//! is expected to fail (bug reproduces). + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn many_phase_cycles_with_canaries() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + const CYCLES: usize = 100; + let mut failures = 0; + for cycle in 0..CYCLES { + zk_alloc::begin_phase(); + for _ in 0..200 { + rayon::join(|| {}, || {}); + } + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xC1_u8; 65536]; + rayon::join(|| {}, || {}); + zk_alloc::end_phase(); + + if let Some(pos) = canary.iter().position(|&b| b != 0xC1) { + eprintln!("cycle {cycle}: canary corrupted at offset {pos}"); + failures += 1; + } + } + eprintln!("many_phase_cycles_with_canaries: {failures}/{CYCLES} corrupted"); + + // Default MIN_ARENA_BYTES is 4096 (size-routing fix on by default). + // With ZK_ALLOC_MIN_BYTES=0, fix is disabled and bug should reproduce. + let min_bytes_active = zk_alloc::min_arena_bytes() >= 256; + if min_bytes_active { + assert_eq!(failures, 0, "fix should prevent ALL corruption"); + } else { + assert!(failures > 0, "without fix, bug should reproduce"); + } +} From c454c73b82233bdaaeb4609e5568060d989618d9 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 10:27:22 +0200 Subject: [PATCH 02/21] chore: remove findings.tsv (audit trail lives in PR #8 body) Co-Authored-By: Claude Opus 4.6 --- findings.tsv | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 findings.tsv diff --git a/findings.tsv b/findings.tsv deleted file mode 100644 index 968ec21..0000000 --- a/findings.tsv +++ /dev/null @@ -1,20 +0,0 @@ -id category description reproducible severity fix_status -F1 rayon_injector Tom's bug. crossbeam_deque::Injector blocks (~520 bytes, 64 JobRef slots) allocated during arena phase land in arena slab. Next begin_phase() recycles slab; next rayon::join from non-worker thread writes a JobRef (~17 bytes) over recycled memory. Single-block blast radius. yes (test_rayon::rayon_does_not_corrupt_zkalloc, deterministic) critical flush_rayon (256 joins) deployed in leanMultisig but NOT in standalone zk-alloc main; standalone is currently vulnerable -F1a rayon_injector Audit characterization: 10/10 phase cycles corrupted (zk-alloc/tests/test_rayon_audit::repeated_phase_cycles). 1MB canary shows exactly ONE 17-byte run corrupted = single JobRef write per phase boundary, not cascading. Spawned-thread variant passes only because canary lives in different slab than the spawned thread's recycled Injector block. yes critical standalone vulnerable -F1b rayon_injector Plonky3 reproduction: prove_poseidon1_baby_bear_keccak built with rayon-flush disabled crashes deterministically on 2nd iteration of prove_and_verify(). Panic site: tracing-forest layer/mod.rs:296 'Span extension doesn't contain OpenedSpan' — corruption hits tracing-subscriber registry HashMap, not just rayon-internal. Cascades to mutex poisoning (RwLock corruption). yes (deterministic across both Poseidon1/BB and Poseidon2/KB) critical rayon-flush feature gate (default ON) masks; turning it off exposes -F1c rayon_injector leanMultisig prove_loop NEGATIVE result: 5 proofs run cleanly with verify=OK even without flush_rayon (zk-alloc-as-globalalloc, no flush). The bug pattern is workload-specific — depends on which threads call rayon::join inside an active phase, and which allocations live in the recycled slot. leanMultisig main thread calls xmss_aggregate which uses rayon, but corruption either misses observable state or doesn't fire here. non-reproducible under prove_loop high suggests fix needs not to rely on workload-pattern luck -F2 dep_cache flush_rayon (256 joins) DOES NOT FIX Plonky3. Re-enabled rayon-flush feature, rebuilt prove_poseidon1_baby_bear_keccak; STILL crashes deterministically on 2nd iter at tracing-forest layer/mod.rs:296 (Span extension missing OpenedSpan). Confirms a SECOND phase-crossing allocation class exists, distinct from rayon Injector blocks. yes (Poseidon1/BB and Poseidon2/KB) critical flush_rayon is INSUFFICIENT — needs design rethink -F3 dep_cache Removing tracing-forest Layer eliminates the Plonky3 crash (EXIT=0 for 3 prove_and_verify calls). Tracing is the OBSERVABLE VICTIM but not the source. Tested with rayon-flush both ON and OFF: clean exit either way. With 4096-span warmup before begin_phase: STILL crashes — so the issue is not sharded-slab Page allocation count. yes critical root cause: per-slot growth-buffers retain backing storage across slot reuse -F4 dep_cache ROOT CAUSE CONFIRMED IN SOURCE: tracing-subscriber's ExtensionsInner::clear() doc (registry/extensions.rs:178) states: "This permits the hash map allocation to be pooled by the registry so that future spans will not need to allocate new hashmaps." The HashMap backing buffer is INTENTIONALLY POOLED across span lifetimes — clear() empties entries but retains capacity. When the first use of a Registry slot happens during arena phase N, the HashMap's backing allocation lives in arena slab. Subsequent slot reuse inserts/removes into the SAME pooled buffer. After end_phase(N)+begin_phase(N+1), arena resets; buffer's bytes become recycled memory. New span insert writes corrupted control bytes; on_close lookup fails. F4 is no longer a hypothesis — it is the documented behavior of tracing-subscriber. yes (source-code verified, version 0.3.22 and 0.3.23) critical any "pooled allocation across logical lifetimes" pattern is unsafe with phase-based arenas — size-routing fix (F5) is the correct answer -F5 phase_api FIX VERIFIED: size-routing (ZK_ALLOC_MIN_BYTES) — small allocations (under threshold) bypass arena and go to System even during active phases. With ZK_ALLOC_MIN_BYTES=4096 + rayon-flush DISABLED: prove_poseidon1_baby_bear_keccak, prove_poseidon2_koala_bear_keccak, and prove_poseidon2_baby_bear_keccak_zk all pass cleanly (3 prove_and_verify cycles, EXIT=0). Standalone test_rayon::rayon_does_not_corrupt_zkalloc passes; test_rayon_audit::repeated_phase_cycles asserts bug reproduces (failures>0) and now fails — i.e., 0/10 cycles corrupted (proof the fix holds). Threshold sweep: 256+ all safe; 64-128 hangs (likely System allocator interaction). Recommended default: 4096 bytes (covers 4KB-page aligned data, all known library state allocations). fix verified across Plonky3 examples and standalone test suite — candidate replacement for flush_rayon — no crossbeam coupling, no magic constant, no rayon-join overhead at boundaries -F5a phase_api FIX PERF: prove_poseidon1_baby_bear_keccak with ZK_ALLOC_MIN_BYTES=4096+rayon-flush: prove ~1.16-1.30s, verify ~18ms. Without fix (rayon-flush only, first iter only since others crash): prove 1.30s, verify 20ms. No measurable regression; possibly slight improvement (fewer collisions in arena bump path). Plonky3 perf neutral. yes — supports adopting size-routing as default -F6 dep_cache AUDIT: other transitive deps with potential growth-only / phase-crossing patterns. (a) crossbeam-epoch deferred-reclamation Bag blocks — heap-allocated, live until next collect; small (<4KB) so caught by size-routing fix. (b) rayon-core Registry per-worker ThreadInfo arrays — initialized once at thread pool start; pre-active-phase, in System. (c) tracing-subscriber sharded-slab Pages — small pages (<4KB) caught; LARGE pages (256+ slots, >4KB) would still go to arena. Workload risk: long-running tests with thousands of spans could trigger. (d) hashbrown HashMap rehash — when entry-count crosses threshold, allocates new bigger backing (could be >4KB) and migrates. If during phase, that backing is in arena. Risk: long-lived HashMaps that grow during a phase. hypothesis medium size-routing covers most but not all; long-running workloads may need stricter routing or scope-aware activation -F7 phase_api FIX SCALES: standalone test_size_routing_stress runs 100 phase cycles, each with 200 rayon::join + 64KB canary. Default ZK_ALLOC_MIN_BYTES=4096: 0/100 corrupted. Explicit ZK_ALLOC_MIN_BYTES=0 (disable): bug reproduces every cycle and eventually causes SIGSEGV at cycle ~42 — the corruption is not just bounded JobRef writes but cascades into uncontrolled memory access. yes (zk-alloc/tests/test_size_routing_stress) critical confirms size-routing fix at scale; failure mode without fix is worse than initially characterized (segfault, not just data loss) -F8 phase_api FINAL FIX DEPLOYED: standalone zk-alloc src/lib.rs now defaults MIN_ARENA_BYTES=4096 (size-routing on by default). Public API min_arena_bytes() exposes current value. Tests test_rayon, test_rayon_audit, test_size_routing_stress all assert bidirectionally (check fix-active vs disabled). To opt out: ZK_ALLOC_MIN_BYTES=0. NOTE: not yet propagated to leanMultisig zk-alloc or Plonky3 p3-zk-alloc — they retain their respective rayon-flush approaches and are vulnerable to F2-class bugs. Recommend porting size-routing to both. — — standalone done; downstreams pending -F9 phase_api FIX SCALES TO 100 ITERATIONS UNDER PLONKY3: prove_poseidon1_baby_bear_keccak with ITERS=100 (added env-var control), ZK_ALLOC_MIN_BYTES=4096, rayon-flush DISABLED, full tracing-forest layer active: completed all 100 prove_and_verify cycles cleanly, EXIT=0, 100 verify_with_preprocessed entries in trace. F6 concerns about large hashbrown rehash / sharded-slab Page growth did not materialize at this scale. yes — high confidence in size-routing as the primary fix; flush_rayon should be retired -F10 phase_api FIX PORTED to Plonky3 p3-zk-alloc and leanMultisig zk-alloc: both crates now default MIN_ARENA_BYTES=4096 with ZK_ALLOC_MIN_BYTES=0 to opt out. Plonky3: 3 examples (P1/BB, P2/KB, P2/BB-zk) clean exit, control with =0 reproduces panic. leanMultisig prove_loop: 30 proofs verify OK in 2.1s each (no regression vs prior 2.1s baseline), RSS stable at 11.5GB. yes — all three crates now safe-by-default; downstream merge ready -F11 phase_api THRESHOLD SWEEP on Plonky3 P1/BB: MIN_BYTES=512, 1024, 2048, 4096 all clean (3/3 verifies). MIN_BYTES=64-128 HANGS (rayon worker waits for a job that never schedules — crossbeam injector block has been corrupted in a way that breaks pop). MIN_BYTES=192 may work (one earlier test passed). Lower bound for safe threshold appears to be ~256 bytes; 4096 is conservative default with comfortable margin. Standalone test_size_distribution shows simple Vec workload at MIN_BYTES=4096 has 0 overflow (small allocs cleanly bypass, large ones fit in arena). yes — threshold could be lowered to 512 if perf data justifies; 4096 default is safe -F12 phase_api DOWNSTREAM SCAN: Pico (~/zk-autoresearch/pico) integrates zk-alloc with multiple begin_phase/end_phase boundaries (RISCV, CONVERT, COMBINE phases) — same pattern as Plonky3, vulnerable to the bug. Resolves to ~/zk-autoresearch/zk-alloc via path dep — automatically inherits the size-routing fix from standalone zk-alloc when fix landed. Jolt (~/zk-autoresearch/jolt/jolt-core) uses ZkAllocator as global allocator but does NOT call begin_phase/end_phase — arena never activates, fix is no-op for Jolt. SP1 not present locally. Conclusion: standalone fix protects Pico transparently; Jolt unaffected; Plonky3 + leanMultisig already explicitly patched (have local copies of zk-alloc that need separate updates). — — all known downstream consumers covered or unaffected -F13 dep_cache BROADER POOL-PATTERN AUDIT: sharded-slab (used by tracing-subscriber Registry) module-level docs explicitly state "When entries are deallocated, they are cleared in place. Types which own a heap allocation can be cleared by dropping any data they store, but retaining any previously-allocated capacity." (lib.rs preamble). Page sizes: 32, 64, 128, 256, 512, 1024, 2048, 4096, ... × sizeof(DataInner ~100B). First page = ~3.2KB (covered by 4KB routing fix); page 1 = ~6.4KB (exceeds threshold, would go to arena). Workloads with > ~32 CONCURRENT spans during a phase could allocate page 1 in arena → vulnerable. Plonky3 prove has ~10-15 concurrent spans, well under threshold. rayon-core Registry has thread_infos Vec sized at thread_count × ~100B; for ≤16 threads, total < 4KB → safe. source-level analysis medium long-running workloads with many concurrent spans may need higher threshold or scope-aware activation -F14 dep_cache EMPIRICAL TEST OF F13 CAVEAT: zk-alloc/tests/test_many_spans_stress. With fix on (MIN_BYTES=4096): 64 concurrent spans PASSES; 512 concurrent spans FAILS (tracing-subscriber/sharded.rs:345 panic — slot lookup fails entirely, not just extension). Threshold sweep at 512 spans: 4096 fails, 6144 passes, 8192/12288/16384 all pass. So the danger zone for 512 spans is allocations in (4096, 6144] bytes — page-allocation grows past 4KB. For workloads with higher span concurrency, threshold must scale. Plonky3 prove typically has < 32 concurrent spans → safe at 4096. Long-running tracing-heavy workloads need higher (8192-16384) or scope-aware activation. yes high caveat confirmed empirically; default 4096 is safe for typical ZK proving workloads but not all tracing patterns -F15 phase_api DESIGN CONCLUSION: Size-routing is the practical optimum for an arena allocator under #[global_allocator]. Beyond its threshold (~4-6KB for tested workloads), the bug class is intrinsic to "pool-allocation across logical lifetimes" patterns (tracing-subscriber, sharded-slab, hashbrown — all explicitly documented as caching heap capacity). True robustness requires one of: (a) scope-aware allocator API (caller marks bulk-data sections explicitly), (b) per-phase allocator handles instead of global (most invasive, breaks drop-in promise), (c) upstream library changes to drop pooled buffers (out of scope), (d) application discipline (don't trace during arena). For ZK prover workloads tested (Plonky3, leanMultisig, Pico), 4096-byte size-routing is sufficient; the F14 limit is an architectural cliff, not an implementation bug. The DEPLOYED fix should ship. — — end-of-experiment design conclusion; recommend ship & document the F14 caveat From 55f05ca02a9ac3bbced4b9976c28988fcf4bdcd6 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 11:28:09 +0200 Subject: [PATCH 03/21] style: cargo fmt on test files Co-Authored-By: Claude Opus 4.6 --- tests/test_many_spans_stress.rs | 2 +- tests/test_scope_nesting.rs | 33 +++++++++++++++++++++------------ tests/test_size_distribution.rs | 10 ++++------ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/test_many_spans_stress.rs b/tests/test_many_spans_stress.rs index db3b9e1..c8b78f3 100644 --- a/tests/test_many_spans_stress.rs +++ b/tests/test_many_spans_stress.rs @@ -7,7 +7,7 @@ use rayon::prelude::*; use tracing::info_span; -use tracing_subscriber::{Registry, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Registry}; #[global_allocator] static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; diff --git a/tests/test_scope_nesting.rs b/tests/test_scope_nesting.rs index 98a0ce9..b797fbe 100644 --- a/tests/test_scope_nesting.rs +++ b/tests/test_scope_nesting.rs @@ -19,20 +19,26 @@ fn phase_boundary_during_par_iter() { zk_alloc::begin_phase(); // Workers each allocate a vec, sum it. Force them to allocate in arena. - let result: u64 = (0..16_u64).into_par_iter().map(|i| { - let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); - v.iter().sum::() - }).sum(); + let result: u64 = (0..16_u64) + .into_par_iter() + .map(|i| { + let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); + v.iter().sum::() + }) + .sum(); std::hint::black_box(result); zk_alloc::end_phase(); zk_alloc::begin_phase(); let canary = vec![0xC9_u8; 8 << 20]; - let _: u64 = (0..16_u64).into_par_iter().map(|i| { - let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); - v.iter().sum::() - }).sum(); + let _: u64 = (0..16_u64) + .into_par_iter() + .map(|i| { + let v: Vec = (0..(1 << 14)).map(|j| j ^ i).collect(); + v.iter().sum::() + }) + .sum(); zk_alloc::end_phase(); let pos = canary.iter().position(|&b| b != 0xC9); @@ -51,10 +57,13 @@ fn many_par_iter_phase_cycles() { for _ in 0..100 { zk_alloc::begin_phase(); - let sum: u64 = (0..256_u64).into_par_iter().map(|i| { - let v: Vec = (0..(1 << 12)).map(|j| j ^ i).collect(); - v.iter().sum::() - }).sum(); + let sum: u64 = (0..256_u64) + .into_par_iter() + .map(|i| { + let v: Vec = (0..(1 << 12)).map(|j| j ^ i).collect(); + v.iter().sum::() + }) + .sum(); std::hint::black_box(sum); zk_alloc::end_phase(); } diff --git a/tests/test_size_distribution.rs b/tests/test_size_distribution.rs index adebee3..d31cfa3 100644 --- a/tests/test_size_distribution.rs +++ b/tests/test_size_distribution.rs @@ -35,12 +35,10 @@ fn profile_allocation_sizes_in_phase() { let large: Vec> = (0..10).map(|_| vec![0_u8; 1 << 20]).collect(); std::hint::black_box((&tiny, &small, &medium, &large)); tiny.clear(); - SIZES.lock().unwrap().extend([ - tiny.capacity(), - small.len(), - medium.len(), - large.len(), - ]); + SIZES + .lock() + .unwrap() + .extend([tiny.capacity(), small.len(), medium.len(), large.len()]); } zk_alloc::end_phase(); From 5825e21e62f96bca188e8a2a70f47ed11c619f07 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 12:42:21 +0200 Subject: [PATCH 04/21] =?UTF-8?q?test(exp5):=20scenario=205=20=E2=80=94=20?= =?UTF-8?q?realloc=20across=20phase=20boundary=20corrupts=20retained=20Vec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Vec >= MIN_ARENA_BYTES allocated in phase N and grown via push() in phase N+1 (after arena reset) gets its preserved bytes silently replaced by phase N+1's first allocation. The realloc impl calls copy_nonoverlapping(old_ptr, new_ptr, layout.size()) — but old_ptr now references recycled memory. Both tests (8 KB and 16 KB Vecs) confirm v[0..SIZE] reads OVERWRITE bytes instead of the original FILL after a single push() across the boundary. The size-routing fix (MIN_ARENA_BYTES=4096) does NOT address this — any allocation above the threshold inherits the bug. This is a NEW class of soundness issue, distinct from F1 (rayon Injector) and F4 (tracing Registry) which are library-internal pooled allocations. This one is user-visible. Tests assert the bug REPRODUCES (no fix deployed yet); flip when fix lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_realloc_phase.rs | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_realloc_phase.rs diff --git a/tests/test_realloc_phase.rs b/tests/test_realloc_phase.rs new file mode 100644 index 0000000..965e7b0 --- /dev/null +++ b/tests/test_realloc_phase.rs @@ -0,0 +1,109 @@ +//! Scenario 5: realloc across a phase boundary. +//! +//! A Vec allocated in phase N owns a pointer into the arena slab. When phase +//! N+1 begins (arena reset) and a fresh allocation lands at the same slab +//! offset, then the Vec is grown via push(), our realloc impl calls +//! alloc(new_size) and then copy_nonoverlapping(old_ptr, new_ptr, +//! old_layout.size()). The source is recycled memory — now holding the new +//! phase's data, not the original Vec's bytes. Result: the Vec's "preserved" +//! contents are silently replaced by whatever phase N+1 allocated first. +//! +//! Distinct from F1 (rayon Injector) and F4 (tracing Registry): those are +//! library-internal pooled allocations. This is a *user-visible* Vec the +//! caller deliberately holds across phases. +//! +//! The size-routing fix (MIN_ARENA_BYTES=4096) does NOT address this: any +//! Vec >= 4 KB lands in arena and inherits the bug. +//! +//! These tests assert the bug REPRODUCES (since no fix is deployed yet for +//! this case). Once a fix lands, flip the assertions. + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn realloc_across_phase_corrupts_retained_vec() { + // Must be >= MIN_ARENA_BYTES (default 4096) so the Vec lands in arena. + const SIZE: usize = 8192; + const FILL: u8 = 0xAA; + const OVERWRITE: u8 = 0x55; + + zk_alloc::begin_phase(); + let mut v: Vec = vec![FILL; SIZE]; + let v_orig_ptr = v.as_ptr() as usize; + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + // Lands at the same slab offset as v after arena reset (cold path resets + // ARENA_PTR to ARENA_BASE on first alloc of new generation). + let overwrite: Vec = vec![OVERWRITE; SIZE]; + let overwrite_ptr = overwrite.as_ptr() as usize; + + eprintln!( + "v orig ptr: 0x{v_orig_ptr:x}, overwrite ptr: 0x{overwrite_ptr:x}, aliased={}", + v_orig_ptr == overwrite_ptr + ); + + // Trigger realloc. len == cap == SIZE → push grows by doubling. + v.push(FILL); + + let v_new_ptr = v.as_ptr() as usize; + let first_corrupted = v[..SIZE].iter().position(|&b| b != FILL); + + zk_alloc::end_phase(); + drop(overwrite); + + eprintln!("v new ptr: 0x{v_new_ptr:x}"); + if let Some(p) = first_corrupted { + eprintln!( + "BUG REPRODUCED: v[{p}] = 0x{:02x} (expected 0x{FILL:02x}, got OVERWRITE 0x{OVERWRITE:02x})", + v[p] + ); + } + + // No fix is deployed for this case yet; bug should reproduce. + assert!( + first_corrupted.is_some(), + "expected realloc-across-phase corruption (v_orig_ptr aliased overwrite_ptr=={}); \ + got pristine FILL bytes — either the bug got fixed or the layout changed", + v_orig_ptr == overwrite_ptr + ); +} + +/// Larger Vec, more conclusive. 16 KB > MIN_ARENA_BYTES default 4 KB and +/// also > most plausible threshold raises. +#[test] +fn realloc_across_phase_corrupts_large_vec() { + const SIZE: usize = 16384; + const FILL: u8 = 0xC1; + const OVERWRITE: u8 = 0x3E; + + let _ = vec![0u8; 1024]; // warm up + + zk_alloc::begin_phase(); + let mut v: Vec = vec![FILL; SIZE]; + let v_orig_ptr = v.as_ptr() as usize; + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let overwrite: Vec = vec![OVERWRITE; SIZE]; + let overwrite_ptr = overwrite.as_ptr() as usize; + + assert_eq!( + v_orig_ptr, overwrite_ptr, + "phase reset should re-bump ARENA_PTR to slab base, aliasing the original vec ptr" + ); + + v.push(FILL); + let first_corrupted = v[..SIZE].iter().position(|&b| b != FILL); + + zk_alloc::end_phase(); + drop(overwrite); + + let p = first_corrupted + .expect("expected realloc-across-phase corruption — got pristine FILL bytes (bug fixed?)"); + eprintln!( + "confirmed: v[{p}] = 0x{:02x} (overwrite 0x{OVERWRITE:02x}, not original 0x{FILL:02x})", + v[p] + ); +} From bb37334538dd580852dfd6a853a1ad26f380444a Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 12:44:48 +0200 Subject: [PATCH 05/21] =?UTF-8?q?test(exp5):=20scenario=203=20=E2=80=94=20?= =?UTF-8?q?panic=20without=20end=5Fphase=20leaves=20arena=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no RAII guard around begin_phase/end_phase. A panic that propagates out before end_phase() is reached leaves ARENA_ACTIVE=true. Subsequent allocations made by user code that believes the phase is over land in arena and are silently recycled on the next begin_phase(). Test confirms: post-panic Vec lands at slab+0; the next begin_phase + 1MB allocation overlaps that range; post_panic[0] reads 0x33 (the new phase's fill) instead of the original 0xCC. This is a public-API hazard, not a library-internal bug. Recommended fix: introduce a PhaseGuard struct with Drop-based end_phase, or document that phases are unsafe across panics. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_panic_phase.rs | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/test_panic_phase.rs diff --git a/tests/test_panic_phase.rs b/tests/test_panic_phase.rs new file mode 100644 index 0000000..c03c1b1 --- /dev/null +++ b/tests/test_panic_phase.rs @@ -0,0 +1,64 @@ +//! Scenario 3: panic unwinding through a phase boundary. +//! +//! There is no RAII guard around begin_phase()/end_phase(). If a panic +//! propagates out of phase code without reaching end_phase(), ARENA_ACTIVE +//! stays true. Subsequent "post-phase" allocations land in arena and get +//! silently recycled on the next begin_phase(). +//! +//! This is a plain API hazard: the recovery path of any prove_with_panic +//! pattern is unsafe. + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn panic_inside_phase_leaves_arena_active() { + use std::panic; + + // Suppress default panic print to minimize incidental allocations between + // the panic and our observation point. + panic::set_hook(Box::new(|_| {})); + let _ = vec![0u8; 1024]; // warm up + + zk_alloc::begin_phase(); + let r = panic::catch_unwind(panic::AssertUnwindSafe(|| panic!("simulated"))); + assert!(r.is_err()); + // No end_phase reached. ARENA_ACTIVE is still true. + + // This Vec lands in arena (since arena is still active and 8192 >= + // MIN_ARENA_BYTES default 4096). + let post_panic: Vec = vec![0xCC; 8192]; + let post_panic_ptr = post_panic.as_ptr() as usize; + + // Begin the next phase (e.g., next iteration of a prove loop). Arena + // resets — anything allocated during the "ghost" phase between panic + // and now gets recycled. + zk_alloc::begin_phase(); + // Span enough of the slab to cover post_panic's offset, regardless of + // how many small bumps the panic introduced. + let big: Vec = vec![0x33; 1 << 20]; + let big_ptr = big.as_ptr() as usize; + let big_end = big_ptr + big.len(); + zk_alloc::end_phase(); + + let _ = panic::take_hook(); + + let in_big_range = post_panic_ptr >= big_ptr && post_panic_ptr < big_end; + let observed = post_panic[0]; + + eprintln!( + "post_panic_ptr=0x{post_panic_ptr:x} big=[0x{big_ptr:x}, 0x{big_end:x}); \ + in_range={in_big_range} observed=0x{observed:02x}" + ); + + assert!( + in_big_range, + "post-panic Vec didn't land in arena's slab — test layout assumption broken" + ); + assert_eq!( + observed, 0x33, + "expected post-panic Vec contents to be recycled by next begin_phase \ + (arena was still active after the panic) — got 0x{observed:02x}" + ); + eprintln!("BUG REPRODUCED: panic without end_phase leaves arena active; post-panic allocations recycled silently."); +} From e5cfa87dfcc50f146966cfdb548facdad0d34bc5 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 12:49:15 +0200 Subject: [PATCH 06/21] =?UTF-8?q?test(exp5):=20scenario=202=20=E2=80=94=20?= =?UTF-8?q?per-worker=20deque=20buffer=20growth=20across=20phase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crossbeam-deque's Worker::push doubles its Buffer at fill capacity and never shrinks. Deep rayon::join recursion (depth 512+) drives buffer growth past 4 KB → lands in arena. Phase boundary recycles the slab; the worker still references the old buffer pointer. If the worker subsequently allocates heap data in phase N+1 that overlaps the buffer's offset, push/ pop reads/writes corrupt memory. Findings: - With ZK_ALLOC_MIN_BYTES=0 + rayon-flush off: SIGSEGV (deterministic) - With ZK_ALLOC_MIN_BYTES=4096 (default): 0/20 cycles corrupted - With ZK_ALLOC_MIN_BYTES=4096 + rayon-flush off: 0/20 cycles corrupted Size-routing alone is sufficient; rayon-flush adds nothing for this case. Worker buffers stay below the 4 KB threshold for typical workloads; the size-routing fix correctly diverts the small intermediate growth steps. Three tests: - worker_deque_growth_during_phase (canary in main slab — does not fire) - deep_recursion_phase_cycle_program_integrity (program survives 50 cycles) - worker_buffer_growth_with_per_worker_canary (per-worker canary — fires under no-fix) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_worker_deque_growth.rs | 145 ++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/test_worker_deque_growth.rs diff --git a/tests/test_worker_deque_growth.rs b/tests/test_worker_deque_growth.rs new file mode 100644 index 0000000..07ae8ca --- /dev/null +++ b/tests/test_worker_deque_growth.rs @@ -0,0 +1,145 @@ +//! Scenario 2: per-worker crossbeam-deque Buffer growth. +//! +//! crossbeam-deque's Worker::push doubles its Buffer when the deque fills. +//! Initial capacity 32 slots × ~16 bytes per JobRef ≈ 512 bytes (under +//! MIN_ARENA_BYTES=4096). At ≥ 256 simultaneously pending tasks, the buffer +//! grows past 4 KB and lands in the arena slab. +//! +//! Workers retain their Buffer across phase boundaries — crossbeam never +//! shrinks. After end_phase + begin_phase, the slab is recycled but the +//! Worker still references the same Buffer pointer. The next push writes +//! a JobRef into recycled memory. +//! +//! Tests: drive deep rayon::join recursion from inside a worker (so pushes +//! land on a worker's local deque, not the global Injector) to force Buffer +//! growth past the size-routing threshold, then look for canary corruption. + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +/// Recursive rayon::join — each level pushes one right-task to the worker's +/// local deque. Peak pending tasks on the deque ≈ depth. +fn nested_join(depth: usize) { + if depth == 0 { + return; + } + rayon::join(|| nested_join(depth - 1), || {}); +} + +#[test] +fn worker_deque_growth_during_phase() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + const CYCLES: usize = 20; + const DEPTH: usize = 1024; // > 256 → forces Buffer growth past 4 KB + + let mut failures = 0; + for cycle in 0..CYCLES { + zk_alloc::begin_phase(); + // Push from a worker context so growth happens on a per-worker deque, + // not the global Injector. + rayon::join(|| nested_join(DEPTH), || {}); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let canary = vec![0xC9_u8; 65536]; + // Force more worker activity to consume / push deque slots. + rayon::join(|| nested_join(64), || {}); + zk_alloc::end_phase(); + + if let Some(pos) = canary.iter().position(|&b| b != 0xC9) { + eprintln!("cycle {cycle}: canary corrupted at offset {pos}"); + failures += 1; + } + } + eprintln!( + "worker_deque_growth_during_phase: {failures}/{CYCLES} cycles corrupted (MIN_ARENA_BYTES={})", + zk_alloc::min_arena_bytes() + ); + + // With size-routing default 4096, Buffers up to 256 slots (~4 KB) go to + // System. Buffers above that — driven here by DEPTH=1024 — go to arena. + // If size-routing is enough, failures==0. If not, failures>0. + if zk_alloc::min_arena_bytes() >= 4096 { + // Document outcome — assertion deferred to actual observation. + } +} + +/// Same idea but with a single very deep recursion, no canary mismatch +/// allowed. If buffer growth + phase recycle causes corruption, this should +/// crash or panic via tracing/rayon internals (similar to F1). +#[test] +fn deep_recursion_phase_cycle_program_integrity() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + for _ in 0..50 { + zk_alloc::begin_phase(); + rayon::join(|| nested_join(2048), || {}); + zk_alloc::end_phase(); + } +} + +/// Drive worker buffer growth via deep recursion that ALSO performs heap +/// allocations in each frame. If the worker's grown Buffer landed in the +/// worker's own slab, then phase 2 worker allocations at the same offset +/// would corrupt either the buffer (visible as a crash on next push/pop) or +/// — if the canary placement aligns — corrupt the canary directly. +fn nested_join_with_alloc(depth: usize) { + if depth == 0 { + return; + } + let v: Vec = vec![depth as u64; 1024]; // 8 KB, > MIN_ARENA_BYTES + rayon::join(|| nested_join_with_alloc(depth - 1), || {}); + std::hint::black_box(v); +} + +#[test] +fn worker_buffer_growth_with_per_worker_canary() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + let mut failures = 0; + const CYCLES: usize = 20; + for cycle in 0..CYCLES { + zk_alloc::begin_phase(); + rayon::join(|| nested_join_with_alloc(512), || {}); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + // Each worker allocates a 16 KB canary in its own slab, then drives + // more rayon work that uses the (potentially recycled) deque buffer. + let results: Vec = (0..32_u64) + .into_par_iter() + .map(|_| { + let canary = vec![0xC9_u8; 16384]; + rayon::join(|| nested_join_with_alloc(64), || {}); + canary.iter().all(|&b| b == 0xC9) + }) + .collect(); + zk_alloc::end_phase(); + + let n_corrupt = results.iter().filter(|&&ok| !ok).count(); + if n_corrupt > 0 { + eprintln!("cycle {cycle}: {n_corrupt}/32 workers saw canary corruption"); + failures += 1; + } + } + eprintln!( + "worker_buffer_growth_with_per_worker_canary: {failures}/{CYCLES} cycles with corruption (MIN_ARENA_BYTES={})", + zk_alloc::min_arena_bytes() + ); + + // With size-routing (default 4096), the worker's grown buffer falls into + // arena only at cap >= 256 slots (4 KB). The 8 KB Vecs allocated in + // each frame do go to arena and could overlap. In practice the + // size-routing fix is enough to prevent corruption observable from the + // canary; without it, the bug manifests as SIGSEGV (verified with + // ZK_ALLOC_MIN_BYTES=0 + --no-default-features). + if zk_alloc::min_arena_bytes() >= 4096 { + assert_eq!( + failures, 0, + "size-routing should prevent worker-deque corruption" + ); + } +} From 5e21cf8bedb3a9c49f0c4b2ae686266ffd54d537 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 12:53:00 +0200 Subject: [PATCH 07/21] =?UTF-8?q?test(exp5):=20scenario=206=20=E2=80=94=20?= =?UTF-8?q?concurrent=20begin=5Fphase/end=5Fphase=20across=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GENERATION is a global atomic. begin_phase() from any thread bumps it, forcing every other thread's next allocation through the cold-reset path and invalidating arena data they still hold. The slabs are per-thread, but the generation counter is global — so cross-thread phase calls reset the wrong slab from the wrong actor. Tests: - cross_thread_begin_phase_invalidates_data: deterministic. T1 holds an arena Vec; T2 calls begin_phase via barrier-controlled handshake; T1's next alloc lands at slab+0 over v's bytes. Asserts v_corrupted=true. - two_threads_running_lifecycle_concurrently_corrupt_each_other: stress observation only — race window for inter-alloc cross-thread interference is too tight to assert reliably. - concurrent_phase_stress_no_crash: 4 worker threads spam begin/end+alloc while main does 5000 begin/end cycles; verifies allocator atomics survive. Same hazard class as F17 (no RAII guard): public-API misuse silently corrupts arena data. Recommended fix: scoped begin_phase that returns PhaseGuard, taking a marker that prevents concurrent / nested misuse. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_concurrent_phase.rs | 156 +++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/test_concurrent_phase.rs diff --git a/tests/test_concurrent_phase.rs b/tests/test_concurrent_phase.rs new file mode 100644 index 0000000..764cab0 --- /dev/null +++ b/tests/test_concurrent_phase.rs @@ -0,0 +1,156 @@ +//! Scenario 6: concurrent begin_phase / end_phase across threads. +//! +//! GENERATION and ARENA_ACTIVE are global atomics. begin_phase() from any +//! thread bumps GENERATION, which forces every other thread's next allocation +//! through the cold path (ARENA_GEN mismatch → reset ARENA_PTR to slab base). +//! That silently invalidates arena data those threads still hold. +//! +//! Race patterns observable: +//! (a) T2.begin_phase() while T1 holds an arena Vec → T1's next alloc lands +//! on top of T1's existing Vec (per-thread slab, but offset-0 conflict). +//! (b) T1.begin_phase() racing T2.end_phase() → ARENA_ACTIVE final state +//! depends on store ordering; allocations between can route either way. +//! +//! These are public-API hazards: the docs imply single-threaded lifecycle. +//! Tests document the failure modes so a future PhaseGuard / scoped API can +//! address them. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; +use std::thread; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn cross_thread_begin_phase_invalidates_data() { + let _: u64 = (0..1024_u64).map(|i| i * 2).sum(); + + let barrier = Arc::new(Barrier::new(2)); + let bug = Arc::new(AtomicBool::new(false)); + + zk_alloc::begin_phase(); + + let bar1 = Arc::clone(&barrier); + let bug1 = Arc::clone(&bug); + let t1 = thread::spawn(move || { + let v: Vec = vec![0xAA; 8192]; + let v_ptr = v.as_ptr() as usize; + bar1.wait(); // [1] v allocated, wait for T2 + bar1.wait(); // [2] T2 has called begin_phase; resume + + // The cross-thread begin_phase bumped GENERATION. T1's ARENA_GEN is + // now stale → cold path on next alloc resets ARENA_PTR to T1's slab + // base, where v lives. w overlaps v. + let w: Vec = vec![0xBB; 8192]; + let w_ptr = w.as_ptr() as usize; + let v_corrupted = v.iter().any(|&b| b != 0xAA); + eprintln!("t1: v=0x{v_ptr:x} w=0x{w_ptr:x} v_corrupt={v_corrupted}"); + if v_corrupted { + bug1.store(true, Ordering::Relaxed); + } + std::hint::black_box((v, w)); + }); + + let bar2 = barrier; + let t2 = thread::spawn(move || { + bar2.wait(); // [1] T1 has v + zk_alloc::begin_phase(); // bumps GENERATION globally + bar2.wait(); // [2] release T1 + }); + + t1.join().unwrap(); + t2.join().unwrap(); + + zk_alloc::end_phase(); + + // Bug should reproduce: cross-thread begin_phase invalidates T1's data. + assert!( + bug.load(Ordering::Relaxed), + "expected cross-thread begin_phase to corrupt T1's arena Vec — \ + either the bug got fixed or per-thread layout prevented overlap" + ); +} + +/// Two threads each running their own begin_phase/work/end_phase loop — +/// expecting each iteration to be self-contained. Because GENERATION is +/// global, A's begin_phase mid-iteration corrupts B's in-flight data when +/// B allocates a second time after the cross-thread reset. +#[test] +fn two_threads_running_lifecycle_concurrently_corrupt_each_other() { + let _: u64 = (0..1024_u64).map(|i| i * 2).sum(); + + const ITERS: usize = 200; + let bug = Arc::new(AtomicUsize::new(0)); + + thread::scope(|s| { + for tid in 0u8..2 { + let bug = Arc::clone(&bug); + s.spawn(move || { + for _ in 0..ITERS { + zk_alloc::begin_phase(); + let pattern = if tid == 0 { 0xA1 } else { 0xB2 }; + // Two allocations per iteration; the second triggers a + // cold-path slab reset if the other thread's begin_phase + // bumped GENERATION between them. + let v: Vec = vec![pattern; 8192]; + let _filler: Vec = vec![0; 8192]; + if v.iter().any(|&b| b != pattern) { + bug.fetch_add(1, Ordering::Relaxed); + } + std::hint::black_box((v, _filler)); + zk_alloc::end_phase(); + } + }); + } + }); + + let n = bug.load(Ordering::Relaxed); + eprintln!( + "two_threads_running_lifecycle_concurrently: {n} cross-thread corruptions over {} iters", + 2 * ITERS + ); + + // Race window is narrow (single-µs alloc-to-alloc gap); count is + // observational, not asserted. The deterministic version is in + // cross_thread_begin_phase_invalidates_data above. + eprintln!("(stress observation: race window too tight to be a reliable assertion)"); +} + +/// Sanity: concurrent begin/end stress doesn't crash the allocator's atomics +/// even if it corrupts user data. Verifies invariants like REGION_BASE are +/// stable. +#[test] +fn concurrent_phase_stress_no_crash() { + let _: u64 = (0..1024_u64).map(|i| i * 2).sum(); + + const ITERS: usize = 5000; + let stop = Arc::new(AtomicBool::new(false)); + + let mut threads = vec![]; + for _ in 0..4 { + let stop = Arc::clone(&stop); + threads.push(thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + zk_alloc::begin_phase(); + let _v = vec![0u8; 16384]; + zk_alloc::end_phase(); + } + })); + } + + thread::sleep(std::time::Duration::from_millis(50)); + for _ in 0..ITERS { + zk_alloc::begin_phase(); + zk_alloc::end_phase(); + } + stop.store(true, Ordering::Relaxed); + for t in threads { + t.join().unwrap(); + } + + zk_alloc::end_phase(); + eprintln!( + "concurrent_phase_stress_no_crash: completed {ITERS} main-thread cycles + worker churn" + ); +} From de204cbaf15ced9e71c7e262ccb79f77096847b0 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 13:05:03 +0200 Subject: [PATCH 08/21] =?UTF-8?q?test(exp5):=20scenario=201=20=E2=80=94=20?= =?UTF-8?q?crossbeam-epoch=20deferred=20garbage=20(empirical)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives many crossbeam-deque buffer growths during a phase to retire objects to crossbeam-epoch's Bag, then crosses the boundary and drives more retires. F6 hypothesized Bag nodes (~1.5 KB) are caught by size-routing; this confirms empirically. Empirical observation: - ZK_ALLOC_MIN_BYTES=4096 (default): 50/50 cycles clean - ZK_ALLOC_MIN_BYTES=0 + rayon-flush off: 50/50 cycles clean (Bag-cycling test alone — par_iter test hangs same as F11, not a Bag issue) Bag nodes do not appear to be a load-bearing vector for the bug class — they're small enough to bypass arena under default routing and small enough not to overlap dangerously even without it (in this workload). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_crossbeam_epoch.rs | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_crossbeam_epoch.rs diff --git a/tests/test_crossbeam_epoch.rs b/tests/test_crossbeam_epoch.rs new file mode 100644 index 0000000..eb3f450 --- /dev/null +++ b/tests/test_crossbeam_epoch.rs @@ -0,0 +1,78 @@ +//! Scenario 1: empirical test for crossbeam-epoch deferred garbage. +//! +//! crossbeam-deque uses crossbeam-epoch to defer-deallocate retired Buffers. +//! Each thread keeps a Local with a list of Bag nodes (~1.5 KB +//! each). Bag nodes themselves are heap-allocated; if allocated during a +//! phase, they live in the arena slab. If the slab is recycled before +//! crossbeam-epoch processes the bag, walking the garbage list reads +//! recycled bytes → silent corruption or crash inside crossbeam. +//! +//! F6 (source audit) hypothesized this is covered by size-routing (Bags < +//! 4 KB go to System). Empirical test: drive many Buffer resizes during a +//! phase to retire many objects to crossbeam-epoch, cross a phase boundary, +//! drive more retires, and assert program integrity over many cycles. + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +/// Force per-worker crossbeam-deque buffer growth via deep recursion. Each +/// growth retires the prior buffer to crossbeam-epoch. +fn nested_join(depth: usize) { + if depth == 0 { + return; + } + rayon::join(|| nested_join(depth - 1), || {}); +} + +#[test] +fn crossbeam_epoch_garbage_survives_phase_cycles() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + const CYCLES: usize = 50; + for _ in 0..CYCLES { + // Phase 1: drive buffer growth → retire old buffers to epoch garbage. + // Depth 1024 → buffer grows 32 → 64 → 128 → 256 → 512 → 1024 → 2048 + // (six resizes per worker that participates). + zk_alloc::begin_phase(); + rayon::join(|| nested_join(1024), || {}); + zk_alloc::end_phase(); + + // Phase 2: drive more growth + epoch participation. If a Bag from + // phase 1 was allocated in arena and its slab was recycled, this + // would crash inside crossbeam-epoch's collect(). + zk_alloc::begin_phase(); + rayon::join(|| nested_join(1024), || {}); + zk_alloc::end_phase(); + } + + eprintln!( + "crossbeam_epoch_garbage_survives_phase_cycles: {CYCLES} cycles OK (MIN_ARENA_BYTES={})", + zk_alloc::min_arena_bytes() + ); +} + +/// par_iter with collect — drives crossbeam-channel + crossbeam-deque +/// allocations through normal rayon usage. Used to confirm typical +/// rayon-heavy workloads survive 100 cycles. +#[test] +fn crossbeam_in_par_iter_collect_survives_cycles() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + for _ in 0..100 { + zk_alloc::begin_phase(); + let v: Vec = (0..4096_u64) + .into_par_iter() + .map(|i| { + let mut acc = 0u64; + for j in 0..32 { + acc = acc.wrapping_add((i * j) ^ 0xDEADBEEF); + } + acc + }) + .collect(); + std::hint::black_box(v); + zk_alloc::end_phase(); + } +} From d12a327336b1b8cc02b8869a980701202fcb6639 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 13:06:08 +0200 Subject: [PATCH 09/21] =?UTF-8?q?test(exp5):=20scenario=204=20=E2=80=94=20?= =?UTF-8?q?thread=20pool=20build=20during=20active=20phase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadPoolBuilder::build() allocates Registry, ThreadInfo arrays, initial deques, and Sleep state. Built during an active phase, those land in arena; held across phase boundaries → Registry pointers reference recycled memory; .install() walks corrupted scheduler state. Findings: - ZK_ALLOC_MIN_BYTES=4096 (default): 10 cycles clean (allocations bypass arena under size-routing). - ZK_ALLOC_MIN_BYTES=0 + rayon-flush off: deterministic SIGSEGV on first cycle's .install(). - Pool built BEFORE any phase: clean across 50 phase cycles, even without the fix. Confirms the hazard is specific to mid-phase construction. Three tests: build during phase + use across boundary, build+use+drop in single phase, build before phase + use across many. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_threadpool_resize.rs | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/test_threadpool_resize.rs diff --git a/tests/test_threadpool_resize.rs b/tests/test_threadpool_resize.rs new file mode 100644 index 0000000..db28a2c --- /dev/null +++ b/tests/test_threadpool_resize.rs @@ -0,0 +1,89 @@ +//! Scenario 4: thread pool resizing / building mid-phase. +//! +//! ThreadPoolBuilder::build() allocates a Registry, per-worker ThreadInfo +//! arrays, initial Worker deques, and a Sleep struct. If built during an +//! active phase, these allocations land in the arena. The pool is held by +//! the user across phase boundaries; Registry pointers reference arena +//! memory that gets recycled on the next begin_phase, so subsequent +//! .install() calls walk corrupted scheduler state. +//! +//! Most of these allocations are sub-KB (per F6/F13 audit) and bypass arena +//! under default size-routing. Empirical test: build a fresh ThreadPool +//! mid-phase, cross a boundary, install work, and look for crashes / hangs. + +use rayon::prelude::*; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn build_threadpool_during_phase_then_use_across_boundary() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + const CYCLES: usize = 10; + for cycle in 0..CYCLES { + zk_alloc::begin_phase(); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(4) + .thread_name(move |i| format!("test-pool-{cycle}-{i}")) + .build() + .expect("build pool"); + zk_alloc::end_phase(); + + // Phase boundary. Pool's Registry, ThreadInfo arrays, deques: any + // that were arena-allocated are now in recycled territory. + zk_alloc::begin_phase(); + // Force the pool to use its Registry via .install + par work. If + // any pointer-walking state was in arena and got recycled, this + // crashes or hangs. + let result: u64 = pool.install(|| (0..1024_u64).into_par_iter().sum()); + assert_eq!(result, 1024 * 1023 / 2); + zk_alloc::end_phase(); + + drop(pool); + } + eprintln!( + "build_threadpool_during_phase: {CYCLES} cycles OK (MIN_ARENA_BYTES={})", + zk_alloc::min_arena_bytes() + ); +} + +#[test] +fn many_threadpool_builds_during_phase() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + for cycle in 0..20 { + zk_alloc::begin_phase(); + // Build, use immediately within phase, drop. All within one phase. + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(2) + .thread_name(move |i| format!("ephemeral-{cycle}-{i}")) + .build() + .expect("build pool"); + let result: u64 = pool.install(|| (0..512_u64).into_par_iter().sum()); + assert_eq!(result, 512 * 511 / 2); + drop(pool); + zk_alloc::end_phase(); + } +} + +/// Build pool BEFORE any phase, use it across many phases. Pool's allocations +/// are pre-phase (System); should be fully isolated from phase resets. +#[test] +fn pre_phase_threadpool_used_across_many_phases() { + let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(4) + .thread_name(|i| format!("pre-phase-{i}")) + .build() + .expect("build pool"); + + for _ in 0..50 { + zk_alloc::begin_phase(); + let result: u64 = pool.install(|| (0..1024_u64).into_par_iter().sum()); + assert_eq!(result, 1024 * 1023 / 2); + zk_alloc::end_phase(); + } + drop(pool); +} From 8e903edeefa0b2bd9151ca0df1d812e89b4fdf9b Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 13:33:31 +0200 Subject: [PATCH 10/21] =?UTF-8?q?test(exp5):=20bonus=20=E2=80=94=20Arc?= =?UTF-8?q?=20refcount=20corrupted=20across=20phase=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc::new allocates ArcInner = (strong_count, weak_count, T). For T large enough that the allocation is >= MIN_ARENA_BYTES (default 4096), the ArcInner lands in arena. Holding the Arc across a phase boundary puts the refcount fields in the path of recycled writes — phase 2's first allocation overwrites them with arbitrary bytes. Test confirms strong_count reads 0x5555555555555555 after a 1 MB filler overwrites the slab. clone() increments to 0x...5556. Arc continues operating but refcount can never reach zero → silent memory leak. Worst cases: refcount underflow (premature drop), use-after-free, or — if ArcInner aligns differently — SIGSEGV on the AtomicUsize CAS. Same fundamental bug class as F16 (data retained across phase corrupted by reset) but applies to refcounted allocations specifically. Size-routing covers small Arcs (e.g., Arc) but not large payloads. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_arc_phase.rs | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_arc_phase.rs diff --git a/tests/test_arc_phase.rs b/tests/test_arc_phase.rs new file mode 100644 index 0000000..8f7ed3c --- /dev/null +++ b/tests/test_arc_phase.rs @@ -0,0 +1,69 @@ +//! Bonus: Arc retained across a phase boundary. +//! +//! Arc::new allocates ArcInner = (strong_count, weak_count, T). The +//! refcounts live at the front of the allocation. If the allocation is in +//! arena (size >= MIN_ARENA_BYTES) and survives a phase boundary, the next +//! phase's first allocation overwrites the refcount fields. Subsequent +//! .clone() / drop on the Arc reads garbage refcounts → undefined behavior: +//! either a crash, a leaked allocation (refcount stays > 0), or a premature +//! "drop" if refcount happens to underflow to 1. +//! +//! Distinct from F16 because: F16 needs a realloc; Arc has fixed layout, no +//! realloc. The corruption mechanism here is plain phase-reset overwrite of +//! a still-live allocation. + +use std::sync::Arc; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn arc_strong_count_corrupted_across_phase() { + // Big enough payload to push ArcInner over MIN_ARENA_BYTES (default 4096). + type Payload = [u8; 16384]; + + zk_alloc::begin_phase(); + let a: Arc = Arc::new([0xAA; 16384]); + let a_ptr = Arc::as_ptr(&a) as usize; + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + // Cover a's ArcInner location: phase reset re-bumps to slab+0; this + // overwrites the (strong, weak, payload) layout with our bytes. + let filler: Vec = vec![0x55; 1 << 20]; // 1 MB ensures overlap + let filler_lo = filler.as_ptr() as usize; + let filler_hi = filler_lo + filler.len(); + let aliased = a_ptr >= filler_lo && a_ptr < filler_hi; + eprintln!("a_ptr=0x{a_ptr:x}, filler=[0x{filler_lo:x}, 0x{filler_hi:x}), aliased={aliased}"); + assert!( + aliased, + "test layout broken: filler doesn't span Arc's allocation" + ); + + // Inspect strong count via a side channel: clone() tries to fetch_add + // on strong_count. If the refcount field has been overwritten with + // 0x55555555..55, the clone will produce a Arc with a bogus refcount + // and drop will not properly tear down. Worst case: SIGSEGV. + let strong_before = Arc::strong_count(&a); + let b = Arc::clone(&a); + let strong_after = Arc::strong_count(&a); + eprintln!("strong_count: before={strong_before}, after_clone={strong_after}"); + + drop(b); + drop(a); + drop(filler); + zk_alloc::end_phase(); + + // Expect the strong count to be GARBAGE (anything other than 1 before + // and 2 after). If it reads a valid refcount, the bug isn't manifesting + // and the test should FAIL — either fixed or layout assumption broken. + let pristine = strong_before == 1 && strong_after == 2; + assert!( + !pristine, + "expected Arc refcount corruption (saw before={strong_before}, after={strong_after}); \ + pristine reads suggest the bug isn't firing — investigate" + ); + eprintln!( + "BUG CONFIRMED: Arc refcount corrupted by phase reset (before={strong_before}, after={strong_after})" + ); +} From f95c9af61e6c2f7d231d8f2e76373a0abce2e7c9 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 13:37:23 +0200 Subject: [PATCH 11/21] =?UTF-8?q?test(exp5):=20bonus=20=E2=80=94=20HashMap?= =?UTF-8?q?=20retained=20across=20phase=20corrupted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HashMap (hashbrown) allocates one contiguous block for ctrl bytes + bucket array. With with_capacity(4096) HashMap, the allocation is ~64 KB — above MIN_ARENA_BYTES so it lands in arena. Held across phase boundary, phase 2's first allocation overwrites the entire structure. Detection: HashMap.get on corrupted ctrl bytes (all 0x55 from filler) infinite-loops in linear probing — every slot looks "full" but no hash matches. Test uses a worker thread with mpsc::recv_timeout to detect hang as the corruption signal. Confirmed: 2s timeout fires deterministically. Same bug class as F22 (Arc) — any large allocation retained across phase is corrupted. Worse failure mode here: silent hang rather than memory leak. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_hashmap_phase.rs | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_hashmap_phase.rs diff --git a/tests/test_hashmap_phase.rs b/tests/test_hashmap_phase.rs new file mode 100644 index 0000000..9000679 --- /dev/null +++ b/tests/test_hashmap_phase.rs @@ -0,0 +1,75 @@ +//! Bonus: HashMap retained across a phase boundary. +//! +//! HashMap (hashbrown under the hood) allocates one contiguous block for its +//! ctrl bytes + bucket array. When held across a phase boundary, the next +//! phase's first large allocation overwrites the entire structure. The +//! corrupted ctrl bytes (typically all 0x55 from filler) make every probe +//! position appear "full" — but no entry actually matches the hash, so +//! HashMap.get enters an infinite probe loop. +//! +//! Detection: spawn a worker thread doing the get(), use a timeout. A hang +//! is the corruption signal. +//! +//! With with_capacity(2048) HashMap, the bucket allocation is +//! ~32 KB — above MIN_ARENA_BYTES (default 4096) so it lands in arena. + +use std::collections::HashMap; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn hashmap_corrupted_across_phase_boundary() { + zk_alloc::begin_phase(); + let mut m: HashMap = HashMap::with_capacity(4096); + for i in 0..2000_u64 { + m.insert(i, i.wrapping_mul(i).wrapping_add(0xDEADBEEF)); + } + let pre_check = m.get(&100).copied(); + assert_eq!( + pre_check, + Some(100_u64.wrapping_mul(100).wrapping_add(0xDEADBEEF)), + "phase-1 invariant broken before boundary" + ); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + // 1 MB filler at slab+0 — overwrites HashMap's bucket region. + let filler: Vec = vec![0x55; 1 << 20]; + let filler_lo = filler.as_ptr() as usize; + let filler_hi = filler_lo + filler.len(); + eprintln!("filler=[0x{filler_lo:x}, 0x{filler_hi:x})"); + + // Move m into a worker thread; detect hang via channel timeout. + let (tx, rx) = mpsc::channel(); + let _hang_thread = thread::spawn(move || { + let i = 100_u64; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| m.get(&i).copied())); + let _ = tx.send(result); + // Don't drop m — its state is corrupted, drop chain would also be UB. + std::mem::forget(m); + }); + + let outcome = rx.recv_timeout(Duration::from_secs(2)); + match &outcome { + Ok(Ok(Some(v))) => eprintln!("HashMap.get returned: {v}"), + Ok(Ok(None)) => eprintln!("HashMap.get returned None (entry vanished)"), + Ok(Err(_)) => eprintln!("HashMap.get panicked"), + Err(_) => eprintln!("HashMap.get TIMED OUT (infinite probe on corrupted ctrl bytes)"), + } + drop(filler); + zk_alloc::end_phase(); + + let expected = 100_u64.wrapping_mul(100).wrapping_add(0xDEADBEEF); + let pristine = matches!(outcome, Ok(Ok(Some(v))) if v == expected); + assert!( + !pristine, + "expected HashMap corruption (timeout / panic / wrong value), got pristine" + ); + eprintln!( + "BUG CONFIRMED: HashMap retained across phase boundary corrupted (outcome: {outcome:?})" + ); +} From 6692235826175f1c37b9eac4a9c0d8800f1de0cc Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 13:38:32 +0200 Subject: [PATCH 12/21] =?UTF-8?q?test(exp5):=20bonus=20=E2=80=94=20Box=20data=20corrupted,=20vtable=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trait object is (data_ptr, vtable_ptr). The vtable lives in .rodata so dispatch survives any heap corruption. The data lives in the Box's heap allocation; held across a phase boundary, the bytes are overwritten by phase 2's first allocation. Tests: - box_dyn_trait_data_corrupted_silent: confirms sentinel field reads 0x5555555555555555 (filler bytes) and payload_sum returns garbage. Vtable dispatch worked; data was wrong. - box_dyn_vtable_dispatch_survives_data_corruption: trait method only increments a global atomic; runs correctly across corrupted self. Failure mode is silent wrong values — no panic, no SIGSEGV, no hang. Worst form of bug class for correctness checks. If the trait impl had chased a pointer in self (e.g., self.inner_box.method()), it would SIGSEGV on dereferencing 0x5555_5555_5555_5555. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_box_dyn_phase.rs | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/test_box_dyn_phase.rs diff --git a/tests/test_box_dyn_phase.rs b/tests/test_box_dyn_phase.rs new file mode 100644 index 0000000..a9104ae --- /dev/null +++ b/tests/test_box_dyn_phase.rs @@ -0,0 +1,132 @@ +//! Bonus: Box retained across phase boundary. +//! +//! A trait object is a fat pointer (data_ptr, vtable_ptr). The vtable lives +//! in .rodata (binary), so vtable lookups remain valid. The data lives in +//! the heap allocation owned by the Box. If that allocation is in arena +//! (Box payload >= MIN_ARENA_BYTES) and the Box is held across a phase, +//! the data bytes are overwritten by phase 2's first allocation. +//! +//! Method calls then read corrupted self fields. For trait impls that touch +//! pointer-typed fields (like another Box inside the struct), the corrupted +//! pointer causes SIGSEGV. For impls that only read scalar fields, the +//! corruption is silent — wrong values returned. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +trait Witness { + fn sentinel(&self) -> u64; + fn payload_sum(&self) -> u64; +} + +struct BigWitness { + sentinel: u64, + _pad: [u8; 16384], + multiplier: u64, + payload: [u64; 256], +} + +impl Witness for BigWitness { + fn sentinel(&self) -> u64 { + self.sentinel + } + fn payload_sum(&self) -> u64 { + self.payload.iter().sum::().wrapping_mul(self.multiplier) + } +} + +#[test] +fn box_dyn_trait_data_corrupted_silent() { + let original_sum = (0..256_u64).sum::().wrapping_mul(13); + + zk_alloc::begin_phase(); + let w: Box = Box::new(BigWitness { + sentinel: 0xDEADBEEFCAFEBABE, + _pad: [0; 16384], + multiplier: 13, + payload: std::array::from_fn(|i| i as u64), + }); + let pre_sentinel = w.sentinel(); + let pre_sum = w.payload_sum(); + assert_eq!(pre_sentinel, 0xDEADBEEFCAFEBABE); + assert_eq!(pre_sum, original_sum); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let filler: Vec = vec![0x55; 1 << 20]; + std::hint::black_box(&filler); + + // Vtable lookup is fine (vtable in .rodata). Self-data fields are + // overwritten with 0x55 bytes. + let post_sentinel = w.sentinel(); + let post_sum = w.payload_sum(); + eprintln!( + "before: sentinel=0x{pre_sentinel:x}, sum={pre_sum}; \ + after: sentinel=0x{post_sentinel:x}, sum={post_sum}" + ); + + drop(filler); + std::mem::forget(w); + zk_alloc::end_phase(); + + let pristine = post_sentinel == pre_sentinel && post_sum == pre_sum; + assert!( + !pristine, + "expected Box data corruption — got pristine reads" + ); + assert_eq!( + post_sentinel, 0x5555555555555555, + "sentinel should be filler bytes after phase reset" + ); + eprintln!("BUG CONFIRMED: Box field reads return filler bytes after phase reset"); +} + +/// Variant: the trait method increments a global counter — confirms the +/// vtable still routes correctly even though `self` data is garbage. +#[test] +fn box_dyn_vtable_dispatch_survives_data_corruption() { + static DISPATCH_COUNT: AtomicUsize = AtomicUsize::new(0); + + trait Counted { + fn tick(&self); + } + struct BigCounter { + _pad: [u8; 16384], + _tag: u64, + } + impl Counted for BigCounter { + fn tick(&self) { + DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed); + } + } + + zk_alloc::begin_phase(); + let c: Box = Box::new(BigCounter { + _pad: [0; 16384], + _tag: 42, + }); + c.tick(); + zk_alloc::end_phase(); + + zk_alloc::begin_phase(); + let filler: Vec = vec![0x55; 1 << 20]; + std::hint::black_box(&filler); + + // Vtable still valid; tick() runs even with corrupted self data. + let pre = DISPATCH_COUNT.load(Ordering::Relaxed); + c.tick(); + let post = DISPATCH_COUNT.load(Ordering::Relaxed); + + drop(filler); + std::mem::forget(c); + zk_alloc::end_phase(); + + assert_eq!( + post, + pre + 1, + "vtable dispatch should survive data corruption" + ); + eprintln!("vtable dispatch OK across corruption (tick count {pre}→{post})"); +} From 98925e3ee9b2712c760d788b21e5fd983b74da12 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:32:37 +0200 Subject: [PATCH 13/21] feat: PhaseGuard RAII for panic-safe phase boundaries (fixes F17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PhaseGuard struct with Drop-based end_phase, plus a phase(|| { ... }) scoped helper. Both compose with the existing free-function API; nothing breaks. Use in place of paired begin_phase/end_phase calls when the phase body can panic. Test (test_phase_guard.rs): - phase_guard_runs_end_phase_on_panic: catch_unwind a panicking phase() body; verify post-unwind allocations land in System (not arena). Mirrors the F17 reproducer with the fix applied — passes deterministically. - phase_guard_runs_end_phase_on_normal_return: sanity check. - nested_phase_guards_compose: nested phase() works. Does not address F19 (concurrent begin/end) — same begin_phase atomics, no cross-thread protection. That needs a separate scoped/exclusive API. Cargo fmt also folded test_box_dyn_phase.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 47 ++++++++++++++++++++ tests/test_box_dyn_phase.rs | 5 ++- tests/test_phase_guard.rs | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_phase_guard.rs diff --git a/src/lib.rs b/src/lib.rs index fee434b..64976e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -170,6 +170,53 @@ fn flush_rayon() { } } +/// RAII guard for an arena phase. Calls `begin_phase()` on construction and +/// `end_phase()` on drop — including during panic unwinding. Use this in +/// place of paired `begin_phase()`/`end_phase()` calls when the phase body +/// can panic, to avoid leaving the arena active across the unwind. +/// +/// ```ignore +/// loop { +/// let _guard = zk_alloc::PhaseGuard::new(); +/// heavy_work_that_might_panic(); +/// // _guard drops here on normal return AND on unwind +/// } +/// ``` +pub struct PhaseGuard { + _private: (), +} + +impl PhaseGuard { + /// Begins a phase. The phase ends when the returned guard is dropped. + pub fn new() -> Self { + begin_phase(); + Self { _private: () } + } +} + +impl Default for PhaseGuard { + fn default() -> Self { + Self::new() + } +} + +impl Drop for PhaseGuard { + fn drop(&mut self) { + end_phase(); + } +} + +/// Runs `f` inside a phase. Equivalent to constructing a `PhaseGuard`, +/// running `f`, and dropping the guard. Panics in `f` propagate, but the +/// phase is guaranteed to end before unwinding leaves this function. +pub fn phase(f: F) -> R +where + F: FnOnce() -> R, +{ + let _guard = PhaseGuard::new(); + f() +} + /// Returns (overflow_count, overflow_bytes) — allocations that fell through to System /// because they exceeded the slab or arrived after all slabs were claimed. pub fn overflow_stats() -> (usize, usize) { diff --git a/tests/test_box_dyn_phase.rs b/tests/test_box_dyn_phase.rs index a9104ae..327019f 100644 --- a/tests/test_box_dyn_phase.rs +++ b/tests/test_box_dyn_phase.rs @@ -33,7 +33,10 @@ impl Witness for BigWitness { self.sentinel } fn payload_sum(&self) -> u64 { - self.payload.iter().sum::().wrapping_mul(self.multiplier) + self.payload + .iter() + .sum::() + .wrapping_mul(self.multiplier) } } diff --git a/tests/test_phase_guard.rs b/tests/test_phase_guard.rs new file mode 100644 index 0000000..fbe3eaf --- /dev/null +++ b/tests/test_phase_guard.rs @@ -0,0 +1,87 @@ +//! Verify that PhaseGuard / phase() makes F17 (panic leaves arena active) +//! impossible by construction. Drop runs during unwind, calling end_phase. +//! +//! Mirrors test_panic_phase but uses the RAII API. Asserts NO corruption. + +#[global_allocator] +static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; + +#[test] +fn phase_guard_runs_end_phase_on_panic() { + use std::panic; + + panic::set_hook(Box::new(|_| {})); + let _ = vec![0u8; 1024]; + + // Mirror of test_panic_phase::panic_inside_phase_leaves_arena_active. + // Use phase() / PhaseGuard around the panic — the guard's Drop ends + // the phase during unwind. + let r = panic::catch_unwind(panic::AssertUnwindSafe(|| { + zk_alloc::phase(|| panic!("simulated")) + })); + assert!(r.is_err()); + + // Arena should now be inactive — this large allocation should land in + // System, not arena. + let post_panic: Vec = vec![0xCC; 8192]; + let post_panic_ptr = post_panic.as_ptr() as usize; + + // Begin a new phase + 1 MB filler. If the previous phase was correctly + // ended, post_panic is in System and won't be recycled. The filler + // lands somewhere in arena slab+0 — but post_panic_ptr is NOT in arena. + zk_alloc::phase(|| { + let big: Vec = vec![0x33; 1 << 20]; + let big_ptr = big.as_ptr() as usize; + let big_end = big_ptr + big.len(); + let in_big_range = post_panic_ptr >= big_ptr && post_panic_ptr < big_end; + eprintln!( + "post_panic_ptr=0x{post_panic_ptr:x} big=[0x{big_ptr:x}, 0x{big_end:x}) \ + in_range={in_big_range}" + ); + // post_panic should NOT be in arena range (it was allocated when + // ARENA_ACTIVE=false because PhaseGuard's Drop ran during the unwind). + assert!( + !in_big_range, + "PhaseGuard didn't run end_phase during unwind — post_panic landed in arena" + ); + }); + + let _ = panic::take_hook(); + + // Verify post_panic's contents are pristine. + assert!( + post_panic.iter().all(|&b| b == 0xCC), + "post_panic was corrupted; PhaseGuard didn't end the phase on panic" + ); + eprintln!("PhaseGuard fix verified: panic unwound through phase, end_phase ran, post-panic Vec safe in System"); +} + +#[test] +fn phase_guard_runs_end_phase_on_normal_return() { + let v = zk_alloc::phase(|| vec![0xAB_u8; 8192]); + // After phase, arena is inactive. Subsequent allocations go to System. + let after: Vec = vec![0xCD_u8; 8192]; + + // Begin another phase + filler. `after` should not be recycled (it's in System). + zk_alloc::phase(|| { + let _filler: Vec = vec![0x77_u8; 1 << 20]; + }); + + assert!( + after.iter().all(|&b| b == 0xCD), + "after-phase Vec was corrupted" + ); + // v is in arena from the first phase; it MAY be corrupted by phase 2. + // That's the F16 family — not what this test is about. We don't assert + // on v. + std::hint::black_box(v); +} + +#[test] +fn nested_phase_guards_compose() { + // Outer phase + inner phase. Inner phase end_phases (sets active=false), + // then outer phase end_phases again. Sequence: begin, begin, end, end. + // Final state: active=false. No panic. + let result = zk_alloc::phase(|| zk_alloc::phase(|| 42_u64)); + assert_eq!(result, 42); +} From 8c2b79e5b727886f878c2f70060a3875fea68859 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:33:35 +0200 Subject: [PATCH 14/21] docs: warn about F16-family retention hazards in begin_phase Document that Vec/Arc/HashMap/Box >= MIN_ARENA_BYTES held across begin_phase() are silently overwritten. Lists each container's specific failure mode and points to clone()-via-system as the workaround. This is documentation only; behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 64976e1..ddef436 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,6 +138,25 @@ fn ensure_region() -> usize { /// Activates the arena and resets every thread's slab. All allocations until the next /// `end_phase()` go to the arena; the previous phase's data is overwritten in place. +/// +/// ## Retention is unsafe +/// +/// Allocations made during phase N that are still held when phase N+1 begins +/// are silently overwritten by phase N+1's first allocations at the same slab +/// offset. Any of the following held across `begin_phase()` will be corrupted: +/// +/// - `Vec` with capacity ≥ [`min_arena_bytes()`] (`push` triggers `realloc` +/// that copies from now-recycled source memory). +/// - `Arc` / `Rc` with payload ≥ [`min_arena_bytes()`] (refcount fields +/// become arbitrary bytes — silent leak or use-after-free). +/// - `HashMap`, `BTreeMap`, etc. with bucket allocation ≥ [`min_arena_bytes()`] +/// (lookup may infinite-loop on corrupted ctrl bytes). +/// - `Box` with backing data ≥ [`min_arena_bytes()`] (vtable +/// dispatch survives but field reads return filler bytes). +/// +/// To preserve data across phases, `clone()` it into a System-backed copy +/// (e.g., wrap in `Box::leak(Box::new(...))` while ARENA_ACTIVE is false, +/// or copy into a `Vec` allocated outside any phase). pub fn begin_phase() { ensure_region(); GENERATION.fetch_add(1, Ordering::Release); From 588a85517cbf09d5f16b1672a3dc05164db684f3 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:45:35 +0200 Subject: [PATCH 15/21] test: move F16-family contract-violation tests off feat/size-routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests demonstrate that retaining arena allocations across phase boundaries silently corrupts user data — they're educational about the phase-scoping contract, not standard regression tests. They live on the exp5-soundness-tests branch alongside the soundness audit notes. Removed: test_realloc_phase, test_arc_phase, test_hashmap_phase, test_box_dyn_phase. Tests that exercise the allocator's correctness under valid usage (test_panic_phase, test_concurrent_phase, test_worker_deque_growth, test_crossbeam_epoch, test_threadpool_resize, test_phase_guard) stay. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_arc_phase.rs | 69 ------------------ tests/test_box_dyn_phase.rs | 135 ------------------------------------ tests/test_hashmap_phase.rs | 75 -------------------- tests/test_realloc_phase.rs | 109 ----------------------------- 4 files changed, 388 deletions(-) delete mode 100644 tests/test_arc_phase.rs delete mode 100644 tests/test_box_dyn_phase.rs delete mode 100644 tests/test_hashmap_phase.rs delete mode 100644 tests/test_realloc_phase.rs diff --git a/tests/test_arc_phase.rs b/tests/test_arc_phase.rs deleted file mode 100644 index 8f7ed3c..0000000 --- a/tests/test_arc_phase.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Bonus: Arc retained across a phase boundary. -//! -//! Arc::new allocates ArcInner = (strong_count, weak_count, T). The -//! refcounts live at the front of the allocation. If the allocation is in -//! arena (size >= MIN_ARENA_BYTES) and survives a phase boundary, the next -//! phase's first allocation overwrites the refcount fields. Subsequent -//! .clone() / drop on the Arc reads garbage refcounts → undefined behavior: -//! either a crash, a leaked allocation (refcount stays > 0), or a premature -//! "drop" if refcount happens to underflow to 1. -//! -//! Distinct from F16 because: F16 needs a realloc; Arc has fixed layout, no -//! realloc. The corruption mechanism here is plain phase-reset overwrite of -//! a still-live allocation. - -use std::sync::Arc; - -#[global_allocator] -static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; - -#[test] -fn arc_strong_count_corrupted_across_phase() { - // Big enough payload to push ArcInner over MIN_ARENA_BYTES (default 4096). - type Payload = [u8; 16384]; - - zk_alloc::begin_phase(); - let a: Arc = Arc::new([0xAA; 16384]); - let a_ptr = Arc::as_ptr(&a) as usize; - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - // Cover a's ArcInner location: phase reset re-bumps to slab+0; this - // overwrites the (strong, weak, payload) layout with our bytes. - let filler: Vec = vec![0x55; 1 << 20]; // 1 MB ensures overlap - let filler_lo = filler.as_ptr() as usize; - let filler_hi = filler_lo + filler.len(); - let aliased = a_ptr >= filler_lo && a_ptr < filler_hi; - eprintln!("a_ptr=0x{a_ptr:x}, filler=[0x{filler_lo:x}, 0x{filler_hi:x}), aliased={aliased}"); - assert!( - aliased, - "test layout broken: filler doesn't span Arc's allocation" - ); - - // Inspect strong count via a side channel: clone() tries to fetch_add - // on strong_count. If the refcount field has been overwritten with - // 0x55555555..55, the clone will produce a Arc with a bogus refcount - // and drop will not properly tear down. Worst case: SIGSEGV. - let strong_before = Arc::strong_count(&a); - let b = Arc::clone(&a); - let strong_after = Arc::strong_count(&a); - eprintln!("strong_count: before={strong_before}, after_clone={strong_after}"); - - drop(b); - drop(a); - drop(filler); - zk_alloc::end_phase(); - - // Expect the strong count to be GARBAGE (anything other than 1 before - // and 2 after). If it reads a valid refcount, the bug isn't manifesting - // and the test should FAIL — either fixed or layout assumption broken. - let pristine = strong_before == 1 && strong_after == 2; - assert!( - !pristine, - "expected Arc refcount corruption (saw before={strong_before}, after={strong_after}); \ - pristine reads suggest the bug isn't firing — investigate" - ); - eprintln!( - "BUG CONFIRMED: Arc refcount corrupted by phase reset (before={strong_before}, after={strong_after})" - ); -} diff --git a/tests/test_box_dyn_phase.rs b/tests/test_box_dyn_phase.rs deleted file mode 100644 index 327019f..0000000 --- a/tests/test_box_dyn_phase.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Bonus: Box retained across phase boundary. -//! -//! A trait object is a fat pointer (data_ptr, vtable_ptr). The vtable lives -//! in .rodata (binary), so vtable lookups remain valid. The data lives in -//! the heap allocation owned by the Box. If that allocation is in arena -//! (Box payload >= MIN_ARENA_BYTES) and the Box is held across a phase, -//! the data bytes are overwritten by phase 2's first allocation. -//! -//! Method calls then read corrupted self fields. For trait impls that touch -//! pointer-typed fields (like another Box inside the struct), the corrupted -//! pointer causes SIGSEGV. For impls that only read scalar fields, the -//! corruption is silent — wrong values returned. - -use std::sync::atomic::{AtomicUsize, Ordering}; - -#[global_allocator] -static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; - -trait Witness { - fn sentinel(&self) -> u64; - fn payload_sum(&self) -> u64; -} - -struct BigWitness { - sentinel: u64, - _pad: [u8; 16384], - multiplier: u64, - payload: [u64; 256], -} - -impl Witness for BigWitness { - fn sentinel(&self) -> u64 { - self.sentinel - } - fn payload_sum(&self) -> u64 { - self.payload - .iter() - .sum::() - .wrapping_mul(self.multiplier) - } -} - -#[test] -fn box_dyn_trait_data_corrupted_silent() { - let original_sum = (0..256_u64).sum::().wrapping_mul(13); - - zk_alloc::begin_phase(); - let w: Box = Box::new(BigWitness { - sentinel: 0xDEADBEEFCAFEBABE, - _pad: [0; 16384], - multiplier: 13, - payload: std::array::from_fn(|i| i as u64), - }); - let pre_sentinel = w.sentinel(); - let pre_sum = w.payload_sum(); - assert_eq!(pre_sentinel, 0xDEADBEEFCAFEBABE); - assert_eq!(pre_sum, original_sum); - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - let filler: Vec = vec![0x55; 1 << 20]; - std::hint::black_box(&filler); - - // Vtable lookup is fine (vtable in .rodata). Self-data fields are - // overwritten with 0x55 bytes. - let post_sentinel = w.sentinel(); - let post_sum = w.payload_sum(); - eprintln!( - "before: sentinel=0x{pre_sentinel:x}, sum={pre_sum}; \ - after: sentinel=0x{post_sentinel:x}, sum={post_sum}" - ); - - drop(filler); - std::mem::forget(w); - zk_alloc::end_phase(); - - let pristine = post_sentinel == pre_sentinel && post_sum == pre_sum; - assert!( - !pristine, - "expected Box data corruption — got pristine reads" - ); - assert_eq!( - post_sentinel, 0x5555555555555555, - "sentinel should be filler bytes after phase reset" - ); - eprintln!("BUG CONFIRMED: Box field reads return filler bytes after phase reset"); -} - -/// Variant: the trait method increments a global counter — confirms the -/// vtable still routes correctly even though `self` data is garbage. -#[test] -fn box_dyn_vtable_dispatch_survives_data_corruption() { - static DISPATCH_COUNT: AtomicUsize = AtomicUsize::new(0); - - trait Counted { - fn tick(&self); - } - struct BigCounter { - _pad: [u8; 16384], - _tag: u64, - } - impl Counted for BigCounter { - fn tick(&self) { - DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed); - } - } - - zk_alloc::begin_phase(); - let c: Box = Box::new(BigCounter { - _pad: [0; 16384], - _tag: 42, - }); - c.tick(); - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - let filler: Vec = vec![0x55; 1 << 20]; - std::hint::black_box(&filler); - - // Vtable still valid; tick() runs even with corrupted self data. - let pre = DISPATCH_COUNT.load(Ordering::Relaxed); - c.tick(); - let post = DISPATCH_COUNT.load(Ordering::Relaxed); - - drop(filler); - std::mem::forget(c); - zk_alloc::end_phase(); - - assert_eq!( - post, - pre + 1, - "vtable dispatch should survive data corruption" - ); - eprintln!("vtable dispatch OK across corruption (tick count {pre}→{post})"); -} diff --git a/tests/test_hashmap_phase.rs b/tests/test_hashmap_phase.rs deleted file mode 100644 index 9000679..0000000 --- a/tests/test_hashmap_phase.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Bonus: HashMap retained across a phase boundary. -//! -//! HashMap (hashbrown under the hood) allocates one contiguous block for its -//! ctrl bytes + bucket array. When held across a phase boundary, the next -//! phase's first large allocation overwrites the entire structure. The -//! corrupted ctrl bytes (typically all 0x55 from filler) make every probe -//! position appear "full" — but no entry actually matches the hash, so -//! HashMap.get enters an infinite probe loop. -//! -//! Detection: spawn a worker thread doing the get(), use a timeout. A hang -//! is the corruption signal. -//! -//! With with_capacity(2048) HashMap, the bucket allocation is -//! ~32 KB — above MIN_ARENA_BYTES (default 4096) so it lands in arena. - -use std::collections::HashMap; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -#[global_allocator] -static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; - -#[test] -fn hashmap_corrupted_across_phase_boundary() { - zk_alloc::begin_phase(); - let mut m: HashMap = HashMap::with_capacity(4096); - for i in 0..2000_u64 { - m.insert(i, i.wrapping_mul(i).wrapping_add(0xDEADBEEF)); - } - let pre_check = m.get(&100).copied(); - assert_eq!( - pre_check, - Some(100_u64.wrapping_mul(100).wrapping_add(0xDEADBEEF)), - "phase-1 invariant broken before boundary" - ); - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - // 1 MB filler at slab+0 — overwrites HashMap's bucket region. - let filler: Vec = vec![0x55; 1 << 20]; - let filler_lo = filler.as_ptr() as usize; - let filler_hi = filler_lo + filler.len(); - eprintln!("filler=[0x{filler_lo:x}, 0x{filler_hi:x})"); - - // Move m into a worker thread; detect hang via channel timeout. - let (tx, rx) = mpsc::channel(); - let _hang_thread = thread::spawn(move || { - let i = 100_u64; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| m.get(&i).copied())); - let _ = tx.send(result); - // Don't drop m — its state is corrupted, drop chain would also be UB. - std::mem::forget(m); - }); - - let outcome = rx.recv_timeout(Duration::from_secs(2)); - match &outcome { - Ok(Ok(Some(v))) => eprintln!("HashMap.get returned: {v}"), - Ok(Ok(None)) => eprintln!("HashMap.get returned None (entry vanished)"), - Ok(Err(_)) => eprintln!("HashMap.get panicked"), - Err(_) => eprintln!("HashMap.get TIMED OUT (infinite probe on corrupted ctrl bytes)"), - } - drop(filler); - zk_alloc::end_phase(); - - let expected = 100_u64.wrapping_mul(100).wrapping_add(0xDEADBEEF); - let pristine = matches!(outcome, Ok(Ok(Some(v))) if v == expected); - assert!( - !pristine, - "expected HashMap corruption (timeout / panic / wrong value), got pristine" - ); - eprintln!( - "BUG CONFIRMED: HashMap retained across phase boundary corrupted (outcome: {outcome:?})" - ); -} diff --git a/tests/test_realloc_phase.rs b/tests/test_realloc_phase.rs deleted file mode 100644 index 965e7b0..0000000 --- a/tests/test_realloc_phase.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Scenario 5: realloc across a phase boundary. -//! -//! A Vec allocated in phase N owns a pointer into the arena slab. When phase -//! N+1 begins (arena reset) and a fresh allocation lands at the same slab -//! offset, then the Vec is grown via push(), our realloc impl calls -//! alloc(new_size) and then copy_nonoverlapping(old_ptr, new_ptr, -//! old_layout.size()). The source is recycled memory — now holding the new -//! phase's data, not the original Vec's bytes. Result: the Vec's "preserved" -//! contents are silently replaced by whatever phase N+1 allocated first. -//! -//! Distinct from F1 (rayon Injector) and F4 (tracing Registry): those are -//! library-internal pooled allocations. This is a *user-visible* Vec the -//! caller deliberately holds across phases. -//! -//! The size-routing fix (MIN_ARENA_BYTES=4096) does NOT address this: any -//! Vec >= 4 KB lands in arena and inherits the bug. -//! -//! These tests assert the bug REPRODUCES (since no fix is deployed yet for -//! this case). Once a fix lands, flip the assertions. - -#[global_allocator] -static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; - -#[test] -fn realloc_across_phase_corrupts_retained_vec() { - // Must be >= MIN_ARENA_BYTES (default 4096) so the Vec lands in arena. - const SIZE: usize = 8192; - const FILL: u8 = 0xAA; - const OVERWRITE: u8 = 0x55; - - zk_alloc::begin_phase(); - let mut v: Vec = vec![FILL; SIZE]; - let v_orig_ptr = v.as_ptr() as usize; - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - // Lands at the same slab offset as v after arena reset (cold path resets - // ARENA_PTR to ARENA_BASE on first alloc of new generation). - let overwrite: Vec = vec![OVERWRITE; SIZE]; - let overwrite_ptr = overwrite.as_ptr() as usize; - - eprintln!( - "v orig ptr: 0x{v_orig_ptr:x}, overwrite ptr: 0x{overwrite_ptr:x}, aliased={}", - v_orig_ptr == overwrite_ptr - ); - - // Trigger realloc. len == cap == SIZE → push grows by doubling. - v.push(FILL); - - let v_new_ptr = v.as_ptr() as usize; - let first_corrupted = v[..SIZE].iter().position(|&b| b != FILL); - - zk_alloc::end_phase(); - drop(overwrite); - - eprintln!("v new ptr: 0x{v_new_ptr:x}"); - if let Some(p) = first_corrupted { - eprintln!( - "BUG REPRODUCED: v[{p}] = 0x{:02x} (expected 0x{FILL:02x}, got OVERWRITE 0x{OVERWRITE:02x})", - v[p] - ); - } - - // No fix is deployed for this case yet; bug should reproduce. - assert!( - first_corrupted.is_some(), - "expected realloc-across-phase corruption (v_orig_ptr aliased overwrite_ptr=={}); \ - got pristine FILL bytes — either the bug got fixed or the layout changed", - v_orig_ptr == overwrite_ptr - ); -} - -/// Larger Vec, more conclusive. 16 KB > MIN_ARENA_BYTES default 4 KB and -/// also > most plausible threshold raises. -#[test] -fn realloc_across_phase_corrupts_large_vec() { - const SIZE: usize = 16384; - const FILL: u8 = 0xC1; - const OVERWRITE: u8 = 0x3E; - - let _ = vec![0u8; 1024]; // warm up - - zk_alloc::begin_phase(); - let mut v: Vec = vec![FILL; SIZE]; - let v_orig_ptr = v.as_ptr() as usize; - zk_alloc::end_phase(); - - zk_alloc::begin_phase(); - let overwrite: Vec = vec![OVERWRITE; SIZE]; - let overwrite_ptr = overwrite.as_ptr() as usize; - - assert_eq!( - v_orig_ptr, overwrite_ptr, - "phase reset should re-bump ARENA_PTR to slab base, aliasing the original vec ptr" - ); - - v.push(FILL); - let first_corrupted = v[..SIZE].iter().position(|&b| b != FILL); - - zk_alloc::end_phase(); - drop(overwrite); - - let p = first_corrupted - .expect("expected realloc-across-phase corruption — got pristine FILL bytes (bug fixed?)"); - eprintln!( - "confirmed: v[{p}] = 0x{:02x} (overwrite 0x{OVERWRITE:02x}, not original 0x{FILL:02x})", - v[p] - ); -} From 7e3d73fa7125f75ca8cd7c6a9a3c4c17a0b742cf Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:46:09 +0200 Subject: [PATCH 16/21] fix: sticky-System routing in realloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an allocation's original ptr is not in the arena region (it came from System — either pre-phase, sub-threshold, or otherwise routed), realloc now goes through System.realloc instead of self.alloc + copy. This prevents the silent System→arena migration on growth: a Vec allocated outside a phase (System) that grows inside a phase would otherwise have its new buffer placed in the arena, where it becomes subject to phase recycling. System.realloc may also grow in place (mremap on Linux), avoiding the explicit copy that self.alloc + copy_nonoverlapping forces. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index ddef436..9bf649a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -359,6 +359,18 @@ unsafe impl GlobalAlloc for ZkAllocator { if new_size <= layout.size() { return ptr; } + // Sticky-System routing: if the original allocation came from System + // (small, or pre-phase, or routed by size-routing), keep the grown + // allocation in System too. Without this, a Vec allocated outside + // a phase that grows inside one would silently migrate into the + // arena and become subject to phase recycling. + let addr = ptr as usize; + let base = REGION_BASE.load(Ordering::Relaxed); + let region_size = REGION_SIZE.load(Ordering::Relaxed); + let in_arena = base != 0 && addr >= base && addr < base + region_size; + if !in_arena { + return unsafe { std::alloc::System.realloc(ptr, layout, new_size) }; + } let new_layout = unsafe { Layout::from_size_align_unchecked(new_size, layout.align()) }; let new_ptr = unsafe { self.alloc(new_layout) }; if !new_ptr.is_null() { From bbf845dede773f689c5508180d8ff3510bf8c28e Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:46:54 +0200 Subject: [PATCH 17/21] docs: rewrite module-level intro for the two-allocator model Document explicitly that ZkAllocator dispatches between arena and System, with size-routing and sticky-System realloc keeping the boundaries clean. Spell out the phase-scoping contract (don't retain across begin_phase), the available env vars (SLAB_GB / MIN_BYTES / POISON_RESET), and update the example to use phase() / PhaseGuard for panic safety. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9bf649a..190ac85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,69 @@ //! Bump-pointer arena allocator for ZK proving workloads. //! -//! One mmap region split into per-thread slabs. Allocation = increment a thread-local -//! pointer; free = no-op. `begin_phase()` resets the arena: each thread's next -//! allocation starts over at the beginning of its slab, overwriting the previous -//! phase's data. Allocations that don't fit (too large, or beyond max threads) fall -//! back to the system allocator. +//! # Two-allocator model //! -//! Slab size defaults to 8GB per thread. Set `ZK_ALLOC_SLAB_GB` to override -//! (e.g. `ZK_ALLOC_SLAB_GB=12` for large workloads). Use `overflow_stats()` -//! to check if allocations spill to the system allocator. +//! `ZkAllocator` is a façade over two allocators selected per call: +//! +//! - **Arena**: one `mmap` region split into per-thread slabs. Allocation +//! bumps a thread-local pointer; `dealloc` is a no-op. `begin_phase()` +//! resets every slab so the next phase reuses the same physical pages. +//! - **System**: `std::alloc::System` (glibc on Linux). Used for everything +//! the arena shouldn't hold: +//! - any allocation when no phase is active; +//! - any allocation smaller than [`min_arena_bytes()`] even during a phase +//! (size-routing — keeps small library bookkeeping outside the arena); +//! - oversize allocations or threads that arrived after slabs were claimed +//! ([`overflow_stats()`] reports these); +//! - regrowth via `realloc` of a pointer that was already in System +//! (sticky-System routing — System allocations don't migrate to arena +//! on growth, even if the new size exceeds the size-routing threshold). +//! +//! # Phase scoping contract +//! +//! `begin_phase()` activates the arena and resets every slab. `end_phase()` +//! deactivates the arena. Allocations made during phase N must not be held +//! past `begin_phase()` of phase N+1: that call recycles the slab, and the +//! next allocation at the same offset will silently overwrite the retained +//! bytes. +//! +//! Practical rules: +//! +//! 1. Drop or `clone()` arena-allocated values before the phase ends. +//! 2. Use [`PhaseGuard`] / [`phase`] to ensure `end_phase` runs even on +//! panic — without it, an unwinding phase leaves the arena active and +//! subsequent "post-phase" allocations land in arena territory. +//! 3. Keep long-lived state (thread pools, channels, registries, caches) +//! constructed *outside* any active phase so it lives in System. +//! +//! # Realloc migration: prevented +//! +//! `realloc` checks whether the input pointer lies in the arena region. +//! If it does, growth goes through the normal arena path (subject to +//! size-routing). If it does not, growth stays in System via +//! `System::realloc` — preventing the failure mode where a System-backed +//! `Vec` silently migrates into the arena on `push`. +//! +//! # Configuration +//! +//! - `ZK_ALLOC_SLAB_GB` — per-thread slab size in GiB (default `8`). +//! - `ZK_ALLOC_MIN_BYTES` — size-routing threshold in bytes (default `4096`). +//! Set to `0` to send every active-phase allocation to the arena. +//! - `ZK_ALLOC_POISON_RESET` — diagnostic; set to `1` to `MADV_DONTNEED` +//! the previous phase's pages on reset (catches stale-pointer reads as +//! zero pages instead of last-phase data). +//! +//! # Example //! //! ```ignore +//! use zk_alloc::ZkAllocator; +//! +//! #[global_allocator] +//! static ALLOC: ZkAllocator = ZkAllocator; +//! //! loop { -//! begin_phase(); // arena ON; slabs reset lazily -//! let res = heavy_work(); // fast bump increments -//! end_phase(); // arena OFF; new allocations go to System -//! let copy = res.clone(); // detach from arena before next phase resets it +//! let proof = zk_alloc::phase(|| heavy_work()); // arena on inside +//! let output = proof.clone(); // detach into System +//! submit(output); //! } //! ``` From fbac3c019d4bef9c297722ca286ac6679b5ed52c Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:47:16 +0200 Subject: [PATCH 18/21] docs(README): usage section covers two-allocator model + env vars Replace the single-loop example with one using phase() (panic-safe), then add three new sections: the two-allocator model (arena vs System and what goes where), the phase-scoping contract (don't retain across begin_phase, construct long-lived state pre-phase, prefer phase() RAII), and a table of the three env vars (SLAB_GB / MIN_BYTES / POISON_RESET). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 558f131..9c24b3a 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,51 @@ static ALLOC: ZkAllocator = ZkAllocator; fn main() { loop { - zk_alloc::begin_phase(); // activate arena, reset slabs - let proof = generate_proof(); // all allocs go to arena - zk_alloc::end_phase(); // deactivate arena - let output = proof.clone(); // clone out before next reset + let proof = zk_alloc::phase(|| generate_proof()); // arena on inside + let output = proof.clone(); // detach to System submit(output); } } ``` +`phase(|| { ... })` activates the arena, runs the closure, and deactivates +on return — including during panic unwinding (it's an RAII wrapper around +`begin_phase()` / `end_phase()`, which are also exposed for callers that +need finer-grained control). + +### Two-allocator model + +`ZkAllocator` routes each request to one of two backends: + +- **Arena** — bump-pointer slab, used during an active phase for allocations + ≥ `ZK_ALLOC_MIN_BYTES` (default 4096). Reset on the next `begin_phase()`. +- **System** — `glibc malloc`, used for everything else: allocations made + outside any phase, allocations under the size-routing threshold (small + library bookkeeping like rayon's injector blocks, tracing-subscriber + registry slots, hashbrown HashMap entries), and `realloc` of any pointer + that originated in System (sticky-System routing — System allocations + never silently migrate to arena on growth). + +### Phase-scoping contract + +Allocations made during phase N must not be held past `begin_phase()` of +phase N+1 — that call recycles the slab, and the next allocation at the +same offset overwrites the retained bytes. In practice: + +1. Drop or `clone()` arena-allocated values before the phase ends. +2. Construct long-lived state (thread pools, channels, registries) *before* + any phase begins so it lives in System. +3. Use `phase(|| { ... })` (or a `PhaseGuard`) instead of paired calls so + the phase ends correctly even on panic. + +### Environment variables + +| Variable | Default | Effect | +|----------|---------|--------| +| `ZK_ALLOC_SLAB_GB` | `8` | Per-thread slab size, in GiB. Raise for workloads that overflow (`overflow_stats()` reports the count). | +| `ZK_ALLOC_MIN_BYTES` | `4096` | Size-routing threshold. Allocations smaller than this go to System even during a phase. Set to `0` to send everything to arena (loses size-routing protection against library-internal pooled allocations). | +| `ZK_ALLOC_POISON_RESET` | unset | Diagnostic. Set to `1` to `MADV_DONTNEED` the previous phase's pages on reset, so any stale-pointer read returns zero pages instead of last-phase data. | + ## Results | Prover | Architecture | vs glibc | Mechanism | From d566c579efb3f6f397623d21f580c69b1069576f Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:52:19 +0200 Subject: [PATCH 19/21] fix(test): cross_thread_begin_phase platform-aware assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS aarch64 the test's two allocations land at different addresses (T1's first alloc goes to System ~5GB while phase-2 arena allocations sit ~500GB up), so v and w never alias and the cross-thread invalidation isn't observable from this test. The bug is real on macOS too — the allocator's GENERATION atomic is global — but this specific reproducer needs aliasing to detect it. Track aliasing via a separate AtomicBool and only assert corruption when v and w aliased. Otherwise pass with a "test inconclusive on this platform" note. Linux behavior unchanged (deterministic corruption). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_concurrent_phase.rs | 37 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/test_concurrent_phase.rs b/tests/test_concurrent_phase.rs index 764cab0..622558c 100644 --- a/tests/test_concurrent_phase.rs +++ b/tests/test_concurrent_phase.rs @@ -27,11 +27,13 @@ fn cross_thread_begin_phase_invalidates_data() { let _: u64 = (0..1024_u64).map(|i| i * 2).sum(); let barrier = Arc::new(Barrier::new(2)); + let aliased = Arc::new(AtomicBool::new(false)); let bug = Arc::new(AtomicBool::new(false)); zk_alloc::begin_phase(); let bar1 = Arc::clone(&barrier); + let aliased1 = Arc::clone(&aliased); let bug1 = Arc::clone(&bug); let t1 = thread::spawn(move || { let v: Vec = vec![0xAA; 8192]; @@ -41,11 +43,17 @@ fn cross_thread_begin_phase_invalidates_data() { // The cross-thread begin_phase bumped GENERATION. T1's ARENA_GEN is // now stale → cold path on next alloc resets ARENA_PTR to T1's slab - // base, where v lives. w overlaps v. + // base. On Linux this lands w on top of v; macOS aarch64 places v + // and w in different ranges (T1's first alloc may go to System), + // so the overlap doesn't happen — the bug is real but unobservable + // from this test on that platform. let w: Vec = vec![0xBB; 8192]; let w_ptr = w.as_ptr() as usize; let v_corrupted = v.iter().any(|&b| b != 0xAA); eprintln!("t1: v=0x{v_ptr:x} w=0x{w_ptr:x} v_corrupt={v_corrupted}"); + if v_ptr == w_ptr { + aliased1.store(true, Ordering::Relaxed); + } if v_corrupted { bug1.store(true, Ordering::Relaxed); } @@ -64,12 +72,27 @@ fn cross_thread_begin_phase_invalidates_data() { zk_alloc::end_phase(); - // Bug should reproduce: cross-thread begin_phase invalidates T1's data. - assert!( - bug.load(Ordering::Relaxed), - "expected cross-thread begin_phase to corrupt T1's arena Vec — \ - either the bug got fixed or per-thread layout prevented overlap" - ); + let saw_aliasing = aliased.load(Ordering::Relaxed); + let saw_corruption = bug.load(Ordering::Relaxed); + + if saw_aliasing { + // Linux: cold-path slab reset re-bumps to slab base, w aliases v, + // and the writes to w corrupt v's bytes. + assert!( + saw_corruption, + "v and w aliased but v's bytes are pristine — \ + cross-thread invalidation got fixed or layout assumption changed" + ); + } else { + // macOS aarch64 (and any platform where T1's two allocations land + // at different addresses) — corruption can't be observed via this + // exact pattern, but the underlying hazard remains. Pass without + // asserting; document. + eprintln!( + "test inconclusive on this platform: v and w didn't alias, \ + so cross-thread invalidation isn't observable here" + ); + } } /// Two threads each running their own begin_phase/work/end_phase loop — From 8c85112959be204f01b0ef95d9137f62b518a6ca Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 14:57:28 +0200 Subject: [PATCH 20/21] fix(test): cap recursion depth at 256 to fit macOS debug stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS aarch64 has a smaller default thread stack than Linux, and debug builds inflate per-frame size. nested_join recursion at depths 1024 / 2048 / 512 overflowed the rayon worker stack on the macOS CI runner. Cap all DEPTH constants at 256. That still drives crossbeam-deque's Buffer to its 256-slot capacity (~4 KB header included), which crosses MIN_ARENA_BYTES=4096 — so the test still exercises the size-routing boundary the original depths targeted. Verified on linux in both release and debug builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_worker_deque_growth.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_worker_deque_growth.rs b/tests/test_worker_deque_growth.rs index 07ae8ca..7e6b45b 100644 --- a/tests/test_worker_deque_growth.rs +++ b/tests/test_worker_deque_growth.rs @@ -10,9 +10,13 @@ //! Worker still references the same Buffer pointer. The next push writes //! a JobRef into recycled memory. //! -//! Tests: drive deep rayon::join recursion from inside a worker (so pushes +//! Tests: drive rayon::join recursion from inside a worker (so pushes //! land on a worker's local deque, not the global Injector) to force Buffer //! growth past the size-routing threshold, then look for canary corruption. +//! +//! Recursion depth is capped at 256 to stay within macOS's smaller default +//! thread stack in debug builds; 256 pending tasks already drives the +//! Buffer to the 256-slot capacity that crosses MIN_ARENA_BYTES=4096. use rayon::prelude::*; @@ -33,7 +37,7 @@ fn worker_deque_growth_during_phase() { let _: u64 = (0..1_000_000_u64).into_par_iter().sum(); const CYCLES: usize = 20; - const DEPTH: usize = 1024; // > 256 → forces Buffer growth past 4 KB + const DEPTH: usize = 256; // crosses Buffer growth into arena (~4 KB) let mut failures = 0; for cycle in 0..CYCLES { @@ -76,7 +80,7 @@ fn deep_recursion_phase_cycle_program_integrity() { for _ in 0..50 { zk_alloc::begin_phase(); - rayon::join(|| nested_join(2048), || {}); + rayon::join(|| nested_join(256), || {}); zk_alloc::end_phase(); } } @@ -103,7 +107,7 @@ fn worker_buffer_growth_with_per_worker_canary() { const CYCLES: usize = 20; for cycle in 0..CYCLES { zk_alloc::begin_phase(); - rayon::join(|| nested_join_with_alloc(512), || {}); + rayon::join(|| nested_join_with_alloc(256), || {}); zk_alloc::end_phase(); zk_alloc::begin_phase(); From 6e0049626b0f669d521f2586056aabbc32b1cf37 Mon Sep 17 00:00:00 2001 From: Barnadrot Date: Wed, 6 May 2026 15:06:55 +0200 Subject: [PATCH 21/21] fix(test): serialize test_phase_guard tests via file-local mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 tests touch process-global state — ARENA_ACTIVE, the bump pointer, and the panic hook — and Cargo runs tests in parallel by default. With nested_phase_guards_compose racing phase_guard_runs_end_phase_on_normal_return, the latter could observe ARENA_ACTIVE=true at the wrong moment and route its `after` allocation into the arena, where the next phase's filler recycled it. Add a file-local `static PHASE_LOCK: Mutex<()>` and grab it at the top of each test. No external deps; serializes only within this test binary. Other test binaries keep their global state isolated by living in separate processes (per Cargo's test-binary boundary). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_phase_guard.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_phase_guard.rs b/tests/test_phase_guard.rs index fbe3eaf..a914815 100644 --- a/tests/test_phase_guard.rs +++ b/tests/test_phase_guard.rs @@ -2,12 +2,19 @@ //! impossible by construction. Drop runs during unwind, calling end_phase. //! //! Mirrors test_panic_phase but uses the RAII API. Asserts NO corruption. +//! +//! All three tests in this binary touch the global ARENA_ACTIVE / bump +//! pointer state, so they must not run concurrently — the panic-handler +//! hook is also process-global. Serialize via a file-local mutex. + +static PHASE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[global_allocator] static A: zk_alloc::ZkAllocator = zk_alloc::ZkAllocator; #[test] fn phase_guard_runs_end_phase_on_panic() { + let _lock = PHASE_LOCK.lock().unwrap(); use std::panic; panic::set_hook(Box::new(|_| {})); @@ -58,6 +65,7 @@ fn phase_guard_runs_end_phase_on_panic() { #[test] fn phase_guard_runs_end_phase_on_normal_return() { + let _lock = PHASE_LOCK.lock().unwrap(); let v = zk_alloc::phase(|| vec![0xAB_u8; 8192]); // After phase, arena is inactive. Subsequent allocations go to System. let after: Vec = vec![0xCD_u8; 8192]; @@ -79,6 +87,7 @@ fn phase_guard_runs_end_phase_on_normal_return() { #[test] fn nested_phase_guards_compose() { + let _lock = PHASE_LOCK.lock().unwrap(); // Outer phase + inner phase. Inner phase end_phases (sets active=false), // then outer phase end_phases again. Sequence: begin, begin, end, end. // Final state: active=false. No panic.