Skip to content

The reaper task swallows a Ctrl-C (InterruptException) meant for the main task, and then stops reaping #254

Closed

Description

@PingoLee

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_loop waits on a repeating Timer inside try 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 with Task(reaper_loop), sticky = false, and schedules it. The task inherits the threadpool of the task that creates it, which is :interactive when 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 every REAPER_INTERVAL_S (0.5 s).
  • Julia delivers SIGINT as an InterruptException thrown into a task on thread 1, and with the reaper waking there twice a second, that task is the reaper.
  • The catch; break; end around wait(t) is there for the timer being closed. It catches the interrupt as well, so the loop exits with isopen(t) still true. After each lost interrupt the reaper task was done, had not failed, and its timer was open. With the timer open, that break is 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

  1. The actual fix: the reaper should not live on the :interactive pool. Off thread 1, it would no longer be the task the runtime picks for the interrupt. How to place it given the --trim constraints in reaper.jl is your call.

  2. 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

  1. quinnj-bot commented on Nov 5, 2026

    @quinnj-bot
    Contributor

    [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 InterruptException into whichever task is resident on thread 1, and the reaper (waking every 0.5 s) is usually that task during a long statement. The try wait(t) catch; break end was meant for a closed timer, which nothing ever closes, so it swallowed the interrupt, ended the loop, and ensure_reaper! (keyed on the timer, not the task) never started another one.

    Fix: #255. The reaper catches nothing around its wait, so the InterruptException leaves the task and Base's task_done_hook can 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 auto on 1.12) it never parks on thread 1. With the fix your -t 2,1 repro delivers the InterruptException to 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions