From f8ae7322b3a4f8d5e7bc4df1d1db442bc79dc0f6 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 4 Nov 2026 22:17:53 -0700 Subject: [PATCH 1/4] test: cover a SIGINT landing in the reaper task Track the reaper task in REAPER_TASK so a test can reach it. The new testset throws an InterruptException into the reaper while it waits on its timer, the way a Ctrl-C does when the reaper is the task resident on thread 1, and asserts that the exception leaves the task and that the next ensure_reaper! call starts a reaper that still reaps. Before the fix the testset fails: the catch around wait(t) swallows the InterruptException, the task ends normally, and nothing restarts it. Co-Authored-By: Claude Fable 5.1 --- src/reaper.jl | 2 ++ test/protocol/native_tests.jl | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/reaper.jl b/src/reaper.jl index 4af60b3..7a43a99 100644 --- a/src/reaper.jl +++ b/src/reaper.jl @@ -36,6 +36,7 @@ const REAPER_LOCK = Threads.SpinLock() const REAPER_QUEUE = Ref{Union{Nothing, ReapEntry}}(nothing) const REAPER_QUEUE_LENGTH = Ref(0) const REAPER_TIMER = Ref{Union{Nothing, Timer}}(nothing) +const REAPER_TASK = Ref{Union{Nothing, Task}}(nothing) const REAPER_INTERVAL_S = 0.5 const REAPER_STATS = Ref((enqueued=0, closed=0)) @@ -162,6 +163,7 @@ function ensure_reaper!() task.sticky = false # reaper_loop handles timer shutdown and logs reclamation errors itself. # Base.errormonitor would add an untrimmable dynamic error-display task. + REAPER_TASK[] = task schedule(task) atexit(reaper_atexit) finally diff --git a/test/protocol/native_tests.jl b/test/protocol/native_tests.jl index 6fb202c..8b69621 100644 --- a/test/protocol/native_tests.jl +++ b/test/protocol/native_tests.jl @@ -476,6 +476,53 @@ end @test all(ref -> ref.value === nothing, refs) end +@testset "reaper: an interrupt leaves the task and the next registration restarts it" begin + N.ensure_reaper!() + reaper = N.REAPER_TASK[] + @test reaper isa Task + # Off the interactive pool, where the user's work runs when one exists, so a Ctrl-C + # (thrown into the task resident on thread 1) does not land in the reaper. + @test Threads.threadpool(reaper) === :default + timer = N.REAPER_TIMER[] + # Throw an InterruptException into the reaper while it is parked in `wait(timer)`, as a + # Ctrl-C does when the reaper is the task resident on thread 1. The timer's lock keeps + # the timer tick from waking the reaper between the check and the throw. + injected = false + deadline = time() + 15 + while !injected && time() < deadline + @lock timer.cond begin + if reaper.queue === timer.cond.waitq + schedule(reaper, InterruptException(); error=true) + injected = true + end + end + injected || sleep(0.01) + end + @test injected + deadline = time() + 15 + while !istaskdone(reaper) && time() < deadline + sleep(0.01) + end + # The interrupt leaves the task, so Base's task_done_hook can hand it to the REPL. + @test istaskfailed(reaper) + @test reaper.result isa InterruptException + # The next registration replaces the dead reaper, and the replacement reaps. + N.ensure_reaper!() + fresh = N.REAPER_TASK[] + @test fresh isa Task && fresh !== reaper && !istaskdone(fresh) + counter = P.FaultTransport(IOBuffer()) + entry = N.ReapEntry(counter) + while (@atomic entry.state) == :live + N.enqueue_from_finalizer!(entry) || yield() + end + deadline = time() + 15 + while (@atomic entry.state) != :closed && time() < deadline + sleep(0.05) + end + @test (@atomic entry.state) == :closed + @test (@atomic counter.close_count) == 1 +end + @testset "no descriptor growth across connect/close cycles" begin Sys.iswindows() && return multi_accept_server() do port From b1abda1e1cc9192bcf3b29ae000be9f36550aad6 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 4 Nov 2026 22:32:23 -0700 Subject: [PATCH 2/4] fix: let a SIGINT leave the reaper task and restart it on the next connect Julia throws a SIGINT's InterruptException into whichever task is resident on thread 1. The reaper wakes every 0.5 s and parks again, so during a long statement it is usually that task. Its catch around wait(t) took the InterruptException, ended the loop, and left the timer open, so the Ctrl-C never reached the user's task and nothing reaped again until exit. Remove the catch: the exception now leaves the task, which is what Base's task_done_hook needs to hand it to the REPL backend. Track the reaper task and let ensure_reaper! start a new one when the old one is done. Place the reaper on the default pool, so that when an interactive pool exists it never parks on thread 1 while the user's work runs there. Co-Authored-By: Claude Fable 5.1 --- docs/protocol-notes.md | 5 ++++- src/reaper.jl | 30 ++++++++++++++++++------------ test/protocol/native_tests.jl | 1 - 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 480a4b2..614db45 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -104,7 +104,10 @@ source are never read. - **Finalizers never do I/O**: a dropped `MySQL.Handle` enqueues its `ReapEntry` (CAS `:live → :pending`); the reaper (0.5 s timer, `reap_now!`, `atexit`) removes entries under `REAPER_LOCK`, then closes each transport after releasing the lock. `close!` retires the - entry first so a later finalizer is a no-op. Reseau's own poll-FD finalizer is the + entry first so a later finalizer is a no-op. The reaper task runs on the default pool and + catches nothing around its timer wait, so a Ctrl-C that lands in it leaves the task as an + `InterruptException` (which Base hands to the REPL); `ensure_reaper!` starts a new task on + the next connect when the old one is done. Reseau's own poll-FD finalizer is the last-resort fd reclaimer. - **RSA-OAEP through OpenSSL_jll's libcrypto** (`crypto.jl`): explicit SHA-1 OAEP + MGF1, `k - 42` plaintext cap, every handle freed in `finally`, 20k-iteration leak test. The diff --git a/src/reaper.jl b/src/reaper.jl index 7a43a99..b9e2e5c 100644 --- a/src/reaper.jl +++ b/src/reaper.jl @@ -128,15 +128,15 @@ const REAPER_SETUP_LOCK = ReentrantLock() # `Timer(callback)`: Base dispatches timer callbacks from an internal closure task, which # a trimmed executable cannot run, while `wait(::Timer)` is woken from the event loop's C # side. The task body and the atexit hook are `@noinline` with static call edges below. +# +# Nothing is caught around `wait`: a SIGINT is thrown into whichever task is resident on +# thread 1, and when that is the reaper the InterruptException must leave the task so that +# Base's task_done_hook can hand it to the REPL. `ensure_reaper!` replaces a dead reaper. @noinline function reaper_loop() t = REAPER_TIMER[] t === nothing && return nothing while isopen(t) - try - wait(t) - catch - break # timer closed - end + wait(t) try reap_now!() catch err @@ -148,24 +148,30 @@ end @noinline reaper_atexit() = return (try; reap_now!(); catch; end; nothing) -# Starts the timer once. A ReentrantLock (not the finalizer-safe spinlock) because creating a -# Timer and registering the atexit hook may yield. +# Starts the timer once and a reaper task whenever none is alive. A ReentrantLock (not the +# finalizer-safe spinlock) because creating a Timer and registering the atexit hook may yield. function ensure_reaper!() lock(REAPER_SETUP_LOCK) try - REAPER_TIMER[] === nothing || return nothing - REAPER_TIMER[] = Timer(REAPER_INTERVAL_S; interval=REAPER_INTERVAL_S) + task = REAPER_TASK[] + task === nothing || istaskdone(task) || return nothing + if REAPER_TIMER[] === nothing + REAPER_TIMER[] = Timer(REAPER_INTERVAL_S; interval=REAPER_INTERVAL_S) + atexit(reaper_atexit) + end if TRIM_CALL_EDGE[] reaper_loop() reaper_atexit() end task = Task(reaper_loop) task.sticky = false - # reaper_loop handles timer shutdown and logs reclamation errors itself. - # Base.errormonitor would add an untrimmable dynamic error-display task. + # The default pool: when an interactive pool exists the user's tasks run there, and + # a Ctrl-C goes to the task resident on thread 1, which must not be the reaper. + Threads._spawn_set_thrpool(task, :default) + # reaper_loop logs reclamation errors itself. Base.errormonitor would add an + # untrimmable dynamic error-display task. REAPER_TASK[] = task schedule(task) - atexit(reaper_atexit) finally unlock(REAPER_SETUP_LOCK) end diff --git a/test/protocol/native_tests.jl b/test/protocol/native_tests.jl index 8b69621..a1d6212 100644 --- a/test/protocol/native_tests.jl +++ b/test/protocol/native_tests.jl @@ -506,7 +506,6 @@ end # The interrupt leaves the task, so Base's task_done_hook can hand it to the REPL. @test istaskfailed(reaper) @test reaper.result isa InterruptException - # The next registration replaces the dead reaper, and the replacement reaps. N.ensure_reaper!() fresh = N.REAPER_TASK[] @test fresh isa Task && fresh !== reaper && !istaskdone(fresh) From 90fcc06bf4c61f801e3458d369a79ae35861cae2 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 4 Nov 2026 23:05:47 -0700 Subject: [PATCH 3/4] test: find the parked reaper through the timer's wait queue The parked check compared `reaper.queue` with the timer's wait queue, which no longer holds on Julia 1.15-dev: a parked task registers a WaitEntry there and `queue` stays `nothing`, so the nightly jobs never injected the interrupt. Check membership in the timer's wait queue instead, under its lock, reading the entry's atomic `task` field on 1.15 and the entry itself before that. Co-Authored-By: Claude Fable 5.1 --- test/protocol/native_tests.jl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/protocol/native_tests.jl b/test/protocol/native_tests.jl index a1d6212..8867cac 100644 --- a/test/protocol/native_tests.jl +++ b/test/protocol/native_tests.jl @@ -484,6 +484,10 @@ end # (thrown into the task resident on thread 1) does not land in the reaper. @test Threads.threadpool(reaper) === :default timer = N.REAPER_TIMER[] + # Under the timer's lock, a task linked in the timer's wait queue is parked in `wait(timer)`. + # The queue holds the tasks themselves on Julia <= 1.14 and WaitEntry records with an + # atomic `task` field on 1.15. + in_waitq(t::Task, c) = any(w -> (w isa Task ? w : (@atomic :monotonic w.task)) === t, c.waitq) # Throw an InterruptException into the reaper while it is parked in `wait(timer)`, as a # Ctrl-C does when the reaper is the task resident on thread 1. The timer's lock keeps # the timer tick from waking the reaper between the check and the throw. @@ -491,7 +495,7 @@ end deadline = time() + 15 while !injected && time() < deadline @lock timer.cond begin - if reaper.queue === timer.cond.waitq + if in_waitq(reaper, timer.cond) schedule(reaper, InterruptException(); error=true) injected = true end From eb4c5f347aecb0070deecc64ec95ab4f78c00d85 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 5 Nov 2026 00:20:59 -0700 Subject: [PATCH 4/4] test: name the Julia version where timer wait queues switched to WaitEntry records Julia 1.13 keeps tasks in a Timer's wait queue; 1.14.0-alpha1 and later keep WaitEntry records. Co-Authored-By: Claude Opus 5.5 --- test/protocol/native_tests.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/protocol/native_tests.jl b/test/protocol/native_tests.jl index 8867cac..dc9c902 100644 --- a/test/protocol/native_tests.jl +++ b/test/protocol/native_tests.jl @@ -485,8 +485,8 @@ end @test Threads.threadpool(reaper) === :default timer = N.REAPER_TIMER[] # Under the timer's lock, a task linked in the timer's wait queue is parked in `wait(timer)`. - # The queue holds the tasks themselves on Julia <= 1.14 and WaitEntry records with an - # atomic `task` field on 1.15. + # The queue holds the tasks themselves on Julia <= 1.13 and WaitEntry records with an + # atomic `task` field from 1.14 on. in_waitq(t::Task, c) = any(w -> (w isa Task ? w : (@atomic :monotonic w.task)) === t, c.waitq) # Throw an InterruptException into the reaper while it is parked in `wait(timer)`, as a # Ctrl-C does when the reaper is the task resident on thread 1. The timer's lock keeps