diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 643403f..8b80583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,13 @@ jobs: needs: [lint, changes] if: needs.changes.outputs.python == 'true' || github.event_name == 'push' runs-on: ${{ matrix.os }} + env: + # setup-python on macOS upgrades certifi via pip; pip's HTTP cache + # occasionally fails to deserialize across runner image versions and + # surfaces as a noisy "Cache entry deserialization failed" annotation. + # Disabling pip's cache eliminates the warning at zero cost (we use uv + # for actual package installs). + PIP_NO_CACHE_DIR: "1" strategy: matrix: os: [ubuntu-latest] @@ -247,26 +254,10 @@ jobs: args: --release --features extension-module,postgres,redis,native-async,workflows - name: Run Python test suite - run: | - set +e - uv run python -m pytest tests/python/ -v --junitxml=test-results.xml - PYTEST_EXIT=$? - if [ $PYTEST_EXIT -eq 0 ]; then exit 0; fi - # SIGABRT (134) during interpreter shutdown is a known PyO3 issue; - # check junit XML to confirm all tests actually passed. - if [ $PYTEST_EXIT -eq 134 ] && [ -f test-results.xml ]; then - FAILURES=$(python -c " - import xml.etree.ElementTree as ET - r = ET.parse('test-results.xml').getroot() - print(int(r.get('failures',0)) + int(r.get('errors',0))) - ") - if [ "$FAILURES" = "0" ]; then - echo "::warning::Tests passed but process crashed during cleanup (known PyO3 issue)" - exit 0 - fi - fi - exit $PYTEST_EXIT - shell: bash + # The pytest_unconfigure hook in tests/python/conftest.py calls + # ``os._exit(0)`` on a clean run to bypass CPython finalization and + # avoid the PyO3 daemon-thread SIGABRT we used to paper over here. + run: uv run python -m pytest tests/python/ -v --junitxml=test-results.xml ci-status: name: CI status diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 5042703..2d82fd7 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -1,5 +1,7 @@ """Shared fixtures for taskito tests.""" +import os +import sys import threading from collections.abc import Generator from pathlib import Path @@ -24,3 +26,34 @@ def run_worker(queue: Queue) -> Generator[threading.Thread]: yield thread queue._inner.request_shutdown() thread.join(timeout=5) + + +_PYTEST_EXIT_STATUS: int = 0 + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + """Capture the exit status for ``pytest_unconfigure`` to act on.""" + global _PYTEST_EXIT_STATUS + _PYTEST_EXIT_STATUS = int(exitstatus) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Bypass CPython's interpreter finalization on a clean exit. + + Many tests leave PyO3-backed daemon threads (heartbeat, async executor, + webhook delivery, distributed-lock extender) running at process end. + During ``Py_Finalize`` those threads may try to (re)acquire the GIL after + it has been torn down, producing ``FATAL: exception not rethrown`` and a + SIGABRT — even though every test passed. ``os._exit`` skips finalization + entirely after the terminal summary and junit XML are already written, + eliminating the spurious crash. + + ``pytest_unconfigure`` fires after every other hook (terminal summary, + junitxml plugin, etc.), so output and side effects are preserved. We + skip the bypass on failure so pytest's normal traceback machinery still + runs. + """ + if _PYTEST_EXIT_STATUS == 0: + sys.stdout.flush() + sys.stderr.flush() + os._exit(0)