diff --git a/.codecov.yml b/.codecov.yml index 4caafbf6..a8a58b5a 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,3 +1,10 @@ +# Exactly 2 coverage uploads per commit (CI.yml: ubuntu × {lts, 1.x} — the +# two version trees). Wait for both before posting statuses/comments, so a +# partially-merged report can never surface as a phantom coverage drop. +codecov: + notify: + after_n_builds: 2 + coverage: status: project: @@ -16,3 +23,4 @@ comment: layout: "reach,diff,flags,files" behavior: default require_changes: true + after_n_builds: 2 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d051de51..9e97e1e9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -41,11 +41,20 @@ jobs: - uses: julia-actions/julia-runtest@v1 + # Coverage: upload from exactly TWO jobs — ubuntu × {lts, 1.x}. The two + # Julia versions compile different version-gated branches of the shared + # sources (legacy vs modern tree), so both are needed for a complete + # merged report; the other 7 matrix legs add no unique lines (no + # OS-conditional source, and 1.11 exercises the same legacy tree as lts). + # .codecov.yml `after_n_builds: 2` holds notification until both arrive, + # so a partially-merged report can't surface as a phantom coverage drop. - uses: julia-actions/julia-processcoverage@v1 + if: matrix.os == 'ubuntu-latest' && matrix.version != '1.11' with: directories: src - uses: codecov/codecov-action@v5 + if: matrix.os == 'ubuntu-latest' && matrix.version != '1.11' with: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 463e1e29..6aa91853 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ LocalPreferences.toml # Auto-generated docs (created by make.jl) /docs/src/index.md + +# Release changelog — maintained manually outside version control +/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 6b918ea7..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,100 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -This package does not yet commit to Semantic Versioning strictness pre-1.0; -version numbers below indicate scope of change, not a stability contract. - -## [0.4.0] — 2026-07-10 - -> **Version note:** the three new escape-error patterns below apply on all -> supported Julia versions; the parametric-static-type and tp-hoisting -> performance improvements apply on Julia >= 1.12 only (the legacy tree -> keeps its previous expansion). - -### BREAKING - -`@with_pool` (and its `@maybe_with_pool` / `@safe_with_pool` / -`@safe_maybe_with_pool` variants) now reject three additional "incidental" -tail patterns at **macro-expansion time** — previously these only errored at -runtime (and only when `RUNTIME_CHECK >= 1`): - -- a direct acquire-family call as the scope's last expression -- a broadcast-assignment (`x .= v`) tail, where the **LHS** `x` (the array - being written into) is pool-backed — a broadcast assignment evaluates to - `x`, not `v`, so an RHS-only pool value (e.g. `x .= v` with a non-pool `x`) - is safe and unflagged -- a plain assignment (`y = v`) tail, where the RHS is pool-backed - -These join the existing return-looking patterns (bare variable, `return v`, -tuple/vector/NamedTuple literals) that already errored at expansion time. - -```julia -# before (compiled and ran; `v .= 0.0` evaluates to `v` itself, so the pool -# array escaped as the scope's return value — invalid the moment the caller -# touched it, since it had already been rewound): -@with_pool pool begin - v = acquire!(pool, Float64, 100) - v .= 0.0 -end - -# after (PoolEscapeError at expansion time). Fix: discard-style scopes must -# end with `nothing`: -@with_pool pool begin - v = acquire!(pool, Float64, 100) - v .= 0.0 - nothing -end -``` - -A new `escape_lint` preference is the migration escape hatch for this -breaking change: - -```julia -using Preferences -Preferences.set_preferences!("AdaptiveArrayPools", "escape_lint" => "warn") -# "error" (default) — throw PoolEscapeError, same as the pre-existing patterns -# "warn" — print the same diagnostic via @warn and continue -# "off" — skip this stage of checking entirely -``` - -The `RUNTIME_CHECK >= 1` runtime validation is unaffected and remains -authoritative for patterns static analysis cannot see (aliases through -opaque function calls, closures, conditional tails). - -### Added - -- `Vector{Float64}`-style parametric type literals (curly `acquire!` calls - with no locally-bound free names, e.g. `acquire!(pool, Vector{Float64}, n)`) - now take the static typed checkpoint/rewind path instead of demoting to the - dynamic/lazy path. Curly types built from a locally-bound name - (`Vector{T}` where `T` is assigned earlier in the same scope) still demote, - since the concrete type isn't known until runtime. -- `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 - -- Typed scopes now hoist `get_typed_pool!` to a single lookup per static type - per scope: each static type's `TypedPool` is bound once right after - `checkpoint!` and threaded through new `_*_impl!(pool, tp, dims...)` - method variants, removing the per-`acquire!` fallback-registry lookup - (fixed-slot types were already zero-cost field loads; this closes the gap - for fallback/dynamic types with repeated same-type acquires in one scope). - -### Fixed - -- `_transform_acquire_calls` no longer recurses into a nested - `@with_pool`/`@maybe_with_pool`/`@safe_with_pool`/`@safe_maybe_with_pool` - macrocall. Previously, transforming an outer scope's body would rewrite - `acquire!` calls inside a syntactically nested inner `@with_pool` block - before the inner macro ever ran its own expansion pass, causing the inner - scope's type-extraction to find no `acquire!` calls and silently demote to - the dynamic/lazy path regardless of what the inner scope actually did. diff --git a/docs/src/safety/compile-time.md b/docs/src/safety/compile-time.md index 4bd21ddd..fa978418 100644 --- a/docs/src/safety/compile-time.md +++ b/docs/src/safety/compile-time.md @@ -4,18 +4,43 @@ The `@with_pool` macro statically analyzes your code at macro expansion time — ## Escape Analysis (`PoolEscapeError`) -Rejects any expression that would return a pool-backed array. Tracks aliases, containers, and convenience wrappers: +Detects any tail expression that would deliver a pool-backed array out of the +scope. Tracks aliases, containers, and convenience wrappers. **Severity is +form-based**, because the two forms differ in what the macro can know: + +- **Function form** and **explicit `return`** (either form): the value + definitively reaches the enclosing function's caller — a `begin` block is + not a function boundary, so `return` inside a block form returns from the + *enclosing function*. These always throw `PoolEscapeError` at expansion. +- **Block form, implicit last expression**: the macro cannot see its own call + site, so whether the block's value is used (`out = @with_pool ...`) or + discarded (a loop body, a bare statement) is undecidable. These emit a + `@warn` and the expansion **replaces the escaping value with an inert + `EscapedPoolArray` guard**: discarded → completely silent; used → the first + operation throws `EscapedPoolUseError` with the variable name, shape, and + scope location. ```julia -@with_pool pool begin +@with_pool pool function bad() + A = acquire!(pool, Float64, 3, 3) + return A # ← PoolEscapeError: function return, always an error +end + +out = @with_pool pool begin v = acquire!(pool, Float64, 100) w = v # alias tracked - w # ← PoolEscapeError: w escapes + w # ← @warn + guard: `out` is an EscapedPoolArray end - -@with_pool pool function bad() - A = acquire!(pool, Float64, 3, 3) - return A # ← PoolEscapeError: explicit return +out[1] # ← EscapedPoolUseError: names `w`, its shape, and the scope + +# The guard is inert when the value is never used — this common +# copy-out-and-discard shape runs silently and correctly: +Threads.@threads for k in 1:nmodels + @with_pool pool begin + y = acquire!(pool, Float64, m, n) + compute!(y, k) + results[:, :, k] = y # copy runs; the yielded `y` is guarded; @threads discards it + end end ``` @@ -64,7 +89,8 @@ end end ``` -Fix: end the block with `nothing` if the value is meant to be discarded: +Fix: end the block with `nothing` if the value is meant to be discarded — +this also silences the warning: ```julia @with_pool pool begin @@ -74,14 +100,20 @@ Fix: end the block with `nothing` if the value is meant to be discarded: end ``` -These three patterns are gated by the `escape_lint` preference (default -`"error"`, matching the direct-return patterns above). Set `"warn"` to -downgrade to a `@warn` diagnostic (migration escape hatch), or `"off"` to -disable this stage of checking entirely: +In block form these follow the same warn + `EscapedPoolArray`-guard treatment +as all implicit tails; in function form or under an explicit `return`, they +throw. The `escape_lint` preference tunes the **block-form** severity: + +- `"warn"` (default) — `@warn` diagnostic + the guard rewrite described above. +- `"error"` — block-form implicit tails also throw `PoolEscapeError` + (the strict pre-guard behavior; no guard rewrite needed). +- `"off"` — no warning and no guard rewrite (the block tail escapes the raw + pool array, as before this feature). Function-form and explicit-`return` + direct escapes still always error. ```julia using Preferences -Preferences.set_preferences!("AdaptiveArrayPools", "escape_lint" => "warn") +Preferences.set_preferences!("AdaptiveArrayPools", "escape_lint" => "error") ``` ## Mutation Analysis (`PoolMutationError`) diff --git a/docs/src/safety/runtime.md b/docs/src/safety/runtime.md index 36769148..0e64b914 100644 --- a/docs/src/safety/runtime.md +++ b/docs/src/safety/runtime.md @@ -107,8 +107,10 @@ zero-GC hot path under `S=1` — use `S=0`. 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. +return-position cases, and the [compile-time analysis](compile-time.md) handles +the common syntactic ones (function-form/`return` escapes error at expansion; +block-tail escapes are replaced by an `EscapedPoolArray` guard that traps on +first use even at `S=0`). **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 diff --git a/src/AdaptiveArrayPools.jl b/src/AdaptiveArrayPools.jl index 188a73f9..73d12d7d 100644 --- a/src/AdaptiveArrayPools.jl +++ b/src/AdaptiveArrayPools.jl @@ -27,14 +27,20 @@ export AbstractTypedPool, AbstractArrayPool # For subtyping export DisabledPool, DISABLED_CPU, pooling_enabled # Disabled pool support # Note: Extensions add methods to _get_pool_for_backend(::Val{:backend}) directly -# Expansion-time incidental-tail escape severity: "error" (default) | "warn" | "off". +# Expansion-time block-form escape severity: "warn" (default) | "error" | "off". +# Under "warn", block-form implicit-tail escapes warn AND the escaping value is +# replaced by an EscapedPoolArray guard (see src/escape_guard.jl); function-form +# and explicit-return escapes always error regardless of this setting. # Read once at package load; a compile-time constant like RUNTIME_CHECK. -const ESCAPE_LINT = let v = @load_preference("escape_lint", "error") +const ESCAPE_LINT = let v = @load_preference("escape_lint", "warn") v in ("error", "warn", "off") || error("escape_lint preference must be \"error\", \"warn\", or \"off\" (got \"$v\")") v end +# Version-independent escape guard (used by macro expansion on both trees) +include("escape_guard.jl") + # All includes grouped under a single version branch @static if VERSION >= v"1.12-" include("types.jl") diff --git a/src/escape_guard.jl b/src/escape_guard.jl new file mode 100644 index 00000000..dee65869 --- /dev/null +++ b/src/escape_guard.jl @@ -0,0 +1,122 @@ +# ============================================================================== +# EscapedPoolArray — inert guard replacing a pool-backed value that escapes a +# block-form `@with_pool` scope as its implicit tail value. +# +# The macro cannot see its own call site, so whether a block's value is used +# (`out = @with_pool ...`) or discarded (loop body, bare statement) is +# undecidable at expansion time. Instead of erroring on safe-but-flagged code +# or warning on buggy code, the expansion replaces the escaping value with this +# guard: discarded → completely inert; used → throws `EscapedPoolUseError` +# with full provenance at the first trapped operation. +# +# Deliberately metadata-only — the guard does NOT hold the array: it must not +# extend the rewound buffer's GC lifetime, and "unwrap and use anyway" must be +# impossible. `typeof`/`size` are recorded at the tail-capture point (before +# rewind), where the array is still valid. +# +# Version-independent (shared by the modern and legacy trees, like macros.jl). +# ============================================================================== + +struct EscapedPoolArray + var::Symbol # source variable name, or :expression for anonymous tails + arraytype::Type # typeof(x) at escape, e.g. Matrix{Float64} + dims::Tuple{Vararg{Int}} # size(x) at escape; () when not an AbstractArray + file::Union{String, Nothing} # @with_pool scope location + line::Union{Int, Nothing} +end + +# Expansion-emitted constructor: records provenance, discards the array. +function EscapedPoolArray( + x, var::Symbol, file::Union{String, Nothing}, line::Union{Int, Nothing}, + ) + return EscapedPoolArray(var, typeof(x), x isa AbstractArray ? size(x) : (), file, line) +end + +""" + EscapedPoolUseError <: Exception + +Thrown on the first use of an [`EscapedPoolArray`](@ref) guard — a pool-backed +array that escaped its `@with_pool` scope as the block's implicit tail value. +The array was recycled by the scope's rewind, so any use of the escaped value +is invalid; the guard defers the error to the moment of actual use (a merely +discarded escape stays silent). +""" +struct EscapedPoolUseError <: Exception + guard::EscapedPoolArray + op::Symbol # the trapped operation that was attempted +end + +# "`x` (3×2 Matrix{Float64})" / "an anonymous expression (Vector{Float64})" +function _show_guard_identity(io::IO, g::EscapedPoolArray) + if g.var === :expression + print(io, "an anonymous expression") + else + print(io, "`", g.var, "`") + end + if isempty(g.dims) + print(io, " (", g.arraytype, ")") + else + print(io, " (", join(g.dims, "×"), " ", g.arraytype, ")") + end + return nothing +end + +_guard_location(g::EscapedPoolArray) = + g.file === nothing ? nothing : (g.line === nothing ? g.file : "$(g.file):$(g.line)") + +# Non-throwing, informative display: the REPL showing a discarded-but-displayed +# guard must explain itself, not crash. +function Base.show(io::IO, g::EscapedPoolArray) + print(io, "EscapedPoolArray: pool-backed array ") + _show_guard_identity(io, g) + print(io, " escaped its `@with_pool` scope") + loc = _guard_location(g) + loc !== nothing && print(io, " at ", loc) + print(io, " — invalid after the scope rewinds; any use throws EscapedPoolUseError") + return nothing +end + +function Base.showerror(io::IO, e::EscapedPoolUseError) + g = e.guard + printstyled(io, "EscapedPoolUseError"; color = :red, bold = true) + print(io, ": `", e.op, "` on a pool-backed array that escaped its `@with_pool` scope\n\n") + printstyled(io, " Escaped value: "; color = :light_black) + _show_guard_identity(io, g) + print(io, "\n") + loc = _guard_location(g) + if loc !== nothing + printstyled(io, " Scope location: "; color = :light_black) + print(io, loc, "\n") + end + printstyled( + io, + "\n A pool-backed array is only valid inside its `@with_pool` scope — the\n" * + " scope's rewind recycles it, so the escaping value was replaced by this\n" * + " inert guard at scope exit.\n"; + color = :light_black, + ) + print(io, "\n Fix options:\n") + print(io, " • materialize an owned copy inside the scope: `collect(x)` / `copy(x)`\n") + print(io, " • return a scalar or other non-pool value instead\n") + print(io, " • end the block with `nothing` if the value is not meant to be used\n") + printstyled( + io, + "\n If this escape looks like a false positive, please file an issue\n" * + " with a minimal reproducer so we can improve the escape detector.\n"; + color = :light_black, + ) + return nothing +end + +# Trap surface: the common array entry points all throw with provenance. Any +# operation not listed here falls through to a MethodError that names the +# guard type — self-documenting. Identity/no-dispatch operations (`===`, +# `isnothing`, `typeof`) intentionally keep working so harmless plumbing +# around a discarded value stays silent. +for f in ( + :getindex, :setindex!, :size, :length, :axes, :iterate, + :firstindex, :lastindex, :similar, :copy, :view, :vec, :keys, + ) + @eval Base.$f(g::EscapedPoolArray, args...) = throw(EscapedPoolUseError(g, $(QuoteNode(f)))) +end +Base.broadcastable(g::EscapedPoolArray) = throw(EscapedPoolUseError(g, :broadcastable)) diff --git a/src/macros.jl b/src/macros.jl index 5fbc6d13..206964aa 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -380,10 +380,19 @@ end ## Escape Detection -`@with_pool` statically analyzes the scope body at macro-expansion time and -rejects code whose return value would be a pool-backed array — a -[`PoolEscapeError`](@ref), thrown at expansion time (zero runtime cost). This -always covers the direct-return patterns (a bare variable, `return v`, +`@with_pool` statically analyzes the scope body at macro-expansion time for +tail expressions whose value would be a pool-backed array. **Severity is +form-based.** A **function-form** scope's tail and an **explicit `return`** +(either form — `return` inside a `begin` block returns from the *enclosing +function*) deliver the value to a caller: these throw [`PoolEscapeError`](@ref) +at expansion time. A **block-form implicit tail** may simply be discarded by +the surrounding code, which the macro cannot see — so by default it emits a +`@warn` and the expansion replaces the escaping value with an inert +`EscapedPoolArray` guard: harmless if the block's value is discarded, and the +first actual use throws an `EscapedPoolUseError` carrying the variable name, +shape, and scope location. + +Detected patterns cover the direct-return shapes (a bare variable, `return v`, container literals like `(v, w)` / `[v]`), and three additional "incidental" tail patterns that don't *look* like a `return` but still expose a pool-backed array as the scope's value: @@ -416,16 +425,22 @@ side effects" scope), end the block with `nothing`: end ``` -Severity for these three incidental-tail patterns is controlled by the -`escape_lint` preference (via `Preferences.jl`, read once at package load as -the `ESCAPE_LINT` compile-time constant): -- `"error"` (default) — throws `PoolEscapeError`, same as the direct-return patterns. -- `"warn"` — prints the same diagnostic via `@warn` and continues (migration escape hatch). -- `"off"` — disables Stage-2 (incidental-tail) checking; direct-return patterns still always error. +The block-form severity is controlled by the `escape_lint` preference (via +`Preferences.jl`, read once at package load as the `ESCAPE_LINT` compile-time +constant): +- `"warn"` (default) — block-form implicit tails warn, and the escaping value + is replaced by the `EscapedPoolArray` guard described above. +- `"error"` — block-form implicit tails throw `PoolEscapeError` instead + (strict mode; no guard rewrite). +- `"off"` — no warning and no guard rewrite for block-form tails (the raw + pool array escapes, as before this feature). + +Function-form and explicit-`return` escapes always throw regardless of the +preference. ```julia using Preferences -Preferences.set_preferences!("AdaptiveArrayPools", "escape_lint" => "warn") +Preferences.set_preferences!("AdaptiveArrayPools", "escape_lint" => "error") ``` See also the "Compile-Time Detection" page in the manual for full error-message @@ -792,7 +807,12 @@ function _generate_pool_code(pool_name, expr, force_enable; safe::Bool = false, # 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) + # Under "warn", statically-detected escaping tails are rewritten so the + # escaping value is replaced by an EscapedPoolArray guard (runtime trap on + # first use). The disabled/else branches below keep the ORIGINAL `expr`: + # with pooling off, acquire returns plain arrays and nothing escapes. + pooled_expr = ESCAPE_LINT == "warn" ? _poison_block_tails(expr, pool_name, source) : expr + inner = _generate_block_inner(pool_name, pooled_expr, safe, source) if force_enable return _wrap_with_dispatch(esc(pool_name), :(get_task_local_pool()), inner) @@ -1070,7 +1090,13 @@ function _generate_pool_code_with_backend(backend::Symbol, pool_name, expr, forc _check_structural_mutation(expr, pool_name, source) # Block logic with runtime check - inner = _generate_block_inner(pool_name, expr, safe, source) + # Under "warn", statically-detected escaping tails are rewritten so the + # escaping value is replaced by an EscapedPoolArray guard (runtime trap + # on first use). The MAYBE_POOLING[] == false branch below keeps the + # ORIGINAL `expr`: with pooling off, acquire returns plain arrays and + # nothing escapes. + pooled_expr = ESCAPE_LINT == "warn" ? _poison_block_tails(expr, pool_name, source) : expr + inner = _generate_block_inner(pool_name, pooled_expr, safe, source) pool_getter = :($_get_pool_for_backend($(Val{backend}()))) enabled_branch = _wrap_with_dispatch(esc(pool_name), pool_getter, inner; backend) return quote @@ -1103,7 +1129,12 @@ function _generate_pool_code_with_backend(backend::Symbol, pool_name, expr, forc _check_structural_mutation(expr, pool_name, source) # Block logic (force_enable=true path) - inner = _generate_block_inner(pool_name, expr, safe, source) + # Under "warn", statically-detected escaping tails are rewritten so the + # escaping value is replaced by an EscapedPoolArray guard (runtime trap on + # first use). The disabled/else branches below keep the ORIGINAL `expr`: + # with pooling off, acquire returns plain arrays and nothing escapes. + pooled_expr = ESCAPE_LINT == "warn" ? _poison_block_tails(expr, pool_name, source) : expr + inner = _generate_block_inner(pool_name, pooled_expr, safe, source) pool_getter = :($_get_pool_for_backend($(Val{backend}()))) return _wrap_with_dispatch(esc(pool_name), pool_getter, inner; backend) end @@ -1135,7 +1166,7 @@ function _generate_function_pool_code_with_backend(backend::Symbol, pool_name, f end # Compile-time escape detection (zero runtime cost) - _esc = _check_compile_time_escape(body, pool_name, source) + _esc = _check_compile_time_escape(body, pool_name, source; function_form = true) _esc !== nothing && return :(throw($_esc)) # Compile-time container-escape warning (conservative, may have false positives) @@ -1194,7 +1225,7 @@ function _generate_function_pool_code(pool_name, func_def, force_enable, disable end # Compile-time escape detection (zero runtime cost) - _esc = _check_compile_time_escape(body, pool_name, source) + _esc = _check_compile_time_escape(body, pool_name, source; function_form = true) _esc !== nothing && return :(throw($_esc)) # Compile-time container-escape warning (conservative, may have false positives) @@ -2667,15 +2698,17 @@ end """Report an incidental-tail escape at `severity`: `"error"` throws a `PoolEscapeError` (storing the classified `(kind, detail)` on the point so `showerror` renders directly), -`"warn"` emits an expansion-time warning. Separated from the const-gated call site so -both severities are unit-testable — `ESCAPE_LINT` is a load-time constant, so the call -site only ever exercises one branch per session.""" +`"warn"` emits an expansion-time warning. The warn path sets `guarded = true` in the +message: under `"warn"` the block codegen also rewrites the tail so the escaping value +is replaced by an [`EscapedPoolArray`](@ref) guard (see `_poison_block_tails`). +Separated from the severity-resolving call site so both branches are unit-testable — +`ESCAPE_LINT` is a load-time constant, so a session's call site exercises one default.""" function _report_incidental_escape(severity, kind, detail, ret_expr, ret_line, file, line) if severity == "error" point = EscapePoint(ret_expr, ret_line, Symbol[], (kind, detail)) throw(PoolEscapeError(Symbol[], file, line, [point])) else # "warn" - @warn _lint_message(kind, detail, ret_expr) _file = file _line = something(ret_line, line, 0) + @warn _lint_message(kind, detail, ret_expr; guarded = true) _file = file _line = something(ret_line, line, 0) end return end @@ -2684,22 +2717,178 @@ end Used both for the `escape_lint = "warn"` path and for rendering `PoolEscapeError` (via `showerror`) when the error originates from an incidental tail rather than an intentional-return pattern.""" -function _lint_message(kind, detail, ret_expr) +function _lint_message(kind, detail, ret_expr; guarded::Bool = false) tail = sprint(Base.show_unquoted, ret_expr) - what = kind === :acquire_call ? - "is a direct acquire call — its pool-backed array" : - kind === :broadcast_assign ? - "evaluates to the pool-backed array `$(detail)`" : + what = if kind === :acquire_call + "is a direct acquire call whose pool-backed array" + elseif kind === :broadcast_assign + "writes into the pool-backed array `$(detail)`, whose value" + elseif kind === :bare_var + "is the pool-backed array `$(detail)` itself, which" + elseif kind === :literal + vars = detail isa AbstractVector ? detail : [detail] + string( + "is a container literal exposing the pool-backed array", + length(vars) == 1 ? " " : "s ", + join(("`$v`" for v in vars), ", "), ", which" + ) + else # :assign "assigns a pool-backed array, and the assignment's value" + end return string( "the scope's last expression `", tail, "` ", what, - " becomes the scope's return value and escapes", - " (a pool array is invalid after the scope rewinds).", - " If the value is meant to be discarded, end the block with `nothing`.", - " [escape_lint preference: \"error\" (default) | \"warn\" | \"off\"]" + " becomes the scope's return value.", + " A pool-backed array is invalid after the scope rewinds, and whether this", + " return value is actually used cannot be determined at compile time.", + guarded ? + " The escaping value has been replaced by an inert guard (EscapedPoolArray) that throws on first use." : + "", + " If it is not meant to escape, end the block with `nothing`.", + " See https://projecttorreypines.github.io/AdaptiveArrayPools.jl/stable/safety/compile-time/", + " [escape_lint: \"warn\" (default) | \"error\" | \"off\";", + " function-form and explicit-return tails always error]" ) end +# ============================================================================== +# Block-tail poisoning: replace a statically-detected escaping tail value with +# an EscapedPoolArray guard (see src/escape_guard.jl for the rationale). +# ============================================================================== + +# Guard constructor call as AST. The type object itself is spliced in as a +# value (the house hygiene pattern, cf. `$_get_pool_for_backend`), so the +# reference survives `esc()` of the user block unchanged. +function _guard_expr(valexpr, name::Symbol, file, line) + return Expr(:call, EscapedPoolArray, valexpr, QuoteNode(name), file, line) +end + +# `_`, `__`, … are write-only identifiers: they can be assigned but never read +# back, so the poison rewrite must not emit `EscapedPoolArray(_, …)`. +_all_underscore(s::Symbol) = all(==('_'), string(s)) + +""" + _poison_block_tails(expr, pool_name, source) -> expr′ + +Rewrite every implicit tail of a block-form `@with_pool` body whose value is a +statically-detected pool-backed escape, replacing the escaping value with an +[`EscapedPoolArray`](@ref) guard. The original tail's side effects (broadcast, +assignment, acquire) still execute; only the *yielded value* changes. Explicit +`return` nodes are never rewritten (they are hard errors in +`_check_compile_time_escape`). Applied only under `ESCAPE_LINT == "warn"`, in +lockstep with the checker's warn diagnostics: the tail shapes rewritten here +mirror `_find_direct_exposure` (bare var, tuple/vect/NamedTuple literals, +`identity` unwrap) and `_incidental_exposure` (acquire-call tail, +broadcast-assign tail, assignment tail). +""" +function _poison_block_tails(expr, pool_name, source::Union{LineNumberNode, Nothing}) + acquired = _extract_ordered_acquired(expr, pool_name) + file = source !== nothing ? string(source.file) : nothing + line = source !== nothing ? source.line : nothing + return _poison_tail(expr, acquired, pool_name, file, line) +end + +# Recursive tail walk mirroring `_get_last_expression_with_line` (blocks) and +# `_collect_implicit_return_values!` (if/elseif branches). +function _poison_tail(expr, acquired, pool_name, file, line) + if expr isa Expr + if expr.head == :block + for i in length(expr.args):-1:1 + expr.args[i] isa LineNumberNode && continue + args = copy(expr.args) + args[i] = _poison_tail(args[i], acquired, pool_name, file, line) + return Expr(:block, args...) + end + return expr + elseif expr.head in (:if, :elseif) + args = copy(expr.args) + for i in 2:length(args) # args[1] is the condition + args[i] = _poison_tail(args[i], acquired, pool_name, file, line) + end + return Expr(expr.head, args...) + end + end + return _poison_leaf(expr, acquired, pool_name, file, line) +end + +# Leaf rewrite: classify the tail expression and substitute the guard. +function _poison_leaf(expr, acquired, pool_name, file, line) + if expr isa Symbol + return expr in acquired ? _guard_expr(expr, expr, file, line) : expr + end + expr isa Expr || return expr + if expr.head == :return + return expr # explicit returns are hard errors, never poisoned + elseif expr.head in (:tuple, :vect, :parameters) + return _poison_literal(expr, acquired, pool_name, file, line) + elseif expr.head == :call && length(expr.args) >= 2 && _is_identity_call(expr.args[1]) + args = copy(expr.args) + args[2] = _poison_leaf(args[2], acquired, pool_name, file, line) + return Expr(:call, args...) + elseif _is_acquire_call(expr, pool_name) + return _guard_expr(expr, :expression, file, line) + elseif _is_dotted_assign_head(expr.head) && length(expr.args) >= 1 + lhs = expr.args[1] + base = Meta.isexpr(lhs, :ref) ? lhs.args[1] : lhs + if base isa Symbol && base in acquired + # run the broadcast, then yield the guard instead of the array + return Expr(:block, expr, _guard_expr(base, base, file, line)) + end + return expr + elseif expr.head == :(=) && length(expr.args) >= 2 + lhs, rhs = expr.args[1], expr.args[2] + if _is_acquire_call(rhs, pool_name) + if lhs isa Symbol && !_all_underscore(lhs) + # run the assignment, then yield the guard for the fresh binding + return Expr(:block, expr, _guard_expr(lhs, lhs, file, line)) + else + # e.g. dest[i] = acquire!(...), or `_ = acquire!(...)` (an + # all-underscore identifier is write-only — reading it back is + # a syntax error): execute the assignment inside the guard ctor + # (an assignment expression evaluates to its RHS), record, discard + return _guard_expr(expr, :expression, file, line) + end + elseif rhs isa Symbol && rhs in acquired + # e.g. dest[:, :, k] = v — the copy still runs; the yielded `v` is guarded + return Expr(:block, expr, _guard_expr(rhs, rhs, file, line)) + end + return expr + end + return expr +end + +# Element-wise poisoning of tuple/vect/NamedTuple literals: only pool-backed +# elements are replaced, so `(x, v)` keeps `x` fully usable. +function _poison_literal(expr, acquired, pool_name, file, line) + args = map(expr.args) do arg + if (Meta.isexpr(arg, :(=)) || Meta.isexpr(arg, :kw)) && length(arg.args) >= 2 + Expr( + arg.head, arg.args[1], + _poison_literal_value(arg.args[2], acquired, pool_name, file, line) + ) + else + _poison_literal_value(arg, acquired, pool_name, file, line) + end + end + return Expr(expr.head, args...) +end + +function _poison_literal_value(arg, acquired, pool_name, file, line) + if arg isa Symbol + return arg in acquired ? _guard_expr(arg, arg, file, line) : arg + elseif arg isa Expr + if arg.head in (:tuple, :vect, :parameters) + return _poison_literal(arg, acquired, pool_name, file, line) + elseif arg.head == :call && length(arg.args) >= 2 && _is_identity_call(arg.args[1]) + args = copy(arg.args) + args[2] = _poison_literal_value(args[2], acquired, pool_name, file, line) + return Expr(:call, args...) + elseif _is_acquire_call(arg, pool_name) + return _guard_expr(arg, :expression, file, line) + end + end + return arg +end + """Collect acquired variable names contained in a literal expression (symbol, tuple, vect).""" function _collect_acquired_in_literal(expr, acquired_keys::Set{Symbol}) @@ -2835,14 +3024,24 @@ Checks if the block/function body's return expression directly contains a pool-backed variable. This catches the most common beginner mistake at zero runtime cost. -All detected escapes are errors — bare symbol (`v`), `return v`, and -container patterns (`(v, w)`, `[v]`, `(key=v,)`) (Stage 1), plus the three -incidental-tail patterns — direct acquire-call tail, broadcast-assign tail, -assignment tail — gated by the `ESCAPE_LINT` preference (Stage 2, default "error"). +Stage 1 — bare symbol (`v`), `return v`, and container patterns (`(v, w)`, +`[v]`, `(key=v,)`) — always throws `PoolEscapeError`. Stage 2 — the three +incidental-tail patterns (direct acquire-call tail, broadcast-assign tail, +assignment tail) — resolves severity by form: with `function_form = true` +(the scope is a function definition, so the tail IS the function's return +value), or when the tail is an explicit `return` (which returns from the +enclosing function even in block form — a `begin` block is not a function +boundary), the escape is definite and always throws. A block-form implicit +tail — whose value may simply be discarded by the surrounding code — is +reported at the `ESCAPE_LINT` severity instead (default `"warn"`; `"error"` +promotes it, `"off"` disables Stage 2 entirely). Skipped when `STATIC_POOLING = false` (pooling disabled, acquire returns normal arrays). """ -function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNode, Nothing}) +function _check_compile_time_escape( + expr, pool_name, source::Union{LineNumberNode, Nothing}; + function_form::Bool = false, + ) # Order-aware extraction: single forward pass that tracks taint per statement order acquired = _extract_ordered_acquired(expr, pool_name) @@ -2850,9 +3049,15 @@ function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNod return_values = _collect_all_return_values(expr) isempty(return_values) && return - # ---- Stage 1: intentional-return patterns → ERROR (existing behavior) ---- + # ---- Stage 1: direct-exposure patterns, form-based severity ---- + # Function-form tails and explicit `return`s deliver the value to the + # enclosing function's caller — definite escapes, always PoolEscapeError + # (also under ESCAPE_LINT == "error", the strict mode, for block tails). + # A block-form implicit tail (bare `v`, `(x, v)` literals) may simply be + # discarded by the surrounding code, so under the default "warn" it gets + # the same warn + EscapedPoolArray-guard treatment as the Stage-2 + # incidental tails; under "off" it is skipped entirely. if !isempty(acquired) - # Check each return point for direct exposure of acquired vars all_escaped = Set{Symbol}() points = EscapePoint[] seen_lines = Set{Int}() @@ -2862,11 +3067,19 @@ function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNod continue end point_escaped = _find_direct_exposure(ret_expr, acquired) - if !isempty(point_escaped) + isempty(point_escaped) && continue + if function_form || Meta.isexpr(ret_expr, :return) || ESCAPE_LINT == "error" push!(points, EscapePoint(ret_expr, ret_line, sort!(collect(point_escaped)))) union!(all_escaped, point_escaped) - ret_line !== nothing && push!(seen_lines, ret_line) - end + elseif ESCAPE_LINT == "warn" + vars = sort!(collect(point_escaped)) + kind = ret_expr isa Symbol ? :bare_var : :literal + detail = length(vars) == 1 ? vars[1] : vars + wfile = source !== nothing ? string(source.file) : nothing + wline = source !== nothing ? source.line : nothing + _report_incidental_escape("warn", kind, detail, ret_expr, ret_line, wfile, wline) + end # ESCAPE_LINT == "off" → skip block-form implicit tails + ret_line !== nothing && push!(seen_lines, ret_line) end if !isempty(all_escaped) sorted = sort!(collect(all_escaped)) @@ -2878,7 +3091,7 @@ function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNod end end - # ---- Stage 2: incidental-tail patterns (error by default) ---- + # ---- Stage 2: incidental-tail patterns (form-based severity) ---- # Reached whenever Stage 1 found nothing to throw on — including when # `acquired` is empty (e.g. a bare `acquire!(pool, ...)` tail with no # assigned variable at all: Stage 1 has nothing to track, but the call's @@ -2890,7 +3103,12 @@ function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNod hit = _incidental_exposure(ret_expr, acquired, pool_name) hit === nothing && continue kind, detail = hit - _report_incidental_escape(ESCAPE_LINT, kind, detail, ret_expr, ret_line, file, line) + # Form-based severity: a function-form tail and an explicit `return` both + # deliver the value to the enclosing function's caller — definite escape, + # always an error. Only the block-form implicit tail (value may be + # discarded) is soft, at the ESCAPE_LINT severity ("warn" by default). + severity = (function_form || Meta.isexpr(ret_expr, :return)) ? "error" : ESCAPE_LINT + _report_incidental_escape(severity, kind, detail, ret_expr, ret_line, file, line) end return end diff --git a/test/cuda/test_cuda_safety.jl b/test/cuda/test_cuda_safety.jl index 2d237008..b4da3b93 100644 --- a/test/cuda/test_cuda_safety.jl +++ b/test/cuda/test_cuda_safety.jl @@ -391,11 +391,12 @@ _cuda_test_leak(x) = x # Compile-time escape detection (@with_pool :cuda) # ============================================================================== - @testset "Compile-time: direct CuArray escape caught at macro expansion" begin - @test_throws PoolEscapeError @macroexpand @with_pool :cuda pool begin + @testset "Compile-time: direct CuArray escape warns + guards (block form)" begin + expanded = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any @macroexpand @with_pool :cuda pool begin v = acquire!(pool, Float32, 10) - v # direct escape in tail position + v # implicit tail — warn + EscapedPoolArray guard end + @test expanded isa Expr end @testset "Compile-time: safe scalar return passes" begin @@ -407,11 +408,12 @@ _cuda_test_leak(x) = x @test ex isa Expr end - @testset "Compile-time: zeros!/ones! escape caught" begin - @test_throws PoolEscapeError @macroexpand @with_pool :cuda pool begin + @testset "Compile-time: zeros!/ones! escape warns + guards (block form)" begin + expanded = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any @macroexpand @with_pool :cuda pool begin v = zeros!(pool, Float32, 10) v end + @test expanded isa Expr end # ============================================================================== diff --git a/test/metal/test_metal_safety.jl b/test/metal/test_metal_safety.jl index 8c4ae86d..8c83cf96 100644 --- a/test/metal/test_metal_safety.jl +++ b/test/metal/test_metal_safety.jl @@ -371,11 +371,12 @@ _metal_test_leak(x) = x # Compile-time escape detection (@with_pool :metal) # ============================================================================== - @testset "Compile-time: direct MtlArray escape caught at macro expansion" begin - @test_throws PoolEscapeError @macroexpand @with_pool :metal pool begin + @testset "Compile-time: direct MtlArray escape warns + guards (block form)" begin + expanded = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any @macroexpand @with_pool :metal pool begin v = acquire!(pool, Float32, 10) - v + v # implicit tail — warn + EscapedPoolArray guard end + @test expanded isa Expr end @testset "Compile-time: safe scalar return passes" begin @@ -386,11 +387,12 @@ _metal_test_leak(x) = x @test ex isa Expr end - @testset "Compile-time: zeros!/ones! escape caught" begin - @test_throws PoolEscapeError @macroexpand @with_pool :metal pool begin + @testset "Compile-time: zeros!/ones! escape warns + guards (block form)" begin + expanded = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any @macroexpand @with_pool :metal pool begin v = zeros!(pool, Float32, 10) v end + @test expanded isa Expr end # ============================================================================== diff --git a/test/test_compile_escape.jl b/test/test_compile_escape.jl index 1723e365..0f5f913a 100644 --- a/test/test_compile_escape.jl +++ b/test/test_compile_escape.jl @@ -12,7 +12,30 @@ import AdaptiveArrayPools: _extract_acquired_vars, _get_last_expression, _find_reassign_maybe_tainted, _is_safe_copy_call, _rhs_call_contains_sym, _extract_container_vars, _is_dotted_assign_head, _incidental_exposure, _lint_message, ESCAPE_LINT, - _report_incidental_escape, PoolEscapeError + _report_incidental_escape, PoolEscapeError, + _poison_block_tails, EscapedPoolArray, EscapedPoolUseError + +# Helper: expansion must throw PoolEscapeError (possibly LoadError-wrapped) — +# the severity of function-form and explicit-return escapes. +function _expansion_escape_error(ex) + try + macroexpand(@__MODULE__, ex) + return false + catch err + err isa LoadError && (err = err.error) + return err isa AdaptiveArrayPools.PoolEscapeError + end +end + +# Helper: block-form expansion succeeds AND emits the escape @warn — the +# default severity of block-form implicit tails (the escaping value is +# additionally replaced by an EscapedPoolArray guard in the expansion). +function _expansion_incidental_warns(ex) + expanded = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any macroexpand( + @__MODULE__, ex + ) + return expanded isa Expr +end function _capture_stderr(f) tmpf = tempname() @@ -232,6 +255,9 @@ end @testset "_check_compile_time_escape" begin src = LineNumberNode(1, :test) + # Detection tests below run with `function_form = true` (the always- + # error severity) so a throw signature pins the DETECTION logic; the + # block-form default severity (warn + guard) is covered separately. # Bare variable return → error "Pool escape" @test_throws PoolEscapeError _check_compile_time_escape( @@ -239,7 +265,7 @@ end v = acquire!(pool, Float64, 10) v end, - :pool, src + :pool, src; function_form = true ) # Tuple containing acquired var → error @@ -249,7 +275,7 @@ end w = acquire!(pool, Float64, 5) (sum(v), w) end, - :pool, src + :pool, src; function_form = true ) # Safe: scalar return → no warning @@ -306,7 +332,7 @@ end v = zeros!(pool, 10) v end, - :pool, src + :pool, src; function_form = true ) @test_throws PoolEscapeError _check_compile_time_escape( @@ -314,7 +340,7 @@ end v = ones!(pool, Float32, 10) v end, - :pool, src + :pool, src; function_form = true ) @test_throws PoolEscapeError _check_compile_time_escape( @@ -322,7 +348,7 @@ end v = similar!(pool, some_array) v end, - :pool, src + :pool, src; function_form = true ) # trues!/falses! also detected @@ -331,7 +357,7 @@ end bv = trues!(pool, 100) bv end, - :pool, src + :pool, src; function_form = true ) # Different pool name → no warning (not our pool) @@ -349,7 +375,7 @@ end v = acquire!(pool, Float64, 10) v end, - :pool, nothing + :pool, nothing; function_form = true ) # `return v` is also a definite escape (error) @@ -371,6 +397,35 @@ end ) end + @testset "_check_compile_time_escape: block-form implicit-tail severity" begin + src = LineNumberNode(1, :test) + # Under the default escape_lint = "warn", a block-form implicit tail + # WARNS instead of throwing (form-based severity). The guard rewrite + # itself happens in block codegen (`_poison_block_tails`), not here. + @test_logs (:warn, r"pool-backed array `v` itself") _check_compile_time_escape( + quote + v = acquire!(pool, Float64, 10) + v + end, + :pool, src + ) + @test_logs (:warn, r"container literal") _check_compile_time_escape( + quote + v = acquire!(pool, Float64, 10) + (sum(v), v) + end, + :pool, src + ) + # Explicit `return` stays an error even in block-form checking + @test_throws PoolEscapeError _check_compile_time_escape( + quote + v = acquire!(pool, Float64, 10) + return v + end, + :pool, src + ) + end + # ============================================================================== # _collect_all_return_values: explicit returns + implicit if/else branches # ============================================================================== @@ -555,7 +610,7 @@ end :pool, src ) - # Implicit return from if/else branches — caught + # Implicit return from if/else branches — caught (function-form severity) @test_throws PoolEscapeError _check_compile_time_escape( quote v = acquire!(pool, Float64, 10) @@ -565,10 +620,10 @@ end v end end, - :pool, src + :pool, src; function_form = true ) - # elseif branch — caught + # elseif branch — caught (function-form severity) @test_throws PoolEscapeError _check_compile_time_escape( quote v = acquire!(pool, Float64, 10) @@ -580,16 +635,16 @@ end length(v) end end, - :pool, src + :pool, src; function_form = true ) - # Ternary with escape — caught + # Ternary with escape — caught (function-form severity) @test_throws PoolEscapeError _check_compile_time_escape( quote v = acquire!(pool, Float64, 10) cond ? v : sum(v) end, - :pool, src + :pool, src; function_form = true ) # Early return in loop — caught @@ -654,11 +709,15 @@ end end end - # Ternary — caught - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - rand() > 0.5 ? sum(v) : v - end + # Ternary — block form: warns + guard (implicit tail) + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + rand() > 0.5 ? sum(v) : v + end + ) + ) # All branches safe — no error expanded = @macroexpand @with_pool pool begin @@ -688,11 +747,15 @@ end # ============================================================================== @testset "Compile-time error through macro pipeline" begin - # Bare variable: macro expansion itself throws - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - v - end + # Bare variable, block form: warns + guard (implicit tail) + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + v + end + ) + ) # Safe return: macro expansion succeeds expanded = @macroexpand @with_pool pool begin @@ -958,84 +1021,128 @@ end end # IMPORTANT: acquire in if/for/while body IS outer scope (no new scope) - # These SHOULD still be caught as escapes - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - if true - v = acquire!(pool, Float64, 10) - end - v - end + # These are still detected — block-form implicit tail → warn + guard + @test _expansion_incidental_warns( + :( + @with_pool pool begin + if true + v = acquire!(pool, Float64, 10) + end + v + end + ) + ) end - @testset "Block form: additional escape scenarios" begin - # zeros! — definite escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = zeros!(pool, 10) - v - end + @testset "Block form: additional escape scenarios (warn + guard)" begin + # Detection coverage across the acquire family and literal shapes. + # Block-form implicit tails → warn (the escaping value is guarded); + # explicit `return`s remain hard errors. - # trues! — definite escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - bv = trues!(pool, 100) - bv - end + # zeros! — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = zeros!(pool, 10) + v + end + ) + ) + + # trues! — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + bv = trues!(pool, 100) + bv + end + ) + ) - # Explicit return — definite escape + # Explicit return — definite escape, still an error @test_throws PoolEscapeError @macroexpand @with_pool pool begin v = acquire!(pool, Float64, 10) return v end - # Tuple with acquired var → escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - (v, 42) - end + # Tuple with acquired var — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + (v, 42) + end + ) + ) - # Array literal → escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - [v, nothing] - end + # Array literal — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + [v, nothing] + end + ) + ) - # return (v, scalar) → escape + # return (v, scalar) → definite escape, still an error @test_throws PoolEscapeError @macroexpand @with_pool pool begin v = acquire!(pool, Float64, 10) return (v, sum(v)) end # Re-acquire reassignment: v still tracked after v = zeros!(pool, ...) - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - v = zeros!(pool, 20) - v - end + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + v = zeros!(pool, 20) + v + end + ) + ) - # NamedTuple with acquired var as VALUE → escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - (result = v, n = 42) - end + # NamedTuple with acquired var as VALUE — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + (result = v, n = 42) + end + ) + ) - # NamedTuple shorthand (v = v) → value IS acquired → escape + # NamedTuple shorthand (v = v) → value IS acquired — detected # (key name coincidentally matches, but VALUE is the acquired var) - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - (v = v,) - end + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + (v = v,) + end + ) + ) - # Destructuring with acquire RHS: v still tracked → escape - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - (v, w) = (acquire!(pool, Float64, 10), safe()) - v - end + # Destructuring with acquire RHS: v still tracked — detected + @test _expansion_incidental_warns( + :( + @with_pool pool begin + (v, w) = (acquire!(pool, Float64, 10), safe()) + v + end + ) + ) # Destructuring doesn't protect if RHS element IS acquire - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - (v, w) = (zeros!(pool, 5), safe()) - v - end + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + (v, w) = (zeros!(pool, 5), safe()) + v + end + ) + ) end # ============================================================================== @@ -1145,12 +1252,16 @@ end return wrapper end - # Bare variable with pre-assignment — still escapes - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = 1 - v = acquire!(pool, Float64, 10) - v - end + # Bare variable with pre-assignment — still detected (block → warn) + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = 1 + v = acquire!(pool, Float64, 10) + v + end + ) + ) # Control: post-acquire reassignment IS safe (no regression) @test_nowarn @macroexpand @with_pool pool begin @@ -1292,17 +1403,25 @@ end # ============================================================================== @testset "@maybe_with_pool block form" begin - # Definite escape → error - @test_throws PoolEscapeError @macroexpand @maybe_with_pool pool begin - v = acquire!(pool, Float64, 10) - v - end + # Implicit tail escape → warn + guard (block form) + @test _expansion_incidental_warns( + :( + @maybe_with_pool pool begin + v = acquire!(pool, Float64, 10) + v + end + ) + ) - # Container escape → error - @test_throws PoolEscapeError @macroexpand @maybe_with_pool pool begin - v = acquire!(pool, Float64, 10) - (v, sum(v)) - end + # Container escape → warn + guard (block form) + @test _expansion_incidental_warns( + :( + @maybe_with_pool pool begin + v = acquire!(pool, Float64, 10) + (v, sum(v)) + end + ) + ) # Safe → no warning @test_nowarn @macroexpand @maybe_with_pool pool begin @@ -1330,10 +1449,14 @@ end # ============================================================================== @testset "@with_pool :cpu block form" begin - @test_throws PoolEscapeError @macroexpand @with_pool :cpu pool begin - v = acquire!(pool, Float64, 10) - v - end + @test _expansion_incidental_warns( + :( + @with_pool :cpu pool begin + v = acquire!(pool, Float64, 10) + v + end + ) + ) @test_nowarn @macroexpand @with_pool :cpu pool begin v = acquire!(pool, Float64, 10) @@ -1354,11 +1477,15 @@ end end @testset "@maybe_with_pool :cpu forms" begin - # Block — error - @test_throws PoolEscapeError @macroexpand @maybe_with_pool :cpu pool begin - v = acquire!(pool, Float64, 10) - v - end + # Block — warn + guard + @test _expansion_incidental_warns( + :( + @maybe_with_pool :cpu pool begin + v = acquire!(pool, Float64, 10) + v + end + ) + ) # Block — safe @test_nowarn @macroexpand @maybe_with_pool :cpu pool begin @@ -1398,16 +1525,20 @@ end sum(v) + inner end - # Outer scope escape → error from outer macro check - @test_throws PoolEscapeError @macroexpand @with_pool pool begin - v = acquire!(pool, Float64, 10) - @with_pool pool begin - w = acquire!(pool, Float64, 5) - w .= 1.0 - nothing - end - v # ← outer definite escape - end + # Outer scope escape → warn + guard from outer macro check (block form) + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 10) + @with_pool pool begin + w = acquire!(pool, Float64, 5) + w .= 1.0 + nothing + end + v # ← outer implicit-tail escape + end + ) + ) end # ============================================================================== @@ -1415,10 +1546,13 @@ end # ============================================================================== @testset "PoolEscapeError carries variable names, points, and formatted message" begin + # Subjects use FUNCTION form: block-form implicit tails warn + guard + # under the default severity, so the rich PoolEscapeError rendering is + # exercised via the always-error form. # Single variable: bare return err = try @macroexpand( - @with_pool pool begin + @with_pool pool function msg_bare_fn() v = acquire!(pool, Float64, 10) v end @@ -1444,7 +1578,7 @@ end # Different variable name err = try @macroexpand( - @with_pool pool begin + @with_pool pool function msg_data_fn() data = zeros!(pool, 10) data end @@ -1473,7 +1607,7 @@ end # Container: only w escapes, not sum(v) err = try @macroexpand( - @with_pool pool begin + @with_pool pool function msg_container_fn() v = acquire!(pool, Float64, 10) w = acquire!(pool, Float64, 5) (sum(v), w) @@ -1489,7 +1623,7 @@ end # Multi-variable: both appear, sorted err = try @macroexpand( - @with_pool pool begin + @with_pool pool function msg_multi_fn() v = acquire!(pool, Float64, 10) w = acquire!(pool, Float64, 5) (v, w) @@ -1609,10 +1743,13 @@ end end @testset "var_info classification in PoolEscapeError" begin + # Subjects use FUNCTION form (always-error) so the classification is + # exercised through a thrown PoolEscapeError; block-form implicit + # tails warn + guard under the default severity instead. # Direct pool array (acquire! now returns Array) err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_array_fn() v = acquire!(pool, Float64, 10) v end @@ -1627,7 +1764,7 @@ end # Direct pool view (acquire_view!) err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_view_fn() v = acquire_view!(pool, Float64, 10) v end @@ -1642,7 +1779,7 @@ end # Direct pool BitArray err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_bit_fn() bv = trues!(pool, 100) bv end @@ -1657,7 +1794,7 @@ end # Container wrapping pool variable err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_container_fn() v = acquire!(pool, Float64, 10) a = [v, 1] a @@ -1676,7 +1813,7 @@ end # Container with multiple pool vars err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_container2_fn() v = acquire!(pool, Float64, 10) w = acquire!(pool, Float64, 5) a = [v, w] @@ -1693,7 +1830,7 @@ end # Alias of pool variable err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_alias_fn() v = acquire!(pool, Float64, 10) d = v d @@ -1730,7 +1867,7 @@ end # zeros! classified as array err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_zeros_fn() data = zeros!(pool, 10) data end @@ -1743,7 +1880,7 @@ end # Tuple container err = try @macroexpand( - @with_pool pool begin + @with_pool pool function vi_tuple_fn() v = acquire!(pool, Float64, 10) t = (v, 42) t @@ -1944,7 +2081,7 @@ end @testset "showerror backtrace suppression" begin err = try @macroexpand( - @with_pool pool begin + @with_pool pool function bts_fn() v = acquire!(pool, Float64, 10) v end @@ -2355,25 +2492,24 @@ end end # ============================================================================== - # Incidental-tail escape detection (Task 3): direct acquire-call tails, - # broadcast-assign tails, and assignment tails — error by default via ESCAPE_LINT. + # Incidental-tail escape detection: direct acquire-call tails, broadcast-assign + # tails, and assignment tails. Form-based severity: a function-form tail or an + # explicit `return` (definite escape to the enclosing function's caller) always + # errors; a block-form implicit tail (value may be discarded) reports at the + # ESCAPE_LINT severity — "warn" by default. # ============================================================================== @static if VERSION >= v"1.12-" - # Helper: expansion must throw PoolEscapeError (possibly LoadError-wrapped) - function _expansion_escape_error(ex) - try - macroexpand(@__MODULE__, ex) - return false - catch err - err isa LoadError && (err = err.error) - return err isa AdaptiveArrayPools.PoolEscapeError - end - end - - @testset "incidental tails error at expansion" begin + # (Helpers `_expansion_escape_error` / `_expansion_incidental_warns` + # are defined at the top of this file — they are also used by the + # version-independent block-form testsets above.) + + @testset "block-form incidental tails warn at expansion (default)" begin + # A block's value may simply be discarded by the surrounding code — the + # macro cannot see its own call site — so under the default + # escape_lint = "warn" these expand cleanly with a @warn diagnostic. # w2: broadcast-assign tail of an acquired var - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin v = acquire!(pool, Float64, 4) @@ -2383,7 +2519,7 @@ end ) # w2': dotted op-assign - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin v = acquire!(pool, Float64, 4) @@ -2393,8 +2529,8 @@ end ) # w1: direct acquire-family call tail (no acquired vars at all — - # regression for the early-return trap at macros.jl:2512) - @test _expansion_escape_error( + # regression for the early-return trap in the Stage-2 reachability) + @test _expansion_incidental_warns( :( @with_pool pool begin acquire!(pool, Float64, 4) @@ -2403,7 +2539,7 @@ end ) # w1 via convenience wrapper - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin zeros!(pool, Float64, 4) @@ -2412,7 +2548,7 @@ end ) # w3: assignment tail whose RHS is an acquire call - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin v = acquire!(pool, Float64, 4) @@ -2420,6 +2556,23 @@ end ) ) + # safe variant shares the same block-form severity (kwarg plumbing) + @test _expansion_incidental_warns( + :( + @safe_with_pool pool begin + v = acquire!(pool, Float64, 4) + v .= 0.0 + end + ) + ) + end + + @testset "function-form and explicit-return incidental tails always error" begin + # A function-form tail IS the function's return value, and an explicit + # `return` returns from the ENCLOSING function even in block form (a + # `begin` block is not a function boundary) — definite escapes to a + # caller, hard errors regardless of the escape_lint severity. + # # function form: implicit x .= v tail is the function's return value. # NOTE: uses the 2-arg `@with_pool pool function ... end` form (pool # captured via closure) — `@with_pool function f(pool) ... end` (pool as @@ -2437,6 +2590,34 @@ end ) ) + # function form, w1: bare acquire-call tail + @test _expansion_escape_error( + :( + @with_pool pool function f() + acquire!(pool, Float64, 4) + end + ) + ) + + # function form, w3: assignment tail + @test _expansion_escape_error( + :( + @with_pool pool function f() + v = acquire!(pool, Float64, 4) + end + ) + ) + + # safe function form shares the always-error severity + @test _expansion_escape_error( + :( + @safe_with_pool pool function f() + v = acquire!(pool, Float64, 4) + v .= 0.0 + end + ) + ) + # w1 with explicit `return`: `return acquire!(...)` must be unwrapped # just like the implicit-tail form above. @test _expansion_escape_error( @@ -2480,11 +2661,12 @@ end ) end - @testset "incidental-tail error message teaches the fix" begin + @testset "incidental-tail diagnostics teach the fix" begin + # Error path (function form): showerror carries the fix hint err = try macroexpand( @__MODULE__, :( - @with_pool pool begin + @with_pool pool function f() v = acquire!(pool, Float64, 4) v .= 0.0 end @@ -2498,6 +2680,16 @@ end msg = sprint(showerror, err) @test occursin("nothing", msg) # suggests the one-line fix @test occursin("last expression", msg) # explains block-tail semantics + + # Warn path (block form): the @warn diagnostic carries the same hint + @test_logs (:warn, r"end the block with `nothing`") match_mode = :any macroexpand( + @__MODULE__, :( + @with_pool pool begin + v = acquire!(pool, Float64, 4) + v .= 0.0 + end + ) + ) end @testset "safe tails do not error" begin @@ -2582,12 +2774,14 @@ end @test !occursin("direct acquire call", msg) end - @testset "incidental tail inside if/else implicit branch errors" begin + @testset "incidental tail inside if/else implicit branch warns (block form)" begin # A common Julia idiom: the block's last expression is an if/else # whose branch tail is an incidental escape. `_collect_all_return_values` - # expands into both branch tails, and Stage 2 must flag the escaping one. + # expands into both branch tails, and Stage 2 must flag the escaping one + # — at block-form severity (warn), since the branch tail is still an + # implicit block tail, not an explicit `return`. # w2 — broadcast-assign branch tail - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin v = acquire!(pool, Float64, 4) @@ -2600,7 +2794,7 @@ end ) ) # w1 — direct acquire-call branch tail - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin if true @@ -2612,7 +2806,7 @@ end ) ) # w3 — assignment branch tail - @test _expansion_escape_error( + @test _expansion_incidental_warns( :( @with_pool pool begin if true @@ -2623,6 +2817,20 @@ end end ) ) + # Same branch-tail shape in FUNCTION form is the function's return + # value — always an error. + @test _expansion_escape_error( + :( + @with_pool pool function f() + v = acquire!(pool, Float64, 4) + if true + v .= 0.0 + else + nothing + end + end + ) + ) # Safe: both branch tails are `nothing` — must NOT error @test macroexpand( @__MODULE__, :( @@ -2642,6 +2850,8 @@ end @testset "incidental-tail showerror uses the stored (kind, detail) label" begin # After the classification is stored on the EscapePoint at throw time, # showerror renders each pattern's own label without re-deriving it. + # Uses FUNCTION form so the incidental tail still throws (block form + # only warns under the default severity). function _escape_err(ex) try macroexpand(@__MODULE__, ex) @@ -2654,7 +2864,7 @@ end # w1 — acquire-call tail e1 = _escape_err( :( - @with_pool pool begin + @with_pool pool function f() acquire!(pool, Float64, 4) end ) @@ -2665,7 +2875,7 @@ end # w2 — broadcast-assign tail names the array e2 = _escape_err( :( - @with_pool pool begin + @with_pool pool function f() v = acquire!(pool, Float64, 4) v .= 0.0 end @@ -2677,7 +2887,7 @@ end # w3 — assignment tail e3 = _escape_err( :( - @with_pool pool begin + @with_pool pool function f() v = acquire!(pool, Float64, 4) end ) @@ -2686,10 +2896,29 @@ end @test occursin("assigns a pool-backed array", sprint(showerror, e3)) end - @testset "existing intentional-return errors unchanged" begin + @testset "form-based severity boundary (regression pins)" begin + # block bare-var tail: warn + guard (implicit tail) + @test _expansion_incidental_warns( + :( + @with_pool pool begin + v = acquire!(pool, Float64, 4) + v + end + ) + ) + # explicit return of the same var: still a hard error @test _expansion_escape_error( :( @with_pool pool begin + v = acquire!(pool, Float64, 4) + return v + end + ) + ) + # function form of the same shape: still a hard error + @test _expansion_escape_error( + :( + @with_pool pool function f() v = acquire!(pool, Float64, 4) v end @@ -2700,11 +2929,12 @@ end # ========================================================================== # Direct unit tests for _incidental_exposure and _lint_message. # - # ESCAPE_LINT is a load-time constant (like RUNTIME_CHECK) — the "warn"/"off" - # severity branches cannot be integration-tested in-process, since the test - # session always runs under the default "error" preference. These unit tests - # exercise the detection helper and message builder directly instead, so the - # "warn"/"off" code paths (which call the same helpers) are covered by proxy. + # ESCAPE_LINT is a load-time constant (like RUNTIME_CHECK); the test session + # runs under the default "warn". The block-form warn path is integration- + # tested above; the "error" severity is integration-tested via the + # function-form / explicit-return paths (always error). The "error"-pref-on- + # block-tails and "off" branches cannot be flipped in-process, so these unit + # tests exercise the detection helper and message builder directly instead. # ========================================================================== @testset "_is_dotted_assign_head" begin @@ -2768,6 +2998,9 @@ end @test occursin("last expression", msg) @test occursin("nothing", msg) @test occursin("escape_lint", msg) + @test occursin("safety/compile-time", msg) # docs pointer + @test occursin("\"warn\" (default)", msg) # warn is now the default severity + @test occursin("compile time", msg) # "can't tell if the value is used" framing broadcast_expr = :(v .= 0.0) msg = _lint_message(:broadcast_assign, :v, broadcast_expr) @@ -2801,4 +3034,285 @@ end end end + # ============================================================================== + # EscapedPoolArray guard: type behavior, tail rewriting, runtime integration + # ============================================================================== + + @testset "EscapedPoolArray guard type (traps, show, provenance)" begin + x = rand(3, 2) + g = EscapedPoolArray(x, :x, "f.jl", 12) + @test g isa EscapedPoolArray + @test g.var === :x + @test g.arraytype === Matrix{Float64} + @test g.dims == (3, 2) + # metadata-only: no field holds the array itself + @test fieldnames(EscapedPoolArray) == (:var, :arraytype, :dims, :file, :line) + + # trapped operations throw with provenance + @test_throws EscapedPoolUseError g[1] + @test_throws EscapedPoolUseError setindex!(g, 0.0, 1) + @test_throws EscapedPoolUseError size(g) + @test_throws EscapedPoolUseError length(g) + @test_throws EscapedPoolUseError axes(g) + @test_throws EscapedPoolUseError iterate(g) + @test_throws EscapedPoolUseError copy(g) + @test_throws EscapedPoolUseError similar(g) + @test_throws EscapedPoolUseError sum(g) + @test_throws EscapedPoolUseError collect(g) + @test_throws EscapedPoolUseError g .+ 1 + + # non-throwing surfaces: identity ops and informative show + @test g === g + @test !isnothing(g) + shown = sprint(show, g) + @test occursin("escaped", shown) + @test occursin("`x`", shown) + @test occursin("3×2", shown) + @test occursin("f.jl:12", shown) + + uerr = try + g[1] + catch e + e + end + @test uerr isa EscapedPoolUseError + m = sprint(showerror, uerr) + @test occursin("getindex", m) + @test occursin("`x`", m) + @test occursin("f.jl:12", m) + @test occursin("nothing", m) # teaches the discard fix + + # anonymous / non-array fallbacks + ga = EscapedPoolArray(42, :expression, nothing, nothing) + @test ga.dims == () + @test occursin("anonymous expression", sprint(show, ga)) + end + + @testset "_poison_block_tails rewrites" begin + lnn = LineNumberNode(7, Symbol("poison.jl")) + guard_call(x) = x isa Expr && x.head == :call && x.args[1] === EscapedPoolArray + lastexpr(b) = last(filter(a -> !(a isa LineNumberNode), b.args)) + + # bare tail → guard ctor with the var name + scope location + out = _poison_block_tails( + :( + begin + v = acquire!(pool, Float64, 4) + v + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test guard_call(tail) + @test tail.args[2] === :v + @test tail.args[3] isa QuoteNode && tail.args[3].value === :v + @test tail.args[4] == "poison.jl" && tail.args[5] == 7 + + # dest[...] = v tail → (dest[...] = v; guard(v)): side effect preserved + out = _poison_block_tails( + :( + begin + v = acquire!(pool, Float64, 4) + dest[:, :, k] = v + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test tail isa Expr && tail.head == :block + @test Meta.isexpr(tail.args[1], :(=)) # original assignment first + @test guard_call(tail.args[2]) + + # tuple: only pool-backed elements replaced + out = _poison_block_tails( + :( + begin + x = 1 + v = acquire!(pool, Float64, 4) + (x, v) + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test Meta.isexpr(tail, :tuple) + @test tail.args[1] === :x # non-pool element untouched + @test guard_call(tail.args[2]) + + # explicit return: never rewritten + out = _poison_block_tails( + :( + begin + v = acquire!(pool, Float64, 4) + return v + end + ), :pool, lnn + ) + @test lastexpr(out) == :(return v) + + # `_ = acquire!(...)` tail: `_` is write-only, so the rewrite must wrap + # the WHOLE assignment (which evaluates to its RHS) instead of reading + # `_` back — regression for the all-underscore rvalue syntax error + out = _poison_block_tails( + :( + begin + _ = acquire!(pool, Float64, 4) + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test guard_call(tail) + @test Meta.isexpr(tail.args[2], :(=)) # ctor arg IS the assignment + @test tail.args[3] isa QuoteNode && tail.args[3].value === :expression + # and no bare `_` appears as a ctor value argument + @test tail.args[2] !== :_ + + # identity(v) tail: unwrapped like _find_direct_exposure does + out = _poison_block_tails( + :( + begin + v = acquire!(pool, Float64, 4) + identity(v) + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test Meta.isexpr(tail, :call) && tail.args[1] === :identity + @test guard_call(tail.args[2]) # inner v guarded + + # literal parity branches: nested tuple, identity element, acquire-call element + out = _poison_block_tails( + :( + begin + x = 1 + v = acquire!(pool, Float64, 4) + (x, (x, v), identity(v), acquire!(pool, Float64, 2)) + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test Meta.isexpr(tail, :tuple) + @test tail.args[1] === :x # untouched + nested = tail.args[2] # (x, v) → (x, guard(v)) + @test Meta.isexpr(nested, :tuple) && nested.args[1] === :x && guard_call(nested.args[2]) + ident = tail.args[3] # identity(v) → identity(guard(v)) + @test Meta.isexpr(ident, :call) && ident.args[1] === :identity && guard_call(ident.args[2]) + @test guard_call(tail.args[4]) # acquire-call element ctor-wrapped + @test tail.args[4].args[3].value === :expression + + # if/else: only the escaping branch tail rewritten + out = _poison_block_tails( + :( + begin + v = acquire!(pool, Float64, 4) + if c + v + else + nothing + end + end + ), :pool, lnn + ) + tail = lastexpr(out) + @test Meta.isexpr(tail, :if) + thenlast = last(filter(a -> !(a isa LineNumberNode), tail.args[2].args)) + elselast = last(filter(a -> !(a isa LineNumberNode), tail.args[3].args)) + @test guard_call(thenlast) + @test elselast === :nothing # the identifier `nothing` is a Symbol in the AST + + # safe tail: structurally unchanged + src = :( + begin + v = acquire!(pool, Float64, 4) + sum(v) + end + ) + @test _poison_block_tails(src, :pool, lnn) == src + end + + @testset "block-tail guard integration (runtime)" begin + # NOTE: subjects are eval'd at RUNTIME so the expansion-time @warn is + # captured by @test_logs (a literal @with_pool here would warn once at + # test-file load instead). + + # bare tail: block value is a guard; first use throws with provenance + out = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any Core.eval( + @__MODULE__, :( + @with_pool pool begin + a = acquire!(pool, Float64, 3) + a .= 7.0 + a + end + ) + ) + @test out isa EscapedPoolArray + @test out.var === :a + @test out.arraytype <: AbstractVector{Float64} + @test out.dims == (3,) + @test_throws EscapedPoolUseError out[1] + @test_throws EscapedPoolUseError sum(out) + + # FLUX shape: dest[...] = v tail — data copied out, guard discarded, silent + res = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any Core.eval( + @__MODULE__, :( + let dest = zeros(2, 3, 2) + for k in 1:2 + @with_pool pool begin + v = zeros!(pool, Float64, 2, 3) + v .= k + dest[:, :, k] = v + end + end + dest + end + ) + ) + @test res[:, :, 1] == fill(1.0, 2, 3) + @test res[:, :, 2] == fill(2.0, 2, 3) + + # partial tuple: non-pool element stays usable, pool element traps + r = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any Core.eval( + @__MODULE__, :( + @with_pool pool begin + x = 42 + a = acquire!(pool, Float64, 3) + (x, a) + end + ) + ) + @test r isa Tuple + @test r[1] === 42 + @test r[2] isa EscapedPoolArray + @test_throws EscapedPoolUseError r[2][1] + + # discarded guard: silent at runtime (warn fires once, at expansion) — + # the FLUX pattern as a reusable function + f = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any Core.eval( + @__MODULE__, :( + function guard_discard_fn(dest) + @with_pool pool begin + v = zeros!(pool, Float64, 4) + v .= 1.0 + dest[:] = v + end + return dest + end + ) + ) + dst = zeros(4) + @test f(dst) == ones(4) # no error, no warn at runtime + @test f(dst) == ones(4) # repeated runs stay silent + + # `_ = acquire!(...)` tail: the canonical discard spelling must expand + # AND run (regression: the rewrite used to read the write-only `_`) + u = @test_logs (:warn, r"becomes the scope's return value") match_mode = :any Core.eval( + @__MODULE__, :( + @with_pool pool begin + _ = acquire!(pool, Float64, 3) + end + ) + ) + @test u isa EscapedPoolArray + @test u.var === :expression + @test u.dims == (3,) + end + end # Compile-Time Escape Detection