(feat): add 2-tier pool safety — compile-time escape detection + runtime validation#25
(feat): add 2-tier pool safety — compile-time escape detection + runtime validation#25
Conversation
@with_pool and @maybe_with_pool function definition forms were missing POOL_DEBUG escape detection. Block forms had it, but the 4 function code-generation paths (_generate_function_pool_code and _generate_function_pool_code_with_backend, force_enable=true/false) did not capture _result or call _validate_pool_return. Also fix latent bug in _check_pointer_overlap where iterating BitTypedPool.vectors called pointer(::BitVector) which is undefined. Added `v isa Array || continue` guard to skip BitVector entries.
Split src/utils.jl into two focused files: - src/debug.jl: POOL_DEBUG, _validate_pool_return, _check_pointer_overlap - src/utils.jl: pool_stats, Base.show (statistics and display only) debug.jl is included after bitarray.jl, eliminating the forward reference to _check_bitchunks_overlap that existed when this code lived in utils.jl. Corresponding test split: - test/test_debug.jl: 17 testsets for escape detection - test/test_utils.jl: 8 testsets for statistics and display
…wind Add 2-tier safety toggle: STATIC_POOL_CHECKS (compile-time via @load_preference) gates POOL_SAFETY (runtime Ref, levels 0/1/2). Level 1 (guard, default): on rewind/reset, released slots are structurally invalidated — backing vectors resize!'d to 0 and cached Array/BitArray wrappers get setfield! size zeroed. Stale references throw BoundsError instead of silently returning corrupted data. Level 2 extends Level 1 with escape detection (POOL_DEBUG backward compat preserved: both POOL_SAFETY>=2 and POOL_DEBUG[] trigger _validate_pool_return). Zero-alloc round-trip preserved: resize!(v,0) keeps capacity, so re-acquire restores length within allocation.
Add structural invalidation on rewind for the legacy (Julia 1.10) code path: - TypedPool: resize! backing vectors to 0 + reset view_lengths to force SubArray cache misses (Array setfield! unavailable on 1.10) - BitTypedPool: resize! BitVectors to 0 + zero nd_ptrs to force N-way cache misses + setfield! BitArray len/dims to 0 - Version-gate unsafe_acquire! Array wrapper tests to Julia 1.11+ - Disable POOL_SAFETY_LV during zero-allocation tests (invalidation forces cache misses → new allocations on legacy)
Poison released backing vectors with detectable sentinel values (NaN for floats, typemax for integers) before structural invalidation. Shared _poison_released_vectors! in debug.jl, called from per-path _invalidate_released_slots! when POOL_SAFETY_LV[] >= 2.
AST analysis at macro expansion time detects pool-backed variables in return expressions. Definite escapes (bare `v`, `return v`) throw compile-time errors; possible escapes (containers) emit warnings. Zero runtime cost — runs only during compilation. Runtime guard tests updated to use identity() bypass so they properly exercise _validate_pool_return at LV2.
…case tests Add 79 new test assertions (11→90 total) covering all macro forms, escape scenarios, false-positive prevention, and error message verification: - Block/function forms for @with_pool, @maybe_with_pool, backend (:cpu) - Tricky-but-safe true negative edge cases (pipe, comprehension, ternary, broadcast, copy, similar, let/do blocks, Dict, string interpolation) - NamedTuple key-name-vs-value distinction, reassignment chain tracking - Nested scope detection, error/warning message content verification
…ring support - Promote tuple/array/NamedTuple container escapes from warning to error (zero false-positive risk after systematic analysis of all reassignment patterns) - Add tuple destructuring support to _extract_acquired_vars and _remove_flat_reassigned! — handles (a, v) = expr and v, x = x, v swap - Remove _is_definite_escape (no longer needed — all escapes are errors) - Unify error message: lists all escaped variables with collect() guidance, singular/plural aware
Replace flat error() string with custom PoolEscapeError exception type and Base.showerror using printstyled for terminal-aware colored output: - Bold red variable names, gray annotations and file path - Multi-line layout with per-variable listing - Two-tier suggestions: trace definitions + identity() escape hatch - Tests use type matching (@test_throws PoolEscapeError) and field inspection
…ernary) Previously only checked the last expression of the block body, missing explicit `return` inside if/else branches, loops, and try/catch. Add _collect_all_return_values with two-source scanning: - _collect_explicit_returns!: walks AST for all `return expr` (skips nested fns) - _collect_implicit_return_values!: recurses into if/elseif/else branch tails
…essions Add EscapePoint tracking and _render_return_expr for rich diagnostics: - Each problematic return shown with source line and expression - Escaped variable names highlighted in red within the expression context - NamedTuple keys stay white, only values get colored (v = v vs v = v) - Line numbers tracked via LineNumberNode walk in _collect_*_returns!
… and identity transparency - Track simple aliases (d = z), container wrapping (d = (z,), d = [z]), and identity() calls as acquired variables - Recurse into nested containers in _find_direct_exposure for deep escape detection - Add @skip_check_vars macro to suppress false positives for listed variables - Make identity() transparent in escape analysis (no longer serves as escape hatch) - Suppress stacktrace via 3-arg Base.showerror for PoolEscapeError - Color PoolEscapeError header red and improve Fix/False-positive messages
…ontainers Runtime LV2 escape detection now inspects inside common container types, catching pool-backed arrays hidden in return values like (sum(v), v) or (data=v, n=10). Also fix identity() transparency tests to use @skip_check_vars for compile-time suppression.
Add _validate_pool_return dispatches for AbstractDict and AbstractSet, and recurse into Array elements when eltype may contain arrays. Includes isconcretetype guard for pointer overlap and _eltype_may_contain_arrays guard to skip leaf-type element iteration.
Replace generic "returned an Array backed by pool memory" with descriptive messages using summary(val) showing exact type, dimensions, and pool type. Pass original_val through overlap checks so SubArray/ReshapedArray cases report the user-visible value, not the internal parent vector. Add LV=3 hint for future acquire-site tracking.
…atted escape messages Introduce PoolRuntimeEscapeError exception type with colored showerror matching compile-time PoolEscapeError style. Shows escaped array summary, pool type, fix suggestions, and LV=3 hint. Suppress stacktrace for cleaner output.
…_LV=3) At LV=3, escape detection errors now show WHERE the problematic acquire! originated (file:line), not just WHAT escaped. Uses a two-field design: _pending_callsite (set by macro before each acquire) + _borrow_log (IdDict keyed by vector identity, lazily initialized). Macro AST pass (_inject_pending_callsite) walks block statements tracking LineNumberNodes to inject callsite assignments. Public API functions use isempty() guard to only set generic fallback labels for direct calls. Zero overhead at LV<3 (compile-time gated + branch-predicted away).
…tection Transform `return expr` in macro-generated code to validate the return value before exiting, preventing pool-backed arrays from bypassing _validate_pool_return via explicit return. Stops recursion at :function and :-> boundaries to respect nested function semantics.
…acking Show the complete source statement (e.g. `v = acquire!(pool, Float64, 10)`) instead of just the acquire call. Add return-site tracking so escape errors display both where the array was acquired and where it escapes via `return`.
…r/alias Enhance compile-time PoolEscapeError messages to distinguish between: - pool-acquired view (acquire!, zeros!, ones!, similar!, reshape!) - pool-acquired array (unsafe_acquire!, acquire_array!) - pool-acquired BitArray (trues!, falses!) - container wrapping pool variables (e.g., a = [v, 1]) - alias of pool variable (e.g., d = v) Fix suggestions now target the actual pool variables (not containers), and note when pool vars should be copied before wrapping.
Return PoolEscapeError from _check_compile_time_escape instead of throwing directly, so the macro returns :(throw(err)) — placing the throw at the user's code location. This keeps only Base-internal frames in the backtrace, which Julia's show_backtrace filters out.
Remove @skip_check_vars macro (and its export) since the escape detector no longer supports suppression annotations. Tests now use _test_leak(x) = x as an opaque function to bypass compile-time escape analysis while still exercising runtime LV2 detection.
Extract and display where each escaping variable was assigned (acquire!, alias, container) with source file and line locations. Improves error readability with descriptive header, declaration section, and location markers on escape return points.
Introduce _CheckOn/_CheckOff singleton tags and _POOL_CHECK_TAG const for idiomatic Julia multiple dispatch instead of @static if blocks. Replace ~50 repetitive 3-line @static if STATIC_POOL_CHECKS blocks across acquire.jl, bitarray.jl, and convenience.jl with single-line _set_pending_callsite! and _maybe_record_borrow! helper calls. Net result: -33 lines (99 insertions, 132 deletions).
There was a problem hiding this comment.
Pull request overview
This PR introduces a two-tier pool safety system: compile-time AST escape detection (STATIC_POOL_CHECKS / PoolEscapeError) that catches pool variable escapes at macro expansion with zero runtime cost, and a new runtime safety level system (POOL_SAFETY_LV 0–3) that provides progressive protections including structural invalidation, NaN/sentinel poisoning of released memory, and a borrow registry for acquire call-site tracking in error messages. The POOL_DEBUG flag is retained as a legacy backward-compatible alias for escape detection.
Changes:
- New
POOL_SAFETY_LVruntime enum with structural invalidation (LV1), poisoning (LV2), and borrow registry (LV3); replaces/supersedesPOOL_DEBUG - Compile-time escape detection via
_check_compile_time_escapeAST analysis at macro expansion time, emittingPoolEscapeError - New test files (
test_safety.jl,test_debug.jl,test_compile_escape.jl,test_borrow_registry.jl) plus migration of old debug tests fromtest_utils.jl
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
src/types.jl |
Adds STATIC_POOL_CHECKS, POOL_SAFETY_LV, borrow registry fields, and tag dispatch helpers |
src/debug.jl |
New file: POOL_DEBUG, _validate_pool_return, PoolRuntimeEscapeError, poisoning, and borrow registry |
src/state.jl |
Structural invalidation (_invalidate_released_slots!) on rewind and reset |
src/macros.jl |
Compile-time escape analysis, PoolEscapeError, callsite injection, return stmt transformation |
src/acquire.jl |
Borrow tracking hooks (_set_pending_callsite!, _maybe_record_borrow!) on each acquire |
src/convenience.jl |
Borrow tracking hooks on all convenience acquire functions |
src/bitarray.jl |
Updated escape error reporting with PoolRuntimeEscapeError |
src/utils.jl |
Removed old _validate_pool_return implementation (moved to debug.jl) |
src/AdaptiveArrayPools.jl |
Exports new public API; includes debug.jl |
src/legacy/types.jl |
Adds borrow registry fields to legacy AdaptiveArrayPool |
src/legacy/state.jl |
Structural invalidation on legacy path; handles no setfield!(:size) on 1.10 |
src/legacy/bitarray.jl |
Updated escape error reporting |
test/runtests.jl |
Adds new test files to both branches |
test/test_safety.jl |
New: invalidation level tests |
test/test_debug.jl |
New: runtime escape validation tests (moved + extended from test_utils.jl) |
test/test_compile_escape.jl |
New: comprehensive compile-time escape detection tests |
test/test_borrow_registry.jl |
New: LV=3 borrow tracking tests |
test/test_utils.jl |
Removed old debug tests |
test/test_state.jl |
Temporarily disables invalidation in capacity-preserving tests |
test/test_basic.jl |
Temporarily disables invalidation in backing-vector size tests |
test/test_allocation.jl |
Disables invalidation for zero-alloc reuse tests |
test/test_backend_macro_expansion.jl |
Updates assertion to reflect new _validate_pool_return in generated code |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Change `return PoolEscapeError(...)` back to `throw(PoolEscapeError(...))` in `_check_compile_time_escape` (regression from 0a474ef broke all @test_throws) - Add _CheckOn/_CheckOff tag dispatch infrastructure to legacy/types.jl so shared convenience.jl can call _set_pending_callsite! on Julia < 1.11 - Fix test string: "escape the" → "escapes the" matching updated showerror
…anup - Clear _pending_callsite in _record_borrow_from_pending! after consuming, so subsequent acquire! calls get fresh callsite (was stale after first) - Add _pending_return_site clearing to reset! (modern + legacy paths) - Add full borrow registry cleanup to legacy reset! (was missing entirely) - Fix POOL_SAFETY_LV comment: mention level 3 (borrow registry)
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #25 +/- ##
==========================================
+ Coverage 95.92% 96.41% +0.48%
==========================================
Files 13 14 +1
Lines 1768 2591 +823
==========================================
+ Hits 1696 2498 +802
- Misses 72 93 +21
🚀 New features to boost your workflow:
|
Summary
Add a two-layer safety system: compile-time AST analysis catches escapes before any code runs, and runtime
POOL_SAFETY_LVlevels provide progressive protection from guard invalidation to full borrow tracking.Key changes
1. Compile-time escape detection (
STATIC_POOL_CHECKS)The
@with_poolmacro analyzes the body AST at macro-expansion time and throwsPoolEscapeErrorif a pool-backed variable escapes:Detects: bare returns,
return v, tuple/array containers(v, w), aliases, destructuring, and explicitreturnin all branches (if/else, loops, ternary).Gated by
STATIC_POOL_CHECKS(compile-time const viaLocalPreferences.toml: pool_checks). Whenfalse, all safety code — both compile-time and runtime — is elided.2. POOL_SAFETY_LV — runtime safety levels
LV 1 — Guard invalidation: On rewind, released slots are structurally invalidated (
resize!+setfield!) so stale references immediately error instead of silently reading corrupted data. Backported to legacy (Julia 1.10) path.LV 2 — Runtime escape detection:
_validate_pool_returnrecursively inspects return values (Tuple, NamedTuple, Pair, Dict, Set, Vector) for pool-backed arrays via pointer overlap. ThrowsPoolRuntimeEscapeErrorwith type/size info. NaN/sentinel poisoning fills released memory.LV 3 — Borrow registry: Tracks which
acquire!call produced each pooled array. On escape, the error message includes exact source locations:3. Tag dispatch for safety helpers
Replaced ~50 repetitive
@static if STATIC_POOL_CHECKSblocks with Julia-idiomatic multiple dispatch on singleton tags: