diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e49e8..7aa0bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Clean SIGPIPE handling for piped output** — `bcli | head`, + `| grep -m 1`, and similar pipe-truncating consumers now terminate + the CLI silently, matching `cat` and `grep` conventions, instead of + emitting a `BrokenPipeError: [Errno 32] Broken pipe` traceback at + interpreter shutdown. Implemented as a new `bcli_cli.app:main` + console-script entry point that installs `SIGPIPE -> SIG_DFL` on + POSIX with a `BrokenPipeError` safety net for Windows. - **Hyphenated saved-query param names** — the workflow template resolver now accepts hyphens in identifiers, so references like `${{ params.vendor-no }}` substitute correctly. Previously the regex diff --git a/pyproject.toml b/pyproject.toml index ae3cf28..d3095b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ Issues = "https://github.com/igor-ctrl/bcli/issues" Documentation = "https://github.com/igor-ctrl/bcli/tree/main/docs" [project.scripts] -bcli = "bcli_cli.app:app" +bcli = "bcli_cli.app:main" bcli-mcp = "bcli_mcp:main" [project.optional-dependencies] diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index 059fd31..f77a9df 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -198,5 +198,37 @@ def _emit_command_summary() -> None: pass +def main() -> None: + """Console-script entry point. + + Wraps the Typer ``app`` with a SIGPIPE handler so that ``bcli ... | head`` + and similar pipe-truncating consumers terminate the CLI silently — + matching the Unix idiom of ``cat``, ``grep`` and friends — instead of + surfacing ``BrokenPipeError`` at interpreter shutdown. + + On Windows the ``signal.SIGPIPE`` constant is absent; the safety-net + ``try`` below catches the error in that path. + """ + import signal + + if hasattr(signal, "SIGPIPE"): + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + try: + app() + except BrokenPipeError: + # Closing stdout/stderr before exit prevents Python's atexit flush + # from re-triggering the same error on a now-dead pipe. + try: + sys.stdout.close() + except Exception: + pass + try: + sys.stderr.close() + except Exception: + pass + sys.exit(0) + + if __name__ == "__main__": - app() + main() diff --git a/tests/test_cli/test_pipe_handling.py b/tests/test_cli/test_pipe_handling.py new file mode 100644 index 0000000..5ff1868 --- /dev/null +++ b/tests/test_cli/test_pipe_handling.py @@ -0,0 +1,65 @@ +"""Regression test: piping CLI output to a closing consumer must not +surface BrokenPipeError on stderr. + +Running ``bcli ... | head -N`` was emitting a traceback like: + + Exception ignored in: <_io.TextIOWrapper name='' mode='w' ...> + BrokenPipeError: [Errno 32] Broken pipe + +at interpreter shutdown when the formatter wrote past the point at which +``head`` had closed its end of the pipe. The fix in ``bcli_cli.app.main`` +installs a SIGPIPE handler so the process exits silently like ``cat``. + +The test exercises the real entry point in a subprocess against a small +in-memory record set — avoiding any BC network call — and asserts that +the stderr produced under truncation contains no traceback or +``BrokenPipeError`` marker. +""" + +from __future__ import annotations + +import signal +import subprocess +import sys + +import pytest + + +pytestmark = pytest.mark.skipif( + not hasattr(signal, "SIGPIPE"), + reason="SIGPIPE only meaningful on POSIX", +) + + +def test_help_piped_to_head_does_not_leak_brokenpipe() -> None: + """``bcli --help | head -1`` should exit clean. + + ``--help`` is the shortest path that exercises the entry point and + writes more than one line to stdout — enough for ``head -1`` to + close the pipe before the writer finishes. + """ + help_proc = subprocess.Popen( + [sys.executable, "-m", "bcli_cli.app", "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + head_proc = subprocess.Popen( + ["head", "-1"], + stdin=help_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert help_proc.stdout is not None + help_proc.stdout.close() # let head's EOF propagate + head_proc.communicate(timeout=15) + _, help_stderr = help_proc.communicate(timeout=15) + + stderr_text = help_stderr.decode("utf-8", errors="replace") + + assert "BrokenPipeError" not in stderr_text, ( + f"BrokenPipeError leaked to stderr:\n{stderr_text}" + ) + assert "Exception ignored" not in stderr_text, ( + f"Python interpreter teardown noise leaked to stderr:\n{stderr_text}" + )