From e1e749b72ddab4bfb89a73707fd08b2a3f04f8b6 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Tue, 12 May 2026 10:11:15 -0500 Subject: [PATCH] fix(cli): silent SIGPIPE termination instead of BrokenPipeError traceback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piping bcli output to a consumer that closes early (e.g. `head -5`, `grep -m 1`, scripted truncation) caused Python's interpreter teardown to flush stdout, hit EPIPE, and emit: Exception ignored in: <_io.TextIOWrapper name='' mode='w' ...> BrokenPipeError: [Errno 32] Broken pipe — scary-looking noise on stderr for what's actually normal Unix behaviour. cat, grep, ls, etc. all exit silently in this case. Introduce a new console-script entry point `bcli_cli.app:main` that restores the POSIX default for SIGPIPE before invoking the Typer app. On platforms without SIGPIPE (Windows), a `BrokenPipeError` safety net catches the same condition and closes stdout/stderr before exiting so the atexit flush can't re-trigger it. The pyproject `[project.scripts] bcli` entry updates to `bcli_cli.app:main`; the `app` object remains exported and importable for tests and `bcli-mcp`. Adds a subprocess regression test that runs the entry point and pipes into `head -1`, asserting the resulting stderr contains no `BrokenPipeError` or `Exception ignored` markers. --- CHANGELOG.md | 7 +++ pyproject.toml | 2 +- src/bcli_cli/app.py | 34 ++++++++++++++- tests/test_cli/test_pipe_handling.py | 65 ++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli/test_pipe_handling.py 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}" + )