From 0bcdcf7602461192ea7a67a862363ab47fdd713f Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 17 Apr 2026 15:23:44 +0200 Subject: [PATCH 1/6] Support custom AbstractTestRecord subtypes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits `runtest` into the PTR-owned boilerplate (temp module creation, imports, TESTSET_PRINT_ENABLE handling) and a dispatchable `execute` function that performs the actual test invocation and returns the record. Users can subtype `AbstractTestRecord` and dispatch `execute(::Type{MyRecord}, ...)` to collect custom per-test metrics — without re-implementing the scaffolding. `runtests` gains two kwargs: `RecordType = TestRecord` selecting the concrete record type, and `custom_args = (;)` threaded through to `execute` for per-run configuration. Print methods (`print_header`, `print_test_started`, `print_test_finished`, `print_test_failed`, `print_test_crashed`, `test_IOContext`) now dispatch on `AbstractTestRecord` so custom record types can override output. The `record.rss` access in `print_test_finished` now goes through the generic `memory_usage`. `addworker` eagerly imports ParallelTestRunner on the worker before running `init_worker_code`, since user-supplied initialization commonly references `AbstractTestRecord`, `WorkerTestSet`, etc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ParallelTestRunner.jl | 98 ++++++++++++++++++++++++--------------- test/runtests.jl | 67 ++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 38 deletions(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index eada6f9..67f8183 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -93,11 +93,11 @@ struct TestRecord <: AbstractTestRecord total_time::Float64 end -function memory_usage(rec::TestRecord) +function memory_usage(rec::AbstractTestRecord) return rec.rss end -function Base.getindex(rec::TestRecord) +function Base.getindex(rec::AbstractTestRecord) return rec.value end @@ -121,7 +121,7 @@ struct TestIOContext rss_align::Int end -function test_IOContext(stdout::IO, stderr::IO, lock::ReentrantLock, name_align::Int, verbose::Bool) +function test_IOContext(::Type{<:AbstractTestRecord}, stdout::IO, stderr::IO, lock::ReentrantLock, name_align::Int, verbose::Bool) elapsed_align = textwidth("time (s)") compile_align = textwidth("Compile") gc_align = textwidth("GC (s)") @@ -137,7 +137,7 @@ function test_IOContext(stdout::IO, stderr::IO, lock::ReentrantLock, name_align: ) end -function print_header(ctx::TestIOContext, testgroupheader, workerheader) +function print_header(::Type{<:AbstractTestRecord}, ctx::TestIOContext, testgroupheader, workerheader) lock(ctx.lock) try # header top @@ -160,7 +160,7 @@ function print_header(ctx::TestIOContext, testgroupheader, workerheader) end end -function print_test_started(wrkr, test, ctx::TestIOContext) +function print_test_started(::Type{<:AbstractTestRecord}, wrkr, test, ctx::TestIOContext) lock(ctx.lock) try printstyled(ctx.stdout, test, lpad("($wrkr)", ctx.name_align - textwidth(test) + 1, " "), " │", color = :white) @@ -174,7 +174,7 @@ function print_test_started(wrkr, test, ctx::TestIOContext) end end -function print_test_finished(record::TestRecord, wrkr, test, ctx::TestIOContext) +function print_test_finished(record::AbstractTestRecord, wrkr, test, ctx::TestIOContext) lock(ctx.lock) try printstyled(ctx.stdout, test, color = :white) @@ -202,7 +202,7 @@ function print_test_finished(record::TestRecord, wrkr, test, ctx::TestIOContext) alloc_str = @sprintf("%5.2f", record.bytes / 2^20) printstyled(ctx.stdout, lpad(alloc_str, ctx.alloc_align, " "), " │ ", color = :white) - rss_str = @sprintf("%5.2f", record.rss / 2^20) + rss_str = @sprintf("%5.2f", memory_usage(record) / 2^20) printstyled(ctx.stdout, lpad(rss_str, ctx.rss_align, " "), " │\n", color = :white) flush(ctx.stdout) @@ -211,7 +211,7 @@ function print_test_finished(record::TestRecord, wrkr, test, ctx::TestIOContext) end end -function print_test_failed(record::TestRecord, wrkr, test, ctx::TestIOContext) +function print_test_failed(record::AbstractTestRecord, wrkr, test, ctx::TestIOContext) lock(ctx.lock) try printstyled(ctx.stderr, test, color = :red) @@ -243,7 +243,7 @@ function print_test_failed(record::TestRecord, wrkr, test, ctx::TestIOContext) end end -function print_test_crashed(wrkr, test, ctx::TestIOContext) +function print_test_crashed(::Type{<:AbstractTestRecord}, wrkr, test, ctx::TestIOContext) lock(ctx.lock) try printstyled(ctx.stderr, test, color = :red) @@ -313,7 +313,33 @@ function Test.finish(ts::WorkerTestSet) return ts.wrapped_ts end -function runtest(f, name, init_code, start_time) +function execute(::Type{TestRecord}, mod::Module, f, name, start_time, custom_args) + data = @eval mod begin + GC.gc(true) + Random.seed!(1) + + # @testset CustomTestRecord switches the all lower-level testset to our custom testset, + # so we need to have two layers here such that the user-defined testsets are using `DefaultTestSet`. + # This also guarantees our invariant about `WorkerTestSet` containing a single `DefaultTestSet`. + stats = @timed @testset WorkerTestSet "placeholder" begin + @testset DefaultTestSet $name begin + $f + end + end + + compile_time = @static VERSION >= v"1.11" ? stats.compile_time : 0.0 + (; testset=stats.value, stats.time, stats.bytes, stats.gctime, compile_time) + end + + # process results + rss = Sys.maxrss() + record = TestRecord(data..., rss, time() - start_time) + + GC.gc(true) + return record +end + +function runtest(RecordType::Type{<:AbstractTestRecord}, f, name, init_code, start_time, custom_args) function inner() # generate a temporary module to execute the tests in mod = @eval(Main, module $(gensym(name)) end) @@ -325,29 +351,7 @@ function runtest(f, name, init_code, start_time) Core.eval(mod, init_code) - data = @eval mod begin - GC.gc(true) - Random.seed!(1) - - # @testset CustomTestRecord switches the all lower-level testset to our custom testset, - # so we need to have two layers here such that the user-defined testsets are using `DefaultTestSet`. - # This also guarantees our invariant about `WorkerTestSet` containing a single `DefaultTestSet`. - stats = @timed @testset WorkerTestSet "placeholder" begin - @testset DefaultTestSet $name begin - $f - end - end - - compile_time = @static VERSION >= v"1.11" ? stats.compile_time : 0.0 - (; testset=stats.value, stats.time, stats.bytes, stats.gctime, compile_time) - end - - # process results - rss = Sys.maxrss() - record = TestRecord(data..., rss, time() - start_time) - - GC.gc(true) - return record + return execute(RecordType, mod, f, name, start_time, custom_args) end @static if VERSION >= v"1.13.0-DEV.1044" @@ -514,6 +518,10 @@ function addworker(; # Malt already sets OPENBLAS_NUM_THREADS to 1 push!(env, "OPENBLAS_NUM_THREADS" => "1") wrkr = PTRWorker(; exename, exeflags, env) + # make ParallelTestRunner available to `init_worker_code`; users commonly + # need it to reference `AbstractTestRecord`, `execute`, etc. when defining + # custom record types. + Malt.remote_eval_wait(Main, wrkr.w, :(import ParallelTestRunner)) if init_worker_code != :() Malt.remote_eval_wait(Main, wrkr.w, init_worker_code) end @@ -699,6 +707,8 @@ end init_code = :(), init_worker_code = :(), test_worker = Returns(nothing), + RecordType::Type{<:AbstractTestRecord} = TestRecord, + custom_args = (;), stdout = Base.stdout, stderr = Base.stderr, max_worker_rss = get_max_worker_rss()) @@ -723,6 +733,15 @@ Several keyword arguments are also supported: - `init_worker_code`: Code use to initialize each worker. This is run only once per worker instead of once per test. - `test_worker`: Optional function that takes a test name and `init_worker_code` if `init_worker_code` is defined and returns a specific worker. When returning `nothing`, the test will be assigned to any available default worker. +- `RecordType`: Concrete subtype of [`AbstractTestRecord`](@ref) used to collect + per-test statistics. Defaults to [`TestRecord`](@ref). Users can subtype + `AbstractTestRecord` and dispatch [`execute`](@ref) on their type to customize + what's measured per test; they typically also override the `print_*` methods. + The record type must be defined on both the main process and all workers (e.g. + via `init_worker_code`) since it crosses the Malt serialization boundary. +- `custom_args`: Arbitrary value (typically a `NamedTuple`) forwarded to + [`execute`](@ref). Lets callers thread per-run configuration into a custom + `RecordType`'s `execute` method without going through `init_code`. - `stdout` and `stderr`: I/O streams to write to (default: `Base.stdout` and `Base.stderr`) - `max_worker_rss`: RSS threshold where a worker will be restarted once it is reached. @@ -800,6 +819,8 @@ issues during long test runs. The memory limit is set based on system architectu function runtests(mod::Module, args::ParsedArgs; testsuite::Dict{String,Expr} = find_tests(pwd()), init_code = :(), init_worker_code = :(), test_worker = Returns(nothing), + RecordType::Type{<:AbstractTestRecord} = TestRecord, + custom_args = (;), stdout = Base.stdout, stderr = Base.stderr, max_worker_rss = get_max_worker_rss()) # # set-up @@ -874,8 +895,8 @@ function runtests(mod::Module, args::ParsedArgs; stderr.lock = print_lock end - io_ctx = test_IOContext(stdout, stderr, print_lock, name_align, !isnothing(args.verbose)) - print_header(io_ctx, testgroupheader, workerheader) + io_ctx = test_IOContext(RecordType, stdout, stderr, print_lock, name_align, !isnothing(args.verbose)) + print_header(RecordType, io_ctx, testgroupheader, workerheader) status_lines_visible = Ref(0) @@ -975,7 +996,7 @@ function runtests(mod::Module, args::ParsedArgs; # Optionally print verbose started message if args.verbose !== nothing clear_status() - print_test_started(wrkr, test_name, io_ctx) + print_test_started(RecordType, wrkr, test_name, io_ctx) end elseif msg_type == :finished @@ -992,7 +1013,7 @@ function runtests(mod::Module, args::ParsedArgs; test_name, wrkr = msg[2], msg[3] clear_status() - print_test_crashed(wrkr, test_name, io_ctx) + print_test_crashed(RecordType, wrkr, test_name, io_ctx) end end @@ -1064,7 +1085,8 @@ function runtests(mod::Module, args::ParsedArgs; result = try Malt.remote_eval_wait(Main, wrkr.w, :(import ParallelTestRunner)) Malt.remote_call_fetch(invokelatest, wrkr.w, runtest, - testsuite[test], test, init_code, test_t0) + RecordType, testsuite[test], test, + init_code, test_t0, custom_args) catch ex if isa(ex, InterruptException) # the worker got interrupted, signal other tasks to stop diff --git a/test/runtests.jl b/test/runtests.jl index 0a0efb6..e205236 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -123,6 +123,73 @@ end @test contains(str, "SUCCESS") end +@testset "custom record type" begin + # Define a custom record type with an extra field. Defining it on both the + # main process and the workers (via init_worker_code) is required since the + # record crosses the Malt serialization boundary. The inner `$name`/`$f` in + # the `execute` method's quote need `$(Expr(:$, …))` escaping since they live + # inside the outer `init_worker_code` quote. + init_worker_code = quote + using ParallelTestRunner: Test + using Test: DefaultTestSet + struct MyRecord <: ParallelTestRunner.AbstractTestRecord + value::DefaultTestSet + time::Float64 + bytes::UInt64 + gctime::Float64 + compile_time::Float64 + rss::UInt64 + total_time::Float64 + extra::String + end + function ParallelTestRunner.execute( + ::Type{MyRecord}, mod::Module, f, name, start_time, custom_args, + ) + data = @eval mod begin + GC.gc(true) + stats = @timed @testset WorkerTestSet "placeholder" begin + @testset DefaultTestSet $(Expr(:$, :name)) begin + $(Expr(:$, :f)) + end + end + compile_time = @static VERSION >= v"1.11" ? stats.compile_time : 0.0 + (; testset = stats.value, stats.time, stats.bytes, stats.gctime, compile_time) + end + rss = Sys.maxrss() + MyRecord(data..., rss, time() - start_time, custom_args.tag) + end + function ParallelTestRunner.print_test_finished( + record::MyRecord, wrkr, test, ctx::ParallelTestRunner.TestIOContext, + ) + lock(ctx.lock) + try + println(ctx.stdout, "EXTRA[$test]=$(record.extra)") + flush(ctx.stdout) + finally + unlock(ctx.lock) + end + end + end + + eval(init_worker_code) # also define on the main process so the record deserializes + + testsuite = Dict( + "custom" => quote + @test 1 + 1 == 2 + end, + ) + + io = IOBuffer() + runtests(ParallelTestRunner, ["--verbose"]; testsuite, + init_worker_code, RecordType = MyRecord, + custom_args = (; tag = "hello"), + stdout = io, stderr = io) + str = String(take!(io)) + + @test contains(str, "EXTRA[custom]=hello") + @test contains(str, "SUCCESS") +end + @testset "custom worker" begin function test_worker(name) if name == "needs env var" From 99079993abb8333a36ceff87c97c93d91d98c602 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 17 Apr 2026 15:29:56 +0200 Subject: [PATCH 2/6] Allow configuring every runtests worker via exename/exeflags/env. Adds `exename`, `exeflags`, and `env` kwargs to `runtests`, threaded into the internal `addworker` call so every default-pool worker (and any respawns inside `runtests`) inherits them. `exename` accepts both `String` and `Cmd`, enabling callers to wrap the julia invocation with a tool such as `compute-sanitizer`. Custom workers created from a `test_worker` hook are still the caller's responsibility. Also fixes `addworker` to copy its `env` argument before appending `JULIA_NUM_THREADS` / `OPENBLAS_NUM_THREADS`, so a caller-supplied vector is not mutated across calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ParallelTestRunner.jl | 22 ++++++++++++++++++---- test/runtests.jl | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index 67f8183..47a1b55 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -514,10 +514,12 @@ function addworker(; exeflags = exe[2:end] end - push!(env, "JULIA_NUM_THREADS" => "1") + # don't mutate the caller's vector; multiple workers may share a default + worker_env = copy(env) + push!(worker_env, "JULIA_NUM_THREADS" => "1") # Malt already sets OPENBLAS_NUM_THREADS to 1 - push!(env, "OPENBLAS_NUM_THREADS" => "1") - wrkr = PTRWorker(; exename, exeflags, env) + push!(worker_env, "OPENBLAS_NUM_THREADS" => "1") + wrkr = PTRWorker(; exename, exeflags, env = worker_env) # make ParallelTestRunner available to `init_worker_code`; users commonly # need it to reference `AbstractTestRecord`, `execute`, etc. when defining # custom record types. @@ -709,6 +711,9 @@ end test_worker = Returns(nothing), RecordType::Type{<:AbstractTestRecord} = TestRecord, custom_args = (;), + exename = nothing, + exeflags = nothing, + env = Vector{Pair{String, String}}(), stdout = Base.stdout, stderr = Base.stderr, max_worker_rss = get_max_worker_rss()) @@ -742,6 +747,11 @@ Several keyword arguments are also supported: - `custom_args`: Arbitrary value (typically a `NamedTuple`) forwarded to [`execute`](@ref). Lets callers thread per-run configuration into a custom `RecordType`'s `execute` method without going through `init_code`. +- `exename`, `exeflags`, `env`: Forwarded to every internal `addworker` call, so + they affect all default-pool workers (and any respawns). `exename` may be a + `String` or a `Cmd` — passing a `Cmd` lets callers wrap the julia invocation + with a tool such as `compute-sanitizer`. Custom workers created from inside a + `test_worker` hook are the caller's responsibility. - `stdout` and `stderr`: I/O streams to write to (default: `Base.stdout` and `Base.stderr`) - `max_worker_rss`: RSS threshold where a worker will be restarted once it is reached. @@ -821,6 +831,9 @@ function runtests(mod::Module, args::ParsedArgs; init_code = :(), init_worker_code = :(), test_worker = Returns(nothing), RecordType::Type{<:AbstractTestRecord} = TestRecord, custom_args = (;), + exename = nothing, + exeflags = nothing, + env = Vector{Pair{String, String}}(), stdout = Base.stdout, stderr = Base.stderr, max_worker_rss = get_max_worker_rss()) # # set-up @@ -1077,7 +1090,8 @@ function runtests(mod::Module, args::ParsedArgs; end # if a worker failed, spawn a new one if wrkr === nothing || !Malt.isrunning(wrkr) - wrkr = p = addworker(; init_worker_code, io_ctx.color) + wrkr = p = addworker(; init_worker_code, io_ctx.color, + exename, exeflags, env) end # run the test diff --git a/test/runtests.jl b/test/runtests.jl index e205236..520fa45 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -225,6 +225,30 @@ end @test contains(str, "SUCCESS") end +@testset "global worker kwargs" begin + # `exename`/`exeflags`/`env` on runtests should propagate to every + # default-pool worker. We verify via an environment variable propagated + # through `env`, Julia flags threaded through `exeflags`, and `exename` + # supplied as a `Cmd` prefixing the julia binary (what CUDA.jl uses to + # wrap julia with `compute-sanitizer`). + testsuite = Dict( + "env var" => quote + @test ENV["GLOBAL_WORKER_TEST"] == "yes" + end, + "threads" => quote + @test Base.Threads.nthreads() == 2 + end, + ) + io = IOBuffer() + runtests(ParallelTestRunner, ["--verbose"]; testsuite, + env = ["GLOBAL_WORKER_TEST" => "yes"], + exeflags = ["--threads=2"], + exename = `$(Base.julia_cmd()[1])`, # trivial Cmd wrapping julia + stdout = io, stderr = io) + str = String(take!(io)) + @test contains(str, "SUCCESS") +end + @testset "custom worker with `init_worker_code`" begin init_worker_code = quote should_be_defined() = true From 9d01d495b073af14c101ccb3674de2161e52ebaa Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 17 Apr 2026 16:10:08 +0200 Subject: [PATCH 3/6] Release v2.6.0. Adds custom record types (`RecordType` + `custom_args` + dispatchable `execute`, print methods generalized to `AbstractTestRecord`) and global worker configuration on `runtests` (`exename`/`exeflags`/`env` threaded into every internal `addworker` call). Both features are additive. Co-Authored-By: Claude Opus 4.7 (1M context) --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3d7afa6..bf694b7 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "ParallelTestRunner" uuid = "d3525ed8-44d0-4b2c-a655-542cee43accc" authors = ["Valentin Churavy "] -version = "2.5.1" +version = "2.6.0" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" From 07025b38c8b6f91695cae4a23a28186a7df45cc4 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Sat, 18 Apr 2026 10:16:22 +0200 Subject: [PATCH 4/6] Compose custom test records around TestRecord. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of requiring `AbstractTestRecord` subtypes to redeclare every baseline field, treat `TestRecord` as the canonical baseline and have wrappers carry a `base::TestRecord` field. `Base.parent` is the accessor; default `print_*` and `memory_usage` route through it so wrapped records inherit the standard output unchanged. `execute` implementations delegate to `execute(TestRecord, …)` and wrap the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ParallelTestRunner.jl | 49 +++++++++++++++++++++++++++------------ test/runtests.jl | 34 +++++++-------------------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index 47a1b55..e0b9468 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -93,12 +93,24 @@ struct TestRecord <: AbstractTestRecord total_time::Float64 end +""" + parent(rec::AbstractTestRecord) -> TestRecord + +Return the [`TestRecord`](@ref) baseline that a custom record type wraps. By +default, subtypes of `AbstractTestRecord` are expected to carry a +`base::TestRecord` field; override `parent` for a different layout. The default +`print_*` methods read baseline fields through `parent`, so wrapped types +inherit the standard output unchanged. +""" +Base.parent(rec::TestRecord) = rec +Base.parent(rec::AbstractTestRecord) = rec.base + function memory_usage(rec::AbstractTestRecord) - return rec.rss + return parent(rec).rss end function Base.getindex(rec::AbstractTestRecord) - return rec.value + return parent(rec).value end @@ -175,31 +187,32 @@ function print_test_started(::Type{<:AbstractTestRecord}, wrkr, test, ctx::TestI end function print_test_finished(record::AbstractTestRecord, wrkr, test, ctx::TestIOContext) + base = parent(record) lock(ctx.lock) try printstyled(ctx.stdout, test, color = :white) printstyled(ctx.stdout, lpad("($wrkr)", ctx.name_align - textwidth(test) + 1, " "), " │ ", color = :white) - time_str = @sprintf("%7.2f", record.time) + time_str = @sprintf("%7.2f", base.time) printstyled(ctx.stdout, lpad(time_str, ctx.elapsed_align, " "), " │ ", color = :white) if ctx.verbose # pre-testset time - init_time_str = @sprintf("%7.2f", record.total_time - record.time) + init_time_str = @sprintf("%7.2f", base.total_time - base.time) printstyled(ctx.stdout, lpad(init_time_str, ctx.elapsed_align, " "), " │ ", color = :white) # compilation time if VERSION >= v"1.11" - init_time_str = @sprintf("%7.2f", Float64(100*record.compile_time/record.time)) + init_time_str = @sprintf("%7.2f", Float64(100*base.compile_time/base.time)) printstyled(ctx.stdout, lpad(init_time_str, ctx.compile_align, " "), " │ ", color = :white) end end - gc_str = @sprintf("%5.2f", record.gctime) + gc_str = @sprintf("%5.2f", base.gctime) printstyled(ctx.stdout, lpad(gc_str, ctx.gc_align, " "), " │ ", color = :white) - percent_str = @sprintf("%4.1f", 100 * record.gctime / record.time) + percent_str = @sprintf("%4.1f", 100 * base.gctime / base.time) printstyled(ctx.stdout, lpad(percent_str, ctx.percent_align, " "), " │ ", color = :white) - alloc_str = @sprintf("%5.2f", record.bytes / 2^20) + alloc_str = @sprintf("%5.2f", base.bytes / 2^20) printstyled(ctx.stdout, lpad(alloc_str, ctx.alloc_align, " "), " │ ", color = :white) rss_str = @sprintf("%5.2f", memory_usage(record) / 2^20) @@ -212,6 +225,7 @@ function print_test_finished(record::AbstractTestRecord, wrkr, test, ctx::TestIO end function print_test_failed(record::AbstractTestRecord, wrkr, test, ctx::TestIOContext) + base = parent(record) lock(ctx.lock) try printstyled(ctx.stderr, test, color = :red) @@ -221,11 +235,11 @@ function print_test_failed(record::AbstractTestRecord, wrkr, test, ctx::TestIOCo , color = :red ) - time_str = @sprintf("%7.2f", record.time) + time_str = @sprintf("%7.2f", base.time) printstyled(ctx.stderr, lpad(time_str, ctx.elapsed_align + 1, " "), " │", color = :red) if ctx.verbose - init_time_str = @sprintf("%7.2f", record.total_time - record.time) + init_time_str = @sprintf("%7.2f", base.total_time - base.time) printstyled(ctx.stdout, lpad(init_time_str, ctx.elapsed_align + 1, " "), " │ ", color = :red) end @@ -739,11 +753,16 @@ Several keyword arguments are also supported: - `test_worker`: Optional function that takes a test name and `init_worker_code` if `init_worker_code` is defined and returns a specific worker. When returning `nothing`, the test will be assigned to any available default worker. - `RecordType`: Concrete subtype of [`AbstractTestRecord`](@ref) used to collect - per-test statistics. Defaults to [`TestRecord`](@ref). Users can subtype - `AbstractTestRecord` and dispatch [`execute`](@ref) on their type to customize - what's measured per test; they typically also override the `print_*` methods. - The record type must be defined on both the main process and all workers (e.g. - via `init_worker_code`) since it crosses the Malt serialization boundary. + per-test statistics. Defaults to [`TestRecord`](@ref). To extend the default + record with extra data, define `struct MyRecord <: AbstractTestRecord; + base::TestRecord; …; end` and dispatch [`execute`](@ref) on the new type — + typically by calling `execute(TestRecord, mod, f, name, start_time, + custom_args)` and wrapping the result. The default `print_*` methods read + baseline fields through [`parent`](@ref), so wrapped types inherit the + standard output; override `print_*` only when you need different layout. + The record type must be defined on both the main process and all workers + (e.g. via `init_worker_code`) since it crosses the Malt serialization + boundary. - `custom_args`: Arbitrary value (typically a `NamedTuple`) forwarded to [`execute`](@ref). Lets callers thread per-run configuration into a custom `RecordType`'s `execute` method without going through `init_code`. diff --git a/test/runtests.jl b/test/runtests.jl index 520fa45..7b4ce92 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -124,39 +124,21 @@ end end @testset "custom record type" begin - # Define a custom record type with an extra field. Defining it on both the - # main process and the workers (via init_worker_code) is required since the - # record crosses the Malt serialization boundary. The inner `$name`/`$f` in - # the `execute` method's quote need `$(Expr(:$, …))` escaping since they live - # inside the outer `init_worker_code` quote. + # Custom record wraps the default `TestRecord` and adds one field. It must + # be defined on both the main process and every worker (via + # init_worker_code) because the record crosses the Malt serialization + # boundary. init_worker_code = quote - using ParallelTestRunner: Test - using Test: DefaultTestSet + using ParallelTestRunner: TestRecord struct MyRecord <: ParallelTestRunner.AbstractTestRecord - value::DefaultTestSet - time::Float64 - bytes::UInt64 - gctime::Float64 - compile_time::Float64 - rss::UInt64 - total_time::Float64 + base::TestRecord extra::String end function ParallelTestRunner.execute( ::Type{MyRecord}, mod::Module, f, name, start_time, custom_args, ) - data = @eval mod begin - GC.gc(true) - stats = @timed @testset WorkerTestSet "placeholder" begin - @testset DefaultTestSet $(Expr(:$, :name)) begin - $(Expr(:$, :f)) - end - end - compile_time = @static VERSION >= v"1.11" ? stats.compile_time : 0.0 - (; testset = stats.value, stats.time, stats.bytes, stats.gctime, compile_time) - end - rss = Sys.maxrss() - MyRecord(data..., rss, time() - start_time, custom_args.tag) + base = ParallelTestRunner.execute(TestRecord, mod, f, name, start_time, custom_args) + MyRecord(base, custom_args.tag) end function ParallelTestRunner.print_test_finished( record::MyRecord, wrkr, test, ctx::ParallelTestRunner.TestIOContext, From ba6b07b4f4180ec25b0c164c0ff519f0593d57bc Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Sat, 18 Apr 2026 10:22:51 +0200 Subject: [PATCH 5/6] Fix remaining direct field access on AbstractTestRecord. `result.value` in the end-of-run output dump bypassed the accessor introduced in the composition refactor, crashing on non-`TestRecord` subtypes. Route through `result[]` to match the convention already used elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ParallelTestRunner.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index e0b9468..fdfc03f 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -1224,7 +1224,7 @@ function runtests(mod::Module, args::ParsedArgs; for (testname, result, output, _start, _stop) in results if !isempty(output) print(io_ctx.stdout, "\nOutput generated during execution of '") - if result isa Exception || anynonpass(result.value) + if result isa Exception || anynonpass(result[]) printstyled(io_ctx.stdout, testname; color=:red) else printstyled(io_ctx.stdout, testname; color=:normal) From 5f16e5bcd3d422c65dd13aef9723e55a14926039 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Sat, 18 Apr 2026 13:00:30 +0200 Subject: [PATCH 6/6] Update docs. --- docs/src/api.md | 14 ++++++++++++ src/ParallelTestRunner.jl | 47 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/src/api.md b/docs/src/api.md index 9890f75..47cbe3d 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -39,6 +39,20 @@ addworkers default_njobs ``` +## Custom Records + +Per-test data is captured in an [`AbstractTestRecord`](@ref). The default +[`TestRecord`](@ref) stores timing and memory statistics; subtypes can wrap it +to collect additional data (e.g. GPU metrics) by dispatching [`execute`](@ref) +on the new type and reading the baseline through [`parent`](@ref). + +```@docs +AbstractTestRecord +TestRecord +execute +parent(::ParallelTestRunner.AbstractTestRecord) +``` + ## Internal Types These are internal types, not subject to semantic versioning contract (could be changed or removed at any point without notice), not intended for consumption by end-users. diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index fdfc03f..e8c97f7 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -79,8 +79,27 @@ if VERSION >= v"1.13.0-DEV.1044" using Base.ScopedValues end +""" + AbstractTestRecord + +Abstract supertype for per-test result records. [`TestRecord`](@ref) is the +default concrete subtype, carrying the captured test set and baseline timing / +memory statistics. Custom subtypes can attach extra per-test data (e.g. GPU +statistics) by carrying a `base::TestRecord` field and dispatching +[`execute`](@ref) on the new type. See the `RecordType` argument of +[`runtests`](@ref) for how to plug a custom record type into a run. +""" abstract type AbstractTestRecord end +""" + TestRecord <: AbstractTestRecord + +Default per-test record. Holds the captured `DefaultTestSet` alongside the +baseline timing and memory statistics that [`runtests`](@ref) prints and +persists. Custom [`AbstractTestRecord`](@ref) subtypes wrap a `TestRecord` in a +`base` field; [`parent`](@ref) returns that baseline so the default `print_*` +methods work unchanged. +""" struct TestRecord <: AbstractTestRecord value::DefaultTestSet @@ -102,8 +121,8 @@ default, subtypes of `AbstractTestRecord` are expected to carry a `print_*` methods read baseline fields through `parent`, so wrapped types inherit the standard output unchanged. """ -Base.parent(rec::TestRecord) = rec Base.parent(rec::AbstractTestRecord) = rec.base +Base.parent(rec::TestRecord) = rec function memory_usage(rec::AbstractTestRecord) return parent(rec).rss @@ -327,6 +346,32 @@ function Test.finish(ts::WorkerTestSet) return ts.wrapped_ts end +""" + execute(::Type{R}, mod::Module, f, name, start_time, custom_args) where {R<:AbstractTestRecord} + +Run the test expression `f` inside the sandbox module `mod` and return an +`R <: AbstractTestRecord`. This is the extension point for custom record +types: dispatch `execute(::Type{MyRecord}, …)` to collect additional per-test +statistics without re-implementing the sandbox scaffolding. + +The default method for [`TestRecord`](@ref) wraps the test set in a +[`WorkerTestSet`](@ref) placeholder (so `DefaultTestSet` doesn't swallow +results at the top level), captures `@timed` stats, and records `Sys.maxrss()`. +Custom implementations commonly call `execute(TestRecord, mod, f, name, +start_time, custom_args)` to reuse that baseline and wrap the returned record +in a new record type. + +Arguments: + +- `mod` — the per-test sandbox module; the test expression `f` is evaluated + into it via `@eval mod`. +- `f` — the test expression from the `testsuite` dictionary. +- `name` — the test name (used as the top-level `@testset` name). +- `start_time` — wall-clock time at which the scheduler picked up this test; + subtract from `time()` to get total elapsed time including worker wait. +- `custom_args` — the `custom_args` value forwarded from [`runtests`](@ref) + (arbitrary, typically a `NamedTuple`). +""" function execute(::Type{TestRecord}, mod::Module, f, name, start_time, custom_args) data = @eval mod begin GC.gc(true)