diff --git a/ext/AdaptiveArrayPoolsCUDAExt/debug.jl b/ext/AdaptiveArrayPoolsCUDAExt/debug.jl index 3e74112..d09dca2 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/debug.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/debug.jl @@ -16,7 +16,7 @@ using AdaptiveArrayPools: _runtime_check, _validate_pool_return, _set_pending_callsite!, _maybe_record_borrow!, _invalidate_released_slots!, _check_wrapper_mutation!, _zero_dims_tuple, - _throw_pool_escape_error, + _throw_pool_escape_error, _scope_boundary, PoolRuntimeEscapeError # ============================================================================== @@ -289,23 +289,27 @@ function _check_cuda_pointer_overlap(arr::CuArray, pool::CuAdaptiveArrayPool, or isempty(rs) ? nothing : rs end + current_depth = pool._current_depth + # Check fixed slots AdaptiveArrayPools.foreach_fixed_slot(pool) do tp - _check_tp_cuda_overlap(tp, arr_ptr, arr_end, pool, return_site, original_val) + _check_tp_cuda_overlap(tp, arr_ptr, arr_end, current_depth, pool, return_site, original_val) end # Check others for tp in values(pool.others) - _check_tp_cuda_overlap(tp, arr_ptr, arr_end, pool, return_site, original_val) + _check_tp_cuda_overlap(tp, arr_ptr, arr_end, current_depth, pool, return_site, original_val) end return end @noinline function _check_tp_cuda_overlap( tp::AbstractTypedPool, arr_ptr::UInt, arr_end::UInt, - pool::CuAdaptiveArrayPool, return_site, original_val + current_depth::Int, pool::CuAdaptiveArrayPool, return_site, original_val ) - for v in tp.vectors + boundary = _scope_boundary(tp, current_depth) + for i in (boundary + 1):tp.n_active + v = @inbounds tp.vectors[i] v_ptr = UInt(pointer(v)) v_bytes = length(v) * sizeof(eltype(v)) v_end = v_ptr + v_bytes diff --git a/ext/AdaptiveArrayPoolsMetalExt/debug.jl b/ext/AdaptiveArrayPoolsMetalExt/debug.jl index 7a1316a..1e61b3d 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/debug.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/debug.jl @@ -16,7 +16,7 @@ using AdaptiveArrayPools: _runtime_check, _validate_pool_return, _set_pending_callsite!, _maybe_record_borrow!, _invalidate_released_slots!, _check_wrapper_mutation!, _zero_dims_tuple, - _throw_pool_escape_error, + _throw_pool_escape_error, _scope_boundary, PoolRuntimeEscapeError # ============================================================================== @@ -289,23 +289,27 @@ function _check_metal_overlap(arr::MtlArray, pool::MetalAdaptiveArrayPool, origi isempty(rs) ? nothing : rs end + current_depth = pool._current_depth + # Check fixed slots AdaptiveArrayPools.foreach_fixed_slot(pool) do tp - _check_tp_metal_overlap(tp, arr_buf, arr_off, arr_end, pool, return_site, original_val) + _check_tp_metal_overlap(tp, arr_buf, arr_off, arr_end, current_depth, pool, return_site, original_val) end # Check others for tp in values(pool.others) - _check_tp_metal_overlap(tp, arr_buf, arr_off, arr_end, pool, return_site, original_val) + _check_tp_metal_overlap(tp, arr_buf, arr_off, arr_end, current_depth, pool, return_site, original_val) end return end @noinline function _check_tp_metal_overlap( tp::AbstractTypedPool, abuf, aoff::Int, aend::Int, - pool::MetalAdaptiveArrayPool, return_site, original_val + current_depth::Int, pool::MetalAdaptiveArrayPool, return_site, original_val ) - for v in tp.vectors + boundary = _scope_boundary(tp, current_depth) + for i in (boundary + 1):tp.n_active + v = @inbounds tp.vectors[i] vptr = pointer(v) vbuf = vptr.buffer voff = Int(vptr.offset) diff --git a/src/bitarray.jl b/src/bitarray.jl index b82b518..3d93bc6 100644 --- a/src/bitarray.jl +++ b/src/bitarray.jl @@ -180,7 +180,8 @@ end # Safety Validation (S=1 runtime check mode) # ============================================================================== -# Check if BitArray chunks overlap with the pool's BitTypedPool storage +# Check if BitArray chunks overlap with pool's BitTypedPool storage +# (scope-aware: only checks vectors acquired in the current scope) function _check_bitchunks_overlap(arr::BitArray, pool::AdaptiveArrayPool, original_val = arr) arr_chunks = arr.chunks arr_ptr = UInt(pointer(arr_chunks)) @@ -191,7 +192,10 @@ function _check_bitchunks_overlap(arr::BitArray, pool::AdaptiveArrayPool, origin isempty(rs) ? nothing : rs end - for v in pool.bits.vectors + tp = pool.bits + boundary = _scope_boundary(tp, pool._current_depth) + for i in (boundary + 1):tp.n_active + v = @inbounds tp.vectors[i] v_chunks = v.chunks v_ptr = UInt(pointer(v_chunks)) v_len = length(v_chunks) * sizeof(UInt64) diff --git a/src/debug.jl b/src/debug.jl index 33d1ce6..3e112b9 100644 --- a/src/debug.jl +++ b/src/debug.jl @@ -66,7 +66,17 @@ _eltype_may_contain_arrays(::Type{Symbol}) = false _eltype_may_contain_arrays(::Type{Char}) = false _eltype_may_contain_arrays(::Type) = true -# Check if array memory overlaps with any pool vector. +# Scope-aware boundary: returns the n_active saved at checkpoint for `depth`. +# Vectors with index <= boundary belong to an outer scope and are NOT escapees. +# If this type has no checkpoint at `depth`, it was never touched in this scope → all safe. +@inline function _scope_boundary(tp::AbstractTypedPool, depth::Int) + @inbounds if tp._checkpoint_depths[end] == depth + return tp._checkpoint_n_active[end] # vectors[1:boundary] are from outer scopes + end + return tp.n_active # no checkpoint at this depth → nothing acquired here → all safe +end + +# Check if array memory overlaps with any pool vector **acquired in the current scope**. # `original_val` is the user-visible value (e.g., SubArray) for error reporting; # `arr` may be its parent Array used for the actual pointer comparison. function _check_pointer_overlap(arr::Array, pool::AdaptiveArrayPool, original_val = arr) @@ -78,8 +88,12 @@ function _check_pointer_overlap(arr::Array, pool::AdaptiveArrayPool, original_va isempty(rs) ? nothing : rs end + current_depth = pool._current_depth + check_overlap = function (tp) - for v in tp.vectors + boundary = _scope_boundary(tp, current_depth) + for i in (boundary + 1):tp.n_active + v = @inbounds tp.vectors[i] v isa Array || continue # Skip BitVector (no pointer(); checked via _check_bitchunks_overlap) v_ptr = UInt(pointer(v)) v_len = length(v) * sizeof(eltype(v)) @@ -260,9 +274,27 @@ end _poison_value(::Type{T}) where {T <: AbstractFloat} = T(NaN) _poison_value(::Type{T}) where {T <: Integer} = typemax(T) _poison_value(::Type{Complex{T}}) where {T} = Complex{T}(_poison_value(T), _poison_value(T)) -_poison_value(::Type{T}) where {T} = zero(T) # generic fallback +_poison_value(::Type{T}) where {T} = zero(T) # generic fallback (Rational, etc.) -_poison_fill!(v::Vector{T}) where {T} = fill!(v, _poison_value(T)) +function _poison_fill!(v::Vector{T}) where {T} + isempty(v) && return nothing + if !isbitstype(T) + # non-isbits (reference types): skip poison, resize!(v, 0) handles invalidation + return nothing + end + # isbits: try _poison_value dispatch (NaN, typemax, zero for known types), + # then duck-type 0 * first(v) for custom structs without zero(T). + # If neither works, skip poisoning — must not throw during rewind. + try + fill!(v, _poison_value(T)) + catch + try + fill!(v, 0 * first(v)) + catch + end + end + return nothing +end _poison_fill!(v::BitVector) = fill!(v, true) """ diff --git a/src/legacy/bitarray.jl b/src/legacy/bitarray.jl index ade867e..35bf25e 100644 --- a/src/legacy/bitarray.jl +++ b/src/legacy/bitarray.jl @@ -220,14 +220,18 @@ end # Safety Validation (S=1 runtime check mode) # ============================================================================== -# Check if BitArray chunks overlap with the pool's BitTypedPool storage +# Check if BitArray chunks overlap with pool's BitTypedPool storage +# (scope-aware: only checks vectors acquired in the current scope) function _check_bitchunks_overlap(arr::BitArray, pool::AdaptiveArrayPool, original_val = arr) arr_chunks = arr.chunks arr_ptr = UInt(pointer(arr_chunks)) arr_len = length(arr_chunks) * sizeof(UInt64) arr_end = arr_ptr + arr_len - for v in pool.bits.vectors + tp = pool.bits + boundary = _scope_boundary(tp, pool._current_depth) + for i in (boundary + 1):tp.n_active + v = @inbounds tp.vectors[i] v_chunks = v.chunks v_ptr = UInt(pointer(v_chunks)) v_len = length(v_chunks) * sizeof(UInt64) diff --git a/src/macros.jl b/src/macros.jl index e04b451..4a63d03 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -764,15 +764,18 @@ function _generate_block_inner(pool_name, expr, safe::Bool, source) local $(esc(entry_depth_var)) = $(esc(pool_name))._current_depth $checkpoint_call local _result = $(esc(transformed_expr)) - if $_RUNTIME_CHECK_REF($(esc(pool_name))) - $_validate_pool_return(_result, $(esc(pool_name))) - end + # Leaked scope cleanup BEFORE validation: if an inner @with_pool threw + # without rewind, _current_depth is still the inner depth. Validation + # uses _current_depth via _scope_boundary, so we must normalize first. if $_RUNTIME_CHECK_REF($(esc(pool_name))) && $(esc(pool_name))._current_depth > $(esc(entry_depth_var)) + 1 $_WARN_LEAKED_SCOPE_REF($(esc(pool_name)), $(esc(entry_depth_var))) end while $(esc(pool_name))._current_depth > $(esc(entry_depth_var)) + 1 $_REWIND_REF($(esc(pool_name))) end + if $_RUNTIME_CHECK_REF($(esc(pool_name))) + $_validate_pool_return(_result, $(esc(pool_name))) + end $rewind_call _result end @@ -839,15 +842,18 @@ function _generate_function_inner(pool_name, expr, safe::Bool, source) local $(esc(entry_depth_var)) = $(esc(pool_name))._current_depth $checkpoint_call local _result = $(esc(transformed_expr)) - if $_RUNTIME_CHECK_REF($(esc(pool_name))) - $_validate_pool_return(_result, $(esc(pool_name))) - end + # Leaked scope cleanup BEFORE validation: if an inner @with_pool threw + # without rewind, _current_depth is still the inner depth. Validation + # uses _current_depth via _scope_boundary, so we must normalize first. if $_RUNTIME_CHECK_REF($(esc(pool_name))) && $(esc(pool_name))._current_depth > $(esc(entry_depth_var)) + 1 $_WARN_LEAKED_SCOPE_REF($(esc(pool_name)), $(esc(entry_depth_var))) end while $(esc(pool_name))._current_depth > $(esc(entry_depth_var)) + 1 $_REWIND_REF($(esc(pool_name))) end + if $_RUNTIME_CHECK_REF($(esc(pool_name))) + $_validate_pool_return(_result, $(esc(pool_name))) + end $rewind_call _result end diff --git a/test/cuda/runtests.jl b/test/cuda/runtests.jl index cdff379..ef0b4f5 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -47,5 +47,6 @@ else include("test_disabled_pool.jl") include("test_cuda_safety.jl") include("test_runtime_mutation.jl") + include("test_scope_depth_validation.jl") end end diff --git a/test/cuda/test_scope_depth_validation.jl b/test/cuda/test_scope_depth_validation.jl new file mode 100644 index 0000000..7f81ec2 --- /dev/null +++ b/test/cuda/test_scope_depth_validation.jl @@ -0,0 +1,176 @@ +import AdaptiveArrayPools: PoolRuntimeEscapeError, _validate_pool_return, + _lazy_checkpoint!, _lazy_rewind! + +const _make_cuda_pool_scope = ext._make_cuda_pool + +# ============================================================================== +# CUDA Scope-Aware Validation: mirrors CPU test_scope_depth_validation.jl +# for CuAdaptiveArrayPool overlap checks (_check_tp_cuda_overlap). +# ============================================================================== + +@testset "CUDA Scope-depth-aware validation" begin + + # ------------------------------------------------------------------ + # Outer acquire, inner validate — should NOT throw + # ------------------------------------------------------------------ + @testset "outer acquire, inner validate — should NOT throw (CuArray)" begin + pool = _make_cuda_pool_scope(true) + + checkpoint!(pool) + v = acquire!(pool, Float32, 100) + CUDA.fill!(v, 1.0f0) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 50) # inner-scope acquire + + # v was acquired in depth 1 — returning it from depth 2 is safe + _validate_pool_return(v, pool) # ← Should NOT throw + + rewind!(pool) + @test CUDA.sum(v) ≈ 100.0f0 + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Inner-scope acquire SHOULD still throw + # ------------------------------------------------------------------ + @testset "inner-scope acquire — SHOULD still throw" begin + pool = _make_cuda_pool_scope(true) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 100) # outer acquire (safe) + + checkpoint!(pool) + w = acquire!(pool, Float32, 50) # inner acquire (should be caught) + + @test_throws PoolRuntimeEscapeError _validate_pool_return(w, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # 3-level nesting: only deepest acquire should throw + # ------------------------------------------------------------------ + @testset "3-level nesting: only deepest acquire should throw" begin + pool = _make_cuda_pool_scope(true) + + # Depth 1 + checkpoint!(pool) + v1 = acquire!(pool, Float32, 100) + + # Depth 2 + checkpoint!(pool) + v2 = acquire!(pool, Float32, 50) + + # Depth 3 + checkpoint!(pool) + v3 = acquire!(pool, Float32, 25) + + # At depth 3: v1 and v2 are from outer scopes → safe + _validate_pool_return(v1, pool) + _validate_pool_return(v2, pool) + + # v3 is from current (depth 3) scope → escape! + @test_throws PoolRuntimeEscapeError _validate_pool_return(v3, pool) + + rewind!(pool) # exit depth 3 + + # At depth 2: v1 is outer → safe, v2 is current → escape + _validate_pool_return(v1, pool) + @test_throws PoolRuntimeEscapeError _validate_pool_return(v2, pool) + + rewind!(pool) # exit depth 2 + + # At depth 1: v1 is current → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(v1, pool) + + rewind!(pool) # exit depth 1 + end + + # ------------------------------------------------------------------ + # Mixed types: outer Float32, inner Int32 + # ------------------------------------------------------------------ + @testset "outer Float32, inner Int32 — outer should NOT throw" begin + pool = _make_cuda_pool_scope(true) + + checkpoint!(pool) + v_f32 = acquire!(pool, Float32, 20) + + checkpoint!(pool) + _ = acquire!(pool, Int32, 10) # different type in inner scope + + _validate_pool_return(v_f32, pool) # ← Should NOT throw + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Lazy path — outer acquire, inner validate + # ------------------------------------------------------------------ + @testset "lazy path — outer acquire, inner validate" begin + pool = _make_cuda_pool_scope(true) + + _lazy_checkpoint!(pool) + v = acquire!(pool, Float32, 100) + CUDA.fill!(v, 5.0f0) + + _lazy_checkpoint!(pool) + _ = acquire!(pool, Float32, 30) + + # v acquired at outer depth → safe + _validate_pool_return(v, pool) # ← Should NOT throw + + _lazy_rewind!(pool) + @test CUDA.sum(v) ≈ 500.0f0 + _lazy_rewind!(pool) + end + + # ------------------------------------------------------------------ + # Container wrapping outer-scope array — should NOT throw + # ------------------------------------------------------------------ + @testset "outer array inside tuple — should NOT throw" begin + pool = _make_cuda_pool_scope(true) + + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + CUDA.fill!(v, 1.0f0) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 5) + + # v inside tuple — v belongs to outer scope → safe + _validate_pool_return((CUDA.sum(v), v), pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Leaked inner scope — outer array must still be caught + # ------------------------------------------------------------------ + @testset "leaked inner scope — outer array must still be caught" begin + pool = _make_cuda_pool_scope(true) + + entry_depth = pool._current_depth + checkpoint!(pool) + + v = acquire!(pool, Float32, 100) + + # Inner scope leaks (no rewind) + checkpoint!(pool) + _ = acquire!(pool, Float32, 50) + + # Leaked scope cleanup (simulates macro reorder fix) + while pool._current_depth > entry_depth + 1 + rewind!(pool) + end + + # v acquired at this scope → escape → must throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(v, pool) + + rewind!(pool) + end + +end diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index f8d2ad0..44c5083 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -46,6 +46,7 @@ else include("test_disabled_pool.jl") include("test_metal_safety.jl") include("test_runtime_mutation.jl") + include("test_scope_depth_validation.jl") include("test_reshape.jl") include("test_task_local_pool.jl") end diff --git a/test/metal/test_scope_depth_validation.jl b/test/metal/test_scope_depth_validation.jl new file mode 100644 index 0000000..2dfebf7 --- /dev/null +++ b/test/metal/test_scope_depth_validation.jl @@ -0,0 +1,176 @@ +import AdaptiveArrayPools: PoolRuntimeEscapeError, _validate_pool_return, + _lazy_checkpoint!, _lazy_rewind! + +const _make_metal_pool_scope = ext._make_metal_pool + +# ============================================================================== +# Metal Scope-Aware Validation: mirrors CPU test_scope_depth_validation.jl +# for MetalAdaptiveArrayPool overlap checks (_check_tp_metal_overlap). +# ============================================================================== + +@testset "Metal Scope-depth-aware validation" begin + + # ------------------------------------------------------------------ + # Outer acquire, inner validate — should NOT throw + # ------------------------------------------------------------------ + @testset "outer acquire, inner validate — should NOT throw (MtlArray)" begin + pool = _make_metal_pool_scope(true) + + checkpoint!(pool) + v = acquire!(pool, Float32, 100) + Metal.fill!(v, 1.0f0) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 50) # inner-scope acquire + + # v was acquired in depth 1 — returning it from depth 2 is safe + _validate_pool_return(v, pool) # ← Should NOT throw + + rewind!(pool) + @test Metal.sum(v) ≈ 100.0f0 + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Inner-scope acquire SHOULD still throw + # ------------------------------------------------------------------ + @testset "inner-scope acquire — SHOULD still throw" begin + pool = _make_metal_pool_scope(true) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 100) # outer acquire (safe) + + checkpoint!(pool) + w = acquire!(pool, Float32, 50) # inner acquire (should be caught) + + @test_throws PoolRuntimeEscapeError _validate_pool_return(w, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # 3-level nesting: only deepest acquire should throw + # ------------------------------------------------------------------ + @testset "3-level nesting: only deepest acquire should throw" begin + pool = _make_metal_pool_scope(true) + + # Depth 1 + checkpoint!(pool) + v1 = acquire!(pool, Float32, 100) + + # Depth 2 + checkpoint!(pool) + v2 = acquire!(pool, Float32, 50) + + # Depth 3 + checkpoint!(pool) + v3 = acquire!(pool, Float32, 25) + + # At depth 3: v1 and v2 are from outer scopes → safe + _validate_pool_return(v1, pool) + _validate_pool_return(v2, pool) + + # v3 is from current (depth 3) scope → escape! + @test_throws PoolRuntimeEscapeError _validate_pool_return(v3, pool) + + rewind!(pool) # exit depth 3 + + # At depth 2: v1 is outer → safe, v2 is current → escape + _validate_pool_return(v1, pool) + @test_throws PoolRuntimeEscapeError _validate_pool_return(v2, pool) + + rewind!(pool) # exit depth 2 + + # At depth 1: v1 is current → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(v1, pool) + + rewind!(pool) # exit depth 1 + end + + # ------------------------------------------------------------------ + # Mixed types: outer Float32, inner Int32 + # ------------------------------------------------------------------ + @testset "outer Float32, inner Int32 — outer should NOT throw" begin + pool = _make_metal_pool_scope(true) + + checkpoint!(pool) + v_f32 = acquire!(pool, Float32, 20) + + checkpoint!(pool) + _ = acquire!(pool, Int32, 10) # different type in inner scope + + _validate_pool_return(v_f32, pool) # ← Should NOT throw + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Lazy path — outer acquire, inner validate + # ------------------------------------------------------------------ + @testset "lazy path — outer acquire, inner validate" begin + pool = _make_metal_pool_scope(true) + + _lazy_checkpoint!(pool) + v = acquire!(pool, Float32, 100) + Metal.fill!(v, 5.0f0) + + _lazy_checkpoint!(pool) + _ = acquire!(pool, Float32, 30) + + # v acquired at outer depth → safe + _validate_pool_return(v, pool) # ← Should NOT throw + + _lazy_rewind!(pool) + @test Metal.sum(v) ≈ 500.0f0 + _lazy_rewind!(pool) + end + + # ------------------------------------------------------------------ + # Container wrapping outer-scope array — should NOT throw + # ------------------------------------------------------------------ + @testset "outer array inside tuple — should NOT throw" begin + pool = _make_metal_pool_scope(true) + + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + Metal.fill!(v, 1.0f0) + + checkpoint!(pool) + _ = acquire!(pool, Float32, 5) + + # v inside tuple — v belongs to outer scope → safe + _validate_pool_return((Metal.sum(v), v), pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Leaked inner scope — outer array must still be caught + # ------------------------------------------------------------------ + @testset "leaked inner scope — outer array must still be caught" begin + pool = _make_metal_pool_scope(true) + + entry_depth = pool._current_depth + checkpoint!(pool) + + v = acquire!(pool, Float32, 100) + + # Inner scope leaks (no rewind) + checkpoint!(pool) + _ = acquire!(pool, Float32, 50) + + # Leaked scope cleanup (simulates macro reorder fix) + while pool._current_depth > entry_depth + 1 + rewind!(pool) + end + + # v acquired at this scope → escape → must throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(v, pool) + + rewind!(pool) + end + +end diff --git a/test/runtests.jl b/test/runtests.jl index b2852e1..9f86296 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -46,6 +46,7 @@ else include("test_coverage.jl") include("test_allocation.jl") include("test_fallback_reclamation.jl") + include("test_scope_depth_validation.jl") else include("test_aqua.jl") include("test_basic.jl") @@ -72,6 +73,7 @@ else include("test_coverage.jl") include("test_allocation.jl") include("test_fallback_reclamation.jl") + include("test_scope_depth_validation.jl") end # CUDA extension tests (auto-detect, skip with TEST_CUDA=false) diff --git a/test/test_scope_depth_validation.jl b/test/test_scope_depth_validation.jl new file mode 100644 index 0000000..b6485d5 --- /dev/null +++ b/test/test_scope_depth_validation.jl @@ -0,0 +1,683 @@ +import AdaptiveArrayPools: _validate_pool_return, _check_bitchunks_overlap, + PoolRuntimeEscapeError, _make_pool, + _lazy_checkpoint!, _lazy_rewind!, + checkpoint!, rewind! + +# ============================================================================== +# Scope-Aware Validation: outer-scope arrays must NOT trigger escape errors +# in inner scopes. +# +# Fixed bug: _check_pointer_overlap previously iterated ALL tp.vectors, +# not just the ones acquired in the current scope. Now uses _scope_boundary() +# to skip vectors from outer scopes (index <= checkpoint boundary). +# ============================================================================== + +@testset "Scope-depth-aware validation" begin + + # ------------------------------------------------------------------ + # Scenario 1: acquire at depth 1 → validate inside depth 2 + # Array acquired before @with_pool, then used inside @with_pool. + # The inner scope's _validate_pool_return should NOT flag it. + # ------------------------------------------------------------------ + @testset "outer acquire, inner validate — should NOT throw (Array)" begin + pool = _make_pool(true) + + # Depth 1: acquire an array + checkpoint!(pool) + v = acquire!(pool, Float64, 100) + fill!(v, 1.0) + + # Depth 2: inner scope + checkpoint!(pool) + w = acquire!(pool, Float64, 50) # inner-scope acquire + + # v was acquired in depth 1 — returning it from depth 2 is safe + _validate_pool_return(v, pool) # ← Should NOT throw + + rewind!(pool) # rewind depth 2 (releases w, keeps v) + @test sum(v) == 100.0 # v is still valid + rewind!(pool) # rewind depth 1 + end + + @testset "outer acquire, inner validate — should NOT throw (BitArray)" begin + pool = _make_pool(true) + + checkpoint!(pool) + bv = acquire!(pool, Bit, 100) + fill!(bv, true) + + checkpoint!(pool) + _ = acquire!(pool, Bit, 50) # inner-scope acquire + + # bv was acquired in depth 1 — should NOT be flagged + _validate_pool_return(bv, pool) # ← Should NOT throw + + rewind!(pool) + @test count(bv) == 100 + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 2: nested scopes — acquire at depth 2, validate at depth 3 + # ------------------------------------------------------------------ + @testset "nested scope — outer array returned from inner scope" begin + pool = _make_pool(true) + + # Depth 1 + checkpoint!(pool) + + # Depth 2: acquire + checkpoint!(pool) + v = acquire!(pool, Float64, 20) + fill!(v, 2.0) + + # Depth 3: inner scope uses v + checkpoint!(pool) + _ = acquire!(pool, Float64, 10) # inner-scope acquire + + # v belongs to depth 2, we're at depth 3 → safe + _validate_pool_return(v, pool) # ← Should NOT throw + + rewind!(pool) # exit depth 3 + @test sum(v) == 40.0 + + rewind!(pool) # exit depth 2 + rewind!(pool) # exit depth 1 + end + + # ------------------------------------------------------------------ + # Scenario 3: in-place function return pattern + # acquire → pass to in-place function → function returns the array + # → validate on that return value inside the same outer scope + # ------------------------------------------------------------------ + @testset "in-place function return — should NOT throw" begin + inplace_fill!(arr) = (fill!(arr, 3.14); arr) # returns the same array + + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, Float64, 50) + + # Inner scope: call in-place function, result is v + checkpoint!(pool) + result = inplace_fill!(v) + + # result === v, acquired in outer scope → safe + _validate_pool_return(result, pool) # ← Should NOT throw + + rewind!(pool) + @test result[1] ≈ 3.14 + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 4: inner-scope acquire SHOULD still throw + # Only arrays acquired in the CURRENT scope should be flagged. + # ------------------------------------------------------------------ + @testset "inner-scope acquire — SHOULD still throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + _ = acquire!(pool, Float64, 100) # outer acquire (safe) + + checkpoint!(pool) + w = acquire!(pool, Float64, 50) # inner acquire (should be caught) + + @test_throws PoolRuntimeEscapeError _validate_pool_return(w, pool) + + rewind!(pool) + rewind!(pool) + end + + @testset "inner-scope acquire BitArray — SHOULD still throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + _ = acquire!(pool, Bit, 100) # outer + + checkpoint!(pool) + bw = acquire!(pool, Bit, 50) # inner (should be caught) + + @test_throws PoolRuntimeEscapeError _validate_pool_return(bw, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 5: lazy checkpoint/rewind path (same bug, different entry) + # ------------------------------------------------------------------ + @testset "lazy path — outer acquire, inner validate" begin + pool = _make_pool(true) + + _lazy_checkpoint!(pool) + v = acquire!(pool, Float64, 100) + fill!(v, 5.0) + + _lazy_checkpoint!(pool) + _ = acquire!(pool, Float64, 30) + + # v acquired at outer depth → safe + _validate_pool_return(v, pool) # ← Should NOT throw + + _lazy_rewind!(pool) + @test sum(v) == 500.0 + _lazy_rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 6: container wrapping outer-scope array + # Tuple/NamedTuple containing an outer-scope array should pass. + # ------------------------------------------------------------------ + @testset "outer array inside container — should NOT throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, Float64, 10) + fill!(v, 1.0) + + checkpoint!(pool) + _ = acquire!(pool, Float64, 5) + + # v inside tuple — v belongs to outer scope → safe + _validate_pool_return((sum(v), v), pool) # ← Should NOT throw for v + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 7: others (non-fixed-slot) type from outer scope + # ------------------------------------------------------------------ + @testset "outer acquire non-fixed-slot type — should NOT throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, UInt8, 100) + fill!(v, 0x42) + + checkpoint!(pool) + _ = acquire!(pool, UInt8, 50) # inner + + _validate_pool_return(v, pool) # ← Should NOT throw + + rewind!(pool) + @test v[1] == 0x42 + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 8: mixed — outer fixed-slot + inner different type + # The outer Float64 should pass even when inner Int64 exists. + # ------------------------------------------------------------------ + @testset "outer Float64, inner Int64 — outer should NOT throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + v_f64 = acquire!(pool, Float64, 20) + fill!(v_f64, 9.0) + + checkpoint!(pool) + _ = acquire!(pool, Int64, 10) # different type in inner scope + + _validate_pool_return(v_f64, pool) # ← Should NOT throw + + rewind!(pool) + @test sum(v_f64) == 180.0 + rewind!(pool) + end + + # ================================================================== + # Complex scenarios: partial escape from mixed inner/outer arrays + # (compile-time can't catch these; runtime check must) + # ================================================================== + + # ------------------------------------------------------------------ + # Scenario 9: same-type mix — outer + inner acquire same type, + # return tuple where outer is safe but inner escapes + # ------------------------------------------------------------------ + @testset "same-type mix: outer safe, inner escapes via tuple" begin + pool = _make_pool(true) + + checkpoint!(pool) + v_outer = acquire!(pool, Float64, 20) + fill!(v_outer, 1.0) + + checkpoint!(pool) + v_inner = acquire!(pool, Float64, 10) + fill!(v_inner, 2.0) + + # Returning only the inner array should throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(v_inner, pool) + + # Returning tuple with inner array should also throw + @test_throws PoolRuntimeEscapeError _validate_pool_return( + (sum(v_outer), v_inner), pool + ) + + # Returning only the outer array should NOT throw + _validate_pool_return(v_outer, pool) + + # Returning tuple with only outer array and scalars is safe + _validate_pool_return((v_outer, sum(v_inner)), pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 10: in-place function modifies inner array, returns it + # through opaque function — runtime must still catch it + # ------------------------------------------------------------------ + @testset "opaque in-place on inner array — should throw" begin + opaque_fill!(arr) = (fill!(arr, 99.0); arr) + + pool = _make_pool(true) + + checkpoint!(pool) + v_outer = acquire!(pool, Float64, 20) + + checkpoint!(pool) + v_inner = acquire!(pool, Float64, 10) + + # In-place on inner → returns inner → escape + leaked = opaque_fill!(v_inner) + @test_throws PoolRuntimeEscapeError _validate_pool_return(leaked, pool) + + # In-place on outer → returns outer → safe (outer scope) + safe_result = opaque_fill!(v_outer) + _validate_pool_return(safe_result, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 11: 3-level nesting — acquire at each level, + # validate at deepest with mixed results + # ------------------------------------------------------------------ + @testset "3-level nesting: only deepest acquire should throw" begin + pool = _make_pool(true) + + # Depth 1 + checkpoint!(pool) + v1 = acquire!(pool, Float64, 100) + fill!(v1, 1.0) + + # Depth 2 + checkpoint!(pool) + v2 = acquire!(pool, Float64, 50) + fill!(v2, 2.0) + + # Depth 3 + checkpoint!(pool) + v3 = acquire!(pool, Float64, 25) + fill!(v3, 3.0) + + # At depth 3: v1 and v2 are from outer scopes → safe + _validate_pool_return(v1, pool) + _validate_pool_return(v2, pool) + + # v3 is from current (depth 3) scope → escape! + @test_throws PoolRuntimeEscapeError _validate_pool_return(v3, pool) + + # Tuple mixing all three — v3 causes the throw + @test_throws PoolRuntimeEscapeError _validate_pool_return((v1, v2, v3), pool) + + # Tuple with only v1 and v2 — safe + _validate_pool_return((v1, v2), pool) + + rewind!(pool) # exit depth 3 + + # At depth 2: v1 is outer → safe, v2 is current → escape + _validate_pool_return(v1, pool) + @test_throws PoolRuntimeEscapeError _validate_pool_return(v2, pool) + + rewind!(pool) # exit depth 2 + + # At depth 1: v1 is current → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(v1, pool) + + rewind!(pool) # exit depth 1 + end + + # ------------------------------------------------------------------ + # Scenario 12: NamedTuple return with partial escape — + # common pattern: (result=scalar, buffer=pool_array) + # ------------------------------------------------------------------ + @testset "NamedTuple partial escape: inner buffer leaks" begin + pool = _make_pool(true) + + checkpoint!(pool) + outer_buf = acquire!(pool, Float64, 100) + + checkpoint!(pool) + inner_buf = acquire!(pool, Float64, 50) + fill!(inner_buf, 1.0) + + # Realistic return pattern: scalar result + leaked buffer + @test_throws PoolRuntimeEscapeError _validate_pool_return( + (result = sum(inner_buf), buffer = inner_buf), pool + ) + + # Safe pattern: scalar result + outer buffer (not escaping) + _validate_pool_return( + (result = sum(inner_buf), buffer = outer_buf), pool + ) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 13: view of inner array escapes — SubArray detection + # ------------------------------------------------------------------ + @testset "view of inner array — should throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + v_outer = acquire!(pool, Float64, 100) + + checkpoint!(pool) + v_inner = acquire!(pool, Float64, 50) + + # View of inner array → escape + inner_view = view(v_inner, 1:25) + @test_throws PoolRuntimeEscapeError _validate_pool_return(inner_view, pool) + + # View of outer array → safe (outer scope) + outer_view = view(v_outer, 1:50) + _validate_pool_return(outer_view, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 14: BitArray mixed — outer Bit safe, inner Bit escapes + # ------------------------------------------------------------------ + @testset "BitArray mixed scope — inner escapes, outer safe" begin + pool = _make_pool(true) + + checkpoint!(pool) + bv_outer = acquire!(pool, Bit, 200) + fill!(bv_outer, true) + + checkpoint!(pool) + bv_inner = acquire!(pool, Bit, 100) + + # Inner BitArray should throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(bv_inner, pool) + + # Outer BitArray should pass + _validate_pool_return(bv_outer, pool) + + # View of inner → throw + @test_throws PoolRuntimeEscapeError _validate_pool_return( + view(bv_inner, 1:50), pool + ) + + # View of outer → safe + _validate_pool_return(view(bv_outer, 1:100), pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 15: lazy path + mixed types — Float64 outer, Int64 inner + # with partial escape through Dict + # ------------------------------------------------------------------ + @testset "lazy path mixed types — Dict partial escape" begin + pool = _make_pool(true) + + _lazy_checkpoint!(pool) + v_f64 = acquire!(pool, Float64, 30) + fill!(v_f64, 1.0) + + _lazy_checkpoint!(pool) + v_i64 = acquire!(pool, Int64, 20) + fill!(v_i64, 2) + + # Dict with inner array → throw + @test_throws PoolRuntimeEscapeError _validate_pool_return( + Dict(:data => v_i64), pool + ) + + # Dict with only outer array → safe + _validate_pool_return(Dict(:data => v_f64), pool) + + # Mixed Dict — inner array present → throw + @test_throws PoolRuntimeEscapeError _validate_pool_return( + Dict(:outer => v_f64, :inner => v_i64), pool + ) + + _lazy_rewind!(pool) + _lazy_rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 16: ReshapedArray wrapping outer-scope pool vector + # acquire! returns a Vector; reshape() wraps it in ReshapedArray. + # The parent-chain traversal in _validate_pool_return must + # propagate scope-awareness through ReshapedArray → parent. + # ------------------------------------------------------------------ + @testset "ReshapedArray of outer array — should NOT throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, Float64, 12) + fill!(v, 1.0) + + checkpoint!(pool) + _ = acquire!(pool, Float64, 6) # inner-scope acquire + + # reshape outer-scope vector into a matrix + mat = reshape(v, 3, 4) + _validate_pool_return(mat, pool) # ← Should NOT throw + + rewind!(pool) + @test sum(mat) == 12.0 + rewind!(pool) + end + + @testset "ReshapedArray of inner array — SHOULD throw" begin + pool = _make_pool(true) + + checkpoint!(pool) + _ = acquire!(pool, Float64, 12) # outer + + checkpoint!(pool) + v_inner = acquire!(pool, Float64, 8) # inner + fill!(v_inner, 2.0) + + mat_inner = reshape(v_inner, 2, 4) + @test_throws PoolRuntimeEscapeError _validate_pool_return(mat_inner, pool) + + rewind!(pool) + rewind!(pool) + end + + @testset "view of ReshapedArray — scope propagation through parent chain" begin + pool = _make_pool(true) + + checkpoint!(pool) + v_outer = acquire!(pool, Float64, 12) + fill!(v_outer, 3.0) + + checkpoint!(pool) + v_inner = acquire!(pool, Float64, 8) + + # outer: reshape → view → validate (two levels of wrapping) + mat_outer = reshape(v_outer, 3, 4) + sv = view(mat_outer, 1:2, :) + _validate_pool_return(sv, pool) # ← Should NOT throw + + # inner: reshape → view → validate + mat_inner = reshape(v_inner, 2, 4) + sv_inner = view(mat_inner, 1:1, :) + @test_throws PoolRuntimeEscapeError _validate_pool_return(sv_inner, pool) + + rewind!(pool) + rewind!(pool) + end + + # ------------------------------------------------------------------ + # Scenario 17: sentinel edge case — no checkpoint (depth 0) + # _scope_boundary falls back to tp.n_active, meaning "skip all". + # Calling _validate_pool_return on a pool with no checkpoint + # should never throw (there's no scope to escape from). + # ------------------------------------------------------------------ + @testset "no checkpoint (depth 0) — validate should NOT throw" begin + pool = _make_pool(true) + + # Directly acquire without any checkpoint (depth stays at 0/1 depending on init) + # This simulates arrays that exist outside any @with_pool scope. + v = acquire!(pool, Float64, 50) + fill!(v, 7.0) + + # No checkpoint was pushed, so _scope_boundary should return n_active + # for every typed pool → empty check range → no false positive + _validate_pool_return(v, pool) # ← Should NOT throw + + # BitArray variant + bv = acquire!(pool, Bit, 50) + fill!(bv, true) + _validate_pool_return(bv, pool) # ← Should NOT throw + end + + # ------------------------------------------------------------------ + # Scenario 18: leaked inner scope (caught exception) — validation + # must use the OUTER scope's depth, not the leaked inner depth. + # + # Bug (pre-fix): inner @with_pool throws → pool._current_depth stays + # at inner depth → _scope_boundary treats outer arrays as "outer scope" + # relative to the leaked depth → false negative (escape not caught). + # + # Fix: macro reorders leaked scope cleanup BEFORE validation, so + # _current_depth is normalized before _scope_boundary runs. + # ------------------------------------------------------------------ + @testset "leaked inner scope — outer array must still be caught" begin + pool = _make_pool(true) + + # Simulate outer @with_pool: save entry depth, checkpoint + entry_depth = pool._current_depth + checkpoint!(pool) + + v = acquire!(pool, Float64, 100) + fill!(v, 1.0) + + # Simulate inner @with_pool that throws (no rewind) + checkpoint!(pool) + _ = acquire!(pool, Float64, 50) + # "throw" happens here — inner scope never rewinds + # pool._current_depth is now entry_depth + 2 (leaked) + + # Simulate the macro's leaked scope cleanup (runs BEFORE validation now) + while pool._current_depth > entry_depth + 1 + rewind!(pool) + end + + # Now validation runs at the correct depth (entry_depth + 1) + # v was acquired at this depth → it IS an escapee → must throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(v, pool) + + rewind!(pool) # main rewind + end + + @testset "leaked inner scope — BitArray must still be caught" begin + pool = _make_pool(true) + + entry_depth = pool._current_depth + checkpoint!(pool) + + bv = acquire!(pool, Bit, 100) + fill!(bv, true) + + # Inner scope leaks + checkpoint!(pool) + _ = acquire!(pool, Bit, 50) + + # Leaked scope cleanup + while pool._current_depth > entry_depth + 1 + rewind!(pool) + end + + # bv acquired at outer scope depth → escape → must throw + @test_throws PoolRuntimeEscapeError _validate_pool_return(bv, pool) + + rewind!(pool) + end + + # ================================================================== + # Custom struct scenarios: others pool (non-fixed-slot types) + # ================================================================== + + # Custom isbits struct — no zero(T) defined, poison uses 0 * first(v) + struct TestPoint + x::Float64 + y::Float64 + end + Base.:(*)(a::Int, p::TestPoint) = TestPoint(a * p.x, a * p.y) + + # Mutable struct — non-isbits, poison skipped (resize! only) + mutable struct TestParticle + x::Float64 + y::Float64 + end + + # ------------------------------------------------------------------ + # Scenario 19: custom isbits struct — scope-aware validation + poison + # ------------------------------------------------------------------ + @testset "custom isbits struct — scope-aware + poison on rewind" begin + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, TestPoint, 10) + fill!(v, TestPoint(1.0, 2.0)) + + checkpoint!(pool) + w = acquire!(pool, TestPoint, 5) + + # v from outer scope → safe + _validate_pool_return(v, pool) + + # w from inner scope → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(w, pool) + + rewind!(pool) # inner — should poison w with 0 * first(w) without error + @test v[1] == TestPoint(1.0, 2.0) # v still valid + + # v is now current scope → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(v, pool) + rewind!(pool) # outer — poisons v + end + + # ------------------------------------------------------------------ + # Scenario 20: mutable struct (non-isbits) — poison skipped gracefully + # ------------------------------------------------------------------ + @testset "mutable struct (non-isbits) — poison skipped, resize! only" begin + pool = _make_pool(true) + + checkpoint!(pool) + v = acquire!(pool, TestParticle, 10) + for i in 1:10 + v[i] = TestParticle(Float64(i), 0.0) + end + + checkpoint!(pool) + w = acquire!(pool, TestParticle, 5) + + # v from outer scope → safe + _validate_pool_return(v, pool) + + # w from inner scope → escape + @test_throws PoolRuntimeEscapeError _validate_pool_return(w, pool) + + rewind!(pool) # inner — poison skipped (non-isbits), resize!(w, 0) only + @test v[1].x == 1.0 # v still valid + + rewind!(pool) # outer — resize!(v, 0) + end + +end