From b3a453f9dc8642d4265830ad7251459756c79c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mos=C3=A8=20Giordano?= Date: Sun, 9 Aug 2026 00:21:40 +0100 Subject: [PATCH 1/3] Fix StringIndexError when truncating the status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line listing running tests was truncated by slicing with byte indices after checking the character length, so a multi-byte character straddling the cut point threw a StringIndexError. This killed the printer task, which then surfaced as a TaskFailedException from finalization — and the truncation is computed even when stdout is not a TTY, so CI runs with long non-ASCII test names were exposed too. Extract the truncation into a `truncate_line` helper that slices by character, and unit-test it. Co-Authored-By: Claude Fable 5 --- src/ParallelTestRunner.jl | 14 +++++++++++--- test/runtests.jl | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index 45f8f3b..769f980 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -313,6 +313,16 @@ function print_test_crashed(::Type{<:AbstractTestRecord}, wrkr, test, ctx::TestI end end +# Truncate `line` to at most `max_width` characters, appending "..." when truncated. +# Slicing by character (rather than by byte index) keeps this safe for test names +# containing multi-byte characters. +function truncate_line(line::AbstractString, max_width::Int) + if length(line) > max_width + line = first(line, max(0, max_width - 3)) * "..." + end + return line +end + # Adapted from `Malt._stdio_loop` function stdio_loop(worker::Malt.Worker, io::Lockable) Threads.@spawn while !eof(worker.stdout) && Malt.isrunning(worker) @@ -1191,9 +1201,7 @@ function _runtests(mod::Module, args::ParsedArgs; line2 = "Running: " * join(status_parts, ", ") ## truncate max_width = displaysize(io_ctx.stdout)[2] - if length(line2) > max_width - line2 = line2[1:max_width-3] * "..." - end + line2 = truncate_line(line2, max_width) # line 3: progress + ETA line3 = "Progress: $completed/$total tests completed" diff --git a/test/runtests.jl b/test/runtests.jl index 1037322..5d060ec 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -884,6 +884,26 @@ end @test any(contains("--color=yes"), exe.exec) end +@testset "truncate_line" begin + # short lines are untouched + @test ParallelTestRunner.truncate_line("short", 80) == "short" + @test ParallelTestRunner.truncate_line("x"^80, 80) == "x"^80 + + truncated = ParallelTestRunner.truncate_line("x"^100, 80) + @test length(truncated) == 80 + @test endswith(truncated, "...") + + # multi-byte characters must not break the cut: with byte indexing these + # would throw a StringIndexError when the cut lands mid-character + truncated = ParallelTestRunner.truncate_line("t" * "α"^100, 80) + @test length(truncated) == 80 + @test endswith(truncated, "...") + + truncated = ParallelTestRunner.truncate_line("€"^100, 40) + @test length(truncated) == 40 + @test endswith(truncated, "...") +end + @testset "TestHistoryEntry" begin flow = ParallelTestRunner.TestHistoryEntry(1,true) fhigh = ParallelTestRunner.TestHistoryEntry(10,true) From 7399971be4afb12b978672d6460a4c91d6c698f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mos=C3=A8=20Giordano?= Date: Sun, 9 Aug 2026 00:26:21 +0100 Subject: [PATCH 2/3] Print the whole failed-test row to stderr In verbose mode, `print_test_failed` wrote the init-time cell to `ctx.stdout` while every other part of the row went to `ctx.stderr`, splitting a single row across the two streams when they are redirected separately. Co-Authored-By: Claude Fable 5 --- src/ParallelTestRunner.jl | 2 +- test/runtests.jl | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index 769f980..731d7fe 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -280,7 +280,7 @@ function print_test_failed(record::AbstractTestRecord, wrkr, test, ctx::TestIOCo if ctx.verbose init_time_str = @sprintf("%7.2f", base.total_time - base.time) - printstyled(ctx.stdout, lpad(init_time_str, ctx.elapsed_align + 1, " "), " │ ", color = :red) + printstyled(ctx.stderr, lpad(init_time_str, ctx.elapsed_align + 1, " "), " │ ", color = :red) end failed_str = "failed at $(now())\n" diff --git a/test/runtests.jl b/test/runtests.jl index 5d060ec..1cb7314 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -346,6 +346,30 @@ end @test contains(str, "1 == 2") end +@testset "failed test row printed entirely to stderr" begin + testsuite = Dict( + "failing test" => quote + @test 1 == 2 + end + ) + + # use separate streams: the whole row of a failed test must go to stderr + out = IOBuffer() + err = IOBuffer() + @test_throws Test.FallbackTestSetException("Test run finished with errors") begin + runtests(ParallelTestRunner, ["--verbose"]; testsuite, stdout=out, stderr=err) + end + + outs = String(take!(out)) + errs = String(take!(err)) + m = match(r"failing test[^\n]*failed at", errs) + @test m !== nothing + # in verbose mode the row has three cell separators (time, init time) on + # stderr; the init-time cell used to leak to stdout instead + @test m !== nothing && count("│", m.match) == 3 + @test !contains(outs, "failed at") +end + @testset "nested failure" begin testsuite = Dict( "nested" => quote From 63b296e34c4f3badabd124c4b9f32cfa3390cacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mos=C3=A8=20Giordano?= Date: Sun, 9 Aug 2026 00:40:00 +0100 Subject: [PATCH 3/3] Remove too much information in comment --- src/ParallelTestRunner.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ParallelTestRunner.jl b/src/ParallelTestRunner.jl index 731d7fe..8baee3e 100644 --- a/src/ParallelTestRunner.jl +++ b/src/ParallelTestRunner.jl @@ -314,8 +314,6 @@ function print_test_crashed(::Type{<:AbstractTestRecord}, wrkr, test, ctx::TestI end # Truncate `line` to at most `max_width` characters, appending "..." when truncated. -# Slicing by character (rather than by byte index) keeps this safe for test names -# containing multi-byte characters. function truncate_line(line::AbstractString, max_width::Int) if length(line) > max_width line = first(line, max(0, max_width - 3)) * "..."