diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf40e10..7104cb05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper install` and `uninstall` no longer rewrite a config file when they make no change to its data. Previously, the CLI always rewrote a file in a different formatting, such as the tab-indented `config.json` that Docker writes. The command also made a `.bak` copy and reported changes that it did not make. - When the API host name or the proxy host name does not exist, for example because of a typo in `--api-host`, commands now fail immediately. Previously, they retried for approximately 62 seconds without output. The CLI still retries a DNS failure that the operating system reports as temporary. - When the CLI waits before it retries a failed connection or a 5xx response, it now writes `Request failed: retrying in N second(s) ...` to stderr. Previously, it waited without output. +- 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. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/command.py b/cloudsmith_cli/cli/command.py index 46c8be23..87ddc813 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 @@ -202,6 +203,13 @@ def main(self, *args, **kwargs): e.show() _echo_certificate_hint(e) sys.exit(e.exit_code) + except BrokenPipeError: + # Click does not catch a failed write in its Ctrl+C handler. + if not original_standalone_mode: + raise + 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 # 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 c6e7cbda..4794f700 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,41 @@ 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 == [] + + +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"