fix(cli): survive early pipe close (#1807); skip .nox venvs (#1804)

#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 <varuntej07@users.noreply.github.com>
Co-Authored-By: igorregoir-lgtm <igorregoir-lgtm@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed 2026-07-12 15:24:42 +01:00
1 parent 373bc8efd8
commit 90b545c609
5 files changed
+79 -1

No files matched your search

+4
View File
@@ -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/<name>/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=<path>`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README.
+31
View File
@@ -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:
+1 -1
View File
@@ -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)
+31
View File
@@ -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}"
+12
View File
@@ -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"