Skip to content

Exit quietly when the reader closes the pipe - #171

Merged
ESultanik merged 1 commit into
masterfrom
156-broken-pipe
Sep 9, 2026
Merged

ESultanik merged 1 commit into
masterfrom
156-broken-pipe

Conversation

@ESultanik

Copy link
Copy Markdown
Collaborator

Closes #156

graphtage a.json b.json | head -3 printed roughly 107 lines of chained BrokenPipeError tracebacks on STDERR
once head exited. Piping a large diff into head or a pager is a normal way to read it, so the noise is easy to
hit.

What changed

A single except BrokenPipeError around the diff is not enough, because the failure surfaces at four independent
points:

  1. HTMLPrinter.__init__ writes the document header, so --html fails before the diff starts.
  2. formatter.print(printer, diff) writes the diff itself.
  3. Leaving the with printer: block flushes whatever is buffered.
  4. printer.close() in the finally clause flushes again, and HTMLPrinter.close writes </body></html> first.

All four are guarded. The handler inside main() sets a flag instead of returning, because had_edits is bound
only inside the with body and falling through to it after a broken pipe would raise UnboundLocalError.

Catching the exception alone is still not quiet: CPython flushes sys.stdout during interpreter shutdown and
reports the second failure as Exception ignored ... BrokenPipeError. The new silence_broken_pipe() points file
descriptor 1 at os.devnull with os.dup2, which is the remedy the Python documentation recommends for SIGPIPE.
It also lets the cleanup writes that follow succeed instead of raising again.

Exit status

EXIT_BROKEN_PIPE = 141, which is 128 + SIGPIPE, the status a shell reports for a process that a broken pipe
terminated. Python ignores SIGPIPE and raises BrokenPipeError instead, so Graphtage reports the status itself.

EXIT_DIFFERENCES_FOUND (1) would claim the inputs differ, and EXIT_ERROR (2) would claim Graphtage could not
compute the diff. Neither is true when the reader simply stopped listening, and 141 is what a shell with
pipefail already reports for the rest of the pipeline.

graphtage-git-diff

Covered. It writes the repository path to sys.stdout and flushes before delegating to graphtage.__main__.main,
and git runs external diff drivers under a pager, so quitting the pager reaches those writes as well. Its own
writes now return EXIT_BROKEN_PIPE; the delegated diff is covered by the fix in __main__.

Validation

The regression test is test_closing_the_pipe_early_is_quiet in test/test_printer.py. It runs
python -m graphtage on two JSON files whose diff is several times the size of the pipe buffer, reads 64 bytes,
closes the read end, and asserts that STDERR contains neither Traceback nor BrokenPipeError and that the exit
status is 141. It runs for both the plain and the HTML printer.

The test was confirmed to catch the bug: with the guards reverted (keeping only the new constant so the import
resolves), both subtests fail, reproducing exactly the tracebacks from the issue — three chained ones ending at
printer.close() plus the Exception ignored while flushing sys.stdout line. With the guards in place both pass.

Also checked by hand that graphtage c.json d.json | head -3 leaves STDERR free of tracebacks with progress bars
both enabled and disabled, and that set -o pipefail reports 141.

CI run locally on Python 3.14:

  • ruff check graphtage test docs bindist — clean
  • pytest — 141 passed, 2 subtests passed
  • make -C docs html SPHINXOPTS="-W --keep-going" — build succeeded
  • uv lock --check — reports the lockfile needs updating, but that is a sandbox artifact (Resolving despite existing lockfile due to addition of global exclude newer); neither pyproject.toml nor uv.lock is touched by
    this branch

🤖 Generated with Claude Code

https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa

Piping a diff into head or a pager is a normal way to read a large diff,
but doing so left Graphtage writing to a pipe that no longer had a
reader. The resulting BrokenPipeError escaped main() and Python printed
about 107 lines of chained tracebacks, which buried whatever output the
reader had already shown.

One handler is not enough, because the failure surfaces at four
independent points: the HTML printer writes the document header as it is
constructed, the diff is written inside the printer context, the
context's exit flushes what is left, and printer.close() flushes again
in the finally clause. The HTML printer additionally writes its closing
tags during close. Guard all of them, and set a flag rather than
returning from the handler, because had_edits is bound only inside the
context and reading it after a broken pipe would raise UnboundLocalError.

Catching the exception is still not quiet on its own. CPython flushes
sys.stdout while the interpreter shuts down and reports the second
failure as "Exception ignored", so point the standard output file
descriptor at the null device before returning.

Report the broken pipe as exit status 141, which is 128 + SIGPIPE, the
status a shell reports for a process that a broken pipe terminated.
Reusing EXIT_DIFFERENCES_FOUND would claim the inputs differ, and
EXIT_ERROR would claim Graphtage failed to compute the diff; neither is
true when the reader simply stopped listening.

The graphtage-git-diff driver writes the repository path to standard
output before delegating, and git runs it under a pager, so quitting the
pager reaches it too. Guard its own writes as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa
@ESultanik
ESultanik merged commit 856a6df into master Sep 9, 2026
12 checks passed
@ESultanik
ESultanik deleted the 156-broken-pipe branch September 9, 2026 14:52
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.

Closing STDOUT early produces BrokenPipeError tracebacks

1 participant