From 8978c3c801cd72e6699eef5d96e2da2b86c15649 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Wed, 28 Oct 2026 14:14:14 +0000 Subject: [PATCH 1/3] fix(no-ticket): exit quietly when Ctrl+C meets a closed pipe On Ctrl+C, Click writes a newline to stderr before it raises Abort. When the reader of stderr is gone, that write raises BrokenPipeError inside Click's KeyboardInterrupt handler. Click's own EPIPE handler does not catch it, so AliasGroup.main reported it to Sentry as a crash and wrote an error line to the closed pipe. Catch BrokenPipeError in AliasGroup.main and exit with status 1. Fixes CLOUDSMITH-CLI-1R Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + cloudsmith_cli/cli/command.py | 6 ++++++ .../cli/tests/test_unexpected_errors.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf559d4e..ba2c03afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `--api-headers "Authorization=username"` without a password shows a usage error for `--api-headers`. Before, the CLI showed a traceback. - `--api-headers` with a blocked header, such as `user-agent=x`, or with a value that has no `=`, shows a usage error. Before, the CLI showed a traceback. - `cloudsmith credential-helper install pnpm`, `uninstall pnpm`, and `list` now read and write `.npmrc` as UTF-8, as npm and pnpm do. On Windows, a home directory with characters such as Cyrillic or CJK letters caused a `UnicodeEncodeError`, and UTF-8 text in `.npmrc` was read incorrectly. The CLI keeps bytes that are not UTF-8 unchanged, and it reads a `.npmrc` that starts with a UTF-8 byte order mark. +- When you interrupt the CLI with Ctrl+C after the process that reads its output has closed the pipe, the CLI now exits with status 1. Before, it showed `Error: BrokenPipeError` and sent an error report. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/command.py b/cloudsmith_cli/cli/command.py index 46c8be232..098ee8acd 100644 --- a/cloudsmith_cli/cli/command.py +++ b/cloudsmith_cli/cli/command.py @@ -202,6 +202,12 @@ def main(self, *args, **kwargs): e.show() _echo_certificate_hint(e) sys.exit(e.exit_code) + except BrokenPipeError: + # Click handles a closed pipe, but not one that closes while it + # handles Ctrl+C. The pipe is gone, so print nothing. + if not original_standalone_mode: + raise + sys.exit(1) except Exception as e: # pylint: disable=broad-exception-caught # Anything reaching here is unexpected: click's own errors and # handled API errors (which exit via ctx.exit) are dealt with diff --git a/cloudsmith_cli/cli/tests/test_unexpected_errors.py b/cloudsmith_cli/cli/tests/test_unexpected_errors.py index 6bd6fc5c0..8cdfc21b4 100644 --- a/cloudsmith_cli/cli/tests/test_unexpected_errors.py +++ b/cloudsmith_cli/cli/tests/test_unexpected_errors.py @@ -294,6 +294,21 @@ def test_click_errors_are_not_reported(runner, tmp_path, sentry_events, exc): assert sentry_events == [] +def test_interrupt_with_a_closed_stderr_is_not_reported( + runner, tmp_path, sentry_events +): + """Click writes a newline on Ctrl+C, which fails when the pipe is gone.""" + with ( + whoami_raises(KeyboardInterrupt()), + patch("click.core.echo", side_effect=BrokenPipeError), + ): + result = runner.invoke(main, whoami_args(tmp_path)) + + assert result.exit_code == 1 + assert result.stderr == "" + assert sentry_events == [] + + STRICT_HINT = "Hint: The TLS certificate failed a strict X.509 check" From bbe0066ce60d7608a20b5b7184b9face0029411a Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Wed, 28 Oct 2026 14:25:42 +0000 Subject: [PATCH 2/3] fix(no-ticket): keep exit status 1 after a failed stderr write The newline that Click failed to write stays in the stderr buffer. Python flushes stderr again at exit, the flush fails, and the process exits with status 120. Wrap stdout and stderr in Click's _PacifyFlushWrapper before the exit, as Click does for a closed pipe. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- cloudsmith_cli/cli/command.py | 6 ++++- .../cli/tests/test_unexpected_errors.py | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2c03afd..717d7cbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `--api-headers "Authorization=username"` without a password shows a usage error for `--api-headers`. Before, the CLI showed a traceback. - `--api-headers` with a blocked header, such as `user-agent=x`, or with a value that has no `=`, shows a usage error. Before, the CLI showed a traceback. - `cloudsmith credential-helper install pnpm`, `uninstall pnpm`, and `list` now read and write `.npmrc` as UTF-8, as npm and pnpm do. On Windows, a home directory with characters such as Cyrillic or CJK letters caused a `UnicodeEncodeError`, and UTF-8 text in `.npmrc` was read incorrectly. The CLI keeps bytes that are not UTF-8 unchanged, and it reads a `.npmrc` that starts with a UTF-8 byte order mark. -- When you interrupt the CLI with Ctrl+C after the process that reads its output has closed the pipe, the CLI now exits with status 1. Before, it showed `Error: BrokenPipeError` and sent an error report. +- The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/command.py b/cloudsmith_cli/cli/command.py index 098ee8acd..161a13abf 100644 --- a/cloudsmith_cli/cli/command.py +++ b/cloudsmith_cli/cli/command.py @@ -4,6 +4,7 @@ from collections import OrderedDict import click.exceptions +from click.utils import _PacifyFlushWrapper from click_didyoumean import DYMGroup from ..core.certificate_errors import describe_certificate_error @@ -203,10 +204,13 @@ def main(self, *args, **kwargs): _echo_certificate_hint(e) sys.exit(e.exit_code) except BrokenPipeError: - # Click handles a closed pipe, but not one that closes while it + # Click handles a closed pipe, but not a failed write while it # handles Ctrl+C. The pipe is gone, so print nothing. if not original_standalone_mode: raise + # Python flushes the streams at exit. A failed flush gives status 120. + sys.stdout = _PacifyFlushWrapper(sys.stdout) + sys.stderr = _PacifyFlushWrapper(sys.stderr) sys.exit(1) except Exception as e: # pylint: disable=broad-exception-caught # Anything reaching here is unexpected: click's own errors and diff --git a/cloudsmith_cli/cli/tests/test_unexpected_errors.py b/cloudsmith_cli/cli/tests/test_unexpected_errors.py index 8cdfc21b4..c8f573021 100644 --- a/cloudsmith_cli/cli/tests/test_unexpected_errors.py +++ b/cloudsmith_cli/cli/tests/test_unexpected_errors.py @@ -6,7 +6,9 @@ """ import json +import os import runpy +import subprocess import sys from unittest.mock import patch @@ -309,6 +311,26 @@ def test_interrupt_with_a_closed_stderr_is_not_reported( assert sentry_events == [] +def test_interrupt_with_a_closed_stderr_exits_1(tmp_path): + """Python flushes stderr again at exit, which must not change the status.""" + code = f""" +import os +from cloudsmith_cli.cli.commands.main import main +from cloudsmith_cli.cli.tests.test_unexpected_errors import whoami_args, whoami_raises + +read_end, write_end = os.pipe() +os.close(read_end) +os.dup2(write_end, 2) +with whoami_raises(KeyboardInterrupt()): + main(whoami_args({str(tmp_path)!r})) +""" + # Unbuffered stderr keeps no bytes for the exit flush, which hides the failure. + env = {**os.environ, "PYTHONUNBUFFERED": ""} + result = subprocess.run([sys.executable, "-c", code], env=env, check=False) + + assert result.returncode == 1 + + STRICT_HINT = "Hint: The TLS certificate failed a strict X.509 check" From cd8cf5b002c581642f32643cfb31b5f1dae4870f Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Wed, 28 Oct 2026 14:39:35 +0000 Subject: [PATCH 3/3] chore(no-ticket): shorten the broken pipe handler comment Co-Authored-By: Claude Opus 5.5 --- cloudsmith_cli/cli/command.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/command.py b/cloudsmith_cli/cli/command.py index 161a13abf..87ddc813e 100644 --- a/cloudsmith_cli/cli/command.py +++ b/cloudsmith_cli/cli/command.py @@ -204,11 +204,9 @@ def main(self, *args, **kwargs): _echo_certificate_hint(e) sys.exit(e.exit_code) except BrokenPipeError: - # Click handles a closed pipe, but not a failed write while it - # handles Ctrl+C. The pipe is gone, so print nothing. + # Click does not catch a failed write in its Ctrl+C handler. if not original_standalone_mode: raise - # Python flushes the streams at exit. A failed flush gives status 120. sys.stdout = _PacifyFlushWrapper(sys.stdout) sys.stderr = _PacifyFlushWrapper(sys.stderr) sys.exit(1)