Repository navigation
Description
Summary
Once a process has opened a connection, a SIGINT (Ctrl-C) sent while the main task waits is lost: the main task's wait/fetch returns normally, with no InterruptException. MySQL.jl's reaper task takes the interrupt:
reaper_loopwaits on a repeatingTimerinsidetry wait(t) catch; break end.- The catch-all takes the
InterruptException, leaves the loop and ends the task.
The timer stays open afterwards, so nothing restarts the reaper. Transports that finalizers enqueue are no longer reclaimed until atexit.
Reproduced with MySQL.jl 2.2.1 + Reseau 1.6.6 on Julia 1.13.3 / Linux, under julia -t 2,1: 5 of 5 runs with the reaper lost the interrupt, and 2 of 2 runs without it delivered it. An earlier run with 2.2.0 + Reseau 1.6.3 behaved the same. No server is needed; ensure_reaper!() is the only MySQL.jl code that runs.
Mechanism
ensure_reaper!creates the reaper withTask(reaper_loop),sticky = false, andschedules it. The task inherits the threadpool of the task that creates it, which is:interactivewhen the first connection opens from the main task. In every run below,Threads.threadpool(reaper_task)was:interactive, the same pool as the main task. The reaper wakes there everyREAPER_INTERVAL_S(0.5 s).- Julia delivers SIGINT as an
InterruptExceptionthrown into a task on thread 1, and with the reaper waking there twice a second, that task is the reaper. - The
catch; break; endaroundwait(t)is there for the timer being closed. It catches the interrupt as well, so the loop exits withisopen(t)stilltrue. After each lost interrupt the reaper task was done, had not failed, and its timer was open. With the timer open, thatbreakis the loop's only exit.
Minimal repro (no database)
# repro.jl: run as `julia -t 2,1 repro.jl reaper` (or `control` to skip the reaper)
Base.exit_on_sigint(false)
using MySQL
reaper_task = nothing
if ARGS[1] == "reaper"
MySQL.ensure_reaper!() # what opening the first connection does
t = MySQL.REAPER_TIMER[]
while reaper_task === nothing # MySQL.jl keeps no reference; find it among the timer's waiters
lock(t.cond); q = collect(t.cond.waitq); unlock(t.cond)
isempty(q) ? sleep(0.005) : (global reaper_task = only(q))
end
println("reaper pool: ", Threads.threadpool(reaper_task), "; main pool: ", Threads.threadpool())
end
work = Threads.@spawn :default ccall(:sleep, Cuint, (Cuint,), 8) # a wait outside libuv
println("READY"); flush(stdout)
t0 = time()
outcome = try
fetch(work); "returned normally"
catch e
e isa InterruptException ? "InterruptException" : string(typeof(e))
end
println(outcome, " after ", round(time() - t0, digits = 2), " s")
reaper_task === nothing ||
println("reaper done: ", istaskdone(reaper_task), ", failed: ", istaskfailed(reaper_task),
", timer open: ", isopen(MySQL.REAPER_TIMER[]))Send SIGINT one second after READY (kill -INT <pid>). MySQL.jl 2.2.1 / Reseau 1.6.6:
| run | count | reaper pool | outcome | reaper task after the interrupt |
|---|---|---|---|---|
reaper |
5 of 5 | :interactive |
returned normally after 7.99 s | done, not failed, timer open |
control |
2 of 2 | — | InterruptException after 7.99 s |
— |
The control rows have no reaper or timer values because the reaper never started (REAPER_TIMER[] === nothing). The 7.99 s comes from Julia, not MySQL.jl: an interrupt waits for the main task to run again. What matters is whether it arrives.
Expected behaviour
A Ctrl-C reaches the task the user is running, as it does without the reaper, and the reaper keeps reaping.
Possible fixes
-
The actual fix: the reaper should not live on the
:interactivepool. Off thread 1, it would no longer be the task the runtime picks for the interrupt. How to place it given the--trimconstraints inreaper.jlis your call. -
A guard only: do not let a stray exception end the loop. After a failed
wait(t), continue while the timer is open and break only once it is closed:try wait(t) catch isopen(t) || break end
This keeps the reaper alive. It does not give the interrupt back: the user's Ctrl-C is still lost.
Context
Found in PormG.jl (PingoLee/PormG.jl#1520), where a Ctrl-C during a long MySQL statement was meant to send KILL QUERY.
Generated by Claude Code
Activity
quinnj-bot commentedon Nov 5, 2026
[comment generated via AI]
Thanks for the precise report and repro; it reproduced here on 1.12.6 (3 of 3 with
-t 2,1, and with-t 1).Root cause: Julia throws a SIGINT's
InterruptExceptioninto whichever task is resident on thread 1, and the reaper (waking every 0.5 s) is usually that task during a long statement. Thetry wait(t) catch; break endwas meant for a closed timer, which nothing ever closes, so it swallowed the interrupt, ended the loop, andensure_reaper!(keyed on the timer, not the task) never started another one.Fix: #255. The reaper catches nothing around its wait, so the
InterruptExceptionleaves the task and Base'stask_done_hookcan hand it to the REPL backend, as it does for Base's own timer tasks;ensure_reaper!starts a new reaper on the next connect when the old one is done; and the reaper runs on the default pool, so with an interactive pool (-t 2,1, or-t autoon 1.12) it never parks on thread 1. With the fix your-t 2,1repro delivers theInterruptExceptionto the main task.Two things the package cannot change, for your PormG handler: with a single thread the reaper (or any Base timer task, or the Profile SIGINFO listener Julia starts at boot) can still be the resident task, so the first Ctrl-C ends it rather than reaching your task; and on macOS about 1 in 3 interrupts was dropped inside the runtime even with no MySQL code loaded. Running with an interactive pool is the reliable layout.
I went with your option 1 rather than option 2: keeping the catch and continuing would also lose every later Ctrl-C, and a one-thread script could then no longer be stopped with Ctrl-C at all.