Skip to content

[v2] Handle broken pipe when command output is piped to an early-exiting reader - #10538

Open
HenrikGharagyozyan wants to merge 1 commit into
aws:v2from
HenrikGharagyozyan:fix/s3-broken-pipe
Open

[v2] Handle broken pipe when command output is piped to an early-exiting reader#10538
HenrikGharagyozyan wants to merge 1 commit into
aws:v2from
HenrikGharagyozyan:fix/s3-broken-pipe

Conversation

@HenrikGharagyozyan

Copy link
Copy Markdown

What

Piping a command's output into a reader that exits early — aws s3 ls s3://bucket/ | head -1 — currently prints [Errno 32] Broken pipe, an Exception ignored in: <_io.TextIOWrapper ...> traceback, and exits non-zero. This makes it exit quietly with 141.

Root cause

s3 ls writes each page through _display_page()uni_print()sys.stdout. When the downstream reader exits, the write raises BrokenPipeError, which reaches the blanket except BaseException in CLIDriver.main() and is handled by GeneralExceptionHandler (255). The interpreter's final stdout flush at shutdown then fails on the same closed pipe, emitting the "Exception ignored" line and replacing the exit status with Python's own flush-failure code — which is why #5899 has reports of both 255 and 120.

Observed on v2 before this change, against a local stub endpoint:

$ aws s3 ls s3://test-bucket/ --endpoint-url http://127.0.0.1:8899 | head -n1
2024-01-01 04:00:00       1234 object-0000000.txt
aws_rc=120
aws: [ERROR]: [Errno 32] Broken pipe
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe

After: aws_rc=141, empty stderr. An unpiped listing is unchanged at rc 0.

Approach

Adds a BrokenPipeExceptionHandler to both handler chains, modelled directly on the existing InterruptExceptionHandler (RC = 128 + signal.SIGINT). It returns 128 + SIGPIPE (141), the same status head and grep themselves report for a closed pipe under pipefail. Before returning it points the stdout descriptor at devnull, per the note on SIGPIPE in the Python docs, so the shutdown flush cannot fail.

Registered in construct_cli_error_handlers_chain() as well as construct_entry_point_handlers_chain(), since CLIDriver.main() catches the exception first.

This follows treatment the codebase already applies elsewhere rather than introducing a new convention: awscli/customizations/logs/ui.py guards SIGPIPE with if sys.platform != "win32", and AWSCLIPagerManager.get_pager_stream() already swallows OSError because "a pager is closed abruptly and causes a broken pipe". The plain-stdout path simply had no equivalent.

Alternative considered: restoring signal.SIG_DFL for SIGPIPE. Rejected — Python sets SIGPIPE to SIG_IGN at startup so socket writes raise EPIPE as catchable exceptions; restoring the default would make broken socket writes terminate the process outright instead of being retried by botocore.

getattr(signal, 'SIGPIPE', 13) keeps the return code identical on Windows, which has no SIGPIPE but can still raise BrokenPipeError.

Testing

  • Unit tests (tests/unit/test_errorhandler.py): both chains return 141 with no stderr; the RC matches 128 + signal.SIGPIPE; the devnull redirect discards writes and is a no-op for streams with no file descriptor; and unrelated OSErrors still report 255, since BrokenPipeError is an OSError subclass.
  • Functional test (tests/functional/s3/test_ls_command.py): end-to-end through the real s3 ls path. Fails on v2 today with 255 != 141.
  • Full suite on this branch: 9,390 unit passed / 88,184 functional passed, 28 skipped, 0 failures.
  • Documented return code 141 in aws help return-codes.

Closes #5899


This change was generated by AI tools, and reviewed by Henrik Gharagyozyan.

Piping a command's output into a reader that exits early, such as
"aws s3 ls s3://bucket/ | head -1", printed "[Errno 32] Broken pipe"
along with an "Exception ignored" traceback and exited non-zero.

BrokenPipeError from writing to the closed pipe reached the blanket
except clause in CLIDriver.main() and was handled by
GeneralExceptionHandler. The interpreter's final stdout flush then
failed on the same pipe, which emitted the traceback and replaced the
exit status with Python's own flush failure code.

Add a BrokenPipeExceptionHandler to both handler chains, modelled on
the existing InterruptExceptionHandler, returning 128 + SIGPIPE to
match what standard Unix utilities report for a closed pipe. Point the
stdout descriptor at devnull before returning so the shutdown flush
cannot fail.

Restoring signal.SIG_DFL for SIGPIPE was considered and rejected:
Python sets SIGPIPE to SIG_IGN at startup so socket writes raise EPIPE
as catchable exceptions, and restoring the default would make broken
socket writes terminate the process instead of being retried.
@HenrikGharagyozyan
HenrikGharagyozyan requested a review from a team as a code owner August 7, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant