From 90b545c60919e04988042f736f996e43c6285c90 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sun, 12 Jul 2026 15:24:42 +0100 Subject: [PATCH] fix(cli): survive early pipe close (#1807); skip .nox venvs (#1804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1807 — piping graphify into a reader that stops early (head, Select-Object -First N, sed q) disconnected stdout mid-write, raising an unhandled BrokenPipeError (OSError(EINVAL) on Windows) and exiting 255, so CI wrappers and agent harnesses read a successful query as a failure. The console entry point now wraps the CLI body: a closed-pipe reader is treated as success — stdout is redirected to devnull so shutdown flush can't raise again, and the process exits 0. Adds a subprocess regression test. #1804 — .nox/ (nox virtualenvs, tox's successor, same .nox/ tree shape) was missing from _SKIP_DIRS while .tox was present, so nox site-packages got fully indexed (one repo came out 91% venv noise). Added next to .tox with a regression test. Reported by @varuntej07 (#1807) and @igorregoir-lgtm (#1804). Co-Authored-By: varuntej07 Co-Authored-By: igorregoir-lgtm Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ graphify/__main__.py | 31 +++++++++++++++++++++++++++++++ graphify/detect.py | 2 +- tests/test_cli_broken_pipe.py | 31 +++++++++++++++++++++++++++++++ tests/test_detect.py | 12 ++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_broken_pipe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e144dfb3..40c2fc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: Visual Studio *solution folder* nodes no longer embed the absolute scan path (including the local username) in their `id` and `source_file` (#1789, thanks @fremat79). A solution folder is a virtual grouping, not a file — VS writes its name as both the display name and the "path" — but `extract_sln` resolved it to an absolute filesystem path anyway and keyed the node id off that. The CLI's id-relativization pass only remaps ids of real files in the scan set, so a virtual folder never matched and its absolute id survived into a committed `graph.json` (e.g. `id=/Users//proj/Plugins` instead of `id=plugins`). Solution folders are now detected (name == path) and keyed off the folder name only; real project files still resolve as before. (The earlier fix covered `.csproj`/`.sln` file nodes but missed the virtual folders — this completes it.) +- Fix: the CLI no longer crashes with exit code 255 when a downstream reader closes the pipe early (#1807, thanks @varuntej07). Truncating output with `head`, PowerShell's `Select-Object -First N`, or `sed q` disconnected the reader mid-write, graphify hit an unhandled `BrokenPipeError` (or `OSError(EINVAL)` on Windows) and exited 255 — so CI wrappers and agent harnesses that both trim output and check the exit code read a successful query as a command failure. An early-closing reader is now treated as success: stdout is redirected to devnull so the interpreter's shutdown flush can't raise again, and the process exits 0. + +- Fix: `.nox/` (nox virtualenvs) is now skipped during detection alongside `.tox/` (#1804, thanks @igorregoir-lgtm). nox is tox's successor and creates a `.nox/` tree of the same shape, but only `.tox` was in the skip set — so a repo with a nox env got its site-packages fully indexed (one real repo came out 91% venv noise: 6,720 of 7,365 nodes from `.nox/`) and semantic extraction burned tokens reading venv docs. + ## 0.9.13 (2026-07-12) - Fix: the query log is now opt-in (off by default) (#1797, thanks @adam-pond-agent). `querylog` wrote every `query`/`path`/`explain` question and corpus path (and full responses if `GRAPHIFY_QUERY_LOG_RESPONSES`) to a default-on, unbounded, fail-silent plaintext file at `~/.cache/graphify-queries.log` — outside any repo's .gitignore/retention, and undocumented, which contradicts graphify's on-device / no-telemetry posture. Logging is now OFF unless you opt in with `GRAPHIFY_QUERY_LOG_ENABLE=1` (default path) or `GRAPHIFY_QUERY_LOG=`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README. diff --git a/graphify/__main__.py b/graphify/__main__.py index 708c4581..8aa7315e 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1,6 +1,7 @@ """graphify CLI - `graphify install` sets up the Claude Code skill.""" from __future__ import annotations +import errno import functools import json import os @@ -442,9 +443,39 @@ _CODEX_HOOK = { + + +def _silence_broken_pipe() -> None: + """Handle a downstream reader that closed the pipe early. Redirect stdout to + devnull so the interpreter's shutdown flush does not raise a second time, then + exit 0 — the reader (head, `Select-Object -First N`, `sed q`) has what it needs.""" + try: + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + except Exception: + pass + sys.exit(0) def main() -> None: + """Console entry point. Wraps the CLI so that when a downstream consumer closes + stdout early, graphify treats it as success instead of crashing with an + unhandled write-to-closed-pipe error and exit 255 — which made CI wrappers and + agent harnesses read a successful query as a command failure (#1807).""" + try: + _run_cli() + except BrokenPipeError: + _silence_broken_pipe() + except OSError as exc: + # Windows surfaces a write to a closed pipe as OSError(EINVAL) rather than + # BrokenPipeError; EPIPE is the POSIX form when it slips past the above. + if getattr(exc, "errno", None) in (errno.EPIPE, errno.EINVAL): + _silence_broken_pipe() + else: + raise + + +def _run_cli() -> None: for _stream in (sys.stdout, sys.stderr): if _stream is not None and hasattr(_stream, "reconfigure"): try: diff --git a/graphify/detect.py b/graphify/detect.py index 84dd5e1b..3f68a81b 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -692,7 +692,7 @@ _SKIP_DIRS = { "dist", "build", "target", "out", "site-packages", "lib64", ".pytest_cache", ".mypy_cache", ".ruff_cache", - ".tox", ".eggs", "*.egg-info", + ".tox", ".nox", ".eggs", "*.egg-info", # nox is tox's successor, same .nox/ venv shape (#1804) "graphify-out", GRAPHIFY_OUT_NAME, # never treat own output as source input (#524); honour GRAPHIFY_OUT (#1423) # Coverage/test-artefact dirs — generated, never architecturally meaningful "coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870) diff --git a/tests/test_cli_broken_pipe.py b/tests/test_cli_broken_pipe.py new file mode 100644 index 00000000..d190a282 --- /dev/null +++ b/tests/test_cli_broken_pipe.py @@ -0,0 +1,31 @@ +"""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}" diff --git a/tests/test_detect.py b/tests/test_detect.py index 7ff76976..ec53166c 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -537,6 +537,18 @@ def test_detect_skips_next_cache(tmp_path): assert any("index.tsx" in f for f in all_files) +def test_detect_skips_nox_virtualenv(tmp_path): + """.nox/ (nox virtualenvs, tox's successor) must be excluded like .tox (#1804).""" + nox = tmp_path / ".nox" / "tests" / "lib" / "site-packages" / "pydeck" + nox.mkdir(parents=True) + (nox / "widget.py").write_text("class Deck: pass") + (tmp_path / "app.py").write_text("def go(): pass") + result = detect(tmp_path) + all_files = [f for files in result["files"].values() for f in files] + assert not any(".nox" in f for f in all_files) + assert any("app.py" in f for f in all_files) + + def test_detect_skips_graphify_own_cache(tmp_path): """.graphify/ (extraction cache) must never be re-indexed as source (#873).""" cache = tmp_path / ".graphify" / "cache"