From ab3add946107299c4fa234029813b1fb9a259257 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 10:23:54 -0700 Subject: [PATCH 1/7] (fix): runtime PoolEscapeError hint teaches `nothing` ending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S=1 PoolRuntimeEscapeError fix suggestion only offered collect() and "compute a scalar". Add the `nothing` ending — the most common accidental-escape fix, since a scope's last expression is its return value — and align the wording with the compile-time `_lint_message` so both escape errors read consistently. --- src/debug.jl | 6 ++++-- test/test_borrow_registry.jl | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/debug.jl b/src/debug.jl index 2b893ece..b2a3122d 100644 --- a/src/debug.jl +++ b/src/debug.jl @@ -287,9 +287,11 @@ function Base.showerror(io::IO, e::PoolRuntimeEscapeError) println(io) printstyled(io, " Fix: "; bold = true) - printstyled(io, "Wrap with "; color = :light_black) + printstyled(io, "end the block with "; color = :light_black) + printstyled(io, "nothing"; bold = true) + printstyled(io, " if the value is discarded, "; color = :light_black) printstyled(io, "collect()"; bold = true) - printstyled(io, " to return an owned copy, or compute a scalar result.\n"; color = :light_black) + printstyled(io, " it for an owned copy, or return a scalar.\n"; color = :light_black) return nothing end diff --git a/test/test_borrow_registry.jl b/test/test_borrow_registry.jl index ad4e73e1..b0d26d6c 100644 --- a/test/test_borrow_registry.jl +++ b/test/test_borrow_registry.jl @@ -255,6 +255,21 @@ _test_leak(x) = x @test contains(msg, "RUNTIME_CHECK >= 1") end + @testset "showerror: Fix line teaches nothing/collect/scalar" begin + # A1 (PR5): the runtime Fix suggestion must match the compile-time + # `_lint_message` wording — offer the `nothing` ending (the most common + # accidental-escape fix) alongside collect() and scalar. + err = PoolRuntimeEscapeError("Vector{Float64}", "Float64", "test.jl:42", nothing) + io = IOBuffer() + showerror(io, err) + msg = String(take!(io)) + + @test contains(msg, "Fix:") + @test contains(msg, "nothing") # discard-value ending + @test contains(msg, "collect()") # owned copy + @test contains(msg, "scalar") # scalar result + end + # ============================================================================== # Multiple types: each gets correct callsite # ============================================================================== From aa2de39e1eadda06a357bf1dcdb327b5cfd0f513 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 10:24:02 -0700 Subject: [PATCH 2/7] (test): safe/non-safe divergence matrix + split rationale; S=1 docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a parametrized guard that runs identical scenarios (typed, fallback, nested, mixed) through all four macros — @with_pool / @maybe_with_pool / @safe_with_pool / @safe_maybe_with_pool — asserting identical results and no depth/active leak, plus the safe-exception-rewinds and non-safe-parent-cleanup invariants. This is the mechanical guard the PR4 tp_bindings-inside-try leak bug slipped past. Document why the safe/non-safe split stays: re-measured try/finally overhead on Julia 1.12.6 is a fixed ~4-11 ns/scope (~20% on a hot typed scope, persists with real work), so unifying to one always-safe path is rejected. Add a macros.jl comment recording this and the "touch a generator -> verify both paths" rule, and note that @maybe_with_pool auto-syncs (force_enable is a flag over one shared inner). Expand the runtime-safety docs with capacity-retention, zero-allocation, and what-it-cannot-catch guarantees, and align the runtime error example. --- CHANGELOG.md | 5 ++ docs/src/safety/runtime.md | 27 ++++++- src/macros.jl | 22 +++++- test/test_macros.jl | 142 +++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3990f11..6b918ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,11 @@ opaque function calls, closures, conditional tails). - `escape_lint` preference (`"error"` default | `"warn"` | `"off"`), loaded once at package load as the compile-time constant `ESCAPE_LINT`, controlling the severity of the three new incidental-tail patterns above. +- The runtime `PoolEscapeError` (`RUNTIME_CHECK >= 1`) fix hint now teaches the + `nothing` ending (the most common accidental-escape fix) alongside `collect()` + and returning a scalar, matching the compile-time lint wording. Expanded the + runtime-safety docs with capacity-retention, zero-allocation, and + what-it-cannot-catch guarantees. ### Performance diff --git a/docs/src/safety/runtime.md b/docs/src/safety/runtime.md index b596dd23..8d53466f 100644 --- a/docs/src/safety/runtime.md +++ b/docs/src/safety/runtime.md @@ -71,9 +71,11 @@ PoolEscapeError (runtime, RUNTIME_CHECK >= 1) ← acquired at src/solver.jl:42 v = acquire!(pool, Float64, n) - Fix: Wrap with collect() to return an owned copy, or compute a scalar result. + Fix: end the block with nothing if the value is discarded, collect() it for an owned copy, or return a scalar. ``` +The `nothing` ending is the most common fix: a scope's **last expression is its return value**, so a block that ends on a pool-backed array leaks it by accident. End with `nothing` (or a scalar) when the array is only used for side effects. + ### 5. Mutation Detection Detects structural mutations that escaped compile-time analysis by comparing wrapper state against backing storage at rewind: @@ -86,6 +88,29 @@ Detects structural mutations that escaped compile-time analysis by comparing wra Emits a one-time advisory `@warn` (`maxlog=1`). The pool **self-heals** on next `acquire!` — no data corruption, only pooling benefits are temporarily lost. +## Guarantees & Limitations + +**Capacity is retained.** Poisoning and structural invalidation mark a slot's +*current* contents as stale — they do **not** shrink the pool. The slot keeps its +allocated capacity and is reused on the next `acquire!`; `compact!` skips +still-poisoned slots. So `RUNTIME_CHECK=1` never trades away pooling's memory +reuse — it only makes stale references fail loudly. + +**Still zero-allocation.** The checks compare against and overwrite existing +buffers; they allocate nothing on the happy path. `S=1` adds runtime cost (the +comparisons and poison fills) but no GC pressure. + +**What it cannot catch.** Escape detection inspects the *return value* at scope +exit — it cannot prove ownership through arrays captured by closures, stored in +globals that outlive the scope, or aliased behind opaque calls that never surface +in the return value. These are undecidable statically; `S=1` catches the +return-position cases, and the [compile-time lint](compile-time.md) catches the +common syntactic ones. + +**Test hygiene.** For tests that run under both `S=0` and `S=1`: keep asserts +S-adaptive (don't assert poison values unless `S≥1`), and end `@with_pool` blocks +with `nothing` unless you deliberately return an owned value. + ## Recommended Workflow ```toml diff --git a/src/macros.jl b/src/macros.jl index 9592bbc5..e15b3a54 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -786,7 +786,12 @@ function _generate_pool_code(pool_name, expr, force_enable; safe::Bool = false, # Compile-time structural mutation detection (zero runtime cost) _check_structural_mutation(expr, pool_name, source) - # Block logic — shared with backend-specific code generation + # Block logic — shared with backend-specific code generation. + # NOTE: `@with_pool` (force_enable) and `@maybe_with_pool` (!force_enable) reuse + # this single `inner`; the only difference below is the runtime MAYBE_POOLING[] + # gate. So they never need manual syncing — any change to _generate_block_inner + # (or the escape/mutation checks above, which run before this branch) applies to + # both. The axis that DOES diverge is safe ↔ non-safe (see _generate_block_inner). inner = _generate_block_inner(pool_name, expr, safe, source) if force_enable @@ -854,6 +859,21 @@ function _generate_block_inner(pool_name, expr, safe::Bool, source) end transformed_expr = _inject_pending_callsite(transformed_expr, pool_name, expr) + # ── safe ↔ non-safe divergence (the axis that MUST be kept in sync) ────────── + # These two branches are fundamentally different control flow, NOT a shared body + # with a flag: `safe` wraps the work in try/finally (rewind guaranteed even when + # an exception escapes the outermost scope); non-safe uses a direct rewind + + # entry-depth guard + leaked-scope cleanup + break/continue injection. + # + # The split is deliberate and measurement-justified: try/finally costs a fixed + # ~4–11 ns/scope on Julia 1.12.6 (≈20% on a hot typed scope, persists with real + # work) — unacceptable for the zero-overhead default, so `safe` stays opt-in. + # See docs/plans/DESIGN_fallback_touch_tracking.md §5.1-B for the numbers. + # + # ⚠️ Any change to the checkpoint/rewind/tp-hoisting contract above MUST be + # verified on BOTH branches — the parametrized divergence matrix in + # test/test_macros.jl ("safe/non-safe divergence") is the mechanical guard + # (PR4's tp_bindings-inside-try leak bug slipped precisely because it wasn't). if safe transformed_expr = _transform_return_stmts(transformed_expr, pool_name) return quote diff --git a/test/test_macros.jl b/test/test_macros.jl index b2cf0a20..edf5752b 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -624,4 +624,146 @@ import AdaptiveArrayPools: checkpoint!, rewind! end end + # ========================================================================== + # safe/non-safe divergence guard (PR5) + # ========================================================================== + # `@with_pool`/`@maybe_with_pool` (non-safe) and `@safe_*` (try/finally) are + # two fundamentally different generator branches. This matrix runs identical + # scenarios through ALL FOUR macros and asserts they agree on result AND leave + # no depth/active leak — the mechanical guard that a change to one branch + # (checkpoint/rewind/tp-hoisting) must not silently break the other. PR4's + # tp_bindings-inside-try leak bug is exactly what the safe rows catch. + + # Build one runner per macro at module scope (avoids 4× body duplication). + # A macro name interpolated as `$sym pool begin … end` is not valid syntax, so + # each scope is assembled as an explicit `Expr(:macrocall, …)`. Each runner runs + # 4 scenarios exercising the divergent paths: typed fixed-slot (tp hoisting), + # fallback/lazy (local-var demotion), nested, mixed (2 fixed slots). + _divmat_mc(m, body) = Expr(:macrocall, m, LineNumberNode(0, :divmat), :pool, body) + for macname in (:with_pool, :maybe_with_pool, :safe_with_pool, :safe_maybe_with_pool) + fname = Symbol("_divmat_", macname) + m = Symbol("@", macname) + typed = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 8) + @inbounds for i in 1:8 + a[i] = Float64(i) + end + sum(a) + end + ) + fallback = _divmat_mc( + m, quote + T = Vector{Float64} # local var → macro demotes to lazy path + a = acquire!(pool, T, 4) + a[1] = Float64[1.0] + length(a) + end + ) + nested = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 4) + a[1] = 10.0 + inner = $( + _divmat_mc( + m, quote + b = acquire!(pool, Float64, 4) + b[1] = 20.0 + b[1] + end + ) + ) + a[1] + inner + end + ) + mixed = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 4) + a[1] = 1.0 + c = acquire!(pool, Int32, 2) + c[1] = Int32(2) + Float64(a[1]) + c[1] + end + ) + @eval $fname() = ($typed, $fallback, $nested, $mixed) + end + + # Safe-macro exception guard: an exception AFTER a typed acquire must still + # rewind via `finally`. If tp_bindings sat before the try (the PR4 bug), the + # checkpoint would leak and depth would not return to baseline. + @eval _divmat_throw_safe() = @safe_with_pool pool begin + a = acquire!(pool, Float64, 8) + a[1] = 1.0 + error("boom") + end + @eval _divmat_throw_safe_maybe() = @safe_maybe_with_pool pool begin + a = acquire!(pool, Float64, 8) + a[1] = 1.0 + error("boom") + end + + @testset "divergence matrix: all 4 macros agree (result + no leak)" begin + MAYBE_POOLING[] = true # @maybe_* take the pooled branch + reset!(get_task_local_pool()) + + runners = ( + ("@with_pool", _divmat_with_pool), + ("@maybe_with_pool", _divmat_maybe_with_pool), + ("@safe_with_pool", _divmat_safe_with_pool), + ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), + ) + baseline = nothing + for (label, f) in runners + r = f() + pool = get_task_local_pool() + @test pool._current_depth == 1 # no depth leak ($label) + @test pool.float64.n_active == 0 # memory reclaimed ($label) + if baseline === nothing + baseline = r + else + @test r == baseline # identical result ($label) + end + end + + MAYBE_POOLING[] = false + end + + @testset "safe macros: exception rewinds despite throw (typed scope)" begin + MAYBE_POOLING[] = true + reset!(get_task_local_pool()) + + for f in (_divmat_throw_safe, _divmat_throw_safe_maybe) + pool = get_task_local_pool() + d0 = pool._current_depth + @test_throws ErrorException f() + @test pool._current_depth == d0 # finally rewound + @test pool.float64.n_active == 0 # no leaked capacity in use + end + + MAYBE_POOLING[] = false + end + + @testset "non-safe: parent normalizes a leaked inner scope on throw" begin + reset!(get_task_local_pool()) + # Inner @with_pool throws without rewinding; the outer scope's leaked-scope + # cleanup loop (the non-safe counterpart of the safe finally) restores depth. + f() = @with_pool pool begin + a = acquire!(pool, Float64, 4) + a[1] = 1.0 + try + @with_pool pool begin + b = acquire!(pool, Float64, 4) + error("inner boom") + end + catch + end + a[1] + end + r = f() + pool = get_task_local_pool() + @test r == 1.0 + @test pool._current_depth == 1 # outer cleanup normalized the leak + @test pool.float64.n_active == 0 + end + end # Macro System From b1054961ea22158973da3d9f2eb23e93232d26b5 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 10:54:42 -0700 Subject: [PATCH 3/7] (docs): drop internal design-stage labels from comments Remove transient PR-stage references (PR4/PR5, A1) and an internal design-doc path from code comments and test annotations; the technical rationale (measured try/finally overhead, the tp-bindings-inside-try invariant, the divergence guard) stays, stated on its own terms. No code or test behavior changes. --- src/macros.jl | 8 ++++---- test/test_borrow_registry.jl | 6 +++--- test/test_macros.jl | 11 ++++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/macros.jl b/src/macros.jl index e15b3a54..5fbc6d13 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -866,14 +866,14 @@ function _generate_block_inner(pool_name, expr, safe::Bool, source) # entry-depth guard + leaked-scope cleanup + break/continue injection. # # The split is deliberate and measurement-justified: try/finally costs a fixed - # ~4–11 ns/scope on Julia 1.12.6 (≈20% on a hot typed scope, persists with real + # ~4–11 ns/scope on Julia 1.12+ (≈20% on a hot typed scope, persists with real # work) — unacceptable for the zero-overhead default, so `safe` stays opt-in. - # See docs/plans/DESIGN_fallback_touch_tracking.md §5.1-B for the numbers. # # ⚠️ Any change to the checkpoint/rewind/tp-hoisting contract above MUST be # verified on BOTH branches — the parametrized divergence matrix in - # test/test_macros.jl ("safe/non-safe divergence") is the mechanical guard - # (PR4's tp_bindings-inside-try leak bug slipped precisely because it wasn't). + # test/test_macros.jl ("divergence matrix") is the mechanical guard. In + # particular, tp bindings must stay inside the try (see below): emitting them + # before it would leak the checkpoint if get_typed_pool! throws. if safe transformed_expr = _transform_return_stmts(transformed_expr, pool_name) return quote diff --git a/test/test_borrow_registry.jl b/test/test_borrow_registry.jl index b0d26d6c..1973a10e 100644 --- a/test/test_borrow_registry.jl +++ b/test/test_borrow_registry.jl @@ -256,9 +256,9 @@ _test_leak(x) = x end @testset "showerror: Fix line teaches nothing/collect/scalar" begin - # A1 (PR5): the runtime Fix suggestion must match the compile-time - # `_lint_message` wording — offer the `nothing` ending (the most common - # accidental-escape fix) alongside collect() and scalar. + # The runtime Fix suggestion must match the compile-time `_lint_message` + # wording — offer the `nothing` ending (the most common accidental-escape + # fix) alongside collect() and scalar. err = PoolRuntimeEscapeError("Vector{Float64}", "Float64", "test.jl:42", nothing) io = IOBuffer() showerror(io, err) diff --git a/test/test_macros.jl b/test/test_macros.jl index edf5752b..abeea9ee 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -625,14 +625,15 @@ import AdaptiveArrayPools: checkpoint!, rewind! end # ========================================================================== - # safe/non-safe divergence guard (PR5) + # safe/non-safe divergence guard # ========================================================================== # `@with_pool`/`@maybe_with_pool` (non-safe) and `@safe_*` (try/finally) are # two fundamentally different generator branches. This matrix runs identical # scenarios through ALL FOUR macros and asserts they agree on result AND leave # no depth/active leak — the mechanical guard that a change to one branch - # (checkpoint/rewind/tp-hoisting) must not silently break the other. PR4's - # tp_bindings-inside-try leak bug is exactly what the safe rows catch. + # (checkpoint/rewind/tp-hoisting) must not silently break the other. A leaked + # checkpoint from tp bindings emitted before the try (instead of inside it) is + # exactly what the safe rows catch. # Build one runner per macro at module scope (avoids 4× body duplication). # A macro name interpolated as `$sym pool begin … end` is not valid syntax, so @@ -689,8 +690,8 @@ import AdaptiveArrayPools: checkpoint!, rewind! end # Safe-macro exception guard: an exception AFTER a typed acquire must still - # rewind via `finally`. If tp_bindings sat before the try (the PR4 bug), the - # checkpoint would leak and depth would not return to baseline. + # rewind via `finally`. If tp_bindings sat before the try instead of inside it, + # the checkpoint would leak and depth would not return to baseline. @eval _divmat_throw_safe() = @safe_with_pool pool begin a = acquire!(pool, Float64, 8) a[1] = 1.0 From 0093ddb83505616887a55445aa15194505006461 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 11:17:06 -0700 Subject: [PATCH 4/7] (test): harden divergence guard; correct S=1 allocation docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address second-opinion review findings on the divergence matrix: - Restore MAYBE_POOLING[] to its entry value in a `finally` so the toggling testsets can't leave later test files running under altered global config. - Assert every pool the matrix touches is rewound (float64, int32, and the fallback `_others_values`), not just float64 — a lazy/fallback-only rewind regression would otherwise slip through the no-leak guard. - Add a structural @macroexpand test that the hoisted get_typed_pool! binding sits INSIDE the safe-form try. The exception test alone can't pin this: the throw fires inside the try, so moving the binding before it would still pass. Verified non-vacuous (fails when the binding is emitted before the try). - runtime.md: correct the zero-allocation claim. S=0 is zero-alloc; S=1 is a development tool that DOES allocate (borrow tracking builds an IdDict and a callsite string per acquire), so don't measure a zero-GC hot path under S=1. --- docs/src/safety/runtime.md | 9 ++- test/test_macros.jl | 122 ++++++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/docs/src/safety/runtime.md b/docs/src/safety/runtime.md index 8d53466f..eccd3559 100644 --- a/docs/src/safety/runtime.md +++ b/docs/src/safety/runtime.md @@ -96,9 +96,12 @@ allocated capacity and is reused on the next `acquire!`; `compact!` skips still-poisoned slots. So `RUNTIME_CHECK=1` never trades away pooling's memory reuse — it only makes stale references fail loudly. -**Still zero-allocation.** The checks compare against and overwrite existing -buffers; they allocate nothing on the happy path. `S=1` adds runtime cost (the -comparisons and poison fills) but no GC pressure. +**Allocation.** `S=0` (the production default) is zero-allocation — the checks +are dead-code-eliminated. `S=1` is a development tool and *does* allocate: borrow +tracking lazily builds an `IdDict` and records a callsite string per `acquire!` +so escape/mutation errors can name the source. The poison fills and rewind +comparisons overwrite existing buffers without allocating, but don't measure a +zero-GC hot path under `S=1` — use `S=0`. **What it cannot catch.** Escape detection inspects the *return value* at scope exit — it cannot prove ownership through arrays captured by closures, stored in diff --git a/test/test_macros.jl b/test/test_macros.jl index abeea9ee..858708b2 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -689,59 +689,107 @@ import AdaptiveArrayPools: checkpoint!, rewind! @eval $fname() = ($typed, $fallback, $nested, $mixed) end - # Safe-macro exception guard: an exception AFTER a typed acquire must still - # rewind via `finally`. If tp_bindings sat before the try instead of inside it, - # the checkpoint would leak and depth would not return to baseline. + # These runners throw after a hoisted (fallback-type) acquire, so the safe rows + # verify that a scope rewinds on exception. Fixed slots aren't hoisted, so a + # fallback type (Vector{Float64}) is needed to actually exercise the hoisted + # binding. Note this alone does NOT pin the binding's position — the structural + # expansion test below is what guards "binding stays inside the try". @eval _divmat_throw_safe() = @safe_with_pool pool begin - a = acquire!(pool, Float64, 8) - a[1] = 1.0 + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] error("boom") end @eval _divmat_throw_safe_maybe() = @safe_maybe_with_pool pool begin - a = acquire!(pool, Float64, 8) - a[1] = 1.0 + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] error("boom") end + # Every pool the matrix touches (fixed float64/int32 + fallback others) must be + # fully rewound; checking only float64 would miss a lazy/fallback rewind bug. + _divmat_clean(pool) = pool.float64.n_active == 0 && + pool.int32.n_active == 0 && + all(tp -> tp.n_active == 0, pool._others_values) + @testset "divergence matrix: all 4 macros agree (result + no leak)" begin + old_maybe = MAYBE_POOLING[] MAYBE_POOLING[] = true # @maybe_* take the pooled branch - reset!(get_task_local_pool()) + try + reset!(get_task_local_pool()) - runners = ( - ("@with_pool", _divmat_with_pool), - ("@maybe_with_pool", _divmat_maybe_with_pool), - ("@safe_with_pool", _divmat_safe_with_pool), - ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), - ) - baseline = nothing - for (label, f) in runners - r = f() - pool = get_task_local_pool() - @test pool._current_depth == 1 # no depth leak ($label) - @test pool.float64.n_active == 0 # memory reclaimed ($label) - if baseline === nothing - baseline = r - else - @test r == baseline # identical result ($label) + runners = ( + ("@with_pool", _divmat_with_pool), + ("@maybe_with_pool", _divmat_maybe_with_pool), + ("@safe_with_pool", _divmat_safe_with_pool), + ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), + ) + baseline = nothing + for (label, f) in runners + r = f() + pool = get_task_local_pool() + @test pool._current_depth == 1 # no depth leak ($label) + @test _divmat_clean(pool) # all touched pools reclaimed ($label) + if baseline === nothing + baseline = r + else + @test r == baseline # identical result ($label) + end end + finally + MAYBE_POOLING[] = old_maybe end - - MAYBE_POOLING[] = false end - @testset "safe macros: exception rewinds despite throw (typed scope)" begin + @testset "safe macros: exception rewinds despite throw" begin + old_maybe = MAYBE_POOLING[] MAYBE_POOLING[] = true - reset!(get_task_local_pool()) - - for f in (_divmat_throw_safe, _divmat_throw_safe_maybe) - pool = get_task_local_pool() - d0 = pool._current_depth - @test_throws ErrorException f() - @test pool._current_depth == d0 # finally rewound - @test pool.float64.n_active == 0 # no leaked capacity in use + try + reset!(get_task_local_pool()) + for f in (_divmat_throw_safe, _divmat_throw_safe_maybe) + pool = get_task_local_pool() + d0 = pool._current_depth + @test_throws ErrorException f() + @test pool._current_depth == d0 # finally rewound + @test _divmat_clean(pool) # no leaked capacity in use + end + finally + MAYBE_POOLING[] = old_maybe end + end - MAYBE_POOLING[] = false + @testset "safe+typed expansion keeps the tp binding inside the try" begin + # Direct structural guard for the invariant the exception test cannot see: + # the hoisted get_typed_pool! binding must live INSIDE the try, so a throw + # from it still hits the finally rewind. If the binding moved before the + # try, the try body would no longer reference get_typed_pool! and this + # fails. Only meaningful where curly types are hoisted (Julia >= 1.12). + if AdaptiveArrayPools._MACRO_TYPED_UPGRADES + # The macro splices the get_typed_pool! *function value* (via `$`), so + # match that as well as a GlobalRef/Symbol spelling. + gtp = AdaptiveArrayPools.get_typed_pool! + refs_gtp(e) = + e === gtp || + (e isa GlobalRef && e.name === :get_typed_pool!) || + (e isa Symbol && e === :get_typed_pool!) || + (e isa Expr && any(refs_gtp, e.args)) + function find_try(e) + e isa Expr || return nothing + e.head === :try && return e + for a in e.args + t = find_try(a) + t === nothing || return t + end + return nothing + end + ex = @macroexpand @safe_with_pool pool begin + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] + nothing + end + tnode = find_try(ex) + @test tnode !== nothing + @test refs_gtp(tnode.args[1]) # binding is inside the try body + end end @testset "non-safe: parent normalizes a leaked inner scope on throw" begin @@ -764,7 +812,7 @@ import AdaptiveArrayPools: checkpoint!, rewind! pool = get_task_local_pool() @test r == 1.0 @test pool._current_depth == 1 # outer cleanup normalized the leak - @test pool.float64.n_active == 0 + @test _divmat_clean(pool) end end # Macro System From c650d803dc3020701118bc6ca48e1e2888da03c7 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 11:41:29 -0700 Subject: [PATCH 5/7] (test): close divergence-matrix vacuity gaps from adversarial review - Guard the matrix on STATIC_POOLING: with pooling compile-disabled every macro takes the DisabledPool path, so the guard would pass without touching a pool. - Assert exact known results (36.0, 4, 30.0, 3.0) instead of only mutual agreement, so a shared regression returning the same wrong tuple still fails. - Restore MAYBE_POOLING to the file-entry value at the end of the Macro System testset, so this file cannot leave the process-global flag altered for later test files (some legacy testsets hard-reset to `true` instead of preserving it). --- test/test_macros.jl | 60 ++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/test/test_macros.jl b/test/test_macros.jl index 858708b2..fe30a7a1 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -2,6 +2,11 @@ import AdaptiveArrayPools: checkpoint!, rewind! @testset "Macro System" begin + # File-wide guard: several testsets below toggle the process-global + # MAYBE_POOLING flag (and some legacy ones hard-reset to `true` rather than + # preserving the entry value). Capture it here and restore it at the end so a + # run of this file cannot contaminate later test files. + _macro_system_entry_maybe = MAYBE_POOLING[] @testset "Explicit pool with checkpoint!/rewind!" begin pool = AdaptiveArrayPool() @@ -712,31 +717,36 @@ import AdaptiveArrayPools: checkpoint!, rewind! all(tp -> tp.n_active == 0, pool._others_values) @testset "divergence matrix: all 4 macros agree (result + no leak)" begin - old_maybe = MAYBE_POOLING[] - MAYBE_POOLING[] = true # @maybe_* take the pooled branch - try - reset!(get_task_local_pool()) - - runners = ( - ("@with_pool", _divmat_with_pool), - ("@maybe_with_pool", _divmat_maybe_with_pool), - ("@safe_with_pool", _divmat_safe_with_pool), - ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), - ) - baseline = nothing - for (label, f) in runners - r = f() - pool = get_task_local_pool() - @test pool._current_depth == 1 # no depth leak ($label) - @test _divmat_clean(pool) # all touched pools reclaimed ($label) - if baseline === nothing - baseline = r - else - @test r == baseline # identical result ($label) + # Only meaningful when pooling is compile-time enabled. With + # STATIC_POOLING = false every macro takes the DisabledPool path, so the + # guard would pass vacuously (no typed pool is ever touched). + if AdaptiveArrayPools.STATIC_POOLING + old_maybe = MAYBE_POOLING[] + MAYBE_POOLING[] = true # @maybe_* take the pooled branch + try + reset!(get_task_local_pool()) + + # Known-good results. Asserting these exact values (not just mutual + # agreement) means a shared regression that makes all four return + # the same WRONG tuple still fails: typed sum(1..8)=36.0, + # fallback length=4, nested 10+20=30.0, mixed 1+2=3.0. + expected = (36.0, 4, 30.0, 3.0) + runners = ( + ("@with_pool", _divmat_with_pool), + ("@maybe_with_pool", _divmat_maybe_with_pool), + ("@safe_with_pool", _divmat_safe_with_pool), + ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), + ) + for (label, f) in runners + r = f() + pool = get_task_local_pool() + @test pool._current_depth == 1 # no depth leak ($label) + @test _divmat_clean(pool) # all touched pools reclaimed ($label) + @test r == expected # exact known result ($label) end + finally + MAYBE_POOLING[] = old_maybe end - finally - MAYBE_POOLING[] = old_maybe end end @@ -815,4 +825,8 @@ import AdaptiveArrayPools: checkpoint!, rewind! @test _divmat_clean(pool) end + # Restore the file-entry MAYBE_POOLING value (see capture at top). @testset + # continues past inner failures, so this runs even if a testset above failed. + MAYBE_POOLING[] = _macro_system_entry_maybe + end # Macro System From d8ad35ef90e920d2f15f129a00a726a770e62369 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 12:01:04 -0700 Subject: [PATCH 6/7] (docs): clarify Fix-hint wording and its test comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments: - Reword the runtime/doc Fix hint "collect() it for an owned copy" to "collect() to return an owned copy, or compute a scalar" — clearer, and no longer readable as a zero-argument collect() call. - Correct the message test comment: the `nothing` ending is what aligns with the compile-time `_lint_message`; collect()/scalar are additional options `_lint_message` does not list. --- docs/src/safety/runtime.md | 2 +- src/debug.jl | 2 +- test/test_borrow_registry.jl | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/safety/runtime.md b/docs/src/safety/runtime.md index eccd3559..36769148 100644 --- a/docs/src/safety/runtime.md +++ b/docs/src/safety/runtime.md @@ -71,7 +71,7 @@ PoolEscapeError (runtime, RUNTIME_CHECK >= 1) ← acquired at src/solver.jl:42 v = acquire!(pool, Float64, n) - Fix: end the block with nothing if the value is discarded, collect() it for an owned copy, or return a scalar. + Fix: end the block with nothing if the value is discarded, collect() to return an owned copy, or compute a scalar. ``` The `nothing` ending is the most common fix: a scope's **last expression is its return value**, so a block that ends on a pool-backed array leaks it by accident. End with `nothing` (or a scalar) when the array is only used for side effects. diff --git a/src/debug.jl b/src/debug.jl index b2a3122d..636441de 100644 --- a/src/debug.jl +++ b/src/debug.jl @@ -291,7 +291,7 @@ function Base.showerror(io::IO, e::PoolRuntimeEscapeError) printstyled(io, "nothing"; bold = true) printstyled(io, " if the value is discarded, "; color = :light_black) printstyled(io, "collect()"; bold = true) - printstyled(io, " it for an owned copy, or return a scalar.\n"; color = :light_black) + printstyled(io, " to return an owned copy, or compute a scalar.\n"; color = :light_black) return nothing end diff --git a/test/test_borrow_registry.jl b/test/test_borrow_registry.jl index 1973a10e..75361fdd 100644 --- a/test/test_borrow_registry.jl +++ b/test/test_borrow_registry.jl @@ -256,9 +256,9 @@ _test_leak(x) = x end @testset "showerror: Fix line teaches nothing/collect/scalar" begin - # The runtime Fix suggestion must match the compile-time `_lint_message` - # wording — offer the `nothing` ending (the most common accidental-escape - # fix) alongside collect() and scalar. + # The runtime Fix suggestion should teach the `nothing` ending (the most + # common accidental-escape fix — the same ending the compile-time + # `_lint_message` recommends), alongside the collect() and scalar options. err = PoolRuntimeEscapeError("Vector{Float64}", "Float64", "test.jl:42", nothing) io = IOBuffer() showerror(io, err) From f28f5073562f642cff46f740a8cb9da4b048e255 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 11 Jul 2026 12:31:53 -0700 Subject: [PATCH 7/7] (fix): gate divergence matrix to Julia >= 1.12 (1.11 compiler segfault) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The divergence-matrix block @eval-generates four macro runners × four scenarios with a nested macrocall spliced via `$(...)`. On CI this crashes Julia 1.11's type inference with a segfault (signal 11) on ubuntu and macos — a 1.11 compiler bug that does not reproduce on 1.10, 1.12, or 1.11-windows. Wrap the block in `@static if VERSION >= v"1.12-"` so the pathological code is never compiled on 1.11; the matrix targets the >= 1.12 tp-hoisting divergence anyway, and legacy macro behavior stays covered by the other testsets. Verified: 1.12 runs the matrix (Macro System 100 tests), 1.11 loads clean without it (77 tests). --- test/test_macros.jl | 358 +++++++++++++++++++++++--------------------- 1 file changed, 184 insertions(+), 174 deletions(-) diff --git a/test/test_macros.jl b/test/test_macros.jl index fe30a7a1..4d507ccf 100644 --- a/test/test_macros.jl +++ b/test/test_macros.jl @@ -629,201 +629,211 @@ import AdaptiveArrayPools: checkpoint!, rewind! end end - # ========================================================================== - # safe/non-safe divergence guard - # ========================================================================== - # `@with_pool`/`@maybe_with_pool` (non-safe) and `@safe_*` (try/finally) are - # two fundamentally different generator branches. This matrix runs identical - # scenarios through ALL FOUR macros and asserts they agree on result AND leave - # no depth/active leak — the mechanical guard that a change to one branch - # (checkpoint/rewind/tp-hoisting) must not silently break the other. A leaked - # checkpoint from tp bindings emitted before the try (instead of inside it) is - # exactly what the safe rows catch. - - # Build one runner per macro at module scope (avoids 4× body duplication). - # A macro name interpolated as `$sym pool begin … end` is not valid syntax, so - # each scope is assembled as an explicit `Expr(:macrocall, …)`. Each runner runs - # 4 scenarios exercising the divergent paths: typed fixed-slot (tp hoisting), - # fallback/lazy (local-var demotion), nested, mixed (2 fixed slots). - _divmat_mc(m, body) = Expr(:macrocall, m, LineNumberNode(0, :divmat), :pool, body) - for macname in (:with_pool, :maybe_with_pool, :safe_with_pool, :safe_maybe_with_pool) - fname = Symbol("_divmat_", macname) - m = Symbol("@", macname) - typed = _divmat_mc( - m, quote - a = acquire!(pool, Float64, 8) - @inbounds for i in 1:8 - a[i] = Float64(i) + # Gated to Julia >= 1.12. The metaprogramming below (@eval-generating four + # macro runners × four scenarios, with a nested macrocall spliced via `$(...)`) + # crashes Julia 1.11's type inference with a segfault on some CI platforms — a + # 1.11 compiler bug that does not reproduce on 1.10 or 1.12. The matrix targets + # the >= 1.12 tp-hoisting divergence anyway, and legacy macro behavior is + # covered by the testsets above; @static keeps this uncompiled on 1.11. + @static if VERSION >= v"1.12-" + + # ========================================================================== + # safe/non-safe divergence guard + # ========================================================================== + # `@with_pool`/`@maybe_with_pool` (non-safe) and `@safe_*` (try/finally) are + # two fundamentally different generator branches. This matrix runs identical + # scenarios through ALL FOUR macros and asserts they agree on result AND leave + # no depth/active leak — the mechanical guard that a change to one branch + # (checkpoint/rewind/tp-hoisting) must not silently break the other. A leaked + # checkpoint from tp bindings emitted before the try (instead of inside it) is + # exactly what the safe rows catch. + + # Build one runner per macro at module scope (avoids 4× body duplication). + # A macro name interpolated as `$sym pool begin … end` is not valid syntax, so + # each scope is assembled as an explicit `Expr(:macrocall, …)`. Each runner runs + # 4 scenarios exercising the divergent paths: typed fixed-slot (tp hoisting), + # fallback/lazy (local-var demotion), nested, mixed (2 fixed slots). + _divmat_mc(m, body) = Expr(:macrocall, m, LineNumberNode(0, :divmat), :pool, body) + for macname in (:with_pool, :maybe_with_pool, :safe_with_pool, :safe_maybe_with_pool) + fname = Symbol("_divmat_", macname) + m = Symbol("@", macname) + typed = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 8) + @inbounds for i in 1:8 + a[i] = Float64(i) + end + sum(a) end - sum(a) - end - ) - fallback = _divmat_mc( - m, quote - T = Vector{Float64} # local var → macro demotes to lazy path - a = acquire!(pool, T, 4) - a[1] = Float64[1.0] - length(a) - end - ) - nested = _divmat_mc( - m, quote - a = acquire!(pool, Float64, 4) - a[1] = 10.0 - inner = $( - _divmat_mc( - m, quote - b = acquire!(pool, Float64, 4) - b[1] = 20.0 - b[1] - end + ) + fallback = _divmat_mc( + m, quote + T = Vector{Float64} # local var → macro demotes to lazy path + a = acquire!(pool, T, 4) + a[1] = Float64[1.0] + length(a) + end + ) + nested = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 4) + a[1] = 10.0 + inner = $( + _divmat_mc( + m, quote + b = acquire!(pool, Float64, 4) + b[1] = 20.0 + b[1] + end + ) ) - ) - a[1] + inner - end - ) - mixed = _divmat_mc( - m, quote - a = acquire!(pool, Float64, 4) - a[1] = 1.0 - c = acquire!(pool, Int32, 2) - c[1] = Int32(2) - Float64(a[1]) + c[1] + a[1] + inner + end + ) + mixed = _divmat_mc( + m, quote + a = acquire!(pool, Float64, 4) + a[1] = 1.0 + c = acquire!(pool, Int32, 2) + c[1] = Int32(2) + Float64(a[1]) + c[1] + end + ) + @eval $fname() = ($typed, $fallback, $nested, $mixed) + end + + # These runners throw after a hoisted (fallback-type) acquire, so the safe rows + # verify that a scope rewinds on exception. Fixed slots aren't hoisted, so a + # fallback type (Vector{Float64}) is needed to actually exercise the hoisted + # binding. Note this alone does NOT pin the binding's position — the structural + # expansion test below is what guards "binding stays inside the try". + @eval _divmat_throw_safe() = @safe_with_pool pool begin + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] + error("boom") + end + @eval _divmat_throw_safe_maybe() = @safe_maybe_with_pool pool begin + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] + error("boom") + end + + # Every pool the matrix touches (fixed float64/int32 + fallback others) must be + # fully rewound; checking only float64 would miss a lazy/fallback rewind bug. + _divmat_clean(pool) = pool.float64.n_active == 0 && + pool.int32.n_active == 0 && + all(tp -> tp.n_active == 0, pool._others_values) + + @testset "divergence matrix: all 4 macros agree (result + no leak)" begin + # Only meaningful when pooling is compile-time enabled. With + # STATIC_POOLING = false every macro takes the DisabledPool path, so the + # guard would pass vacuously (no typed pool is ever touched). + if AdaptiveArrayPools.STATIC_POOLING + old_maybe = MAYBE_POOLING[] + MAYBE_POOLING[] = true # @maybe_* take the pooled branch + try + reset!(get_task_local_pool()) + + # Known-good results. Asserting these exact values (not just mutual + # agreement) means a shared regression that makes all four return + # the same WRONG tuple still fails: typed sum(1..8)=36.0, + # fallback length=4, nested 10+20=30.0, mixed 1+2=3.0. + expected = (36.0, 4, 30.0, 3.0) + runners = ( + ("@with_pool", _divmat_with_pool), + ("@maybe_with_pool", _divmat_maybe_with_pool), + ("@safe_with_pool", _divmat_safe_with_pool), + ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), + ) + for (label, f) in runners + r = f() + pool = get_task_local_pool() + @test pool._current_depth == 1 # no depth leak ($label) + @test _divmat_clean(pool) # all touched pools reclaimed ($label) + @test r == expected # exact known result ($label) + end + finally + MAYBE_POOLING[] = old_maybe + end end - ) - @eval $fname() = ($typed, $fallback, $nested, $mixed) - end - - # These runners throw after a hoisted (fallback-type) acquire, so the safe rows - # verify that a scope rewinds on exception. Fixed slots aren't hoisted, so a - # fallback type (Vector{Float64}) is needed to actually exercise the hoisted - # binding. Note this alone does NOT pin the binding's position — the structural - # expansion test below is what guards "binding stays inside the try". - @eval _divmat_throw_safe() = @safe_with_pool pool begin - a = acquire!(pool, Vector{Float64}, 8) - a[1] = Float64[] - error("boom") - end - @eval _divmat_throw_safe_maybe() = @safe_maybe_with_pool pool begin - a = acquire!(pool, Vector{Float64}, 8) - a[1] = Float64[] - error("boom") - end + end - # Every pool the matrix touches (fixed float64/int32 + fallback others) must be - # fully rewound; checking only float64 would miss a lazy/fallback rewind bug. - _divmat_clean(pool) = pool.float64.n_active == 0 && - pool.int32.n_active == 0 && - all(tp -> tp.n_active == 0, pool._others_values) - - @testset "divergence matrix: all 4 macros agree (result + no leak)" begin - # Only meaningful when pooling is compile-time enabled. With - # STATIC_POOLING = false every macro takes the DisabledPool path, so the - # guard would pass vacuously (no typed pool is ever touched). - if AdaptiveArrayPools.STATIC_POOLING + @testset "safe macros: exception rewinds despite throw" begin old_maybe = MAYBE_POOLING[] - MAYBE_POOLING[] = true # @maybe_* take the pooled branch + MAYBE_POOLING[] = true try reset!(get_task_local_pool()) - - # Known-good results. Asserting these exact values (not just mutual - # agreement) means a shared regression that makes all four return - # the same WRONG tuple still fails: typed sum(1..8)=36.0, - # fallback length=4, nested 10+20=30.0, mixed 1+2=3.0. - expected = (36.0, 4, 30.0, 3.0) - runners = ( - ("@with_pool", _divmat_with_pool), - ("@maybe_with_pool", _divmat_maybe_with_pool), - ("@safe_with_pool", _divmat_safe_with_pool), - ("@safe_maybe_with_pool", _divmat_safe_maybe_with_pool), - ) - for (label, f) in runners - r = f() + for f in (_divmat_throw_safe, _divmat_throw_safe_maybe) pool = get_task_local_pool() - @test pool._current_depth == 1 # no depth leak ($label) - @test _divmat_clean(pool) # all touched pools reclaimed ($label) - @test r == expected # exact known result ($label) + d0 = pool._current_depth + @test_throws ErrorException f() + @test pool._current_depth == d0 # finally rewound + @test _divmat_clean(pool) # no leaked capacity in use end finally MAYBE_POOLING[] = old_maybe end end - end - @testset "safe macros: exception rewinds despite throw" begin - old_maybe = MAYBE_POOLING[] - MAYBE_POOLING[] = true - try - reset!(get_task_local_pool()) - for f in (_divmat_throw_safe, _divmat_throw_safe_maybe) - pool = get_task_local_pool() - d0 = pool._current_depth - @test_throws ErrorException f() - @test pool._current_depth == d0 # finally rewound - @test _divmat_clean(pool) # no leaked capacity in use - end - finally - MAYBE_POOLING[] = old_maybe - end - end - - @testset "safe+typed expansion keeps the tp binding inside the try" begin - # Direct structural guard for the invariant the exception test cannot see: - # the hoisted get_typed_pool! binding must live INSIDE the try, so a throw - # from it still hits the finally rewind. If the binding moved before the - # try, the try body would no longer reference get_typed_pool! and this - # fails. Only meaningful where curly types are hoisted (Julia >= 1.12). - if AdaptiveArrayPools._MACRO_TYPED_UPGRADES - # The macro splices the get_typed_pool! *function value* (via `$`), so - # match that as well as a GlobalRef/Symbol spelling. - gtp = AdaptiveArrayPools.get_typed_pool! - refs_gtp(e) = - e === gtp || - (e isa GlobalRef && e.name === :get_typed_pool!) || - (e isa Symbol && e === :get_typed_pool!) || - (e isa Expr && any(refs_gtp, e.args)) - function find_try(e) - e isa Expr || return nothing - e.head === :try && return e - for a in e.args - t = find_try(a) - t === nothing || return t + @testset "safe+typed expansion keeps the tp binding inside the try" begin + # Direct structural guard for the invariant the exception test cannot see: + # the hoisted get_typed_pool! binding must live INSIDE the try, so a throw + # from it still hits the finally rewind. If the binding moved before the + # try, the try body would no longer reference get_typed_pool! and this + # fails. Only meaningful where curly types are hoisted (Julia >= 1.12). + if AdaptiveArrayPools._MACRO_TYPED_UPGRADES + # The macro splices the get_typed_pool! *function value* (via `$`), so + # match that as well as a GlobalRef/Symbol spelling. + gtp = AdaptiveArrayPools.get_typed_pool! + refs_gtp(e) = + e === gtp || + (e isa GlobalRef && e.name === :get_typed_pool!) || + (e isa Symbol && e === :get_typed_pool!) || + (e isa Expr && any(refs_gtp, e.args)) + function find_try(e) + e isa Expr || return nothing + e.head === :try && return e + for a in e.args + t = find_try(a) + t === nothing || return t + end + return nothing end - return nothing - end - ex = @macroexpand @safe_with_pool pool begin - a = acquire!(pool, Vector{Float64}, 8) - a[1] = Float64[] - nothing + ex = @macroexpand @safe_with_pool pool begin + a = acquire!(pool, Vector{Float64}, 8) + a[1] = Float64[] + nothing + end + tnode = find_try(ex) + @test tnode !== nothing + @test refs_gtp(tnode.args[1]) # binding is inside the try body end - tnode = find_try(ex) - @test tnode !== nothing - @test refs_gtp(tnode.args[1]) # binding is inside the try body end - end - @testset "non-safe: parent normalizes a leaked inner scope on throw" begin - reset!(get_task_local_pool()) - # Inner @with_pool throws without rewinding; the outer scope's leaked-scope - # cleanup loop (the non-safe counterpart of the safe finally) restores depth. - f() = @with_pool pool begin - a = acquire!(pool, Float64, 4) - a[1] = 1.0 - try - @with_pool pool begin - b = acquire!(pool, Float64, 4) - error("inner boom") + @testset "non-safe: parent normalizes a leaked inner scope on throw" begin + reset!(get_task_local_pool()) + # Inner @with_pool throws without rewinding; the outer scope's leaked-scope + # cleanup loop (the non-safe counterpart of the safe finally) restores depth. + f() = @with_pool pool begin + a = acquire!(pool, Float64, 4) + a[1] = 1.0 + try + @with_pool pool begin + b = acquire!(pool, Float64, 4) + error("inner boom") + end + catch end - catch + a[1] end - a[1] + r = f() + pool = get_task_local_pool() + @test r == 1.0 + @test pool._current_depth == 1 # outer cleanup normalized the leak + @test _divmat_clean(pool) end - r = f() - pool = get_task_local_pool() - @test r == 1.0 - @test pool._current_depth == 1 # outer cleanup normalized the leak - @test _divmat_clean(pool) - end + + end # @static if VERSION >= v"1.12-" (divergence matrix) # Restore the file-entry MAYBE_POOLING value (see capture at top). @testset # continues past inner failures, so this runs even if a testset above failed.