From 75a71b5bc504e9eb791f15d9e0fbf9bcfc305512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Tue, 21 Jul 2026 11:13:29 +0200 Subject: [PATCH 1/4] Make persistent task test more robust - Reports a precompilation failure as a precompilation error instead of misclassifying it as a persistent task. - Set the default for `tmax` consistently to 30 seconds to reduce false positives when e.g. the system load is high. Fixes: #315 --- CHANGELOG.md | 5 +++ docs/src/persistent_tasks.md | 16 +++++++- src/persistent_tasks.jl | 69 +++++++++++++++++++++++++++-------- test/test_persistent_tasks.jl | 18 +++++---- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1838245d..10b4b911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The minimum supported julia version is increased to 1.6. ([#328]) +- `test_persistent_tasks` now reports a precompilation failure as a precompilation + error instead of misclassifying it as a persistent task. The default `tmax` is + also raised to 30 seconds (still configurable) to reduce false positives when a + package without persistent tasks is merely slow to shut down. ([#315]) ## Version [v0.8.16] - 2026-06-05 @@ -350,6 +354,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#297]: https://github.com/JuliaTesting/Aqua.jl/issues/297 [#309]: https://github.com/JuliaTesting/Aqua.jl/issues/309 [#313]: https://github.com/JuliaTesting/Aqua.jl/issues/313 +[#315]: https://github.com/JuliaTesting/Aqua.jl/issues/315 [#319]: https://github.com/JuliaTesting/Aqua.jl/issues/319 [#322]: https://github.com/JuliaTesting/Aqua.jl/issues/322 [#328]: https://github.com/JuliaTesting/Aqua.jl/issues/328 diff --git a/docs/src/persistent_tasks.md b/docs/src/persistent_tasks.md index 5fe3e107..6f86d00f 100644 --- a/docs/src/persistent_tasks.md +++ b/docs/src/persistent_tasks.md @@ -84,8 +84,20 @@ end) This test works by launching a Julia process that tries to precompile a dummy package similar to `PkgB` above, modified to signal back to Aqua when -`PkgA` has finished loading. The test fails if the gap between loading `PkgA` -and finishing precompilation exceeds time `tmax`. +`PkgA` has finished loading. + +Aqua then waits — without any time limit — for that signal, so slow, cold, or +`--check-bounds=yes` precompilation of the dependencies never affects the +result. Once `PkgA` has loaded, a persistent `Task` shows up as the +precompilation process being unable to write its cache and exit: it hangs +indefinitely. A package without persistent tasks always exits eventually, so +Aqua waits up to `tmax` seconds for a clean shutdown before reporting a failure. +With many or slow-to-precompile dependencies this shutdown can be slow, so if a +package you know to be free of persistent tasks is misreported, increase `tmax`. + +If precompilation instead fails outright (for example because a dependency +cannot be precompiled), that is reported as a precompilation error rather than a +persistent task, so the failure message points at the real cause. ## How to fix failing packages diff --git a/src/persistent_tasks.jl b/src/persistent_tasks.jl index 2daef554..f1354fb5 100644 --- a/src/persistent_tasks.jl +++ b/src/persistent_tasks.jl @@ -18,9 +18,16 @@ On Julia version 1.9 and before, this test always succeeds. # Keyword Arguments - `broken::Bool = false`: If true, it uses `@test_broken` instead of `@test`. -- `tmax::Real = 5`: the maximum time (in seconds) to wait after loading the - package before forcibly shutting down the precompilation process (triggering - a test failure). +- `tmax::Real = 30`: the maximum time (in seconds) to wait for the + precompilation process to exit *after* `package` has finished loading, before + concluding that a persistent `Task` is holding the process open (triggering a + test failure). Only the shutdown of an already-loaded package counts against + this budget; the time spent precompiling and loading the dependencies + does not. A persistent `Task` blocks precompilation indefinitely, whereas a + healthy package always exits eventually, so a slow-but-clean shutdown with + many or cold-cached dependencies (for example under `Pkg.test`'s + `--check-bounds=yes`) is *not* a persistent task. If such a package is + misreported, increase `tmax`. - `expr::Expr = quote end`: An expression to run in the precompile package. !!! note @@ -44,7 +51,7 @@ function test_persistent_tasks(package::Module; kwargs...) test_persistent_tasks(PkgId(package); kwargs...) end -function has_persistent_tasks(package::PkgId; expr::Expr = quote end, tmax = 10) +function has_persistent_tasks(package::PkgId; expr::Expr = quote end, tmax = 30) root_project_path, found = root_project_toml(package) found || error("Unable to locate Project.toml") return !precompile_wrapper(root_project_path, tmax, expr) @@ -118,25 +125,55 @@ end code = """touch("$(escape_string(statusfile))")""" `$(Base.julia_cmd()) -e $code` else - `$(Base.julia_cmd()) --project=$wrapperdir -e 'push!(LOAD_PATH, "@stdlib"); using Pkg; Pkg.precompile(; io = devnull)'` + `$(Base.julia_cmd()) --project=$wrapperdir -e 'push!(LOAD_PATH, "@stdlib"); using Pkg; Pkg.precompile()'` end - cmd = pipeline(cmd; stdout, stderr) + # Capture the subprocess's stderr so that a genuine precompilation error + # can be distinguished from a persistent task and reported on its own terms + # instead of masquerading as a persistent-task failure. `Pkg.precompile` + # writes its error report to stderr; the capture is reported only when + # precompilation fails. stdout is discarded to keep a passing run quiet. + errlog = joinpath(wrapperdir, "precompile-stderr.log") + cmd = pipeline(cmd; stdout = devnull, stderr = errlog) proc = run(cmd; wait = false)::Base.Process - while !isfile(statusfile) && process_running(proc) - sleep(0.5) - end + + # Phase 1 (unbounded): wait for the package to finish loading. The wrapper + # writes `statusfile` from inside precompilation once `using $pkgname` (and + # any `expr`) has run. Slow, cold, or `--check-bounds=yes` precompilation of + # the dependencies only prolongs this phase; it never counts against + # the persistent-task verdict. + timedwait(() -> isfile(statusfile) || !process_running(proc), Inf; pollint = 0.5) if !isfile(statusfile) - @error "Unexpected error: $statusfile was not created, but precompilation exited" - return false - end - # Check whether precompilation finishes in the required time - t = time() - while process_running(proc) && time() - t < tmax - sleep(0.1) + # The process exited before the package finished loading. This is a + # precompilation failure in `$pkgname` or one of its dependencies, not + # a persistent task, so report it as its own error rather than a + # misleading persistent-task result. + wait(proc) + error( + "Loading `$pkgname` for the persistent-task check failed before " * + "precompilation completed (process exited with code " * + "$(proc.exitcode)). This indicates a precompilation error, not a " * + "persistent task. Captured output:\n\n" * + (isfile(errlog) ? read(errlog, String) : ""), + ) end + + # Phase 2 (bounded by `tmax`): the package loaded cleanly. A persistent task + # keeps the precompilation process from ever writing its cache and exiting, + # so it hangs indefinitely. A healthy package exits once cache serialization + # and runtime teardown finish; with many or cold-cached dependencies this + # can still take a while, so allow up to `tmax` seconds before concluding + # that a task is holding the process open. + timedwait(() -> !process_running(proc), tmax; pollint = 0.1) success = !process_running(proc) if !success + @warn( + "Loading `$pkgname` prevented the precompilation process from " * + "exiting within $tmax seconds, which usually means a persistent " * + "task is still running. If `$pkgname` merely has many or " * + "slow-to-precompile dependencies, a clean shutdown may need " * + "more time; re-run with a larger `tmax` to rule that out." + ) # SIGKILL to prevent julia from printing the SIG 15 handler, which can # misleadingly look like it's caused by an issue in the user's program. kill(proc, Base.SIGKILL) diff --git a/test/test_persistent_tasks.jl b/test/test_persistent_tasks.jl index 1339f159..6e288b6b 100644 --- a/test/test_persistent_tasks.jl +++ b/test/test_persistent_tasks.jl @@ -21,9 +21,9 @@ end @test result == [] if Base.VERSION >= v"1.10-" - @test Aqua.has_persistent_tasks(getid("PersistentTask")) + @test Aqua.has_persistent_tasks(getid("PersistentTask"); tmax = 2) - result = Aqua.find_persistent_tasks_deps(getid("UsesBoth")) + result = Aqua.find_persistent_tasks_deps(getid("UsesBoth"); tmax = 2) @test result == ["PersistentTask"] end filter!(str -> !occursin("PersistentTasks", str), LOAD_PATH) @@ -37,11 +37,15 @@ end fetch(Threads.@spawn nothing) end, ) - @test Aqua.has_persistent_tasks(getid("TransientTask"), expr = quote - Threads.@spawn while true - sleep(0.5) - end - end) + @test Aqua.has_persistent_tasks( + getid("TransientTask"), + tmax = 2, + expr = quote + Threads.@spawn while true + sleep(0.5) + end + end, + ) end end From a82d8e0da15d0f9f4f272c102ea0e27cea234760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Tue, 21 Jul 2026 15:18:56 +0200 Subject: [PATCH 2/4] Test failing precompilation This tests the persistent task logic. --- .../PersistentTasks/FailsToPrecompile/Project.toml | 2 ++ .../FailsToPrecompile/src/FailsToPrecompile.jl | 7 +++++++ test/test_persistent_tasks.jl | 13 ++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 test/pkgs/PersistentTasks/FailsToPrecompile/Project.toml create mode 100644 test/pkgs/PersistentTasks/FailsToPrecompile/src/FailsToPrecompile.jl diff --git a/test/pkgs/PersistentTasks/FailsToPrecompile/Project.toml b/test/pkgs/PersistentTasks/FailsToPrecompile/Project.toml new file mode 100644 index 00000000..a1d82983 --- /dev/null +++ b/test/pkgs/PersistentTasks/FailsToPrecompile/Project.toml @@ -0,0 +1,2 @@ +name = "FailsToPrecompile" +uuid = "f9ce74f8-1127-4e05-8a6a-300e168e70af" diff --git a/test/pkgs/PersistentTasks/FailsToPrecompile/src/FailsToPrecompile.jl b/test/pkgs/PersistentTasks/FailsToPrecompile/src/FailsToPrecompile.jl new file mode 100644 index 00000000..7771db5e --- /dev/null +++ b/test/pkgs/PersistentTasks/FailsToPrecompile/src/FailsToPrecompile.jl @@ -0,0 +1,7 @@ +module FailsToPrecompile + +# Fail during precompilation so the persistent-task check hits its +# precompilation-error branch instead of loading the package successfully. +error("Intentional precompilation failure for testing Aqua's persistent-task check") + +end diff --git a/test/test_persistent_tasks.jl b/test/test_persistent_tasks.jl index 6e288b6b..0dc14cb9 100644 --- a/test/test_persistent_tasks.jl +++ b/test/test_persistent_tasks.jl @@ -26,7 +26,18 @@ end result = Aqua.find_persistent_tasks_deps(getid("UsesBoth"); tmax = 2) @test result == ["PersistentTask"] end - filter!(str -> !occursin("PersistentTasks", str), LOAD_PATH) + filter!(!occursin("PersistentTasks"), LOAD_PATH) +end + +@testset "precompilation failure is reported as an error" begin + if Base.VERSION >= v"1.10-" + # A package that fails to precompile must be reported as a + # precompilation error rather than misclassified as a persistent task. + @test_throws "precompilation error" Aqua.has_persistent_tasks( + getid("FailsToPrecompile"), + ) + end + filter!(!occursin("PersistentTasks"), LOAD_PATH) end @testset "test_persistent_tasks(expr)" begin From 405d9e88845fcbd93761b9d7526f3240a719a81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Mon, 3 Aug 2026 16:37:25 +0200 Subject: [PATCH 3/4] Remove incorrect shutdown description and shorten comments --- docs/src/persistent_tasks.md | 12 ++++------ src/persistent_tasks.jl | 44 ++++++++++++------------------------ 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/docs/src/persistent_tasks.md b/docs/src/persistent_tasks.md index 6f86d00f..2de1aaa1 100644 --- a/docs/src/persistent_tasks.md +++ b/docs/src/persistent_tasks.md @@ -86,13 +86,11 @@ This test works by launching a Julia process that tries to precompile a dummy package similar to `PkgB` above, modified to signal back to Aqua when `PkgA` has finished loading. -Aqua then waits — without any time limit — for that signal, so slow, cold, or -`--check-bounds=yes` precompilation of the dependencies never affects the -result. Once `PkgA` has loaded, a persistent `Task` shows up as the -precompilation process being unable to write its cache and exit: it hangs -indefinitely. A package without persistent tasks always exits eventually, so -Aqua waits up to `tmax` seconds for a clean shutdown before reporting a failure. -With many or slow-to-precompile dependencies this shutdown can be slow, so if a +Aqua waits — without any time limit — for that signal, so slow precompilation of +the dependencies never affects the result. Once `PkgA` has loaded, a persistent +`Task` shows up as the precompilation process being unable to exit: it hangs +indefinitely. A package without persistent tasks always exits eventually, so Aqua +waits up to `tmax` seconds for its shutdown before reporting a failure. If a package you know to be free of persistent tasks is misreported, increase `tmax`. If precompilation instead fails outright (for example because a dependency diff --git a/src/persistent_tasks.jl b/src/persistent_tasks.jl index f1354fb5..67231ae7 100644 --- a/src/persistent_tasks.jl +++ b/src/persistent_tasks.jl @@ -19,15 +19,10 @@ On Julia version 1.9 and before, this test always succeeds. - `broken::Bool = false`: If true, it uses `@test_broken` instead of `@test`. - `tmax::Real = 30`: the maximum time (in seconds) to wait for the - precompilation process to exit *after* `package` has finished loading, before - concluding that a persistent `Task` is holding the process open (triggering a - test failure). Only the shutdown of an already-loaded package counts against - this budget; the time spent precompiling and loading the dependencies - does not. A persistent `Task` blocks precompilation indefinitely, whereas a - healthy package always exits eventually, so a slow-but-clean shutdown with - many or cold-cached dependencies (for example under `Pkg.test`'s - `--check-bounds=yes`) is *not* a persistent task. If such a package is - misreported, increase `tmax`. + precompilation process to exit *after* `package` has finished loading. Only + this shutdown counts against `tmax`, not the time spent loading the + dependencies. A persistent `Task` blocks the exit indefinitely, so if a + package free of persistent tasks is misreported, increase `tmax`. - `expr::Expr = quote end`: An expression to run in the precompile package. !!! note @@ -128,26 +123,19 @@ end `$(Base.julia_cmd()) --project=$wrapperdir -e 'push!(LOAD_PATH, "@stdlib"); using Pkg; Pkg.precompile()'` end - # Capture the subprocess's stderr so that a genuine precompilation error - # can be distinguished from a persistent task and reported on its own terms - # instead of masquerading as a persistent-task failure. `Pkg.precompile` - # writes its error report to stderr; the capture is reported only when - # precompilation fails. stdout is discarded to keep a passing run quiet. + # Capture the subprocess's stderr so a genuine precompilation error can be + # reported on its own terms instead of masquerading as a persistent task. errlog = joinpath(wrapperdir, "precompile-stderr.log") cmd = pipeline(cmd; stdout = devnull, stderr = errlog) proc = run(cmd; wait = false)::Base.Process # Phase 1 (unbounded): wait for the package to finish loading. The wrapper - # writes `statusfile` from inside precompilation once `using $pkgname` (and - # any `expr`) has run. Slow, cold, or `--check-bounds=yes` precompilation of - # the dependencies only prolongs this phase; it never counts against - # the persistent-task verdict. + # writes `statusfile` once `using $pkgname` (and any `expr`) has run. Slow + # precompilation of the dependencies only prolongs this phase. timedwait(() -> isfile(statusfile) || !process_running(proc), Inf; pollint = 0.5) if !isfile(statusfile) - # The process exited before the package finished loading. This is a - # precompilation failure in `$pkgname` or one of its dependencies, not - # a persistent task, so report it as its own error rather than a - # misleading persistent-task result. + # The process exited before the package finished loading: a + # precompilation failure, not a persistent task. wait(proc) error( "Loading `$pkgname` for the persistent-task check failed before " * @@ -159,20 +147,16 @@ end end # Phase 2 (bounded by `tmax`): the package loaded cleanly. A persistent task - # keeps the precompilation process from ever writing its cache and exiting, - # so it hangs indefinitely. A healthy package exits once cache serialization - # and runtime teardown finish; with many or cold-cached dependencies this - # can still take a while, so allow up to `tmax` seconds before concluding - # that a task is holding the process open. + # keeps the process from exiting, so it hangs indefinitely. A healthy package + # exits once its shutdown finishes, so allow up to `tmax` seconds for it. timedwait(() -> !process_running(proc), tmax; pollint = 0.1) success = !process_running(proc) if !success @warn( "Loading `$pkgname` prevented the precompilation process from " * "exiting within $tmax seconds, which usually means a persistent " * - "task is still running. If `$pkgname` merely has many or " * - "slow-to-precompile dependencies, a clean shutdown may need " * - "more time; re-run with a larger `tmax` to rule that out." + "task is still running. If `$pkgname` is free of persistent tasks, " * + "re-run with a larger `tmax` to give its shutdown more time." ) # SIGKILL to prevent julia from printing the SIG 15 handler, which can # misleadingly look like it's caused by an issue in the user's program. From 5f3012113670958b754709ac837fef98663f904d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Mon, 3 Aug 2026 16:59:11 +0200 Subject: [PATCH 4/4] Add improvements from #390 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR #389 vs PR #390 **#389 (`more_robust_persistent_task`, this branch)** — Fixes the root cause: splits the wait into an *unbounded* load phase and a *`tmax`-bounded* shutdown phase, so dependency precompilation no longer counts toward the persistent-task verdict. Raises `tmax` 5→30. Reports a precompile failure by *throwing an error* (with captured stderr), not by mislabeling it a persistent task. **#390 (`report-precompile-failure-in-persistent-tasks`)** — Only reports *why* the probe failed: captures `Pkg.precompile`'s `io` output to a file and logs an `@error` with `exitcode`/`termsignal`, then returns `true`. Leaves the timing model and `tmax` untouched. **Why we took over only `termsignal`:** #389 already captures **stderr**, and Julia sends exceptions *and* warnings there (`Pkg.precompile`'s default is `io = stderr`). So #390's separate `io`-to-file capture is redundant — the same information through a different door. The only genuinely additive detail was `termsignal`, which distinguishes a signal-killed subprocess from a normal nonzero exit. --- src/persistent_tasks.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistent_tasks.jl b/src/persistent_tasks.jl index 67231ae7..d0f66a37 100644 --- a/src/persistent_tasks.jl +++ b/src/persistent_tasks.jl @@ -140,8 +140,8 @@ end error( "Loading `$pkgname` for the persistent-task check failed before " * "precompilation completed (process exited with code " * - "$(proc.exitcode)). This indicates a precompilation error, not a " * - "persistent task. Captured output:\n\n" * + "$(proc.exitcode), signal $(proc.termsignal)). This indicates a " * + "precompilation error, not a persistent task. Captured output:\n\n" * (isfile(errlog) ? read(errlog, String) : ""), ) end