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:
safishamsi
2026-07-12 15:24:42 +01:00
co-authored by varuntej07 igorregoir-lgtm Claude Opus 4.8
parent 373bc8efd8
commit 90b545c609
5 changed files with 79 additions and 1 deletions
+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"