diff --git a/.github/workflows/nightly-chaos.yml b/.github/workflows/nightly-chaos.yml index fea82073..f9c35261 100644 --- a/.github/workflows/nightly-chaos.yml +++ b/.github/workflows/nightly-chaos.yml @@ -340,6 +340,10 @@ jobs: " || true - name: Run Python worker benchmarks (sweep, jitter, rescue) timeout-minutes: 15 + env: + # A native crash here prints no Python frame on its own; #434 was + # diagnosable only from the shell's "Segmentation fault" line. + PYTHONFAULTHANDLER: "1" run: | set -o pipefail PYTHONPATH=scripts .venv/bin/python scripts/benchmark_runtime.py --scenario workers \ diff --git a/awa-python/scripts/benchmark_runtime.py b/awa-python/scripts/benchmark_runtime.py index d545edf0..55bec18a 100644 --- a/awa-python/scripts/benchmark_runtime.py +++ b/awa-python/scripts/benchmark_runtime.py @@ -3,6 +3,7 @@ import argparse import asyncio import os +import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone from statistics import quantiles @@ -1074,9 +1075,36 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def _flush_std_streams() -> None: + """Flush stdout and stderr, tolerating an already-closed stream. + + `os._exit` skips stdio, this script uses buffered output rather than + `flush=True` per print, and the nightly tees stdout into the artifact the + regression checker reads — so without this the artifact comes out empty. + + A raising flush would propagate out of `main` and hand control back to + normal interpreter finalization, which is the crash path the caller is + avoiding. Mirrors `_flush_std_streams` in `tests/_subprocess_exit.py`; + duplicated because that module lives outside this script's import path. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except (OSError, ValueError): + pass + + def main() -> None: asyncio.run(async_main(parse_args())) + # Exit before the interpreter finalizer phase: the pyo3-async-runtimes + # tokio runtime is process-global and its outstanding tasks hold Python + # references, so finalization can SIGSEGV after correct output (#434, + # #228, PyO3/pyo3#1415). `tests/_subprocess_exit.py` does the same for + # the chaos helpers. + _flush_std_streams() + os._exit(0) + if __name__ == "__main__": main() diff --git a/awa-python/tests/_subprocess_exit.py b/awa-python/tests/_subprocess_exit.py index 452405e8..3acedefb 100644 --- a/awa-python/tests/_subprocess_exit.py +++ b/awa-python/tests/_subprocess_exit.py @@ -1,12 +1,29 @@ import asyncio import os import signal +import sys from collections.abc import Awaitable, Callable from types import FrameType from typing import NoReturn +def _flush_std_streams() -> None: + """Flush stdout and stderr before a forced exit, tolerating a closed stream. + + `os._exit` skips stdio, so callers may rely on this for their final + flush rather than passing `flush=True` on every print. A raising flush + is swallowed: letting it propagate would hand control back to normal + interpreter finalization, which is what the caller is avoiding. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: + pass + + def _exit_from_signal(signum: int, _frame: FrameType | None) -> NoReturn: + _flush_std_streams() os._exit(128 + signum) @@ -18,4 +35,5 @@ def install_exit_without_finalizers_on_signals() -> None: def run_async_main_without_finalizers(main: Callable[[], Awaitable[None]]) -> NoReturn: install_exit_without_finalizers_on_signals() asyncio.run(main()) + _flush_std_streams() os._exit(0)