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
25 changes: 24 additions & 1 deletion src/synth_panel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,22 @@ def _quiet_broken_pipe() -> None:

def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns exit code."""
if hasattr(signal, "SIGPIPE"):
# SIGPIPE disposition is process-global. ``main`` is the console-script
# entry point, but it is *also* imported and called directly by the test
# suite and library callers — so installing SIG_DFL unconditionally and
# never undoing it leaks the disposition into the calling process. A later,
# unrelated broken-pipe write (pytest output capture, coverage teardown, an
# xdist worker pipe) then takes a SIGPIPE and the whole process dies
# silently with exit 141. That is the non-deterministic CI killer tracked
# under sy-6zq / sy-1n1. Capture the prior handler so we can hand the
# disposition back when ``main`` returns. (Gating on ``isatty()`` would be
# wrong: stdout is *not* a tty exactly when piped to ``head``, which is the
# case the SIG_DFL restore exists to handle.)
prev_sigpipe = None
have_sigpipe = hasattr(signal, "SIGPIPE")
if have_sigpipe:
# Windows has no SIGPIPE — that's why the hasattr guard.
prev_sigpipe = signal.getsignal(signal.SIGPIPE)
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
try:
return _main(argv)
Expand All @@ -109,6 +124,14 @@ def main(argv: list[str] | None = None) -> int:
# before the interpreter's own shutdown flush triggers the
# "Exception ignored" warning on stderr.
_quiet_broken_pipe()
# Restore the caller's SIGPIPE handler. For the real CLI process this
# is harmless cleanup right before exit (stdout is already pointed at
# /dev/null by _quiet_broken_pipe); for in-process callers it stops
# SIG_DFL from outliving this invocation. ``getsignal`` returns None
# when the prior handler was installed from non-Python code, which
# ``signal.signal`` cannot reinstall — skip that rare case.
if have_sigpipe and prev_sigpipe is not None:
signal.signal(signal.SIGPIPE, prev_sigpipe)


def _main(argv: list[str] | None) -> int:
Expand Down
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class of bugs structurally impossible in CI.

from __future__ import annotations

import signal
import socket
from pathlib import Path

Expand Down Expand Up @@ -55,3 +56,35 @@ def _isolate_credentials_store(tmp_path_factory: pytest.TempPathFactory, monkeyp
"""
sandbox: Path = tmp_path_factory.mktemp("synthpanel-creds")
monkeypatch.setenv("SYNTHPANEL_CREDENTIALS_PATH", str(sandbox / "credentials.json"))


@pytest.fixture(autouse=True)
def _restore_sigpipe_disposition():
"""Snapshot and restore the process-global SIGPIPE handler around each test.

``synth_panel.main.main`` and its ``_quiet_broken_pipe`` helper deliberately
install ``SIGPIPE=SIG_DFL`` so piped CLI output (``synthpanel … | head``)
ends silently like a normal Unix tool. The disposition is *process-global*,
so a test that calls ``main()`` or ``_quiet_broken_pipe()`` can leave
SIG_DFL installed for the remainder of the pytest session. After that, any
broken-pipe write during pytest's output capture or coverage teardown is
delivered as SIGPIPE and silently kills the runner with exit 141 — the
non-deterministic CI failure tracked under sy-1n1 / sy-6zq (the matrix
entry that dies varies run to run because it depends on test ordering).

Restoring the prior handler after every test makes that leak structurally
impossible regardless of which code paths a test exercises, in the same
spirit as the network-block fixture above.
"""
if not hasattr(signal, "SIGPIPE"):
# Windows has no SIGPIPE; nothing to guard.
yield
return
prev = signal.getsignal(signal.SIGPIPE)
try:
yield
finally:
# ``getsignal`` returns None when the handler was installed from
# non-Python code; ``signal.signal`` cannot reinstall that, so skip it.
if prev is not None:
signal.signal(signal.SIGPIPE, prev)
Loading