diff --git a/CHANGELOG.md b/CHANGELOG.md index 4051db1b..6c979390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +* Bug fix: on Linux, coordinate pytest's diagnostic plugin with explicitly enabled Julia signal handling to prevent shutdown crashes. * Bug fix: avoid precompilation cache miss when `check_bounds` option set. * Bug fix: `juliacall` set the environment variable `JULIA_PYTHONCALL_EXECUTABLE` instead of `JULIA_PYTHONCALL_EXE`, so child Julia processes resolved a new diff --git a/pyproject.toml b/pyproject.toml index 74a07b03..4a48dff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ classifiers = [ requires-python = ">=3.10, <4" dependencies = ["juliapkg >=0.1.26, <0.2"] +[project.entry-points.pytest11] +juliacall = "juliacall_pytest" + [dependency-groups] dev = [ "flake8>=5.0", @@ -25,4 +28,4 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["pysrc/juliacall"] +packages = ["pysrc/juliacall", "pysrc/juliacall_pytest"] diff --git a/pysrc/juliacall_pytest/__init__.py b/pysrc/juliacall_pytest/__init__.py new file mode 100644 index 00000000..173110b6 --- /dev/null +++ b/pysrc/juliacall_pytest/__init__.py @@ -0,0 +1,36 @@ +import os +import sys + +import pytest + + +@pytest.hookimpl(tryfirst=True) +def pytest_load_initial_conftests(early_config): + handle_signals = sys._xoptions.get("juliacall-handle-signals") + if handle_signals is None: + handle_signals = os.environ.get("PYTHON_JULIACALL_HANDLE_SIGNALS") + if sys.platform != "linux" or handle_signals != "yes": + return + if not early_config.pluginmanager.hasplugin("faulthandler"): + return + try: + exit_on_timeout = early_config.getini("faulthandler_exit_on_timeout") + except ValueError as error: + if not isinstance(error.__cause__, KeyError) or error.__cause__.args != ("faulthandler_exit_on_timeout",): + raise + # Older pytest versions have no exit-on-timeout option. + exit_on_timeout = False + if exit_on_timeout and float(early_config.getini("faulthandler_timeout")) > 0: + raise pytest.UsageError( + "Julia signal handling conflicts with pytest's faulthandler_exit_on_timeout. " + "Use an external process timeout instead." + ) + early_config.pluginmanager.set_blocked("faulthandler") + early_config.issue_config_time_warning( + pytest.PytestConfigWarning( + "Julia signal handling is enabled: disabling pytest's faulthandler plugin " + "and its timeout diagnostics to preserve Julia's signal handlers. " + "Julia fatal diagnostics remain enabled. Use -p no:juliacall to opt out." + ), + stacklevel=2, + ) diff --git a/pytest/test_signals.py b/pytest/test_signals.py new file mode 100644 index 00000000..05d3a2d1 --- /dev/null +++ b/pytest/test_signals.py @@ -0,0 +1,67 @@ +import os +import subprocess +import sys + +import pytest + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux signal handling") +def test_pytest_shutdown(tmp_path): + test_file = tmp_path / "test_shutdown.py" + test_file.write_text(''' +import atexit +from juliacall import Main as jl + +atexit.register(lambda: print("PYTHON_ATEXIT", flush=True)) +jl.seval(""" +atexit(() -> println("JULIA_ATEXIT")) +const retained = Ref(0) +finalizer(x -> println("JULIA_FINALIZER"), retained) +""") + +def test_gc(): + jl.seval("Threads.@threads for i in 1:100; zeros(10000); GC.gc(); end") +''') + env = dict(os.environ, PYTHON_JULIACALL_HANDLE_SIGNALS="yes", PYTHON_JULIACALL_THREADS="6") + env.pop("PYTEST_ADDOPTS", None) + result = subprocess.run( + [sys.executable, "-m", "pytest", "-s", "-q", str(test_file)], + env=env, capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr + for marker in ("PYTHON_ATEXIT", "JULIA_ATEXIT", "JULIA_FINALIZER"): + assert marker in result.stdout + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux signal handling") +@pytest.mark.parametrize("setting,xoption,plugins,enabled", [ + (None, None, [], True), + ("no", None, [], True), + ("yes", None, [], False), + ("yes", "no", [], True), + ("no", "yes", [], False), + ("yes", "", [], True), + ("yes", "invalid", [], True), + ("yes", None, ["-p", "no:juliacall"], True), +]) +def test_pytest_configuration(tmp_path, setting, xoption, plugins, enabled): + test_file = tmp_path / "test_configuration.py" + test_file.write_text(f''' +import faulthandler +import sys + +def test_configuration(): + assert "juliacall" not in sys.modules + assert faulthandler.is_enabled() is {enabled!r} +''') + env = dict(os.environ) + for name in ("PYTEST_ADDOPTS", "PYTHONFAULTHANDLER", "PYTHON_JULIACALL_HANDLE_SIGNALS"): + env.pop(name, None) + if setting is not None: + env["PYTHON_JULIACALL_HANDLE_SIGNALS"] = setting + command = [sys.executable] + if xoption is not None: + command += ["-X", "juliacall-handle-signals" + ("=" + xoption if xoption else "")] + command += ["-m", "pytest", "-q", *plugins, str(test_file)] + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr