From 41abd8e3930cc28a3de6f5577adc1601a77d25c9 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 21 Aug 2026 17:09:06 -0700 Subject: [PATCH] Read coverage counts as Int64, narrowing only at the vendored boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `x86` leg that runs with coverage errors on whatever test item happens to be running when the counters are collected: OverflowError: overflow parsing "2345022144" at readfile (packages/CoverageTools/src/lcov.jl:99) `collect_coverage_data!` writes the runtime's counters with `jl_write_coverage_data` and reads them straight back with `CoverageTools.LCOV.readfile`. Julia's counters are 64 bit whatever the word size, and a line in a hot loop passes `typemax(Int32)` easily, but the count was parsed as `Int` — `Int32` on a 32 bit platform — so the value the runtime had just written did not fit. The fix cannot widen `CovCount`: `packages/` holds git subtrees that are never edited by hand, and `scripts/update_vendored_packages.jl` deliberately skips CoverageTools, so there is no re-vendoring route either. So the file is read here instead. `shared/coverage_counts.jl` parses the same `SF:`/`DA:` lines that `LCOV.readfile` does, accumulating in `Int64`, and narrows a count only where it crosses into a vendored `FileCoverage` — where it saturates rather than throwing, because knowing a line ran at least 2147483647 times beats losing the run over the exact figure. The controller applies the same narrowing to counts arriving over the wire, so a 64 bit test process reporting to a 32 bit controller cannot throw an `InexactError` either. The protocol's `FileCoverage` now carries `Int64`, so the JSON payload does not depend on either side's word size. `TestrunResultFileCoverage` stays at `Int`: what reaches it comes out of the vendored `merge_coverage_counts`, which produces `Int` whatever we declare. Co-Authored-By: Claude Opus 5 --- shared/coverage_counts.jl | 75 +++++++++++++++++++ shared/testserver_protocol.jl | 6 +- src/TestItemControllers.jl | 1 + src/testitemcontroller.jl | 7 +- test/test_coverage.jl | 36 +++++++++ .../TestItemServer/src/TestItemServer.jl | 5 +- 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 shared/coverage_counts.jl diff --git a/shared/coverage_counts.jl b/shared/coverage_counts.jl new file mode 100644 index 0000000..06dedb5 --- /dev/null +++ b/shared/coverage_counts.jl @@ -0,0 +1,75 @@ +# Line hit counts, and the one place they have to be narrowed. +# +# Julia's coverage counters are 64 bit whatever the platform's word size is, and +# `jl_write_coverage_data` writes out what they hold — a line in a hot loop passes +# `typemax(Int32)` easily. The vendored `CoverageTools` stores a count as a `CovCount`, +# which is `Union{Nothing,Int}`, so on a 32 bit run there is nowhere to put such a value: +# reading the file back with `CoverageTools.LCOV.readfile` threw +# +# OverflowError: overflow parsing "2345022144" +# +# and, because coverage is collected while a test item is running, that error was reported +# as a failure of whatever item happened to be running at the time. +# +# `packages/` holds git subtrees that are never edited by hand, so the file is read here +# instead, and a count is narrowed only where it crosses into one of those vendored +# structures. One that does not fit saturates rather than throwing: knowing a line ran at +# least 2147483647 times is worth more than losing the run over the exact figure. + +# Stands in for "not instrumentable" while counts are accumulated, so that the vector can +# stay concretely typed. No real count can reach it. +const _COUNT_ABSENT = typemin(Int64) + +""" + saturating_count(n) + +Narrow a 64 bit hit count to what a vendored `CoverageTools.CovCount` can hold. A no-op on a +64 bit platform, where `Int` is already `Int64`. +""" +saturating_count(n::Integer) = Int(clamp(n, typemin(Int), typemax(Int))) +saturating_count(::Nothing) = nothing + +""" + read_lcov_counts(path) -> Vector{CoverageTools.FileCoverage} + +Read an LCOV info file into the `FileCoverage` entries the rest of the coverage path expects. + +This is `CoverageTools.LCOV.readfile` with the counts accumulated in `Int64` and narrowed by +[`saturating_count`](@ref) on the way into the `CovCount` vector. +""" +function read_lcov_counts(path::AbstractString) + files = Tuple{String,Vector{Int64}}[] + counts = nothing + + for line in eachline(path) + if startswith(line, "end_of_record") + counts = nothing + elseif (m = match(r"^SF:(.+)", line)) !== nothing + counts = Int64[] + push!(files, (String(m[1]), counts)) + elseif (m = match(r"^DA:(\d+),(-?\d+)(,[^,\s]+)?", line)) !== nothing + counts === nothing && continue + + ln = parse(Int64, m[1]) + da = parse(Int64, m[2]) + ln > 0 || continue + + if length(counts) < ln + filled = length(counts) + resize!(counts, ln) + fill!(view(counts, (filled + 1):ln), _COUNT_ABSENT) + end + + counts[ln] = counts[ln] == _COUNT_ABSENT ? da : counts[ln] + da + end + end + + return [ + CoverageTools.FileCoverage( + filename, + "", + CoverageTools.CovCount[i == _COUNT_ABSENT ? nothing : saturating_count(i) for i in counts] + ) + for (filename, counts) in files + ] +end diff --git a/shared/testserver_protocol.jl b/shared/testserver_protocol.jl index b05ba1a..27d141d 100644 --- a/shared/testserver_protocol.jl +++ b/shared/testserver_protocol.jl @@ -62,15 +62,17 @@ TestMessage(message, location) = TestMessage(message, missing, missing, location timeoutMs::Union{Missing,Float64} end +# `Int64` rather than `Int`: a controller and a test process can run at different word +# sizes, and Julia's coverage counters are 64 bit on both. struct FileCoverage <: JSONRPC.Outbound uri::String - coverage::Vector{Union{Int,Nothing}} + coverage::Vector{Union{Int64,Nothing}} end function FileCoverage(d::Dict) return FileCoverage( d["uri"], - Union{Int,Nothing}[i for i in d["coverage"]] + Union{Int64,Nothing}[i for i in d["coverage"]] ) end diff --git a/src/TestItemControllers.jl b/src/TestItemControllers.jl index 31c2239..c18b706 100644 --- a/src/TestItemControllers.jl +++ b/src/TestItemControllers.jl @@ -32,6 +32,7 @@ export write_junit_xml, write_lcov include("json_protocol.jl") include("../shared/testserver_protocol.jl") include("../shared/urihelper.jl") +include("../shared/coverage_counts.jl") include("datatypes.jl") include("results.jl") diff --git a/src/testitemcontroller.jl b/src/testitemcontroller.jl index a538729..364f13c 100644 --- a/src/testitemcontroller.jl +++ b/src/testitemcontroller.jl @@ -920,7 +920,12 @@ function handle!(c::TestItemController, msg::TestItemPassedMsg) _record_testitem_result!(c, msg.testitem_id, :passed, msg.duration) if msg.coverage !== nothing - append!(tr.coverage, map(i -> CoverageTools.FileCoverage(uri2filepath(i.uri), "", i.coverage), msg.coverage)) + # `saturating_count` because `CoverageTools.CovCount` is vendored and cannot be + # widened: a 64 bit test process can report a count this controller's `Int` does + # not hold when the two run at different word sizes. + append!(tr.coverage, map(msg.coverage) do i + CoverageTools.FileCoverage(uri2filepath(i.uri), "", CoverageTools.CovCount[saturating_count(n) for n in i.coverage]) + end) end else _log_unexpected_missing_work(tr, msg.testitem_id, msg.testprocess_id, test_env_id, "passed") diff --git a/test/test_coverage.jl b/test/test_coverage.jl index 9daaaf9..75ee64b 100644 --- a/test/test_coverage.jl +++ b/test/test_coverage.jl @@ -313,3 +313,39 @@ end @test cov[transform_line] !== nothing && cov[transform_line] > 0 end end + +@testitem "Coverage counts wider than the platform's Int" begin + # Julia's coverage counters are 64 bit on every platform, and `jl_write_coverage_data` + # writes out what they hold — a line in a hot loop passes `typemax(Int32)` easily. + # Reading that back used to throw an `OverflowError` on a 32 bit run, reported as a + # failure of whatever test item was running when coverage was collected. + using TestItemControllers: read_lcov_counts, saturating_count + + wide = Int64(typemax(Int32)) + 1 + lcov = tempname() * ".info" + hot = joinpath(@__DIR__, "hot.jl") + + try + write(lcov, "SF:$hot\nDA:1,$wide\nDA:2,0\nDA:4,1\nend_of_record\n") + + file_coverage = read_lcov_counts(lcov) + + @test length(file_coverage) == 1 + @test file_coverage[1].filename == hot + + cov = file_coverage[1].coverage + @test length(cov) == 4 + @test cov[1] == saturating_count(wide) + @test cov[2] == 0 + @test cov[3] === nothing + @test cov[4] == 1 + finally + rm(lcov, force=true) + end + + # The count survives intact where `Int` is 64 bit, and saturates rather than throwing + # where it is not — the vendored `CoverageTools.CovCount` is a `Union{Nothing,Int}` and + # cannot be widened from here + @test saturating_count(wide) == (Int === Int64 ? wide : typemax(Int)) + @test saturating_count(nothing) === nothing +end diff --git a/testprocess/TestItemServer/src/TestItemServer.jl b/testprocess/TestItemServer/src/TestItemServer.jl index e951a88..a605a81 100644 --- a/testprocess/TestItemServer/src/TestItemServer.jl +++ b/testprocess/TestItemServer/src/TestItemServer.jl @@ -3,13 +3,14 @@ module TestItemServer include("pkg_imports.jl") import .JSONRPC: @dict_readable -import .CoverageTools: LCOV, amend_coverage_from_src! +import .CoverageTools: amend_coverage_from_src! import .CancellationTokens: CancellationToken import Test, Pkg, Sockets import Logging import Profile include("../../../shared/testserver_protocol.jl") +include("../../../shared/coverage_counts.jl") """ A crash-reporting handler that returns instead of ending the process, or `nothing` when @@ -304,7 +305,7 @@ function collect_coverage_data!(coverage_results, roots) lcov_filename = tempname() * ".info" @ccall jl_write_coverage_data(lcov_filename::Cstring)::Cvoid cov_info = try - LCOV.readfile(lcov_filename) + read_lcov_counts(lcov_filename) finally rm(lcov_filename) end