From 6f7020532ea4acaec279ca692381b13aa983b609 Mon Sep 17 00:00:00 2001 From: Filippo Ferrazzini Date: Tue, 8 Sep 2026 14:20:35 +0200 Subject: [PATCH] Match PETSc object leaks on the syntax tree and not on source text audit_petsc_file paired object creations with destroy calls by running seven near-identical regexes over each source line. That approach had four defects, all of which the fixture in test/fixtures/leaky.jl now covers: - each regex block ended in `continue`, so only one event per line was recorded; `v = VecCreateSeq(...); VecDestroy(petsclib, v)` counted the creation and missed the release - only whole-line comments were skipped, so `# destroy!(dm)` and a destroy mentioned inside a string literal both counted as releases - only bare and PETSc.-qualified `destroy` were recognised, so LibPETSc.VecDestroy read as a leak - DMPlex objects were not tracked at all, and the high-level Mat/Vec factories (MatSeqAIJ, MatSeqDense, VecSeq, ...) were missed because their names carry no Create or Duplicate marker Walking the parsed AST collapses the seven blocks into one traversal over two name sets. Destroy calls now record every plain name in the argument list, since the object is the first argument high-level (destroy!(v)) and the second low-level (VecDestroy(petsclib, v)). audit_petsc_file returns a NamedTuple of created, destroyed, finalized and leaked instead of nothing, so the behaviour can be asserted; pass verbose = false to suppress the printed report. Nothing tested this file before. --- src/audit.jl | 446 ++++++++++++++++++++++++------------ test/fixtures/constructs.jl | 81 +++++++ test/fixtures/leaky.jl | 33 +++ test/runtests.jl | 1 + test/test_audit.jl | 136 +++++++++++ 5 files changed, 553 insertions(+), 144 deletions(-) create mode 100644 test/fixtures/constructs.jl create mode 100644 test/fixtures/leaky.jl create mode 100644 test/test_audit.jl diff --git a/src/audit.jl b/src/audit.jl index f0d1727f6..6a6f01282 100644 --- a/src/audit.jl +++ b/src/audit.jl @@ -1,171 +1,329 @@ +# ============================================================================ +# PETSc object leak audit +# ============================================================================ +# +# Matches object creations against destroy calls by walking the parsed syntax tree. +# Matching on source text instead would miss multi-statement lines and +# would fire inside comments and string literals. + +# Names that create an object the caller owns +# ---------------------------------------------------------------------------- + +const AUDIT_TYPE_CREATORS = + Dict(:KSP => "KSP", :SNES => "SNES", :DMDA => "DM", :DMStag => "DM", :DMPlex => "DM") + +const AUDIT_NAMED_CREATORS = Dict( + :DMGlobalVec => "Vec", + :DMLocalVec => "Vec", + :DMGetCoordinateDM => "DM", + :DMStagCreateCompatibleDMStag => "DM", + :DMCreateMatrix => "Mat", + :MatCreateVecs => "Vec", + # high-level factories, whose names carry no Create/Duplicate marker + :VecSeq => "Vec", + :MatAIJ => "Mat", + :MatShell => "Mat", + :MatSeqAIJ => "Mat", + :MatSeqDense => "Mat", +) + """ - audit_petsc_file(path::AbstractString) - -Scan a PETSc.jl source file and report PETSc object creations and destroys. - -This utility reads `path`, looks for common PETSc object creation patterns -(`KSP(...)`, `SNES(...)`, `DMDA(...)`, `DMStag(...)`, as well as -`VecCreate...`, `VecDuplicate...`, `Vec...WithArray(...)` and corresponding -`Mat...` creators via either `PETSc.` or `LibPETSc.`), plus common DM/Vec/Mat -allocators like `DMGlobalVec`, `DMLocalVec`, `DMGetCoordinateDM`, -`DMStagCreateCompatibleDMStag`, and `DMCreateMatrix`; and for explicit -`destroy(x)` calls (optionally prefixed with `PETSc.`). - -It prints three sections: - - CREATION statements: line numbers and variables assigned to created objects - - DESTROY calls: line numbers and variables destroyed - - POSSIBLY UNDESTROYED objects: variables that appear in creations but have no - matching destroy call - - FINALIZE calls: presence of `PETSc.finalize(petsclib)`; suggests adding it - if missing - -Notes: - - Heuristic only: matches common constructors and `LibPETSc` creation routines; - it does not follow control flow or scopes. - - Creations without assignment cannot be cross-checked against destroys. - - Objects produced via helpers (e.g., `similar(...)`) are not detected unless - they use known creation patterns. - -Returns `nothing`. -""" -function audit_petsc_file(path::AbstractString) - # Read full content and strip block comments (#= ... =#) before line parsing - content = read(path, String) - content = replace(content, r"(?s)#=.*?=#" => "") - lines = split(content, '\n') - - # Patterns for creation calls - type_creators = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?(KSP|SNES|DMDA|DMStag)\s*\(" - # Match common PETSc Vec/Mat creators, including suffixed forms like - # VecCreateSeq, VecCreateMPI, VecDuplicateVecs, MatCreateSeqAIJ, etc. - vec_create = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?Vec(?:Create\w*|Duplicate\w*|Load\w*|\w*WithArray)\s*\(" - mat_create = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?Mat(?:Create\w*|Duplicate\w*|Load\w*|\w*With\w*Arrays)\s*\(" - # Additional allocators that should be destroyed - dm_vec_alloc = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?(DMGlobalVec|DMLocalVec)\s*\(" - dm_alloc_dm = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?(DMGetCoordinateDM|DMStagCreateCompatibleDMStag)\s*\(" - dm_alloc_mat = r"^(?:\s*)(?:([A-Za-z_]\w*)\s*=\s*)?(?:(?:PETSc|LibPETSc)\.)?(DMCreateMatrix)\s*\(" - - # Some PETSc calls create multiple objects via tuple assignment - # e.g. `x,b = LibPETSc.MatCreateVecs(petsclib, A)` - mat_create_vecs = r"^(?:\s*)(.+?)\s*=\s*(?:(?:PETSc|LibPETSc)\.)?MatCreateVecs\s*\(" - - # Pattern for destroy calls - destroy_pat = r"^(?:\s*)(?:(?:PETSc)\.)?destroy\s*\(\s*([A-Za-z_]\w*)" - # Pattern for finalize calls - finalize_pat = r"^(?:\s*)(?:(?:PETSc)\.)?finalize\s*\(\s*([A-Za-z_]\w*)" - - created = Vector{Tuple{Int,Union{Nothing,String},String}}() - destroyed = Vector{Tuple{Int,String}}() - finalized = Vector{Tuple{Int,String}}() - - for (i, ln_raw) in enumerate(lines) - # Skip empty and full-line comments - s = strip(ln_raw) - if isempty(s) || startswith(s, "#") - continue - end - ln_proc = ln_raw - - if occursin(type_creators, ln_proc) - m = match(type_creators, ln_proc) - var = m.captures[1] - func = m.captures[2] - push!(created, (i, var, func)) - continue - end - if occursin(vec_create, ln_proc) - m = match(vec_create, ln_proc) - var = m.captures[1] - push!(created, (i, var, "Vec")) - continue - end - if occursin(mat_create, ln_proc) - m = match(mat_create, ln_proc) - var = m.captures[1] - push!(created, (i, var, "Mat")) - continue + audit_creator(name::Symbol) -> Union{Nothing, String} + +The kind of object `name` creates, or `nothing` if it creates none. Covers the +`Vec`/`Mat` families (`VecCreateSeq`, `MatDuplicate`, `MatSeqAIJWithArrays`, …) +by shape rather than by listing every member. +""" +function audit_creator(name::Symbol) + haskey(AUDIT_TYPE_CREATORS, name) && return AUDIT_TYPE_CREATORS[name] + haskey(AUDIT_NAMED_CREATORS, name) && return AUDIT_NAMED_CREATORS[name] + s = String(name) + for (prefix, kind) in (("Vec", "Vec"), ("Mat", "Mat")) + startswith(s, prefix) || continue + rest = s[(length(prefix) + 1):end] + if occursin("Create", rest) || + occursin("Duplicate", rest) || + occursin("Load", rest) || + occursin("WithArray", rest) + return kind end - if occursin(dm_vec_alloc, ln_proc) - m = match(dm_vec_alloc, ln_proc) - var = m.captures[1] - push!(created, (i, var, "Vec")) - continue + end + return nothing +end + +""" + audit_destroyer(name::Symbol) -> Bool + +Whether `name` releases a PETSc object. Covers the high-level `destroy`/`destroy!` +and the low-level `VecDestroy`, `MatDestroy`, `DMDestroy` and friends, which the +previous text-matching version treated as leaks. + +`finalizer` counts too. `finalizer(destroy, v)` hands the release to the garbage +collector rather than performing it, but the object is accounted for and must not +read as a leak. The package uses that idiom for sequential objects. +""" +function audit_destroyer(name::Symbol) + name in (:destroy, :destroy!, :finalizer) && return true + s = String(name) + return endswith(s, "Destroy") && length(s) > length("Destroy") +end + +# Syntax tree helpers +# ———————————————————————————————————————————————————————————————————————————— + +""" + audit_isbroadcast(ex) -> Bool + +Whether `ex` is a dot-call such as `destroy!.(vs)`. Those parse as `Expr(:.)` +with a tuple of arguments, not as `Expr(:call)`, so they need recognising +separately. Plain field access `a.b` carries a `QuoteNode` instead and is not a +call. +""" +audit_isbroadcast(ex) = + ex isa Expr && + ex.head === :. && + length(ex.args) == 2 && + ex.args[2] isa Expr && + ex.args[2].head === :tuple + +""" + audit_callee(ex) -> Union{Nothing, Symbol} + +The bare name a call expression invokes, discarding any module qualification, so +`f(x)`, `PETSc.f(x)`, `PETSc.LibPETSc.f(x)` and `PETSc.f.(xs)` all yield `:f`. +""" +function audit_callee(ex) + ex isa Expr || return nothing + if ex.head === :call + f = ex.args[1] + elseif audit_isbroadcast(ex) + f = ex.args[1] + else + return nothing + end + f isa Symbol && return f + while f isa Expr && f.head === :. + f = f.args[2] + end + f isa QuoteNode && (f = f.value) + return f isa Symbol ? f : nothing +end + +""" + audit_argnames(ex) -> Vector{Symbol} + +The plain names a call passes, for either call form, looking through splats and +one level of array or tuple literal. `destroy!(v)`, `destroy!(v...)` and +`destroy!.([v, w])` all name the objects they release. +""" +function audit_argnames(ex) + args = if ex.head === :call + ex.args[2:end] + elseif audit_isbroadcast(ex) + ex.args[2].args + else + return Symbol[] + end + + names = Symbol[] + for a in args + if a isa Expr && a.head === :... + a = a.args[1] end - if occursin(dm_alloc_dm, ln_proc) - m = match(dm_alloc_dm, ln_proc) - var = m.captures[1] - push!(created, (i, var, "DM")) - continue + if a isa Symbol + push!(names, a) + elseif a isa Expr && a.head in (:vect, :tuple) + append!(names, (x for x in a.args if x isa Symbol)) end - if occursin(dm_alloc_mat, ln_proc) - m = match(dm_alloc_mat, ln_proc) - var = m.captures[1] - push!(created, (i, var, "Mat")) - continue + end + return names +end + +""" + audit_targets(lhs) -> Vector{Symbol} + +The variables an assignment binds. Handles `x = …`, `x, y = …` and `(x, y) = …`, +skipping anything that is not a plain name. +""" +function audit_targets(lhs) + lhs isa Symbol && return [lhs] + if lhs isa Expr && lhs.head in (:tuple, :block) + return Symbol[a for a in lhs.args if a isa Symbol] + end + return Symbol[] +end + +""" + audit_walk(f, ex, line = 0) -> Int + +Apply `f(expr, line)` to every `Expr` in `ex`, tracking the source line from the +`LineNumberNode`s the parser emits. Returns the last line seen. +""" +function audit_walk(f, ex, line::Int = 0) + if ex isa LineNumberNode + return ex.line + elseif ex isa Expr + # Quoted code is data, not execution. A `destroy!(v)` in a macro body + # releases nothing where it is written, and counting it would report a + # genuinely leaking file as clean. + ex.head === :quote && return line + f(ex, line) + for a in ex.args + line = audit_walk(f, a, line) end + end + return line +end + +""" + audit_hasparseerror(ex) -> Bool + +Whether the parsed tree contains an error or incomplete node, which happens when +the file has a syntax error. `Meta.parseall` reports those in the tree rather +than throwing. +""" +function audit_hasparseerror(ex) + ex isa Expr || return false + ex.head in (:error, :incomplete) && return true + return any(audit_hasparseerror, ex.args) +end + +# ———————————————————————————————————————————————————————————————————————————— + +""" + audit_petsc_file(path::AbstractString; verbose::Bool = true) + +Scan a Julia source file for PETSc objects that are created but never destroyed. + +Creations are calls to a type constructor (`KSP`, `SNES`, `DMDA`, `DMStag`, +`DMPlex`), to a `Vec`/`Mat` creation routine (`VecCreateSeq`, `MatDuplicate`, +`MatSeqAIJWithArrays`, …), or to one of the DM allocators (`DMGlobalVec`, +`DMLocalVec`, `DMCreateMatrix`, …), through either `PETSc` or `LibPETSc`. +Releases are `destroy`/`destroy!` and the low-level `VecDestroy`-style routines. + +Returns a `NamedTuple`: + +- `created`: `(line, var, kind)` for each creation, `var === nothing` when the + result is not assigned +- `destroyed`: `(line, var)` for each release +- `finalized`: `(line, var)` for each `finalize` call +- `leaked`: variables that are created and never released + +Pass `verbose = false` to suppress the printed report. + +# Notes + +Heuristic: it does not follow control flow, scopes, or aliasing, and creations +whose result is not assigned cannot be matched against a release. + +# Examples + +```julia +julia> report = audit_petsc_file("examples/ex1.jl"); - if occursin(mat_create_vecs, ln_proc) - m = match(mat_create_vecs, ln_proc) - lhs = m.captures[1] - # Strip parentheses and split on commas: "(x, b)" or "x,b" etc. - lhs = replace(lhs, "(" => "", ")" => "") - vars = [strip(v) for v in split(lhs, ',') if !isempty(strip(v))] - if isempty(vars) - push!(created, (i, nothing, "Vec")) - else - for v in vars - if occursin(r"^[A-Za-z_]\w*$", v) - push!(created, (i, v, "Vec")) +julia> isempty(report.leaked) +true +``` +""" +function audit_petsc_file(path::AbstractString; verbose::Bool = true) + ast = Meta.parseall(read(path, String); filename = path) + + # A file that does not parse yields no creations, which would otherwise be + # reported as "no leaks" and read as a clean bill of health. + if audit_hasparseerror(ast) + @warn "$(path) does not parse; the audit below covers only what could be read" + end + + created = Tuple{Int, Union{Nothing, Symbol}, String}[] + destroyed = Tuple{Int, Symbol}[] + finalized = Tuple{Int, Symbol}[] + assigned = Base.IdSet{Any}() + + audit_walk(ast) do ex, line + if ex.head === :(=) && ex.args[2] isa Expr + rhs = ex.args[2] + callee = audit_callee(rhs) + if callee !== nothing + kind = audit_creator(callee) + if kind !== nothing + push!(assigned, rhs) + targets = audit_targets(ex.args[1]) + if isempty(targets) + push!(created, (line, nothing, kind)) + else + for t in targets + push!(created, (line, t, kind)) + end end end end - continue + return end - if occursin(destroy_pat, ln_proc) - m = match(destroy_pat, ln_proc) - var = m.captures[1] - push!(destroyed, (i, var)) - continue + callee = audit_callee(ex) + callee === nothing && return + + if audit_creator(callee) !== nothing && !(ex in assigned) + push!(created, (line, nothing, audit_creator(callee))) end - if occursin(finalize_pat, ln_proc) - m = match(finalize_pat, ln_proc) - var = m.captures[1] - push!(finalized, (i, var)) - continue + + # The object is the first argument high-level (`destroy!(v)`) but the + # second low-level (`VecDestroy(petsclib, v)`), so record every plain + # name the call mentions. `petsclib` is never a tracked object, so the + # extra entries cannot mask a leak. + if audit_destroyer(callee) || callee === :finalize + sink = callee === :finalize ? finalized : destroyed + for a in audit_argnames(ex) + push!(sink, (line, a)) + end end end + created_vars = Set{Symbol}(v for (_, v, _) in created if v !== nothing) + released = Set{Symbol}(v for (_, v) in destroyed) + leaked = sort!(collect(setdiff(created_vars, released))) + + # Only report releases of objects this file created; the rest are arguments + # that happened to sit in a destroy call, such as `petsclib`. + filter!(((_, v),) -> v in created_vars, destroyed) + + verbose && audit_report(created, destroyed, finalized, leaked) + + return ( + created = created, + destroyed = destroyed, + finalized = finalized, + leaked = leaked, + ) +end + +""" + audit_report(created, destroyed, finalized, leaked) + +Print the human-readable form of an [`audit_petsc_file`](@ref) result. +""" +function audit_report(created, destroyed, finalized, leaked) println("CREATION statements:") - for (lnum, var, func) in created - if isnothing(var) - println(" line $(lnum): $(func)(...) (no assignment)") + for (line, var, kind) in created + if var === nothing + println(" line $(line): $(kind)(...) (no assignment)") else - println(" line $(lnum): $(var) = $(func)(...)") + println(" line $(line): $(var) = $(kind)(...)") end end println("DESTROY calls:") - for (lnum, var) in destroyed - println(" line $(lnum): destroy($(var))") + for (line, var) in destroyed + println(" line $(line): destroy($(var))") end - created_vars = Set{String}() - for (_, var, _) in created - if !isnothing(var) - push!(created_vars, var::String) - end - end - destroyed_vars = Set{String}(map(x -> x[2], destroyed)) - - missing = setdiff(created_vars, destroyed_vars) - if !isempty(missing) + if isempty(leaked) + println("No obvious leaks: all assigned creations have a destroy call.") + else println("POSSIBLY UNDESTROYED objects:") - for v in sort(collect(missing)) + for v in leaked println(" $(v)") end - else - println("No obvious leaks: all assigned creations have a destroy call.") end if isempty(finalized) @@ -173,8 +331,8 @@ function audit_petsc_file(path::AbstractString) println("Suggestion: add PETSc.finalize(petsclib) at the end of the routine.") else println("FINALIZE calls:") - for (lnum, var) in finalized - println(" line $(lnum): finalize($(var))") + for (line, var) in finalized + println(" line $(line): finalize($(var))") end end diff --git a/test/fixtures/constructs.jl b/test/fixtures/constructs.jl new file mode 100644 index 000000000..f999a5668 --- /dev/null +++ b/test/fixtures/constructs.jl @@ -0,0 +1,81 @@ +# Fixture for test_audit.jl. Never executed, only parsed. +# +# Every construct here has a known answer. Only `never_freed` and `quoted_v` +# leak; everything else is created and released, or is not a tracked creation. + +using PETSc + +# released inside a loop +for i in 1:3 + loop_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + LibPETSc.VecDestroy(petsclib, loop_v) +end + +# released in a finally block +function in_try() + try_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + try + use(try_v) + finally + PETSc.destroy!(try_v) + end +end + +# created on both branches, released once +if cond + branch_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +else + branch_v = LibPETSc.VecCreateSeq(petsclib, comm, 20) +end +PETSc.destroy!(branch_v) + +# creation wrapped in a macro +@time macro_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!(macro_v) + +# released by broadcasting over a literal +bcast_a = LibPETSc.VecCreateSeq(petsclib, comm, 10) +bcast_b = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!.([bcast_a, bcast_b]) + +# released through a splat +splat_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!(splat_v...) + +# release call carrying a keyword argument +kwarg_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!(kwarg_v; force = true) + +# handed to the garbage collector, in both finalizer spellings +fin_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +finalizer(destroy, fin_v) +fin_m = PETSc.MatSeqAIJ(petsclib, 10, 10, 3) +finalizer(m -> (destroy(m); data), fin_m) + +# borrowed pointer: the caller does not own it +borrowed = PETSc.VecPtr(petsclib, some_ptr, false) + +# let block, global binding, and a non-ASCII name +let + let_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + PETSc.destroy!(let_v) +end +global global_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!(global_v) +Δv = LibPETSc.VecCreateSeq(petsclib, comm, 10) +PETSc.destroy!(Δv) + +#= +block_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) +=# + +# the release below is quoted, so it releases nothing here +macro cleanup() + return quote + PETSc.destroy!(quoted_v) + end +end +quoted_v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + +# the control: nothing releases this +never_freed = LibPETSc.VecCreateSeq(petsclib, comm, 10) diff --git a/test/fixtures/leaky.jl b/test/fixtures/leaky.jl new file mode 100644 index 000000000..4dc7ea7fa --- /dev/null +++ b/test/fixtures/leaky.jl @@ -0,0 +1,33 @@ +# Fixture for test_audit.jl. Never executed, only parsed. +# +# Exercises what the auditor has to get right: qualified and unqualified calls, +# tuple assignment, several statements on one line, low-level destroys, and +# creations that appear only inside comments or string literals. + +using PETSc + +function leaky(petsclib, comm) + # created and destroyed through the high-level name + ksp = PETSc.KSP(petsclib, comm) + PETSc.destroy!(ksp) + + # created and destroyed through the low-level name + v1 = LibPETSc.VecCreateSeq(petsclib, comm, 10) + LibPETSc.VecDestroy(petsclib, v1) + + # two statements on one line: both must register + v2 = LibPETSc.VecCreateSeq(petsclib, comm, 10); LibPETSc.VecDestroy(petsclib, v2) + + # tuple assignment creates two objects, only one is released + x, b = LibPETSc.MatCreateVecs(petsclib, A) + LibPETSc.VecDestroy(petsclib, x) + + # never destroyed + dm = PETSc.DMStag(petsclib, comm, (PETSc.DM_BOUNDARY_NONE,), (10,), 1, 1) + mat = PETSc.MatSeqAIJ(petsclib, 10, 10, 3) + + # a comment mentioning PETSc.destroy!(dm) must not count as a release + note = "call LibPETSc.MatDestroy(petsclib, mat) when finished" + + return note +end diff --git a/test/runtests.jl b/test/runtests.jl index ba1e0aeaa..c24835fb3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -43,6 +43,7 @@ include("dmproduct.jl") # test for DMProduct example include("matshell.jl") # autowrapped! include("test_dmstag.jl") include("test_snes.jl") +include("test_audit.jl") # leak auditor include("old_test.jl") include("low_level_viewer.jl") # Low-level viewer convenience functions include("low_level_ts.jl") # Low-level TS functions diff --git a/test/test_audit.jl b/test/test_audit.jl new file mode 100644 index 000000000..2d615b6c9 --- /dev/null +++ b/test/test_audit.jl @@ -0,0 +1,136 @@ +using Test +using PETSc + +const LEAKY = joinpath(@__DIR__, "fixtures", "leaky.jl") +const CONSTRUCTS = joinpath(@__DIR__, "fixtures", "constructs.jl") + +@testset "audit_petsc_file" begin + report = PETSc.audit_petsc_file(LEAKY; verbose = false) + + created = Set(v for (_, v, _) in report.created if v !== nothing) + destroyed = Set(v for (_, v) in report.destroyed) + + @testset "creations" begin + # high-level constructor, low-level creator, and tuple assignment + for v in (:ksp, :v1, :v2, :x, :b, :dm, :mat) + @test v in created + end + end + + @testset "releases" begin + # both PETSc.destroy! and LibPETSc.VecDestroy count + for v in (:ksp, :v1, :v2, :x) + @test v in destroyed + end + end + + @testset "leaks" begin + @test report.leaked == [:b, :dm, :mat] + end + + @testset "two statements on one line" begin + # v2 is created and destroyed on the same source line; the previous + # text-matching implementation stopped after the first match + line = only(l for (l, v, _) in report.created if v === :v2) + @test any(report.destroyed) do (l, v) + v === :v2 && l == line + end + end + + @testset "comments and strings are not code" begin + # the fixture mentions destroy!(dm) in a comment and MatDestroy(mat) + # inside a string; neither may register as a release + @test !(:dm in destroyed) + @test !(:mat in destroyed) + end + + @testset "finalizer counts as a release" begin + mktemp() do path, io + write( + io, + """ + v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + finalizer(destroy, v) + + mat = PETSc.MatSeqAIJ(petsclib, 10, 10, 3) + finalizer(m -> (destroy(m); data), mat) + + dm = PETSc.DMStag(petsclib, comm, bt, sz, 1, 1) + """, + ) + close(io) + # v and mat are handed to the garbage collector; only dm leaks + @test PETSc.audit_petsc_file(path; verbose = false).leaked == [:dm] + end + end + + @testset "borrowed pointers are not creations" begin + mktemp() do path, io + # VecPtr/MatPtr wrap a handle the caller may not own, so they must + # not be reported as objects needing a destroy + write(io, "f = PETSc.VecPtr(petsclib, f_ptr, false)\n") + close(io) + @test isempty(PETSc.audit_petsc_file(path; verbose = false).leaked) + end + end + + @testset "clean file has no leaks" begin + mktemp() do path, io + write( + io, + """ + v = LibPETSc.VecCreateSeq(petsclib, comm, 10) + LibPETSc.VecDestroy(petsclib, v) + """, + ) + close(io) + @test isempty(PETSc.audit_petsc_file(path; verbose = false).leaked) + end + end + + @testset "language constructs" begin + # Every construct in the fixture is released except the two named here. + # A failure names the construct that broke. + report = PETSc.audit_petsc_file(CONSTRUCTS; verbose = false) + @test report.leaked == [:never_freed, :quoted_v] + end + + @testset "does not parse" begin + mktemp() do path, io + write(io, "v = LibPETSc.VecCreateSeq(petsclib, comm, 10\nfunction broken(\n") + close(io) + # a syntax error must not silently read as a clean bill of health + @test_logs (:warn, r"does not parse") PETSc.audit_petsc_file( + path; + verbose = false, + ) + end + end + + @testset "helpers" begin + @test PETSc.audit_creator(:VecCreateSeq) == "Vec" + @test PETSc.audit_creator(:MatSeqAIJWithArrays) == "Mat" + @test PETSc.audit_creator(:DMStag) == "DM" + @test PETSc.audit_creator(:solve!) === nothing + + @test PETSc.audit_destroyer(:destroy) + @test PETSc.audit_destroyer(:destroy!) + @test PETSc.audit_destroyer(:VecDestroy) + @test PETSc.audit_destroyer(:finalizer) + @test PETSc.audit_creator(:VecPtr) === nothing + @test PETSc.audit_creator(:MatPtr) === nothing + @test !PETSc.audit_destroyer(:Destroy) + @test !PETSc.audit_destroyer(:assemble!) + + @test PETSc.audit_callee(:(PETSc.LibPETSc.f(x))) === :f + @test PETSc.audit_callee(:(PETSc.f.(xs))) === :f + @test !PETSc.audit_isbroadcast(:(a.b)) + @test PETSc.audit_isbroadcast(:(f.(x))) + @test PETSc.audit_argnames(:(destroy!(v))) == [:v] + @test PETSc.audit_argnames(:(destroy!(v...))) == [:v] + @test PETSc.audit_argnames(:(destroy!.([v, w]))) == [:v, :w] + @test PETSc.audit_argnames(:(VecDestroy(lib, v))) == [:lib, :v] + @test PETSc.audit_callee(:(f(x))) === :f + @test PETSc.audit_callee(:(x + 1)) === :+ + end +end