Files
graphify/tests/test_cli_broken_pipe.py
T
safishamsi a46eee49ef fix(export): coerce non-scalar attrs in graphml; harden pipe-close flush
#1831 — `graphify export graphml` crashed on any dict/list-valued
attribute (per-node metadata dict, graph-level hyperedges list) because
nx.write_graphml only accepts scalars; a real ~2,300-node graph failed
every export and left a 0-byte .graphml behind. to_graphml now coerces
None->"" and JSON-serializes non-scalars across graph/node/edge scopes
(int/float/bool/str pass through), and writes atomically via a temp file
so a failed export can't leave a partial file. Closes #1830.

#1807 followup — adopt @varuntej07's explicit in-guard sys.stdout.flush()
from #1811: piped stdout is block-buffered, so a small fully-buffered
output would only flush at interpreter shutdown (outside the guard),
where a closed-pipe reader escapes as a noisy shutdown error and nonzero
exit. Flushing inside the try closes that gap. Closes #1811.

Reported by @hofmockel (#1831) and @varuntej07 (#1807/#1811).

Co-Authored-By: hofmockel <hofmockel@users.noreply.github.com>
Co-Authored-By: varuntej07 <varuntej07@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:57:16 +01:00

50 lines
2.0 KiB
Python

"""CLI must not crash when a downstream reader closes the pipe early (#1807).
Truncating a command's output (`head`, PowerShell `Select-Object -First N`,
`sed q`) is routine. graphify used to keep writing after the reader disconnected,
hit an unhandled BrokenPipeError, and exit 255 — so CI wrappers and agent
harnesses that both trim output and check the exit code read a successful query
as a failure. An early-closing reader is now treated as success (exit 0).
"""
from __future__ import annotations
import subprocess
import sys
PYTHON = sys.executable
def test_help_survives_reader_closing_pipe_early():
"""`graphify --help | head -n1` must leave graphify exiting 0, not 255."""
producer = subprocess.Popen(
[PYTHON, "-m", "graphify", "--help"], stdout=subprocess.PIPE
)
reader = subprocess.Popen(
[PYTHON, "-c", "import sys; sys.stdin.readline()"],
stdin=producer.stdout,
stdout=subprocess.DEVNULL,
)
producer.stdout.close() # let the producer see EPIPE when the reader exits
reader.wait()
rc = producer.wait()
# 0 (our handled-and-succeed convention). Never the 255 unhandled-exception code.
assert rc == 0, f"expected clean exit after early pipe close, got {rc}"
def test_small_buffered_output_survives_reader_that_reads_nothing():
"""A short, fully-buffered output (piped stdout is block-buffered) only flushes
at exit. If the reader closed the pipe without reading, that flush must be
handled inside the CLI's guard and exit 0, not escape as a shutdown error."""
producer = subprocess.Popen(
[PYTHON, "-m", "graphify", "--version"], stdout=subprocess.PIPE
)
reader = subprocess.Popen(
[PYTHON, "-c", "pass"], # exits immediately, reads nothing
stdin=producer.stdout,
stdout=subprocess.DEVNULL,
)
producer.stdout.close()
reader.wait()
rc = producer.wait()
assert rc == 0, f"expected clean exit when reader reads nothing, got {rc}"