Skip to content

CLI exits 255 (unhandled BrokenPipeError) when output is truncated by head / Select-Object-First #1807

Description

@varuntej07

TL;DR: piping graphify query into anything that reads only the first few lines makes graphify crash with exit code 255 after the reader disconnects, so scripts and CI treat successful queries as failures — it should treat an early-disconnecting reader as success and exit 0.

Summary

When a downstream consumer closes graphify's stdout early, the CLI crashes with an unhandled write-to-closed-pipe error and exits 255 after emitting partial output. Truncating a CLI's output is routine (head, PowerShell's Select-Object -First N, sed q), and the conventional behavior is to treat a closed pipe as "the reader has what it needs," not as failure.

Repro

graphifyy 0.9.12 (uv tool install), Windows 11 + PowerShell 5.1, and the equivalent on Unix:

 graphify query "anything" | Select-Object -First 2
 $LASTEXITCODE     # -> -1 (raw exit code 255); $? is False

 # POSIX:
 graphify query "anything" | head -n 2; echo $?    # -> 255 (or 1)

Actual: partial output prints, process exits 255 ($LASTEXITCODE reports -1).
Expected: exit 0 (or the SIGPIPE 141 convention on POSIX).
Note: the truncated output prints normally and the shell shows no error - the only symptom is the exit code, so you must check $LASTEXITCODE / $? (or echo $? on POSIX) to see it. Reproduces regardless of output size.

Without truncation, graphify query exits 0 with all output on stdout, so this only bites pipelines - but it breaks any script or CI wrapper that both trims output and checks exit codes, and it makes agent harnesses (Claude Code etc.) render successful query results as command failures.

Suggested fix

Wrap the console entry point (graphify/__main__.py: main()) in the standard CPython pattern (https://docs.python.org/3/library/signal.html#note-on-sigpipe):

def main() -> None:
    try:
        _main()          # current main() body, renamed
    except BrokenPipeError:
        # Reader closed the pipe (head, Select-Object -First N): they
        # have what they need. Redirect stdout to devnull so the
        # interpreter's shutdown flush doesn't raise a second time.
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, sys.stdout.fileno())
        sys.exit(0)

Windows caveat: writes to a closed pipe can also surface as OSError(errno.EINVAL) rather than BrokenPipeError, so the except clause may want except (BrokenPipeError, OSError) as exc: with an errno in (errno.EPIPE, errno.EINVAL) re-raise guard - maintainer's call on how broad to go.

Opening a PR either way!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions