From 5bf1371a742771e02affbb14c43c32cd43de0981 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sat, 24 Oct 2026 08:05:53 +0900 Subject: [PATCH] fix: give SIGINT and SIGTERM back to Python after the JVM starts JPype's default startJVM(interrupt=False) installs JVM handlers for SIGINT and SIGTERM. Ctrl-C then only set a pending flag and never woke a main thread blocked in time.sleep, Lock.acquire or queue.get, and SIGTERM aborted with status -6 (jpype-project/jpype#1497). start_jvm() now re-installs Python's handlers right after the JVM starts, on the main thread, unless interrupt=True. Ported from humemai/arcadedb-embedded-python#494. Refs #19. Co-Authored-By: Claude Opus 5.5 --- src/arcadedb_embedded/jvm.py | 50 ++++++++++++++++++++++++++++++------ tests/test_sigint.py | 49 ++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/arcadedb_embedded/jvm.py b/src/arcadedb_embedded/jvm.py index 75a43907..1459edeb 100644 --- a/src/arcadedb_embedded/jvm.py +++ b/src/arcadedb_embedded/jvm.py @@ -9,6 +9,7 @@ import platform import shlex import shutil +import signal import sys import zipfile from pathlib import Path @@ -17,8 +18,10 @@ import jpype import jpype.imports +from ._logging import get_logger from .exceptions import ArcadeDBError +_LOGGER = get_logger(__name__) _JVM_CONFIG = None _JVM_INTERRUPT = None # the `interrupt` the running JVM was started with @@ -304,14 +307,12 @@ def start_jvm( What Ctrl-C (SIGINT) does. The default, False, leaves it to Python: a KeyboardInterrupt is raised, so ``finally`` blocks, ``atexit`` hooks and the rollback of a ``with db.transaction():`` run. A Java call in - progress (a slow query) is not interrupted: the KeyboardInterrupt - arrives when it returns (``kill -TERM`` from another terminal still - ends the process at once, through the JVM's shutdown hooks). A Java call - that waits in an interruptible way (``Thread.sleep``, ``Object.wait``, - the engine's async ``wait_completion()``) is woken by Ctrl-C, and JPype - 1.7.1 can then raise ``java.lang.InterruptedException`` or - ``RuntimeError`` and deliver the ``KeyboardInterrupt`` late; "Known - Engine Issues" in the documentation has the workaround. + progress (a slow query, a wait in the engine) is not interrupted: the + KeyboardInterrupt arrives when it returns. SIGINT and SIGTERM are the + process's own again after the JVM starts, as in a script without a + JVM: a long blocking Python call (``time.sleep``, ``Lock.acquire``, + ``queue.get``) is interrupted at once, and ``kill -TERM`` ends the + process with status -15 without running Python cleanup. Pass True for JPype's script default, where the JVM handles SIGINT and ends the whole process at once with exit status 130, with no Python cleanup. @@ -425,6 +426,9 @@ def start_jvm( except Exception as e: raise ArcadeDBError(f"Failed to start JVM: {e}") from e + if not _JVM_INTERRUPT: + _restore_python_signal_handlers() + # Registered AFTER JPype's own atexit hook so it runs BEFORE it (atexit # is LIFO): close any Database left open. Engine >= 850ce7c37 (#5418) # also daemonizes its background threads and installs its own JVM @@ -446,6 +450,36 @@ def start_jvm( faulthandler.disable() +def _restore_python_signal_handlers() -> None: + """Give SIGINT and SIGTERM back to Python after JPype took them (``interrupt=False``). + + JPype installs ``sun.misc.Signal`` handlers for both signals while it starts the JVM, which + replaces the handlers Python had registered with the operating system. A SIGINT is then + delivered to the JVM, which tells Python from its signal-dispatcher thread + (``PyErr_SetInterruptEx``). That sets the "interrupt pending" flag but wakes nobody: the main + thread, blocked in a long ``time.sleep()``, ``Lock.acquire()``, ``queue.get()`` or + ``Thread.join()``, got EINTR before the flag was set, went back to sleep, and raises the + ``KeyboardInterrupt`` only when the call ends (observed: a process with an open transaction + that did not exit for the whole 30 s or 120 s of the sleep; 20 of 20 on the JVM wheel, never + on the native one). A SIGTERM with no Python handler crashed the process with exit status -6 + (jpype-project/jpype#1497). The same JPype handler also interrupts the Java thread first, + which is the race of jpype-project/jpype#1496. + + Registering the handler Python already has again puts the operating-system disposition back + (``signal.signal`` calls ``sigaction``), so SIGINT raises ``KeyboardInterrupt`` in the main + thread exactly as in a process without a JVM and SIGTERM ends the process with status -15. + A Java call in progress is then not interrupted by Ctrl-C: the ``KeyboardInterrupt`` arrives + when it returns. Only the main thread may do this; elsewhere it is skipped. + """ + for signum in (signal.SIGINT, signal.SIGTERM): + try: + current = signal.getsignal(signum) + if current is not None: # None: set by C code, nothing Python can restore + signal.signal(signum, current) + except (ValueError, OSError): # not the main thread / not settable + _LOGGER.debug("could not restore the Python handler for signal %s", signum) + + def _close_active_databases(): """Close every Database still open so JVM shutdown cannot block.""" if not jpype.isJVMStarted(): diff --git a/tests/test_sigint.py b/tests/test_sigint.py index 55369498..5cc2f60a 100644 --- a/tests/test_sigint.py +++ b/tests/test_sigint.py @@ -15,6 +15,10 @@ 400 interrupts with two cores idle and 26 of 200 with two busy cores. A call that Ctrl-C cannot wake leaves no such window, and it is the case ``start_jvm`` documents: the ``KeyboardInterrupt`` arrives when the call returns. + +JPype's handlers also stopped a long blocking Python call from being interrupted and turned +SIGTERM into exit status -6 (jpype#1497), so ``start_jvm`` gives SIGINT and SIGTERM back to +Python once the JVM is up (ported from humemai/arcadedb-embedded-python#494). """ import os @@ -48,6 +52,10 @@ server = jpype.JClass("java.net.ServerSocket")(0, 1, loopback) server.setSoTimeout(4000) server.accept() + elif sys.argv[1] == "sleep": + # ONE long blocking call, not a loop of short ones: the main thread is + # in a single clock_nanosleep() and only a signal delivered to it wakes it. + time.sleep(60) else: while True: time.sleep(0.05) @@ -60,7 +68,10 @@ """ -def _run_sigint(tmp_path, mode, interrupt): +def _run_signal(tmp_path, mode, interrupt, sig=signal.SIGINT, bound=60): + """Send ``sig`` to a child with an open transaction; fail if it has not exited within + ``bound`` seconds (a KeyboardInterrupt stuck behind a blocking call looks like this). + """ proc = subprocess.Popen( # nosec B603 - fixed argv, no shell, test-owned [sys.executable, "-c", _CHILD, mode, str(tmp_path / "db"), interrupt], stdout=subprocess.PIPE, @@ -73,11 +84,20 @@ def _run_sigint(tmp_path, mode, interrupt): ) assert "READY" in proc.stdout.readline() time.sleep(1.0) - proc.send_signal(signal.SIGINT) - out, _ = proc.communicate(timeout=60) + proc.send_signal(sig) + try: + out, _ = proc.communicate(timeout=bound) + except subprocess.TimeoutExpired: + proc.kill() # our own child + proc.communicate() + pytest.fail(f"child still running {bound} s after signal {int(sig)}") return proc.returncode, out +def _run_sigint(tmp_path, mode, interrupt): + return _run_signal(tmp_path, mode, interrupt) + + @pytest.mark.parametrize("mode", ["python", "java"]) def test_ctrl_c_raises_keyboard_interrupt_and_runs_cleanup(tmp_path, mode): """Python loop and a blocked Java call that Ctrl-C cannot wake (the @@ -91,6 +111,29 @@ def test_ctrl_c_raises_keyboard_interrupt_and_runs_cleanup(tmp_path, mode): assert "ATEXIT" in lines +def test_ctrl_c_wakes_a_long_blocking_call_with_a_transaction_open(tmp_path): + """A single time.sleep(60) in the main thread inside a transaction: Ctrl-C ends it at once + (KeyboardInterrupt, rollback, cleanup, exit 0). With JPype's own SIGINT handler left in + place the KeyboardInterrupt was pending but nobody woke the sleeping thread, so the + process did not exit for the 60 s (20 of 20 runs on #494's JVM wheel; never on the native). + The 20 s bound is a hang detector, not a latency bound. + """ + code, out = _run_signal(tmp_path, "sleep", "0", bound=20) + assert code == 0, out + lines = out.split() + assert "KEYBOARD_INTERRUPT" in lines and "FINALLY" in lines + assert lines[lines.index("COUNT") + 1] == "0" + assert "ATEXIT" in lines + + +def test_sigterm_ends_the_process_with_status_minus_15(tmp_path): + """No Python handler for SIGTERM, as in a plain script: the process dies by SIGTERM, as + without a JVM. JPype's handler used to crash it with SIGABRT (exit -6, jpype#1497). + """ + code, _ = _run_signal(tmp_path, "sleep", "0", sig=signal.SIGTERM, bound=20) + assert code == -signal.SIGTERM + + def test_interrupt_true_keeps_the_jvm_handling_sigint(tmp_path): """The opt-back: the JVM ends the process with 130 and Python never sees the interrupt."""