Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions src/arcadedb_embedded/jvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import platform
import shlex
import shutil
import signal
import sys
import zipfile
from pathlib import Path
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down
49 changes: 46 additions & 3 deletions tests/test_sigint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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."""
Expand Down
Loading