From 736ece7c300178a91051230f59d5370ffb5553e1 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sat, 12 Sep 2026 02:12:04 +0100 Subject: [PATCH 1/4] test: cover pytest signal ownership and Julia shutdown Co-authored-by: Miles Cranmer --- pytest/test_signals.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 pytest/test_signals.py 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 From 52f4ad89bcb2d68923a2443378d716d8229e8c77 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sat, 12 Sep 2026 02:15:56 +0100 Subject: [PATCH 2/4] fix: coordinate pytest diagnostics with Julia signal handling Co-authored-by: Miles Cranmer --- CHANGELOG.md | 1 + docs/src/juliacall.md | 19 ++++++++++++++++ pyproject.toml | 5 ++++- pysrc/juliacall_pytest/__init__.py | 36 ++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 pysrc/juliacall_pytest/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4051db1b..038ea4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +* 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/docs/src/juliacall.md b/docs/src/juliacall.md index bd7e7c09..89b46a8e 100644 --- a/docs/src/juliacall.md +++ b/docs/src/juliacall.md @@ -225,3 +225,22 @@ Ctrl-C will not raise `KeyboardInterrupt`. Future versions of JuliaCall may make this the default behaviour when using multiple threads. + +On Linux, the JuliaCall pytest plugin disables pytest's `faulthandler` plugin when +Julia signal handling is explicitly enabled before pytest starts. The +`-X juliacall-handle-signals` option takes precedence over +`PYTHON_JULIACALL_HANDLE_SIGNALS`. This prevents pytest teardown from replacing +Julia's signal handlers, which can cause GC safepoints to crash during shutdown. +The plugin does not initialize Julia. + +This policy disables pytest-managed Python fatal-error and timeout tracebacks; +Julia's fatal diagnostics remain available. A configured +`faulthandler_exit_on_timeout` with a positive timeout raises a configuration +error instead of silently removing timeout enforcement. Use an external process +timeout in that case. To opt out, pass `-p no:juliacall` to pytest. When pytest +plugin autoloading is disabled, load it with `-p juliacall_pytest` or use pytest +directly with `-p no:faulthandler`. + +This integration only coordinates pytest's diagnostic plugin. Calling +`faulthandler.disable()` or replacing native signal handlers while Julia is +running can still disrupt Julia's signal handling. 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, + ) From 4f70373bc580499846ff9c75f14b449b5953620a Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sat, 12 Sep 2026 02:54:00 +0100 Subject: [PATCH 3/4] Remove unnecessary bug-fix documentation Co-authored-by: Miles Cranmer --- docs/src/juliacall.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/src/juliacall.md b/docs/src/juliacall.md index 89b46a8e..bd7e7c09 100644 --- a/docs/src/juliacall.md +++ b/docs/src/juliacall.md @@ -225,22 +225,3 @@ Ctrl-C will not raise `KeyboardInterrupt`. Future versions of JuliaCall may make this the default behaviour when using multiple threads. - -On Linux, the JuliaCall pytest plugin disables pytest's `faulthandler` plugin when -Julia signal handling is explicitly enabled before pytest starts. The -`-X juliacall-handle-signals` option takes precedence over -`PYTHON_JULIACALL_HANDLE_SIGNALS`. This prevents pytest teardown from replacing -Julia's signal handlers, which can cause GC safepoints to crash during shutdown. -The plugin does not initialize Julia. - -This policy disables pytest-managed Python fatal-error and timeout tracebacks; -Julia's fatal diagnostics remain available. A configured -`faulthandler_exit_on_timeout` with a positive timeout raises a configuration -error instead of silently removing timeout enforcement. Use an external process -timeout in that case. To opt out, pass `-p no:juliacall` to pytest. When pytest -plugin autoloading is disabled, load it with `-p juliacall_pytest` or use pytest -directly with `-p no:faulthandler`. - -This integration only coordinates pytest's diagnostic plugin. Calling -`faulthandler.disable()` or replacing native signal handlers while Julia is -running can still disrupt Julia's signal handling. From 2111481292b0145e95dae6c610663279a5f7e8ef Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sat, 12 Sep 2026 03:02:49 +0100 Subject: [PATCH 4/4] Match changelog bug fix prefix Co-authored-by: Miles Cranmer --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 038ea4e8..6c979390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Unreleased -* Fix: on Linux, coordinate pytest's diagnostic plugin with explicitly enabled Julia signal handling to prevent shutdown crashes. +* 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