Exit quietly when the reader closes the pipe - #171
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #156
graphtage a.json b.json | head -3printed roughly 107 lines of chainedBrokenPipeErrortracebacks on STDERRonce
headexited. Piping a large diff intoheador a pager is a normal way to read it, so the noise is easy tohit.
What changed
A single
except BrokenPipeErroraround the diff is not enough, because the failure surfaces at four independentpoints:
HTMLPrinter.__init__writes the document header, so--htmlfails before the diff starts.formatter.print(printer, diff)writes the diff itself.with printer:block flushes whatever is buffered.printer.close()in thefinallyclause flushes again, andHTMLPrinter.closewrites</body></html>first.All four are guarded. The handler inside
main()sets a flag instead of returning, becausehad_editsis boundonly inside the
withbody and falling through to it after a broken pipe would raiseUnboundLocalError.Catching the exception alone is still not quiet: CPython flushes
sys.stdoutduring interpreter shutdown andreports the second failure as
Exception ignored ... BrokenPipeError. The newsilence_broken_pipe()points filedescriptor 1 at
os.devnullwithos.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 is128 + SIGPIPE, the status a shell reports for a process that a broken pipeterminated. Python ignores
SIGPIPEand raisesBrokenPipeErrorinstead, so Graphtage reports the status itself.EXIT_DIFFERENCES_FOUND(1) would claim the inputs differ, andEXIT_ERROR(2) would claim Graphtage could notcompute the diff. Neither is true when the reader simply stopped listening, and 141 is what a shell with
pipefailalready reports for the rest of the pipeline.graphtage-git-diffCovered. It writes the repository path to
sys.stdoutand flushes before delegating tographtage.__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_quietintest/test_printer.py. It runspython -m graphtageon 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
TracebacknorBrokenPipeErrorand that the exitstatus 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 theException ignored while flushing sys.stdoutline. With the guards in place both pass.Also checked by hand that
graphtage c.json d.json | head -3leaves STDERR free of tracebacks with progress barsboth enabled and disabled, and that
set -o pipefailreports 141.CI run locally on Python 3.14:
ruff check graphtage test docs bindist— cleanpytest— 141 passed, 2 subtests passedmake -C docs html SPHINXOPTS="-W --keep-going"— build succeededuv lock --check— reports the lockfile needs updating, but that is a sandbox artifact (Resolving despite existing lockfile due to addition of global exclude newer); neitherpyproject.tomlnoruv.lockis touched bythis branch
🤖 Generated with Claude Code
https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa