Repository navigation
Fix double KeyboardInterrupt on PL/Python cancel - #2185
krylosov-aa wants to merge 1 commit into
Conversation
leborchuk
left a comment
There was a problem hiding this comment.
It's not good to change postgres-based code. After that we will have issues with rebasing it.
Instead of this, consider change pl/python and fix double signal here.
Here the explanation how it works in postgres:
In postgres the duplicate signal delivery exists upstream, deliberately, and has never been fixed because for upstream it isn't a bug. Verified against
REL_17_STABLE and current master (github.com/postgres/postgres):
- The cancel path delivers SIGINT twice — same as Cloudberry.
Upstream processCancelRequest calls signal_child(bp->pid, SIGINT), and upstream signal_child (postmaster.c) does exactly what Cloudberry's does:
if (kill(pid, signal) < 0) /* signal the child directly */
elog(DEBUG3, ...);
#ifdef HAVE_SETSID
...
if (kill(-pid, signal) < 0) /* …and its whole process group */
elog(DEBUG3, ...);
#endif- The timeout handlers double-signal — identical to Cloudberry's old code.
/* try to signal whole process group */
kill(-MyProcPid, sig);
...
kill(MyProcPid, sig); /* sent unconditionally too */-
Upstream explicitly documents this as intentional (master postmaster.c:3504):
│ There is a race condition for recently-forked children: they might not have executed setsid() yet. So we signal the child directly as well as the
│ group… We also assume that signaling the child twice will not cause any problems.Why the assumption holds upstream but breaks in Cloudberry
For plain C code, a duplicate SIGINT is invisible: StatementCancelHandler just sets QueryCancelPending = true, an idempotent boolean. Twice the signal,
same result. Nothing to fix.The break comes from PL/Python's asynchronous cancellation, which is not an upstream feature. I traced it in Cloudberry's git history: commit
bfa232e — "Support plpython cancellation. (#4988)" by Hubert Zhang, Greenplum, May 2018 — added the Py_AddPendingCall hook so SIGINT queues a Python
callback raising KeyboardInterrupt. Upstream plpython (checked plpy_main.c, plpy_exec.c, plpy_spi.c on master) has no such machinery — a tight while
True: loop or time.sleep() in upstream PL/Python is simply not interruptible at all; cancellation only happens when execution returns to C code.That mechanism changes the meaning of a second SIGINT from "set an already-set flag" to "queue a second callback" → double KeyboardInterrupt.
My suggestion to change it (It should also be examined. This is merely a proposal based on LLM, outlining how to rectify the issue.):
Every duplicate delivery — postmaster cancel (signal_child does kill(pid) + kill(-pid)), statement/lock timeout (group + direct) — arrives as repeated
SIGINTs into StatementCancelHandler, which calls the single cancel_pending_hook() (postgres.c:3933). That hook is PLy_handle_cancel_interrupt in
plpy_main.c — a file that is already permanently fork-divergent (it carries the "For GPDB Use" cancel machinery from GPDB #4988). Rebase cost there:
zero extra.
The fix replaces the upstream-file changes with queue-point deduplication:
static volatile sig_atomic_t plpy_cancel_callback_queued = false;
/* in PLy_handle_cancel_interrupt (signal-handler context): */
if (PLy_enter_python_intepreter && !plpy_cancel_callback_queued)
{
if (Py_AddPendingCall(PLy_python_cancel_handler, NULL) == 0)
plpy_cancel_callback_queued = true;
}
/* in PLy_python_cancel_handler (normal context): */
plpy_cancel_callback_queued = false;
if (!QueryCancelPending) /* stale callback from an already-handled cancel */
return 0;Only the second delivery is dropped; the flag clears when the callback runs, so independent later cancels still work. sig_atomic_t keeps it
async-signal-safe (same safety class as the existing Py_AddPendingCall usage).
Resulting PR shape
src/pl/plpython/plpy_main.c | 22 ++++++ (the fix — fork-divergent file)
src/test/isolation2/sql/cancel_plpython.sql | 43 +++++ (new test)
src/test/isolation2/expected/cancel_plpython.out | 36 +++ (new expected output)
postmaster.c and postinit.c are byte-identical to upstream — future PG merges touch nothing here.
|
I'll update the PR as follows:
The cancel-request path is already fixed upstream. In PG 18, cancel handling moved from I see two options for that:
Which would you prefer? |
statement_timeout, lock_timeout, and client cancel requests send SIGINT both to the backend's process group and to the backend itself. PLy_handle_cancel_interrupt() queued a KeyboardInterrupt callback for each signal, so the second one aborted the except or finally block handling the first, or failed the next PL/Python call in the session. Keep at most one callback queued, and ignore a callback that runs after the cancellation has already been processed. Related to apache#2181
Related to #2181
What does this PR do?
Statement and lock timeouts can queue duplicate KeyboardInterrupt callbacks, interrupting Python exception handling.
Keep at most one cancellation callback queued in Cloudberry's PL/Python hook. Ignore stale callbacks after PostgreSQL has processed the cancellation, so they cannot affect a later PL/Python call. Clear the queue flag when the callback runs to allow subsequent cancellation requests
Type of Change
Test Plan
Passed
cancel_plpythonwith both planners on Linux ARM64 / Python 3.10.Verified client cancellation, repeated cancellation, and statement/lock timeouts.
Checklist