From 9468ee9775f45c383aeff82d53a0bd582fc52f88 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 30 Jun 2026 12:08:59 +0200 Subject: [PATCH 1/2] Cover the function definition line of device code. The :code_coverage_effect expressions only mark body statements; Julia's codegen visits the signature line separately at the function prologue. Mirror that, or the header shows as missed while the body is hit. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jlgen.jl | 36 ++++++++++++++++++++++++++++-------- test/native.jl | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/jlgen.jl b/src/jlgen.jl index 424b3f00..aaf37ee1 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -715,9 +715,20 @@ end # Julia runtime, so instead we mark those lines as visited when compiling them: Coverage of # device code thus means "this code was compiled", with counts reflecting the number of # compilations rather than executions. -function record_coverage(src::CodeInfo) +function coverage_visit_line(@nospecialize(file), line::Integer) + file isa Symbol || return + filename = String(file) + (isempty(filename) || line <= 0) && return + ccall(:jl_coverage_visit_line, Cvoid, (Cstring, Csize_t, Cint), + filename, ncodeunits(filename), line) + return +end + +function record_coverage(mi::MethodInstance, src::CodeInfo) + tracked = false for (pc, stmt) in enumerate(src.code) (stmt isa Expr && stmt.head === :code_coverage_effect) || continue + tracked = true @static if VERSION >= v"1.12-" scopes = Base.IRShow.buildLineInfoNode(src.debuginfo, nothing, pc) isempty(scopes) && continue @@ -729,11 +740,20 @@ function record_coverage(src::CodeInfo) loc = src.linetable[lineidx]::Core.LineInfoNode file, line = loc.file, loc.line end - file isa Symbol || continue - filename = String(file) - (isempty(filename) || line <= 0) && continue - ccall(:jl_coverage_visit_line, Cvoid, (Cstring, Csize_t, Cint), - filename, ncodeunits(filename), line) + coverage_visit_line(file, line) + end + + # the `:code_coverage_effect` expressions only cover the body statements; the function + # definition (signature) line is handled separately by Julia's codegen, which visits it + # at the function prologue (`toplineno`, i.e., the method's own file and line). Mirror + # that here, or the signature is reported as missed while the body is hit. Only do so for + # methods that are actually tracked (i.e., whose body carries coverage effects), to avoid + # reporting signatures of untracked code. + if tracked + def = mi.def + if def isa Method + coverage_visit_line(def.file, def.line) + end end return end @@ -760,7 +780,7 @@ function compile_method_instance(@nospecialize(job::CompilerJob)) # `ci_cache_populate` does not return sources, this happens after codegen instead) if Base.JLOptions().code_coverage != 0 for (ci, src) in populated - record_coverage(src) + record_coverage(ci.def::MethodInstance, src) end end @@ -1017,7 +1037,7 @@ function compile_method_instance(@nospecialize(job::CompilerJob)) src = Base._uncompressed_ir(ci, src) end if src isa CodeInfo - record_coverage(src) + record_coverage(ci.def::MethodInstance, src) end end end diff --git a/test/native.jl b/test/native.jl index 95980c8e..11425cc5 100644 --- a/test/native.jl +++ b/test/native.jl @@ -229,6 +229,14 @@ end @inline inlined_callee(x) = x + one(x) @noinline noinline_callee(x) = x * 2 entry(x) = noinline_callee(inlined_callee(x)) + + # a genuinely multi-line function, so its definition (signature) line is + # distinct from its body lines; compiled as its own entry below. + function multiline(x) + y = x + 1 + z = y * 2 + return z + end end # whether any line in `lo:hi` of `file` has a nonzero execution count in an @@ -248,12 +256,31 @@ end return false end + # the execution count recorded for an exact line of `file`, or `nothing` if that + # line was not instrumented + function lcov_line_count(tracefile, file, line) + in_block = false + for l in eachline(tracefile) + if startswith(l, "SF:") + in_block = (l == "SF:" * file) + elseif l == "end_of_record" + in_block = false + elseif in_block && startswith(l, "DA:") + ln, cnt = parse.(Int, split(l[4:end], ",")) + ln == line && return cnt + end + end + return nothing + end + if Base.JLOptions().code_coverage == 0 @test_skip "requires --code-coverage" else - job, _ = Native.create_job(mod.entry, (Int,)) - JuliaContext() do ctx - GPUCompiler.compile(:asm, job) + for entry in (mod.entry, mod.multiline) + job, _ = Native.create_job(entry, (Int,)) + JuliaContext() do ctx + GPUCompiler.compile(:asm, job) + end end # flush coverage data in-process; the device functions must show covered @@ -265,6 +292,13 @@ end m = only(methods(f)) @test lcov_any_covered(tracefile, string(m.file), m.line, m.line + 1) end + + # the function definition (signature) line itself must be covered, not + # just the body: Julia's codegen visits it separately at the prologue, so + # device coverage has to mirror that. + m = only(methods(mod.multiline)) + @test lcov_line_count(tracefile, string(m.file), m.line) !== nothing + @test something(lcov_line_count(tracefile, string(m.file), m.line), 0) >= 1 end end end From 4da1b306b7711a8a741e33509407d916245fe41b Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 30 Jun 2026 13:21:16 +0200 Subject: [PATCH 2/2] Defer coverage tracefile cleanup to exit to avoid EBUSY on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jlgen.jl | 9 +++------ test/native.jl | 31 +++++++++++++++---------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/jlgen.jl b/src/jlgen.jl index aaf37ee1..8db65c89 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -743,12 +743,9 @@ function record_coverage(mi::MethodInstance, src::CodeInfo) coverage_visit_line(file, line) end - # the `:code_coverage_effect` expressions only cover the body statements; the function - # definition (signature) line is handled separately by Julia's codegen, which visits it - # at the function prologue (`toplineno`, i.e., the method's own file and line). Mirror - # that here, or the signature is reported as missed while the body is hit. Only do so for - # methods that are actually tracked (i.e., whose body carries coverage effects), to avoid - # reporting signatures of untracked code. + # the definition (signature) line isn't a `:code_coverage_effect`; Julia's codegen + # visits it separately at the prologue. Mirror that, gated on the body being tracked, + # or the signature reads as missed while the body is hit. if tracked def = mi.def if def isa Method diff --git a/test/native.jl b/test/native.jl index 11425cc5..6aebba91 100644 --- a/test/native.jl +++ b/test/native.jl @@ -283,23 +283,22 @@ end end end - # flush coverage data in-process; the device functions must show covered - # lines even though they were never executed by the host. - mktempdir() do dir - tracefile = joinpath(dir, "coverage.info") - ccall(:jl_write_coverage_data, Cvoid, (Cstring,), tracefile) - for f in (mod.inlined_callee, mod.noinline_callee, mod.entry) - m = only(methods(f)) - @test lcov_any_covered(tracefile, string(m.file), m.line, m.line + 1) - end - - # the function definition (signature) line itself must be covered, not - # just the body: Julia's codegen visits it separately at the prologue, so - # device coverage has to mirror that. - m = only(methods(mod.multiline)) - @test lcov_line_count(tracefile, string(m.file), m.line) !== nothing - @test something(lcov_line_count(tracefile, string(m.file), m.line), 0) >= 1 + # flush coverage in-process; device lines show covered despite never running. + # bare mktempdir (cleaned at exit, after a GC) dodges the EBUSY `rm` race the + # `do` form hits on Windows. jl_write_coverage_data needs a `.info` path. + dir = mktempdir() + tracefile = joinpath(dir, "coverage.info") + ccall(:jl_write_coverage_data, Cvoid, (Cstring,), tracefile) + for f in (mod.inlined_callee, mod.noinline_callee, mod.entry) + m = only(methods(f)) + @test lcov_any_covered(tracefile, string(m.file), m.line, m.line + 1) end + + # the definition line must be covered too, not just the body (Julia covers + # it separately at the prologue) + m = only(methods(mod.multiline)) + @test lcov_line_count(tracefile, string(m.file), m.line) !== nothing + @test something(lcov_line_count(tracefile, string(m.file), m.line), 0) >= 1 end end end