Skip to content

Commit 5b5f991

Browse files
zahariashclaude
authored andcommitted
fix(pybind): keep the connection alive across a Ctrl-C guarded call
query(), execute() and query_as_arrow() now hold the connection state for the whole call, so the ClientContext the SIGINT handler interrupts outlives the guard even if another thread closes the connection. Before, a close() from another thread destroyed the Connection while the main thread was still inside the query, after waiting for it with the GIL held. The handler's signal safety is pinned: static_asserts that both atomics it touches are lock-free, and a comment that ClientContext::interrupt() must stay a store to an atomic flag. Comments also state that prepare() is not guarded and why the handler swap is not wrapped in a signal mask. A failure of the main-thread/handler check is reported as unraisable, except during interpreter shutdown, instead of being swallowed; the query still runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent db8604b commit 5b5f991

3 files changed

Lines changed: 79 additions & 5 deletions

File tree

‎src_cpp/py_connection.cpp‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
#include <csignal>
1010
#endif
1111

12+
#if PY_VERSION_HEX < 0x030D0000
13+
#define Py_IsFinalizing _Py_IsFinalizing
14+
#endif
15+
1216
#include "cached_import/py_cached_import.h"
1317
#include "common/constants.h"
1418
#include "common/exception/not_implemented.h"
@@ -193,7 +197,14 @@ namespace {
193197
// Python runs its SIGINT handler only between bytecodes, so not while the main thread waits in
194198
// the engine. During such a wait, SIGINT interrupts the connection's query and then calls
195199
// Python's handler. This is only done on the main thread while Python's default handler
196-
// (KeyboardInterrupt) is active, so custom handlers keep their behaviour.
200+
// (KeyboardInterrupt) is active, so custom handlers keep their behaviour. Only query(),
201+
// execute() and query_as_arrow() install it.
202+
//
203+
// target is set before the handler is installed and cleared after the previous one is
204+
// restored, so a SIGINT in between reaches Python's handler, as without this guard. A signal
205+
// mask would not help: masks are per thread, and a process-directed SIGINT runs the handler on
206+
// whichever thread does not block it. The statics are per process; this single-phase-init
207+
// module cannot be imported into subinterpreters.
197208
class SigintInterruptsQuery {
198209
public:
199210
explicit SigintInterruptsQuery([[maybe_unused]] main::ClientContext* context) {
@@ -246,11 +257,17 @@ class SigintInterruptsQuery {
246257
}
247258
auto& signal = importCache->signal;
248259
return signal.getsignal()(signal.sigint()).is(signal.default_int_handler());
249-
} catch (py::error_already_set&) {
260+
} catch (py::error_already_set& e) {
261+
if (!Py_IsFinalizing()) {
262+
e.discard_as_unraisable("checking the SIGINT handler before a query");
263+
}
250264
return false;
251265
}
252266
}
253267

268+
// Runs in signal context, so it may only use async-signal-safe operations: lock-free atomics
269+
// and Python's own handler, which is written for signal context. ClientContext::interrupt()
270+
// is a store to an std::atomic<bool> in the engine and must stay one.
254271
static void handle(int signum, siginfo_t* info, void* ucontext) {
255272
if (auto* context = target.load()) {
256273
context->interrupt();
@@ -266,6 +283,8 @@ class SigintInterruptsQuery {
266283

267284
static inline std::atomic<main::ClientContext*> target{nullptr};
268285
static inline std::atomic<bool> received{false};
286+
static_assert(std::atomic<main::ClientContext*>::is_always_lock_free);
287+
static_assert(std::atomic<bool>::is_always_lock_free);
269288
static inline struct sigaction previous {};
270289
bool installed = false;
271290
#endif
@@ -280,40 +299,47 @@ std::unique_ptr<PyQueryResult> PyConnection::execute(PyPreparedStatement* prepar
280299
throw RuntimeException("Prepared statement is closed.");
281300
}
282301
auto parameters = transformPythonParameters(params, &stateRef.ref());
302+
// Keeps the ClientContext alive past the guard, even if another thread closes this
303+
// connection while the GIL is released.
304+
auto keepAlive = state;
283305
SigintInterruptsQuery sigint(stateRef.ref().getClientContext());
284306
py::gil_scoped_release release;
285307
auto queryResult =
286308
stateRef.ref().executeWithParams(&preparedStatement->state->ref(), std::move(parameters));
287309
py::gil_scoped_acquire acquire;
288310
sigint.raiseIfInterrupted();
289-
return checkAndWrapQueryResult(queryResult, state);
311+
return checkAndWrapQueryResult(queryResult, keepAlive);
290312
}
291313

292314
std::unique_ptr<PyQueryResult> PyConnection::query(const std::string& statement) {
293315
auto& stateRef = refState();
316+
auto keepAlive = state;
294317
SigintInterruptsQuery sigint(stateRef.ref().getClientContext());
295318
py::gil_scoped_release release;
296319
auto queryResult = stateRef.ref().query(statement);
297320
py::gil_scoped_acquire acquire;
298321
sigint.raiseIfInterrupted();
299-
return checkAndWrapQueryResult(queryResult, state);
322+
return checkAndWrapQueryResult(queryResult, keepAlive);
300323
}
301324

302325
std::unique_ptr<PyQueryResult> PyConnection::queryAsArrow(const std::string& statement,
303326
int64_t chunkSize) {
304327
auto& stateRef = refState();
328+
auto keepAlive = state;
305329
SigintInterruptsQuery sigint(stateRef.ref().getClientContext());
306330
py::gil_scoped_release release;
307331
auto queryResult = stateRef.ref().queryAsArrow(statement, chunkSize);
308332
py::gil_scoped_acquire acquire;
309333
sigint.raiseIfInterrupted();
310-
return checkAndWrapQueryResult(queryResult, state);
334+
return checkAndWrapQueryResult(queryResult, keepAlive);
311335
}
312336

313337
void PyConnection::setMaxNumThreadForExec(uint64_t numThreads) {
314338
refState().ref().setMaxNumThreadForExec(numThreads);
315339
}
316340

341+
// Not guarded by SigintInterruptsQuery: a Ctrl-C while the statement compiles takes effect when
342+
// prepare() returns.
317343
PyPreparedStatement PyConnection::prepare(const std::string& query, const py::dict& parameters) {
318344
auto& stateRef = refState();
319345
auto params = transformPythonParameters(parameters, &stateRef.ref());

‎test/capi_xfails.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
"test/test_keyboard_interrupt.py::test_keyboard_interrupt_stops_the_query[execute_with_parameters]",
7474
"test/test_keyboard_interrupt.py::test_keyboard_interrupt_stops_the_query[query]",
7575
"test/test_keyboard_interrupt.py::test_keyboard_interrupt_stops_the_query[query_as_arrow]",
76+
"test/test_keyboard_interrupt.py::test_keyboard_interrupt_after_close_from_another_thread",
77+
"test/test_keyboard_interrupt.py::test_failed_handler_check_is_reported_not_raised",
7678
# UDF registration is still routed through pybind.
7779
"test/test_blob_parameter.py::test_bytes_param_udf",
7880
"test/test_udf.py::test_udf",

‎test/test_keyboard_interrupt.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,49 @@ def test_custom_sigint_handler_still_runs(tmp_path: Path) -> None:
7777
stdout = run_script(tmp_path, CUSTOM_HANDLER)
7878
# A custom handler is left alone: the query finishes, then the handler runs.
7979
assert "OUTCOME finished handled" in stdout, stdout
80+
81+
82+
CLOSE_DURING_QUERY = """
83+
threading.Timer(0.5, conn._connection.close).start()
84+
started = time.monotonic()
85+
try:
86+
conn.execute(LONG_QUERY.replace('$n', '40000'))
87+
print("OUTCOME finished")
88+
except KeyboardInterrupt:
89+
print("OUTCOME KeyboardInterrupt")
90+
print("ELAPSED", time.monotonic() - started)
91+
"""
92+
93+
94+
@pytest.mark.skipif(sys.platform == "win32", reason="SIGINT is POSIX only")
95+
def test_keyboard_interrupt_after_close_from_another_thread(tmp_path: Path) -> None:
96+
# The running call keeps the connection alive, so closing it from another thread
97+
# returns at once instead of waiting for the query with the GIL held. (Whether the
98+
# context was freed under the running query would need ASan to observe.)
99+
stdout = run_script(tmp_path, CLOSE_DURING_QUERY)
100+
assert "OUTCOME KeyboardInterrupt" in stdout, stdout
101+
assert float(stdout.split("ELAPSED")[1].split()[0]) < 10, stdout
102+
103+
104+
@pytest.mark.skipif(sys.platform == "win32", reason="SIGINT is POSIX only")
105+
def test_failed_handler_check_is_reported_not_raised(tmp_path: Path) -> None:
106+
script = """
107+
import signal, sys
108+
import ladybug as lb
109+
110+
def broken(signum):
111+
raise RuntimeError("getsignal is broken")
112+
113+
signal.getsignal = broken
114+
conn = lb.Connection(lb.Database(sys.argv[1]))
115+
print("RESULT", conn.execute("RETURN 1").get_next()[0])
116+
"""
117+
proc = subprocess.run(
118+
[sys.executable, "-c", script, str(tmp_path / "db")],
119+
capture_output=True,
120+
text=True,
121+
timeout=120,
122+
)
123+
assert proc.returncode == 0, proc.stderr[-2000:]
124+
assert "RESULT 1" in proc.stdout, proc.stdout
125+
assert "getsignal is broken" in proc.stderr, proc.stderr

0 commit comments

Comments
 (0)